From 4f2c113237b7595910956e74bf45b777640c758a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 24 Oct 2026 16:20:53 +0000 Subject: [PATCH] Extend discovery coverage across consumer workflows --- docs/roadmap/implementation.md | 4 +- examples/consumers/discovery_test.go | 45 ++++++++++++++ examples/consumers/sequence_catalog.go | 23 +++++-- facade_discovery_test.go | 86 ++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 examples/consumers/discovery_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9a8fc9c..6915f5b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Configuration Discovery Implementation Plan -Status: in progress. Stages 1, 2, 3, 4, 5, 6, and 7 are complete. +Status: in progress. Stages 1, 2, 3, 4, 5, 6, 7, and 8 are complete. Implement the target behavior in [the discovery roadmap](discovery.md). That document owns the selected conventions and OD-1 through OD-5; this plan owns the @@ -380,7 +380,7 @@ Acceptance: consumers can use the feature entirely through the root package. Run focused facade discovery/context tests, public/dependency boundary tests, and the everyday check. Mark and commit. -## Stage 8 — Complete Cross-Feature Coverage And Consumer Examples +## Stage 8 — Complete Cross-Feature Coverage And Consumer Examples (Complete) 1. Add a small maintained consumer example/test for a simple explicitly selected preset with test-controlled directories, and another for custom-first search diff --git a/examples/consumers/discovery_test.go b/examples/consumers/discovery_test.go new file mode 100644 index 0000000..6766b55 --- /dev/null +++ b/examples/consumers/discovery_test.go @@ -0,0 +1,45 @@ +package consumers_test + +import ( + "fmt" + "os" + "path/filepath" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +type workingDirectoryPresetConfig struct { + Name string `yaml:"name"` +} + +func Example_workingDirectoryPreset() { + original, err := os.Getwd() + if err != nil { + fmt.Println(err) + return + } + defer func() { _ = os.Chdir(original) }() + root, err := os.MkdirTemp("", "configkit-working-search-") + if err != nil { + fmt.Println(err) + return + } + defer os.RemoveAll(root) + if err := os.Chdir(root); err != nil { + fmt.Println(err) + return + } + if err := os.WriteFile(filepath.Join(root, "service.yml"), []byte("name: example\n"), 0o600); err != nil { + fmt.Println(err) + return + } + result, err := configkit.Load[workingDirectoryPresetConfig]( + configkit.SearchConfig("service", configkit.WorkingDirectory()), + ) + if err != nil { + fmt.Println(err) + return + } + fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path)) + // Output: example service.yml +} diff --git a/examples/consumers/sequence_catalog.go b/examples/consumers/sequence_catalog.go index 3685e82..4604c5c 100644 --- a/examples/consumers/sequence_catalog.go +++ b/examples/consumers/sequence_catalog.go @@ -31,13 +31,23 @@ type SequenceCatalogEntry struct { Credential configkit.Secret `yaml:"credential"` } -// LoadSequenceCatalog assembles imported main fragments over a file-backed -// underlay, applies one selected collection overlay, then resolves and validates -// the resulting application configuration. +// LoadSequenceCatalog searches its caller-selected main path before a custom +// fallback directory, assembles imported fragments over a file-backed underlay, +// applies one selected collection overlay, then resolves and validates once. func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) { - root := filepath.Dir(mainPath) + absoluteMain, err := filepath.Abs(mainPath) + if err != nil { + return nil, err + } + root := filepath.Dir(absoluteMain) filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root)) filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem} + mainSearch := configkit.SearchConfig("", configkit.Searches( + configkit.Paths(absoluteMain), + configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{ + Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))}, + }), + ), configkit.SearchOptions{Required: true}) definition := configkit.Definition[SequenceCatalogConfig]{ SequenceIdentities: []configkit.SequenceIdentity{ configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"), @@ -51,8 +61,9 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res Validate: validateSequenceCatalog, } return configkit.LoadLayered(configkit.Input{ - Source: filesystem.File(filepath.Base(mainPath)), - Imports: true, + Source: mainSearch, + Imports: true, + Filesystem: &configkit.FilesystemContext{Root: root}, }, configkit.LayeredOptions[SequenceCatalogConfig]{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{ Source: filesystem.File("underlay.yml"), diff --git a/facade_discovery_test.go b/facade_discovery_test.go index da47a30..78f3fc0 100644 --- a/facade_discovery_test.go +++ b/facade_discovery_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "testing/fstest" ) @@ -14,6 +15,19 @@ type facadeDiscoveryConfig struct { Settings map[string]string `yaml:"settings"` } +type facadeDiscoveryLifecycleConfig struct { + Value string `yaml:"value"` + Resolved string `yaml:"resolved"` +} + +type facadeDiscoveryPlacementConfig struct { + Items []facadeDiscoveryPlacementItem `yaml:"items"` +} + +type facadeDiscoveryPlacementItem struct { + Name string `yaml:"name"` +} + func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) { original, err := os.Getwd() if err != nil { @@ -207,6 +221,78 @@ func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) { } } +func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *testing.T) { + t.Parallel() + + root := t.TempDir() + selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n") + writeFacadeImportFile(t, root, "later.yml", "value: later\n") + defaultsCalls, resolveCalls, validateCalls := 0, 0, 0 + result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{ + Definition: Definition[facadeDiscoveryLifecycleConfig]{ + Defaults: func() facadeDiscoveryLifecycleConfig { + defaultsCalls++ + return facadeDiscoveryLifecycleConfig{} + }, + Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error { + resolveCalls++ + configuration.Resolved = "from:" + configuration.Value + resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value")) + return nil + }, + Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ }, + }, + }) + if err != nil || result.Config.Value != "selected" || result.Config.Resolved != "from:selected" { + t.Fatalf("search lifecycle result = %#v, %v", result, err) + } + if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 { + t.Fatalf("search callbacks ran defaults=%d resolve=%d validate=%d times", defaultsCalls, resolveCalls, validateCalls) + } + sources := result.Sources() + if len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) || sources[0].ID != "source-1" { + t.Fatalf("selected source evidence = %#v", sources) + } + valueInfo, found := result.Metadata().Lookup(Keys("value")) + if !found || len(valueInfo.Origin.Locations) != 1 || valueInfo.Origin.Locations[0].SourceID != sources[0].ID { + t.Fatalf("selected value metadata = %#v, %v", valueInfo, found) + } + var rendered strings.Builder + if err := result.WriteEffective(&rendered); err != nil || !strings.Contains(rendered.String(), "value: selected") || strings.Contains(rendered.String(), "later") { + t.Fatalf("effective output = %q, %v", rendered.String(), err) + } +} + +func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) { + t.Parallel() + + root := t.TempDir() + selectedPath := writeFacadeImportFile(t, root, "item.yml", "name: selected\n") + options := Options[facadeDiscoveryPlacementConfig]{ + Destination: Keys("items"), + PlacementMode: PlacementModeSequenceItem, + } + selected, err := Load(SearchConfig("", Paths(selectedPath)), options) + if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" { + t.Fatalf("selected sequence item = %#v, %v", selected, err) + } + if sources := selected.Sources(); len(sources) != 1 || sources[0].Destination.String() != "/items" || sources[0].PlacementMode != PlacementModeSequenceItem { + t.Fatalf("selected sequence-item source = %#v", sources) + } + + defaults := func() facadeDiscoveryPlacementConfig { + return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}} + } + absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{ + Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults}, + Destination: options.Destination, + PlacementMode: options.PlacementMode, + }) + if err != nil || len(absent.Config.Items) != 0 || len(absent.Sources()) != 0 { + t.Fatalf("absent search created a sequence item: %#v, %v", absent, err) + } +} + func hasFacadeDiagnostic(err *LoadError, code Code) bool { for _, diagnostic := range err.Diagnostics() { if diagnostic.Code == code {