diff --git a/docs/cli.md b/docs/cli.md index 8551145..be896ce 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -26,7 +26,11 @@ go run ./cmd/scriptorium render \ - `scriptorium inspect prompt`: inspect one prompt definition without model execution. - `scriptorium inspect profile`: inspect one effective profile without model execution. -All commands accept `--config ` and reject positional arguments. `run`, +All commands accept `--config ` and `--config-overlay ` and reject +positional arguments. `--config-overlay` selects one application configuration +overlay; it is separate from `--profile`, which selects a Promptkit execution +profile. See [configuration composition](config.md#explicit-imports-and-selected-overlays) +for name, root, and no-base behavior. `run`, `render`, `serve`, and `inspect prompt` require an effective `prompt_dir`; `inspect profile` does not. Supply the directory through the configuration contract or a supported `--prompt-dir` flag. Explicit application flags, @@ -51,6 +55,7 @@ Optional flags: | Flag | Meaning | | --- | --- | | `--config ` | Application configuration file. | +| `--config-overlay ` | Selected application configuration overlay. | | `--prompt-dir ` | Prompt-definition directory override. | | `--profile-dir ` | Custom profile-directory override. | | `--schema-dir ` | Schema base-directory override. | @@ -148,7 +153,7 @@ scriptorium render [flags] ``` `--prompt ` is required. The following optional flags are supported: -`--config`, `--prompt-dir`, `--profile-dir`, `--schema-dir`, `--prompt-version`, `--profile`, +`--config`, `--config-overlay`, `--prompt-dir`, `--profile-dir`, `--schema-dir`, `--prompt-version`, `--profile`, `--input`, `--var`, `--out`, `--llm-base-url`, `--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`, `--reasoning-effort`, `--session-id`, `--timeout`, `--transient-retries`, and `--format text|json`. Their meanings match the corresponding @@ -174,6 +179,7 @@ Optional flags: | Flag | Meaning | | --- | --- | | `--config ` | Application configuration file. | +| `--config-overlay ` | Selected application configuration overlay. | | `--addr ` | HTTP listen-address override. | | `--prompt-dir ` | Prompt-definition directory override. | | `--profile-dir ` | Custom profile-directory override. | @@ -190,7 +196,7 @@ schemas, and error codes are defined in the [HTTP API reference](api.md). ```text scriptorium inspect prompt --prompt ID [--prompt-version VERSION] - [--config PATH] [--prompt-dir DIR] [--format text|json] [--out PATH] + [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out PATH] ``` `--prompt` is required. Inspection uses normal configuration discovery and a @@ -203,7 +209,7 @@ template, reserve backend capacity, or call a model. ```text scriptorium inspect profile --profile ID - [--config PATH] [--profile-dir DIR] [--format text|json] [--out PATH] + [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out PATH] ``` `--profile` is required; built-in profiles need no prompt directory. Inspection diff --git a/docs/config.md b/docs/config.md index 4f86b7f..463cbe2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -20,8 +20,9 @@ When `--config` is omitted, Scriptorium searches If neither exists, it uses built-in defaults. An explicit `--config` path must exist and decode successfully. -The maintained [minimal configuration](../examples/config.yml) and -[complete configuration](../examples/config.full.yml) are copyable examples. +The maintained [minimal configuration](../examples/config.yml), +[complete configuration](../examples/config.full.yml), and +[composed configuration](../examples/config.composed.yml) are copyable examples. ## Application Configuration File @@ -31,8 +32,7 @@ An explicit empty string is a value: it clears a file-provided optional path or, for required `prompt_dir`, fails the command's prerequisite. A scalar `null` is not an empty string and is rejected for scalar fields; nullable fields such as `transient_retries` retain their documented meaning. Raw -API-key fields are not accepted. Imports and selected overlays are not yet -enabled for application configuration. +API-key fields are not accepted. ConfigKit applies defaults, the selected file, then only explicitly supplied application CLI flags before validating the result. Paths in the application @@ -40,6 +40,43 @@ document remain relative to the process working directory, not to the file's directory. Load diagnostics are printed safely on stderr; invalid application configuration exits with status 1. +### Explicit imports and selected overlays + +A file-backed configuration may declare `configkit.imports` with relative +`path` or `glob` entries. Each entry is explicit; Scriptorium does not scan +for fragments on its own. ConfigKit composes the selected file and imports by +conflict, so two fragments cannot define the same field merely to establish +precedence. A required missing import fails; an entry may use +`optional: true` to suppress target nonexistence. Imports are resolved from +the declaring file's canonical directory and remain within the selected +file's canonical directory by default. Scriptorium does not widen that root. + +`--config-overlay NAME` selects one file at `overlays/NAME.yml` below that +root. The name must use letters, digits, `_`, or `-`; it is not a path. Empty +and exact lowercase `default` select no overlay. There is no fallback, +case folding, inherited stack, or automatic selection; a missing selected +overlay fails. ConfigKit merges mapping fields and replaces other values, +including sequences and explicit nulls, before applying explicit CLI flags. +An active overlay needs a selected base file. No-source defaults-only loads +cannot use imports or an active overlay. + +The [composed example](../examples/config.composed.yml) imports two fragments +and has a selectable [local overlay](../examples/overlays/local.yml). From the +repository root: + +```bash +go run ./cmd/scriptorium render \ + --config ./examples/config.composed.yml --config-overlay local \ + --prompt generic.markdown_summary \ + --input transcript=./examples/fixtures/transcript.md \ + --input glossary=./examples/fixtures/glossary.yml +``` + +Import and overlay file locations are constrained by ConfigKit; +*application path values* such as `prompt_dir` remain relative +to the process working directory even when authored in a fragment or overlay. +These source constraints are separate from HTTP artifact containment. + | Field | Default | Meaning | | --- | --- | --- | | `prompt_dir` | unset | Promptkit prompt-definition source directory. `run`, `render`, `serve`, and `inspect prompt` require an effective value. | diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 5d2fba1..00531f7 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -40,8 +40,10 @@ implicit discovery. One ConfigKit load applies explicit application overrides before validation and returns a result containing the resolved document and its frozen inspection records. `Settings` converts that document into the existing runtime settings with backend IDs sorted; it does not read files or -apply another merge. Imports and selected overlays remain disabled until their -own user-facing integration is ready. +apply another merge. File-backed loads enable explicit ConfigKit imports and +select one named overlay when requested. ConfigKit uses the selected file's +canonical directory as the source root; the application does not widen it. +Application path values retain process-working-directory-relative meaning. Every configuration-loading command uses this boundary. The CLI maps only explicitly visited application flags to ConfigKit overrides and prints diff --git a/docs/operations.md b/docs/operations.md index a25d93b..b83387d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -44,6 +44,14 @@ schema sources that follow the tagged framework formats. For an HTTP deployment that accepts file artifacts, use a dedicated, narrow artifact directory rather than a general-purpose or sensitive filesystem tree. +If application configuration uses explicit imports or a selected overlay, +deploy those files beneath the selected configuration file's canonical +directory. ConfigKit keeps imported and overlay files within that root; +Scriptorium does not widen it. Application path values inside those files still +resolve from the process working directory, so set that directory deliberately +in the service manager. This configuration-source boundary is distinct from +the HTTP artifact root and does not constrain prompt or schema directories. + When upgrading existing definitions, review selected profile IDs. `deepseek-3-2`, `deepseek-4-flash`, and `deepseek-4-pro` were removed from the selected catalog. Choose an available catalog profile, one of Promptkit's diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c5bed84..2132915 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -277,7 +277,11 @@ not yet advertised. ## Stage 4: Enable Explicit Imports And Selected Configuration Overlays -**Status: Not started.** +**Status: Complete.** File-backed loads support explicit imports and one +selected overlay under the selected file's canonical directory. All five +existing command modes accept `--config-overlay`; real-file composition and a +render workflow are tested. The composed example and contracts are updated; +full test, race, vet, build, and diff checks passed. Primary files: shared loader, CLI source-selection wiring, configuration and CLI tests, maintained configuration examples. diff --git a/examples/config-fragments/backend.yml b/examples/config-fragments/backend.yml new file mode 100644 index 0000000..3f3e4e7 --- /dev/null +++ b/examples/config-fragments/backend.yml @@ -0,0 +1,4 @@ +backends: + local-gpu: + endpoint: http://localhost:11434/v1 + api_key_env: LOCAL_GPU_API_KEY diff --git a/examples/config-fragments/sources.yml b/examples/config-fragments/sources.yml new file mode 100644 index 0000000..36e3b22 --- /dev/null +++ b/examples/config-fragments/sources.yml @@ -0,0 +1,3 @@ +prompt_dir: ./examples/prompts +profile_dir: ./examples/profiles +schema_dir: ./examples/schemas diff --git a/examples/config.composed.yml b/examples/config.composed.yml new file mode 100644 index 0000000..279fc26 --- /dev/null +++ b/examples/config.composed.yml @@ -0,0 +1,8 @@ +# Select from the repository root; application paths remain working-directory-relative. +configkit: + imports: + - path: config-fragments/sources.yml + - path: config-fragments/backend.yml + +server: + addr: :8080 diff --git a/examples/overlays/local.yml b/examples/overlays/local.yml new file mode 100644 index 0000000..3c20ccb --- /dev/null +++ b/examples/overlays/local.yml @@ -0,0 +1,4 @@ +defaults: + render_format: json +server: + addr: 127.0.0.1:8080 diff --git a/internal/adapter/cli/application_config.go b/internal/adapter/cli/application_config.go index 04fcf32..91d4e86 100644 --- a/internal/adapter/cli/application_config.go +++ b/internal/adapter/cli/application_config.go @@ -42,9 +42,9 @@ func applicationOverrides(fs *flag.FlagSet) []configkit.Override { return overrides } -func loadApplicationSettings(configPath string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) { +func loadApplicationSettings(configPath, overlay string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) { result, err := appconfig.Load(appconfig.LoadOptions{ - Path: configPath, Explicit: explicit, Overrides: overrides, + Path: configPath, Overlay: overlay, Explicit: explicit, Overrides: overrides, }) if err != nil { return appconfig.AppSettings{}, nil, err diff --git a/internal/adapter/cli/inspect.go b/internal/adapter/cli/inspect.go index e9af707..e74a777 100644 --- a/internal/adapter/cli/inspect.go +++ b/internal/adapter/cli/inspect.go @@ -17,6 +17,7 @@ import ( type promptInspectionConfig struct { configPath, promptDir, promptID, promptVersion, outputPath string + configOverlay string configExplicit bool outputFormat appformat.OutputFormat overrides []configkit.Override @@ -25,6 +26,7 @@ type promptInspectionConfig struct { type profileInspectionConfig struct { configPath, profileDir, profileID, outputPath string + configOverlay string configExplicit bool outputFormat appformat.OutputFormat overrides []configkit.Override @@ -127,7 +129,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error) cfg := &profileInspectionConfig{outputFormat: appformat.DefaultOutputFormat} fs := flag.NewFlagSet("inspect profile", flag.ContinueOnError) fs.SetOutput(io.Discard) - registerConfigPathFlag(fs, &cfg.configPath) + registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay) fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profiles") fs.StringVar(&cfg.profileID, "profile", "", "profile ID to inspect") rawFormat := "" @@ -156,7 +158,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error) } func resolveAppSettingsForProfileInspection(cfg *profileInspectionConfig) (engineSettings, error) { - settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides) + settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configOverlay, cfg.configExplicit, cfg.overrides) if err != nil { return engineSettings{}, err } @@ -168,7 +170,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) { cfg := &promptInspectionConfig{outputFormat: appformat.DefaultOutputFormat} fs := flag.NewFlagSet("inspect prompt", flag.ContinueOnError) fs.SetOutput(io.Discard) - registerConfigPathFlag(fs, &cfg.configPath) + registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay) fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definitions") fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to inspect") fs.StringVar(&cfg.promptVersion, "prompt-version", "", "optional prompt version") @@ -198,7 +200,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) { } func resolveAppSettingsForPromptInspection(cfg *promptInspectionConfig) (engineSettings, error) { - settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides) + settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configOverlay, cfg.configExplicit, cfg.overrides) if err != nil { return engineSettings{}, err } diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index 96de21f..bdce241 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -59,6 +59,7 @@ type runConfig struct { transientRetries int configuredTransientRetries *int diagnostics []configkit.Diagnostic + configOverlay string defaultRenderFormat renderformat.PreparedRunOutputFormat @@ -92,6 +93,7 @@ type serveConfig struct { backends []appconfig.BackendSettings transientRetries *int diagnostics []configkit.Diagnostic + configOverlay string } type commonCommandSettings struct { @@ -306,7 +308,7 @@ func parseRunArgs(args []string) (*runConfig, error) { fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) - registerConfigPathFlag(fs, &cfg.configPath) + registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay) registerExecutionRequestFlags(fs, cfg) fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas") fs.DurationVar(&cfg.runTimeout, "run-timeout", 0, "whole-run execution deadline") @@ -363,7 +365,7 @@ func parseRenderArgs(args []string) (*renderConfig, error) { fs := flag.NewFlagSet("render", flag.ContinueOnError) fs.SetOutput(io.Discard) - registerConfigPathFlag(fs, &cfg.configPath) + registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay) registerExecutionRequestFlags(fs, &cfg.runConfig) fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas") @@ -394,7 +396,7 @@ func parseServeArgs(args []string) (*serveConfig, error) { fs := flag.NewFlagSet("serve", flag.ContinueOnError) fs.SetOutput(io.Discard) - registerConfigPathFlag(fs, &cfg.configPath) + registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay) fs.StringVar(&cfg.addr, "addr", "", "HTTP listen address") fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files") fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files") @@ -411,7 +413,7 @@ func parseServeArgs(args []string) (*serveConfig, error) { return nil, fmt.Errorf("unexpected positional args: %v", fs.Args()) } - settings, err := resolveCommonSettings(fs, cfg.configPath) + settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay) if err != nil { return nil, err } @@ -463,7 +465,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { return fmt.Errorf("unexpected positional args: %v", fs.Args()) } - settings, err := resolveCommonSettings(fs, cfg.configPath) + settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay) if err != nil { return err } @@ -500,7 +502,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { return nil } -func registerConfigPathFlag(fs *flag.FlagSet, target *string) { +func registerConfigSelectionFlags(fs *flag.FlagSet, target, overlay *string) { fs.StringVar( target, "config", @@ -511,10 +513,11 @@ func registerConfigPathFlag(fs *flag.FlagSet, target *string) { appconfig.DefaultConfigPath, ), ) + fs.StringVar(overlay, "config-overlay", "", "selected application configuration overlay") } -func resolveCommonSettings(fs *flag.FlagSet, configPath string) (commonCommandSettings, error) { - settings, diagnostics, err := loadApplicationSettings(configPath, flagWasSet(fs, "config"), applicationOverrides(fs)) +func resolveCommonSettings(fs *flag.FlagSet, configPath, overlay string) (commonCommandSettings, error) { + settings, diagnostics, err := loadApplicationSettings(configPath, overlay, flagWasSet(fs, "config"), applicationOverrides(fs)) if err != nil { return commonCommandSettings{}, err } @@ -750,9 +753,9 @@ func runErrorMessage(err error) string { func printUsage(w io.Writer) { fmt.Fprintln(w, "usage: scriptorium ...") - fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]") - fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]") - fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault) - fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--prompt-dir DIR] [--format text|json] [--out path]") - fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--profile-dir DIR] [--format text|json] [--out path]") + fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]") + fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]") + fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--config-overlay NAME] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault) + fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out path]") + fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out path]") } diff --git a/internal/adapter/cli/run_test.go b/internal/adapter/cli/run_test.go index 212f238..a65d844 100644 --- a/internal/adapter/cli/run_test.go +++ b/internal/adapter/cli/run_test.go @@ -1071,6 +1071,41 @@ profile_dir: %s } } +func TestRenderCommandLoadsImportsAndOverlayWithoutChangingExecutionProfile(t *testing.T) { + lib := newCLITestLibrary(t) + inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript") + writePromptFile(t, lib.promptDir, "prompt.render", "local-default") + writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") + if err := os.MkdirAll(filepath.Join(lib.rootDir, "fragments"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(lib.rootDir, "overlays"), 0o755); err != nil { + t.Fatal(err) + } + base := filepath.Join(lib.rootDir, "config.yml") + for path, body := range map[string]string{ + base: "configkit:\n imports:\n - path: fragments/sources.yml\n", + filepath.Join(lib.rootDir, "fragments", "sources.yml"): fmt.Sprintf("prompt_dir: %q\nprofile_dir: %q\n", lib.promptDir, lib.profileDir), + filepath.Join(lib.rootDir, "overlays", "local.yml"): "defaults:\n render_format: json\n", + } { + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + args := []string{"--config", base, "--config-overlay", "local", "--profile", "local-default", "--prompt", "prompt.render", "--input", "transcript=" + inputPath} + cfg, err := parseRenderArgs(args) + if err != nil { + t.Fatal(err) + } + if cfg.profileID != "local-default" || cfg.outputFormat != renderformat.PreparedRunFormatJSON { + t.Fatalf("overlay changed execution profile or output selection: %+v", cfg) + } + code, stdout, stderr := runCLICommand(t, renderCommand, args) + if code != ExitOK || !strings.Contains(stdout, `"selected_profile_id": "local-default"`) { + t.Fatalf("render did not use composed config and independent execution profile: code=%d out=%q err=%q", code, stdout, stderr) + } +} + func TestRenderCommandUsesConfiguredCustomBackend(t *testing.T) { lib := newCLITestLibrary(t) writePromptDefinition(t, lib.promptDir, "custom.yaml", `id: custom diff --git a/internal/config/load.go b/internal/config/load.go index b60ab10..3207bef 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -21,8 +21,10 @@ type LoadOptions struct { // Load resolves one ConfigKit document and retains its inspection snapshot. func Load(options LoadOptions) (*configkit.Result[Document], error) { var source configkit.Source + fileBacked := false if options.Explicit { source = configkit.File(options.Path) + fileBacked = true } else { selected := "" for _, candidate := range defaultConfigSearchPaths { @@ -41,11 +43,14 @@ func Load(options LoadOptions) (*configkit.Result[Document], error) { source = configkit.NoSource() } else { source = configkit.File(selected) + fileBacked = true } } result, err := configkit.Load(source, configkit.Options[Document]{ Definition: documentDefinition(), + Imports: fileBacked, + Overlay: options.Overlay, Overrides: options.Overrides, }) if err != nil { diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 8f80c5c..783e180 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -174,3 +174,56 @@ func TestLoadDiscoveryErrorQuotesPath(t *testing.T) { t.Fatalf("discovery error did not quote path safely: %v", err) } } + +func TestLoadComposesExplicitImportsAndSelectedOverlay(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "fragments"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0o700); err != nil { + t.Fatal(err) + } + base := filepath.Join(dir, "config.yml") + writeApplicationFile(t, base, "configkit:\n imports:\n - glob: fragments/*.yml\nprompt_dir: ./base-prompts\nserver:\n addr: :8081\n") + writeApplicationFile(t, filepath.Join(dir, "fragments", "a.yml"), "backends:\n alpha:\n endpoint: http://alpha.invalid/v1\n") + writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n beta:\n endpoint: http://beta.invalid/v1\n") + writeApplicationFile(t, filepath.Join(dir, "overlays", "local.yml"), "prompt_dir: ./overlay-prompts\nserver:\n addr: :9090\n") + + result, err := Load(LoadOptions{Path: base, Explicit: true}) + if err != nil || result.Config.PromptDir != "base-prompts" || len(result.Config.Backends) != 2 || len(result.Sources()) != 3 { + t.Fatalf("disjoint imports did not compose: result=%+v err=%v", result, err) + } + result, err = Load(LoadOptions{Path: base, Explicit: true, Overlay: "local", Overrides: []configkit.Override{{ + Path: configkit.Keys("server", "addr"), Value: ":7070", Flag: "--addr", + }}}) + if err != nil || result.Config.PromptDir != "overlay-prompts" || result.Config.Server.Addr != ":7070" || len(result.Sources()) != 5 { + t.Fatalf("overlay or explicit override changed: result=%+v err=%v", result, err) + } + if _, err := Load(LoadOptions{Path: base, Explicit: true, Overlay: "missing"}); err == nil { + t.Fatal("missing selected overlay accepted") + } + writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n alpha:\n endpoint: http://conflict.invalid/v1\n") + if _, err := Load(LoadOptions{Path: base, Explicit: true}); err == nil { + t.Fatal("conflicting import accepted") + } else { + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("conflict lost structured diagnostic: %v", err) + } + } +} + +func TestLoadRejectsOverlayWithoutBase(t *testing.T) { + useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml")) + if _, err := Load(LoadOptions{Overlay: "local"}); err == nil { + t.Fatal("overlay without base accepted") + } else { + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("no-base overlay lost structured diagnostic: %v", err) + } + } + if _, err := Load(LoadOptions{Overlay: "default"}); err != nil { + t.Fatalf("default must select no overlay: %v", err) + } +}