diff --git a/docs/internal/sources.md b/docs/internal/sources.md index fe7aa3a..aca5b25 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -34,6 +34,15 @@ mutating the document or exposing rejected values. Application path values keep working-directory-relative meaning. ConfigKit owns strict YAML decoding and presence semantics, including dynamic integer values in backend parameters. +The new `Load` boundary selects an explicit file, the first existing implicit +configuration file, or ConfigKit's defaults-only source. Only absence advances +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. + The executable commands still use the existing loader. The new definition is internal preparation and is not yet an alternative user-selectable configuration mode or a change to the current [configuration contract](../config.md). diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 37ce317..25c257b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -147,7 +147,9 @@ policy. Existing executable behavior still passes its tests. ## Stage 2: Build Discovery And The Shared Loading Boundary -**Status: Not started.** +**Status: Complete.** Ordered source discovery, one ConfigKit load, typed +diagnostics, and settings conversion are covered by focused file/default and +override tests. Existing commands still use the old entry point until cutover. Primary files: `internal/config` and its tests; `docs/internal/sources.md`. diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..33f6e1a --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,91 @@ +package config + +import ( + "errors" + "fmt" + "os" + "sort" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" +) + +// LoadOptions selects one application source and explicit configuration values. +type LoadOptions struct { + Path string + Explicit bool + Overlay string + Overrides []configkit.Override +} + +// Load resolves one ConfigKit document without changing the executable's legacy loader. +func Load(options LoadOptions) (*configkit.Result[Document], error) { + var source configkit.Source + if options.Explicit { + source = configkit.File(options.Path) + } else { + selected := "" + for _, candidate := range defaultConfigSearchPaths { + if _, err := os.Stat(candidate); err == nil { + selected = candidate + break + } else if !errors.Is(err, os.ErrNotExist) { + reason := "filesystem error" + if errors.Is(err, os.ErrPermission) { + reason = "permission denied" + } + return nil, fmt.Errorf("cannot inspect configuration path %q: %s", candidate, reason) + } + } + if selected == "" { + source = configkit.NoSource() + } else { + source = configkit.File(selected) + } + } + + result, err := configkit.Load(source, configkit.Options[Document]{ + Definition: documentDefinition(), + Overrides: options.Overrides, + }) + if err != nil { + return nil, fmt.Errorf("load application configuration: %w", err) + } + return result, nil +} + +// Settings converts a validated document without another merge or normalization. +func Settings(document Document) AppSettings { + settings := AppSettings{ + PromptDir: document.PromptDir, + ProfileDir: document.ProfileDir, + SchemaDir: document.SchemaDir, + ServerAddr: document.Server.Addr, + ArtifactRoot: document.Server.ArtifactRoot, + MaxRequestBytes: document.Server.MaxRequestBytes, + MaxArtifactBytes: document.Server.MaxArtifactBytes, + MaxResponseBytes: document.Server.MaxResponseBytes, + DefaultRenderFormat: renderformat.PreparedRunOutputFormat(document.Defaults.RenderFormat), + TransientRetries: document.TransientRetries, + } + if document.Backends != nil { + ids := make([]string, 0, len(document.Backends)) + for id := range document.Backends { + ids = append(ids, id) + } + sort.Strings(ids) + settings.Backends = make([]BackendSettings, 0, len(ids)) + for _, id := range ids { + backend := document.Backends[id] + settings.Backends = append(settings.Backends, BackendSettings{ + ID: id, + Endpoint: backend.Endpoint, + APIKeyEnv: backend.APIKeyEnv, + ExtraParams: backend.ExtraParams, + ConcurrencyLimit: backend.ConcurrencyLimit, + QueueCapacity: backend.QueueCapacity, + }) + } + } + return settings +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..8f80c5c --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,176 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func useConfigSearchPaths(t *testing.T, paths ...string) { + t.Helper() + previous := defaultConfigSearchPaths + defaultConfigSearchPaths = paths + t.Cleanup(func() { defaultConfigSearchPaths = previous }) +} + +func writeApplicationFile(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestLoadSelectsOrderedSourceAndKeepsItsSnapshot(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first.yml") + second := filepath.Join(dir, "second.yml") + writeApplicationFile(t, first, "prompt_dir: ./first\n") + writeApplicationFile(t, second, "prompt_dir: ./second\n") + useConfigSearchPaths(t, first, second) + + result, err := Load(LoadOptions{Path: filepath.Join(dir, "ignored.yml")}) + if err != nil { + t.Fatalf("load first implicit source: %v", err) + } + if result.Config.PromptDir != "first" || len(result.Sources()) != 1 || result.Sources()[0].Path != first { + t.Fatalf("unexpected implicit source: config=%+v sources=%+v", result.Config, result.Sources()) + } + writeApplicationFile(t, first, "prompt_dir: ./changed\n") + if result.Config.PromptDir != "first" { + t.Fatalf("result did not retain resolved snapshot: %+v", result.Config) + } + + if err := os.Remove(first); err != nil { + t.Fatal(err) + } + result, err = Load(LoadOptions{}) + if err != nil || result.Config.PromptDir != "second" || result.Sources()[0].Path != second { + t.Fatalf("second implicit source not selected: result=%+v err=%v", result, err) + } + + result, err = Load(LoadOptions{Path: first, Explicit: true}) + if result != nil || err == nil { + t.Fatalf("missing explicit source was accepted: result=%+v err=%v", result, err) + } + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("typed load error was lost: %v", err) + } +} + +func TestLoadRejectsExplicitEmptyAndDistinguishesEmptyFile(t *testing.T) { + useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml")) + if _, err := Load(LoadOptions{Explicit: true, Path: ""}); err == nil { + t.Fatal("explicit empty path was accepted") + } else { + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("expected upstream source diagnostic, got %v", err) + } + } + + dir := t.TempDir() + empty := filepath.Join(dir, "empty.yml") + writeApplicationFile(t, empty, "") + if _, err := Load(LoadOptions{Path: empty, Explicit: true}); err == nil { + t.Fatal("empty file was treated as defaults-only") + } + writeApplicationFile(t, empty, "{}\n") + result, err := Load(LoadOptions{Path: empty, Explicit: true}) + if err != nil || !reflect.DeepEqual(result.Config, documentDefaults()) { + t.Fatalf("empty mapping did not load defaults: result=%+v err=%v", result, err) + } +} + +func TestLoadDefaultsOnlyAndOverridesBeforeValidation(t *testing.T) { + useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml")) + result, err := Load(LoadOptions{}) + if err != nil || !reflect.DeepEqual(result.Config, documentDefaults()) || len(result.Sources()) != 0 { + t.Fatalf("defaults-only load: result=%+v err=%v", result, err) + } + + result, err = Load(LoadOptions{Overrides: []configkit.Override{{ + Path: configkit.Keys("server", "max_request_bytes"), Value: int64(0), Flag: "--max-request-bytes", + }}}) + if err != nil || result.Config.Server.MaxRequestBytes != 0 || len(result.Sources()) != 1 || result.Sources()[0].Layer != configkit.LayerCLI { + t.Fatalf("defaults-only override lost: result=%+v err=%v", result, err) + } + + path := filepath.Join(t.TempDir(), "invalid.yml") + writeApplicationFile(t, path, "defaults:\n render_format: invalid-choice\n") + result, err = Load(LoadOptions{Path: path, Explicit: true, Overrides: []configkit.Override{{ + Path: configkit.Keys("defaults", "render_format"), Value: "json", Flag: "--render-format", + }}}) + if err != nil || result.Config.Defaults.RenderFormat != "json" { + t.Fatalf("override did not precede validation: result=%+v err=%v", result, err) + } + if len(result.Sources()) != 2 || result.Sources()[0].Layer != configkit.LayerBase || result.Sources()[1].Layer != configkit.LayerCLI { + t.Fatalf("source order changed: %+v", result.Sources()) + } + writeApplicationFile(t, path, "defaults: [\n") + if _, err := Load(LoadOptions{Path: path, Explicit: true, Overrides: []configkit.Override{{ + Path: configkit.Keys("defaults", "render_format"), Value: "json", Flag: "--render-format", + }}}); err == nil { + t.Fatal("override bypassed malformed lower-level YAML") + } +} + +func TestSettingsCopiesResolvedValuesAndSortsBackends(t *testing.T) { + result, err := loadDocumentBytes(`schema_dir: "" +transient_retries: 0 +server: + addr: "" + artifact_root: "" + max_request_bytes: 0 +defaults: + render_format: json +backends: + zed: + endpoint: http://zed.invalid/v1 + extra_params: + count: 4 + alpha: + queue_capacity: 0 +`) + if err != nil { + t.Fatal(err) + } + settings := Settings(result.Config) + if settings.SchemaDir != "" || settings.ServerAddr != "" || settings.MaxRequestBytes != 0 || settings.DefaultRenderFormat != "json" { + t.Fatalf("resolved scalar values changed: %+v", settings) + } + if settings.TransientRetries == nil || *settings.TransientRetries != 0 { + t.Fatalf("retry presence lost: %+v", settings.TransientRetries) + } + if len(settings.Backends) != 2 || settings.Backends[0].ID != "alpha" || settings.Backends[1].ID != "zed" { + t.Fatalf("backend order changed: %+v", settings.Backends) + } + if settings.Backends[0].QueueCapacity == nil || *settings.Backends[0].QueueCapacity != 0 || settings.Backends[1].QueueCapacity != nil { + t.Fatalf("queue presence changed: %+v", settings.Backends) + } + if value := settings.Backends[1].ExtraParams["count"]; value != int64(4) { + t.Fatalf("dynamic number changed shape: %T %v", value, value) + } + + if got := Settings(documentDefaults()); got.TransientRetries != nil || got.Backends != nil { + t.Fatalf("default nullable values changed: %+v", got) + } +} + +func TestLoadDiscoveryErrorQuotesPath(t *testing.T) { + dir := t.TempDir() + loop := filepath.Join(dir, "loop-\n.yml") + if err := os.Symlink(loop, loop); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + useConfigSearchPaths(t, loop) + _, err := Load(LoadOptions{}) + if err == nil || !strings.Contains(err.Error(), `\n`) || strings.Contains(err.Error(), "loop-\n.yml") { + t.Fatalf("discovery error did not quote path safely: %v", err) + } +}