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) } var effective strings.Builder if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "render_format: text") || len(result.Sources()) != 0 { t.Fatalf("defaults-only inspection changed: effective=%q err=%v sources=%+v", effective.String(), err, result.Sources()) } 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) } } 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) } }