diff --git a/README.md b/README.md index 5186c61..7e9fbac 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,12 @@ Scriptorium. Its module path is: gitea.maximumdirect.net/eric/promptkit ``` -The repository currently provides the independent Go module and its root public -package boundary. Framework behavior and consumer APIs have not yet been -extracted, so there is no installation or usage example at this time. +Framework extraction is in progress. The repository now contains the +`internal/domain` model, application-neutral `internal/defaults`, and +`internal/filecatalog` helpers that form the implementation foundation. These +internal packages are not a consumer API, and the root package does not yet +provide a usable public framework API, so there is no installation or usage +example at this time. Contributors should start with the [development guide](docs/development.md). The [architecture policy](docs/policy/architecture.md) defines the library diff --git a/docs/internal/overview.md b/docs/internal/overview.md index d2ea2ad..980b946 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -12,9 +12,13 @@ contributor workflow and validation. | Component | Implemented responsibility | References | | --- | --- | --- | | Root `promptkit` package | Establishes the public package boundary for the Go module. It does not yet provide migrated framework behavior or exported APIs. | [Package declaration](../../doc.go) | +| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.go) | +| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) | +| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) | -The root `promptkit` package is the sole implemented Go package. No internal -framework packages exist yet. +These packages form the internal extraction foundation. Prompt and profile +sources, rendering, artifact reading, validation, model clients, orchestration, +and a usable public engine are not implemented in Promptkit yet. ## Maintenance diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index 724ce0e..1c4ad64 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -12,10 +12,23 @@ implemented packages without redefining these rules. Promptkit is an importable Go library. It does not provide a runnable command, an HTTP service, or another application process. -The module root contains package `promptkit`, which is the public facade and the -only implemented Go package in the current repository foundation. It declares -the module's public package boundary but does not yet provide migrated framework -behavior or exported APIs. No internal framework packages currently exist. +The module root contains package `promptkit`, which is the public facade. It +declares the module's public package boundary but does not yet provide a usable +exported framework API. + +The implemented internal foundation consists of: + +- `internal/domain`, which owns framework data values shared by later internal + components; +- `internal/defaults`, which owns application-neutral framework defaults and + constructs the default execution target; and +- `internal/filecatalog`, which discovers YAML files and provides source-path + helpers for filesystem and `fs.FS` consumers. + +`internal/defaults` depends on `internal/domain`; the file catalog is +independent and uses only the standard library. Prompt and profile sources, +rendering, artifact reading, validation, model clients, orchestration, and the +public engine have not yet been extracted. Future framework extraction must follow this dependency direction: @@ -32,10 +45,12 @@ downstream consumers, including Scriptorium narrow injected abstractions ``` -The facade may coordinate internal components. Internal components must depend -on narrow abstractions for behavior supplied from outside the library; they -must not depend on consumers or on Scriptorium. This diagram constrains future -work and does not assert that the internal components already exist. +The facade may coordinate internal components once the public engine is +extracted. Internal components must depend on narrow abstractions for behavior +supplied from outside the library; they must not depend on consumers or on +Scriptorium. This diagram is the target dependency direction for later +extraction and does not assert that the public facade already assembles the +implemented foundation. ## Repository And Consumer Boundary diff --git a/internal/defaults/defaults.go b/internal/defaults/defaults.go new file mode 100644 index 0000000..a0cb744 --- /dev/null +++ b/internal/defaults/defaults.go @@ -0,0 +1,34 @@ +package defaults + +import ( + "time" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" +) + +const ( + SchemaDirDefault = "." + OutputArtifactName = "output" + ContentTypeTextPlain = "text/plain" + ContentTypeTextMarkdown = "text/markdown" + ContentTypeApplicationJSON = "application/json" + OpenAIChatCompletionsPath = "/chat/completions" + + ExecutionDefaultTemperature = 0.0 + ExecutionDefaultMaxTokens = 0 + ExecutionDefaultTopP = 1.0 + ExecutionDefaultTimeoutSeconds = 600 +) + +var ( + LLMRequestTimeoutDefault = 10 * time.Minute +) + +func ExecutionTargetDefault() domain.ExecutionTarget { + return domain.ExecutionTarget{ + Temperature: ExecutionDefaultTemperature, + MaxTokens: ExecutionDefaultMaxTokens, + TopP: ExecutionDefaultTopP, + TimeoutSeconds: ExecutionDefaultTimeoutSeconds, + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..0a1e03b --- /dev/null +++ b/internal/domain/domain.go @@ -0,0 +1,288 @@ +package domain + +import ( + "time" +) + +// ArtifactRefType defines how an artifact is referenced. +type ArtifactRefType string + +const ( + ArtifactRefInline ArtifactRefType = "inline" + ArtifactRefFile ArtifactRefType = "file" +) + +// OutputFormat defines the desired format of the generated artifact. +type OutputFormat string + +const ( + FormatText OutputFormat = "text" + FormatMarkdown OutputFormat = "markdown" + FormatJSON OutputFormat = "json" +) + +// ValidationMode defines how the output should be validated. +type ValidationMode string + +const ( + ValidationNone ValidationMode = "none" + ValidationBasic ValidationMode = "basic" + ValidationJSON ValidationMode = "json" + ValidationJSONSchema ValidationMode = "json_schema" +) + +// ValidationStatus defines the result of a validation check. +type ValidationStatus string + +const ( + ValidationPassed ValidationStatus = "passed" + ValidationFailed ValidationStatus = "failed" + ValidationSkipped ValidationStatus = "skipped" +) + +// CacheControlType defines provider cache behavior for prompt content. +type CacheControlType string + +const ( + CacheControlEphemeral CacheControlType = "ephemeral" +) + +const ( + // SessionIDMaxLength is OpenRouter's documented maximum session_id length. + SessionIDMaxLength = 256 +) + +// CacheControl describes provider cache metadata attached to prompt content. +type CacheControl struct { + Type CacheControlType `yaml:"type" json:"type"` + TTL string `yaml:"ttl,omitempty" json:"ttl,omitempty"` +} + +// RunRequest represents a request to generate a single artifact. +type RunRequest struct { + PromptID string + PromptVersion string + ProfileID string + APIKey string `json:"-" yaml:"-"` + Inputs map[string]ArtifactRef + Vars map[string]string + Execution *ExecutionTargetOverride + Validation *OutputContract + Metadata map[string]string +} + +// RunResult represents the complete result of a prompt execution run. +type RunResult struct { + RunID string + Artifact Artifact + RawOutput string + Validation ValidationResult + PromptID string + PromptVersion string + PromptHash string + RenderedPromptHash string + SelectedProfileID string + ModelName string + Endpoint string + EffectiveModelParams ExecutionTarget + InputHashes map[string]string + Usage TokenUsage + StartTime time.Time + EndTime time.Time + Duration time.Duration +} + +// PreparedRun contains pre-LLM execution state from the prepare/render phase. +// It must never include resolved API key values, model output, or validation data. +type PreparedRun struct { + PromptID string `json:"prompt_id"` + PromptVersion string `json:"prompt_version,omitempty"` + PromptHash string `json:"prompt_hash,omitempty"` + SelectedProfileID string `json:"selected_profile_id"` + EffectiveModelParams ExecutionTarget `json:"effective_model_params"` + TargetPresence ExecutionTargetPresence `json:"-"` + OutputContract OutputContract `json:"output_contract"` + StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"` + InputHashes map[string]string `json:"input_hashes,omitempty"` + SessionID string `json:"session_id,omitempty"` + RenderedPromptHash string `json:"rendered_prompt_hash"` + Messages []RenderedMessage `json:"messages"` + StartTime time.Time `json:"start_time,omitempty"` + EndTime time.Time `json:"end_time,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} + +// ArtifactRef represents a reference to an input artifact. +type ArtifactRef struct { + Type ArtifactRefType + URI string + Body string // Used for inline +} + +// Artifact represents the actual loaded content of a reference. +type Artifact struct { + Name string + ContentType string + Body []byte + URI string + Size int64 + Hash string +} + +// PromptDefinition represents a configured prompt execution definition. +type PromptDefinition struct { + ID string `yaml:"id"` + Version string `yaml:"version"` + DefaultProfile string `yaml:"default_profile"` + Description string `yaml:"description"` + SessionID string `yaml:"session_id" json:"session_id,omitempty"` + Inputs []PromptInput `yaml:"inputs"` + Templates []PromptMessageTemplate `yaml:"templates"` + OutputFormat OutputFormat `yaml:"output_format"` + Validation OutputContract `yaml:"validation"` +} + +// PromptInput describes one named input expected by a prompt definition. +type PromptInput struct { + Name string `yaml:"name"` + Required bool `yaml:"required"` + ContentType string `yaml:"content_type"` + Description string `yaml:"description"` +} + +// PromptMessageTemplate defines a template for a chat message. +type PromptMessageTemplate struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + ContentFile string `yaml:"content_file"` + CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"` +} + +// ExecutionProfile describes how and where to execute a model. +type ExecutionProfile struct { + ID string `yaml:"id"` + Endpoint string `yaml:"endpoint"` + Model string `yaml:"model"` + Temperature float64 `yaml:"temperature"` + MaxTokens int `yaml:"max_tokens"` + TopP float64 `yaml:"top_p"` + TimeoutSeconds int `yaml:"timeout_seconds"` + ServiceTier string `yaml:"service_tier"` + ReasoningEffort string `yaml:"reasoning_effort"` + APIKeyEnv string `yaml:"api_key_env"` + APIKeyRequired bool `yaml:"-" json:"-"` + ExtraParams map[string]any `yaml:"extra_params"` +} + +// ExecutionTargetOverride represents per-request runtime setting overrides. +type ExecutionTargetOverride struct { + Endpoint string `json:"endpoint,omitempty"` + Model string `json:"model,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + APIKeyEnv string `json:"api_key_env,omitempty"` + ExtraParams map[string]any `json:"extra_params,omitempty"` +} + +// ExecutionTargetPresence tracks which effective runtime fields came from an +// explicit request override even when the resolved value is a zero value. +type ExecutionTargetPresence struct { + Temperature bool + MaxTokens bool + TopP bool + TimeoutSeconds bool +} + +// ExecutionTarget represents effective model runtime settings for a run. +type ExecutionTarget struct { + Endpoint string `yaml:"endpoint" json:"endpoint"` + Model string `yaml:"model" json:"model"` + Temperature float64 `yaml:"temperature" json:"temperature"` + MaxTokens int `yaml:"max_tokens" json:"max_tokens"` + TopP float64 `yaml:"top_p" json:"top_p"` + TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"` + ServiceTier string `yaml:"service_tier" json:"service_tier"` + ReasoningEffort string `yaml:"reasoning_effort" json:"reasoning_effort"` + APIKeyEnv string `yaml:"api_key_env" json:"api_key_env"` + APIKey string `yaml:"-" json:"-"` + APIKeyRequired bool `yaml:"-" json:"-"` + ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"` +} + +// OutputContract defines the requirements for the output artifact. +type OutputContract struct { + Format OutputFormat `yaml:"format"` + ValidationMode ValidationMode `yaml:"validation_mode"` + SchemaPath string `yaml:"schema_path"` + RepairAttempts int `yaml:"repair_attempts"` +} + +// RenderedPrompt represents the prompt after template application. +type RenderedPrompt struct { + SessionID string `json:"session_id,omitempty"` + Messages []RenderedMessage `json:"messages"` +} + +// RenderedMessage is a single message in a rendered prompt. +type RenderedMessage struct { + Role string `json:"role"` + Content string `json:"content"` + CacheControl *CacheControl `json:"cache_control,omitempty"` +} + +// GenerateRequest is the internal request passed to the LLM client. +type GenerateRequest struct { + Prompt RenderedPrompt + Target ExecutionTarget + TargetPresence ExecutionTargetPresence + StructuredOutput *StructuredOutputSpec +} + +// StructuredOutputType indicates which provider-level output mode is requested. +type StructuredOutputType string + +const ( + StructuredOutputJSONSchema StructuredOutputType = "json_schema" +) + +// StructuredOutputSpec describes provider-level structured output requirements. +type StructuredOutputSpec struct { + Type StructuredOutputType `json:"type"` + JSONSchema *StructuredOutputJSONSpec `json:"json_schema,omitempty"` +} + +// StructuredOutputJSONSpec contains json_schema output constraints. +type StructuredOutputJSONSpec struct { + Name string `json:"name"` + Strict bool `json:"strict"` + Schema any `json:"schema"` +} + +// GenerateResponse is the response received from the LLM client. +type GenerateResponse struct { + Content string + Usage TokenUsage +} + +// TokenUsage tracks token consumption. +type TokenUsage struct { + PromptTokens int + CompletionTokens int + TotalTokens int + CachedTokens int + CacheWriteTokens int +} + +// ValidationResult represents the outcome of an output validation. +type ValidationResult struct { + Status ValidationStatus + Mode ValidationMode + Errors []string + SchemaPath string + RepairAttempts int + IsValid bool +} diff --git a/internal/domain/prepared_run_test.go b/internal/domain/prepared_run_test.go new file mode 100644 index 0000000..cf84c1d --- /dev/null +++ b/internal/domain/prepared_run_test.go @@ -0,0 +1,141 @@ +package domain + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) { + const envName = "SCRIPTORIUM_TEST_API_KEY" + const secret = "super-secret-value" + t.Setenv(envName, secret) + + prepared := PreparedRun{ + PromptID: "prompt.id", + PromptVersion: "v1", + PromptHash: "prompt-hash", + SelectedProfileID: "local-fast", + EffectiveModelParams: ExecutionTarget{ + Endpoint: "http://llm/v1", + Model: "gpt-test", + APIKeyEnv: envName, + APIKey: secret, + }, + InputHashes: map[string]string{"transcript": "hash-1"}, + RenderedPromptHash: "rendered-hash", + Messages: []RenderedMessage{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Summarize this."}, + }, + } + + b, err := json.Marshal(prepared) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + out := string(b) + if strings.Contains(out, secret) { + t.Fatalf("prepared run JSON unexpectedly contains secret value: %s", out) + } + if !strings.Contains(out, `"api_key_env":"`+envName+`"`) { + t.Fatalf("prepared run JSON should include api_key_env name: %s", out) + } + + var top map[string]any + if err := json.Unmarshal(b, &top); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + for _, forbidden := range []string{"raw_output", "validation", "artifact"} { + if _, ok := top[forbidden]; ok { + t.Fatalf("prepared run JSON should not include %q", forbidden) + } + } +} + +func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) { + prepared := PreparedRun{ + PromptID: "prompt.id", + SelectedProfileID: "local-fast", + EffectiveModelParams: ExecutionTarget{ + Endpoint: "http://llm/v1", + Model: "gpt-test", + }, + RenderedPromptHash: "rendered-hash", + Messages: []RenderedMessage{ + { + Role: "system", + Content: "You are helpful.", + CacheControl: &CacheControl{ + Type: CacheControlEphemeral, + TTL: "1h", + }, + }, + {Role: "user", Content: "Summarize this."}, + }, + } + + b, err := json.Marshal(prepared) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded struct { + Messages []map[string]any `json:"messages"` + } + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if len(decoded.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(decoded.Messages)) + } + + cacheControl, ok := decoded.Messages[0]["cache_control"].(map[string]any) + if !ok { + t.Fatalf("expected cache_control on first message, got %#v", decoded.Messages[0]) + } + if cacheControl["type"] != string(CacheControlEphemeral) || cacheControl["ttl"] != "1h" { + t.Fatalf("unexpected cache_control payload: %#v", cacheControl) + } + if _, ok := decoded.Messages[1]["cache_control"]; ok { + t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1]) + } +} + +func TestPreparedRunJSONIncludesSessionIDOnlyWhenPresent(t *testing.T) { + prepared := PreparedRun{ + PromptID: "prompt.id", + SelectedProfileID: "local-fast", + EffectiveModelParams: ExecutionTarget{ + Endpoint: "http://llm/v1", + Model: "gpt-test", + }, + SessionID: "session-123", + RenderedPromptHash: "rendered-hash", + Messages: []RenderedMessage{{Role: "user", Content: "Summarize this."}}, + } + + b, err := json.Marshal(prepared) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if decoded["session_id"] != "session-123" { + t.Fatalf("expected session_id in prepared run JSON, got %#v", decoded["session_id"]) + } + + prepared.SessionID = "" + b, err = json.Marshal(prepared) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(b), "session_id") { + t.Fatalf("expected empty session_id to be omitted, got %s", b) + } +} diff --git a/internal/filecatalog/catalog.go b/internal/filecatalog/catalog.go new file mode 100644 index 0000000..e0c8369 --- /dev/null +++ b/internal/filecatalog/catalog.go @@ -0,0 +1,142 @@ +package filecatalog + +import ( + "context" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +// FindYAMLFiles returns sorted full paths for .yaml and .yml files under root. +func FindYAMLFiles(ctx context.Context, root string) ([]string, error) { + var files []string + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if d.IsDir() { + return nil + } + if !IsYAMLFile(d.Name()) { + return nil + } + files = append(files, path) + return nil + }) + sort.Strings(files) + return files, err +} + +// FindFSYAMLFiles returns sorted paths for .yaml and .yml files under root in fsys. +func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) { + cleanRoot := CleanFSRoot(root) + var files []string + err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if d.IsDir() { + return nil + } + if !IsYAMLFile(d.Name()) { + return nil + } + files = append(files, name) + return nil + }) + sort.Strings(files) + return files, err +} + +// RelativePath computes a clean relative path from root to path. +func RelativePath(root string, filePath string) string { + rel, err := filepath.Rel(root, filePath) + if err != nil { + return filepath.Clean(filePath) + } + return filepath.Clean(rel) +} + +// CleanFSRoot normalizes a root path for use with fs.FS. +func CleanFSRoot(root string) string { + root = strings.TrimSpace(root) + if root == "" || root == "." { + return "." + } + return path.Clean(root) +} + +// DisplayPath returns name relative to root for messages about fs.FS paths. +func DisplayPath(root string, name string) string { + cleanRoot := CleanFSRoot(root) + cleanName := path.Clean(name) + if cleanRoot == "." { + return cleanName + } + prefix := strings.TrimSuffix(cleanRoot, "/") + "/" + if strings.HasPrefix(cleanName, prefix) { + return strings.TrimPrefix(cleanName, prefix) + } + return cleanName +} + +// ResolveFSPath resolves userPath from baseDir and keeps it inside root. +func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) { + cleanRoot := CleanFSRoot(root) + cleanBase := path.Clean(strings.TrimSpace(baseDir)) + if cleanBase == "" { + cleanBase = cleanRoot + } + if !containsFSPath(cleanRoot, cleanBase) { + return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot) + } + + cleanUserPath := strings.TrimSpace(userPath) + if cleanUserPath == "" { + return "", "", fmt.Errorf("path is required") + } + cleanUserPath = path.Clean(cleanUserPath) + if path.IsAbs(cleanUserPath) { + return "", "", fmt.Errorf("path %q must be relative", userPath) + } + + resolved := path.Clean(path.Join(cleanBase, cleanUserPath)) + if !containsFSPath(cleanRoot, resolved) { + return "", "", fmt.Errorf("path %q escapes source root %q", userPath, cleanRoot) + } + return resolved, DisplayPath(cleanRoot, resolved), nil +} + +func containsFSPath(root string, name string) bool { + root = CleanFSRoot(root) + name = path.Clean(name) + if root == "." { + return name == "." || (name != ".." && !strings.HasPrefix(name, "../")) + } + return name == root || strings.HasPrefix(name, strings.TrimSuffix(root, "/")+"/") +} + +// Stem strips .yaml or .yml from a file name. +func Stem(name string) string { + name = strings.TrimSuffix(name, ".yaml") + name = strings.TrimSuffix(name, ".yml") + return name +} + +func IsYAMLFile(name string) bool { + return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") +} diff --git a/internal/filecatalog/catalog_test.go b/internal/filecatalog/catalog_test.go new file mode 100644 index 0000000..6a2a2cc --- /dev/null +++ b/internal/filecatalog/catalog_test.go @@ -0,0 +1,270 @@ +package filecatalog + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "testing/fstest" +) + +func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "z", "prompt.yml"), "id: z") + mustWriteFile(t, filepath.Join(root, "a", "profile.yaml"), "id: a") + mustWriteFile(t, filepath.Join(root, "a", "ignore.txt"), "not yaml") + mustWriteFile(t, filepath.Join(root, "b", "ignore.yaml.bak"), "not yaml") + + got, err := FindYAMLFiles(context.Background(), root) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + want := []string{ + filepath.Join(root, "a", "profile.yaml"), + filepath.Join(root, "z", "prompt.yml"), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected sorted YAML files %v, got %v", want, got) + } +} + +func TestFindYAMLFilesHonorsContextCancellation(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "one.yaml"), "id: one") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := FindYAMLFiles(ctx, root) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestFindFSYAMLFilesNestedSortedAndFiltered(t *testing.T) { + fsys := fstest.MapFS{ + "prompts/z/prompt.yml": &fstest.MapFile{Data: []byte("id: z")}, + "prompts/a/profile.yaml": &fstest.MapFile{Data: []byte("id: a")}, + "prompts/a/ignore.txt": &fstest.MapFile{Data: []byte("not yaml")}, + "prompts/b/ignore.yaml.bak": &fstest.MapFile{Data: []byte("not yaml")}, + "other/ignored.yaml": &fstest.MapFile{Data: []byte("id: ignored")}, + } + + got, err := FindFSYAMLFiles(context.Background(), fsys, " prompts ") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + want := []string{ + "prompts/a/profile.yaml", + "prompts/z/prompt.yml", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected sorted YAML files %v, got %v", want, got) + } +} + +func TestFindFSYAMLFilesHonorsContextCancellation(t *testing.T) { + fsys := fstest.MapFS{ + "one.yaml": &fstest.MapFile{Data: []byte("id: one")}, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := FindFSYAMLFiles(ctx, fsys, ".") + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestRelativePathNested(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "nested", "profiles", "local.yaml") + got := RelativePath(root, path) + want := filepath.Join("nested", "profiles", "local.yaml") + if got != want { + t.Fatalf("expected relative path %q, got %q", want, got) + } +} + +func TestCleanFSRoot(t *testing.T) { + tests := []struct { + name string + root string + want string + }{ + {name: "empty", root: "", want: "."}, + {name: "dot", root: ".", want: "."}, + {name: "trimmed", root: " prompts/../profiles ", want: "profiles"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := CleanFSRoot(tc.root); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestDisplayPath(t *testing.T) { + tests := []struct { + name string + root string + path string + want string + }{ + {name: "root dot", root: ".", path: "profiles/local.yaml", want: "profiles/local.yaml"}, + {name: "nested root", root: "profiles", path: "profiles/local.yaml", want: "local.yaml"}, + {name: "outside root", root: "profiles", path: "other/local.yaml", want: "other/local.yaml"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := DisplayPath(tc.root, tc.path); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestResolveFSPath(t *testing.T) { + tests := []struct { + name string + root string + baseDir string + userPath string + wantPath string + wantDisplay string + wantErr string + }{ + { + name: "sibling inside root", + root: "prompts", + baseDir: "prompts/nested", + userPath: "./messages/user.tmpl", + wantPath: "prompts/nested/messages/user.tmpl", + wantDisplay: "nested/messages/user.tmpl", + }, + { + name: "parent inside root", + root: "prompts", + baseDir: "prompts/nested", + userPath: "../shared/user.tmpl", + wantPath: "prompts/shared/user.tmpl", + wantDisplay: "shared/user.tmpl", + }, + { + name: "escape rejected", + root: "prompts", + baseDir: "prompts/nested", + userPath: "../../outside.tmpl", + wantErr: "escapes source root", + }, + { + name: "absolute path rejected", + root: "prompts", + baseDir: "prompts/nested", + userPath: "/outside.tmpl", + wantErr: "must be relative", + }, + { + name: "empty path rejected", + root: "prompts", + baseDir: "prompts/nested", + userPath: " ", + wantErr: "path is required", + }, + { + name: "dot root allows normal relative path", + root: ".", + baseDir: ".", + userPath: "schemas/events.schema.json", + wantPath: "schemas/events.schema.json", + wantDisplay: "schemas/events.schema.json", + }, + { + name: "dot root rejects parent escape", + root: ".", + baseDir: ".", + userPath: "../outside.tmpl", + wantErr: "escapes source root", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotPath, gotDisplay, err := ResolveFSPath(tc.root, tc.baseDir, tc.userPath) + if tc.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if gotPath != tc.wantPath || gotDisplay != tc.wantDisplay { + t.Fatalf("expected path/display %q/%q, got %q/%q", tc.wantPath, tc.wantDisplay, gotPath, gotDisplay) + } + }) + } +} + +func TestStemStripsYAMLExtensions(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "yaml", in: "prompt.yaml", want: "prompt"}, + {name: "yml", in: "profile.yml", want: "profile"}, + {name: "other", in: "file.txt", want: "file.txt"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Stem(tc.in); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestIsYAMLFile(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {name: "yaml", in: "prompt.yaml", want: true}, + {name: "yml", in: "profile.yml", want: true}, + {name: "backup", in: "profile.yaml.bak", want: false}, + {name: "uppercase", in: "profile.YAML", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsYAMLFile(tc.in); got != tc.want { + t.Fatalf("expected %v, got %v", tc.want, got) + } + }) + } +} + +func mustWriteFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("failed to create directory: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file %q: %v", path, err) + } +}