From 2d93a5c703646185c9a2e06f4e73d9beb29d0a30 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 24 Oct 2026 16:16:07 +0000 Subject: [PATCH] Expose configuration search through the public facade --- discovery.go | 90 +++++++++++++ docs/config.md | 74 +++++++++++ docs/roadmap/implementation.md | 4 +- facade_discovery_test.go | 230 +++++++++++++++++++++++++++++++++ source.go | 3 +- 5 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 discovery.go create mode 100644 facade_discovery_test.go diff --git a/discovery.go b/discovery.go new file mode 100644 index 0000000..a3f003d --- /dev/null +++ b/discovery.go @@ -0,0 +1,90 @@ +package configkit + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" + +// Search is an immutable ordered description of possible host-file inputs. +// It is consumed only when passed to SearchConfig and used by a load. +type Search struct{ value engine.Search } + +// FilenameGroup selects filenames in order for a directory search. +// Construct groups with NamedFilenames, GenericFilenames, or CustomFilenames. +type FilenameGroup struct{ value engine.FilenameGroup } + +// SearchOptions controls whether a search must select a file. +type SearchOptions struct { + // Required reports a missing-source error if the complete search is exhausted. + Required bool +} + +// DirectorySearchOptions selects the filename groups used by SearchDirectory. +type DirectorySearchOptions struct { + // Filenames concatenates groups in order. A nil slice uses Named then Generic; + // a non-nil empty slice selects no filenames. + Filenames []FilenameGroup +} + +// NamedFilenames selects .yml followed by .yaml. +func NamedFilenames() FilenameGroup { return FilenameGroup{value: engine.NamedFilenames()} } + +// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml. +func GenericFilenames() FilenameGroup { return FilenameGroup{value: engine.GenericFilenames()} } + +// CustomFilenames selects literal filename components in the supplied order. +// Names cannot be empty or contain a directory component. +func CustomFilenames(names ...string) FilenameGroup { + return FilenameGroup{value: engine.CustomFilenames(names...)} +} + +// SearchDirectory describes a search within directory. Omitted options or a nil +// Filenames slice uses Named then Generic groups. Explicitly empty Filenames +// selects no names and does not resolve the directory base. +func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search { + options := make([]engine.DirectorySearchOptions, len(supplied)) + for index, option := range supplied { + if option.Filenames == nil { + continue + } + options[index].Filenames = make([]engine.FilenameGroup, len(option.Filenames)) + for groupIndex, group := range option.Filenames { + options[index].Filenames[groupIndex] = group.value + } + } + return Search{value: engine.SearchDirectory(directory, options...)} +} + +// Paths describes literal host-file candidates in the supplied order. +func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} } + +// Searches concatenates descriptions in order and snapshots their entries. +func Searches(searches ...Search) Search { + values := make([]engine.Search, len(searches)) + for index, search := range searches { + values[index] = search.value + } + return Search{value: engine.Searches(values...)} +} + +// UnixSystemDirectories describes the conventional Unix system directories. +// Loading on an unsupported platform fails if this preset is reached. +func UnixSystemDirectories() Search { return Search{value: engine.UnixSystemDirectories()} } + +// UserConfigDirectory describes searches rooted at the Go user-config directory. +func UserConfigDirectory() Search { return Search{value: engine.UserConfigDirectory()} } + +// HomeDirectory describes searches rooted at the Go home directory. +func HomeDirectory() Search { return Search{value: engine.HomeDirectory()} } + +// WorkingDirectory describes application-named files in the current directory. +func WorkingDirectory() Search { return Search{value: engine.WorkingDirectory()} } + +// SearchConfig creates a source that selects the first matching host file when +// consumed by Load or LoadLayered. Without Required, exhausting the search uses +// the absent-input lifecycle. Description and option errors are reported while +// loading. +func SearchConfig(application string, search Search, supplied ...SearchOptions) Source { + options := make([]engine.SearchOptions, len(supplied)) + for index, option := range supplied { + options[index] = engine.SearchOptions{Required: option.Required} + } + return Source{value: engine.SearchConfig(application, search.value, options...)} +} diff --git a/docs/config.md b/docs/config.md index 3c09ae8..a331e05 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,6 +12,7 @@ ConfigKit exposes opaque sources through these constructors: | Constructor | Current contract | | --- | --- | | `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. | +| `SearchConfig(application, search, options...)` | Creates an explicit, reusable search source. Loading selects one host file or follows the absent-input lifecycle when an optional search is exhausted. | | `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. | | `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. | | `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. | @@ -28,6 +29,79 @@ Keys are sorted for stable provenance, integer decimal spelling stays exact, and cycles or unsupported types are rejected without exposing values. Mapping input uses the same directive and version rules as YAML, not CLI override restrictions. +### Configuration discovery + +Discovery is opt-in. Construct `Search` from literal `Paths`, directory searches, +or the supported directory presets, then pass it to `SearchConfig`. Search +descriptions are immutable and reusable; each load resolves dynamic directories +and checks current candidate files again. `Searches` is ordered concatenation. +It preserves the supplied description order and does not deduplicate candidates. + +`NamedFilenames` selects `.yml`, then `.yaml`. +`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then +`main.yaml`. `SearchDirectory` defaults to `[Named, Generic]` when options are +omitted or `Filenames` is nil. A non-nil empty filename list contributes no +candidates and does not resolve its directory. `CustomFilenames` supplies +literal single filename components; it does not expand application-name +placeholders. An application slug is required only when a selected directory or +filename group uses it. Literal custom paths are not expanded for `~`, variables, +or wildcards; relative paths are resolved from the working directory at load time. +The zero `Search`, `Searches()`, `Paths()`, and an empty custom filename group +contribute no candidates. An explicitly supplied empty path or filename is +invalid. Slugs and custom filenames must be usable single host filename +components; ConfigKit preserves their case and spelling. + +Search expansion is ordered concatenation. For each directory search, ConfigKit +tries its filename groups and names before advancing to the next directory. +When multiple directories use the same filename list, candidates follow the +directory-first Cartesian product: every filename in one directory is tried +before the next directory. Explicit file candidates stay at their declared +positions among directory searches. + +The presets expand to these ordered directories and filename groups: + +| Preset | Directory, in order | Filename groups | +| --- | --- | --- | +| `UnixSystemDirectories` | `/usr/local/etc/`, `/etc/` | Named, then Generic | +| `UnixSystemDirectories` | `/etc` | Named only | +| `UserConfigDirectory` | Go user-config directory / `` | Named, then Generic | +| `UserConfigDirectory` | Go user-config directory | Named only | +| `HomeDirectory` | Go home directory / `.` | Named, then Generic | +| `HomeDirectory` | Go home directory | Named only | +| `WorkingDirectory` | Current working directory | Named only | + +The Unix system preset is supported on aix, android, darwin, dragonfly, freebsd, +hurd, illumos, ios, linux, netbsd, openbsd, and solaris; reaching it on another +platform is an option error. Other presets use Go's user-config, home, and +working-directory helpers. A reached +directory-helper error terminates selection. Bases for later searches are not +resolved after an earlier candidate is selected. Missing candidates continue; +the first existing candidate is selected and then follows ordinary file loading +rules. Errors while acquiring or processing a selected file are terminal and do +not resume the search. + +Searches are optional by default. Exhaustion uses the same absent-main lifecycle +as `NoSource()`: there is no main document, source record, source ID, or explicit +presence contribution. Imports may be enabled but there are no main declarations +to traverse. Defaults, version checks, overlays, CLI overrides, resolution, and +validation retain their normal lifecycle. `SearchOptions{Required: true}` makes +exhaustion an error; it does not change candidate or selected-file error handling. + +For an ordinary active overlay after optional exhaustion, provide an explicit +filesystem context. A host context with only `Root` is sufficient; ConfigKit +loads the overlay at its normal root-relative location and does not infer a root +from attempted search directories. The same root-only context can accompany a +search that selects a file: the selected file determines its base and must be +inside `Root`. A selected host file rejects an explicit `BaseDir` or native +filesystem context. Invalid context fails without resuming selection. Layered +overlays keep their independent `OverlayFilesystem` requirement. + +When a search selects a file, inspection reports that actual file with ordinary +source IDs, paths, placement, metadata origins, and resource context. Missing +candidates create no records. `SearchConfig` can be used as the main source of +ordinary or layered loading, including an underlay input, and does not add a new +precedence tier. + Each source is limited to 8 MiB. A load using imports or an active overlay is limited to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b1d8595..9a8fc9c 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, and 6 are complete. +Status: in progress. Stages 1, 2, 3, 4, 5, 6, and 7 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 @@ -356,7 +356,7 @@ Acceptance: engine ordinary and layered loading consume searches through one lifecycle and preserve real-source evidence. Run focused discovery/load/import/ version/overlay tests, engine race tests, and the everyday check; mark and commit. -## Stage 7 — Expose The Root Facade And Verify Public Integration +## Stage 7 — Expose The Root Facade And Verify Public Integration (Complete) 1. Add the specified root-owned discovery types, functions, comments, and explicit conversions, preferably in root `discovery.go`. Keep engine types diff --git a/facade_discovery_test.go b/facade_discovery_test.go new file mode 100644 index 0000000..da47a30 --- /dev/null +++ b/facade_discovery_test.go @@ -0,0 +1,230 @@ +package configkit + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "testing" + "testing/fstest" +) + +type facadeDiscoveryConfig struct { + Value string `yaml:"value"` + Settings map[string]string `yaml:"settings"` +} + +func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) { + original, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(original); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + writeFacadeImportFile(t, root, "myapp.yml", "value: working\n") + + result, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", WorkingDirectory())) + if err != nil || result.Config.Value != "working" { + t.Fatalf("working-directory search = %#v, %v", result, err) + } + if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filepath.Join(root, "myapp.yml")) { + t.Fatalf("working-directory sources = %#v", sources) + } +} + +func TestFacadeSearchCustomGenericNamesAndEmptyFilenameSelection(t *testing.T) { + t.Parallel() + + root := t.TempDir() + filename := writeFacadeImportFile(t, root, "config.yml", "value: found\n") + custom := SearchDirectory(root, DirectorySearchOptions{ + Filenames: []FilenameGroup{CustomFilenames("config.yml")}, + }) + result, err := Load[facadeDiscoveryConfig](SearchConfig("", custom)) + if err != nil || result.Config.Value != "found" { + t.Fatalf("custom generic-only search with empty slug = %#v, %v", result, err) + } + if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filename) { + t.Fatalf("custom generic-only sources = %#v", sources) + } + + defaults := func() facadeDiscoveryConfig { return facadeDiscoveryConfig{Value: "default"} } + empty := SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{}}) + absent, err := Load(SearchConfig("myapp", empty), Options[facadeDiscoveryConfig]{ + Definition: Definition[facadeDiscoveryConfig]{Defaults: defaults}, + }) + if err != nil || absent.Config.Value != "default" || len(absent.Sources()) != 0 { + t.Fatalf("explicit empty filename selection = %#v, %v", absent, err) + } + omitted, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", SearchDirectory(root))) + if err != nil || omitted.Config.Value != "found" { + t.Fatalf("omitted filename selection did not use defaults = %#v, %v", omitted, err) + } +} + +func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) { + t.Parallel() + + root := t.TempDir() + selectedPath := writeFacadeImportFile(t, root, "config.yml", "value: selected\n") + laterPath := writeFacadeImportFile(t, root, "later.yml", "value: later\n") + paths := []string{filepath.Join(root, "missing.yml")} + names := []string{"config.yml"} + groups := []FilenameGroup{CustomFilenames(names...)} + directoryOptions := []DirectorySearchOptions{{Filenames: groups}} + search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath)) + source := SearchConfig("", search, SearchOptions{Required: true}) + + paths[0] = laterPath + names[0] = "later.yml" + groups[0] = CustomFilenames("later.yml") + directoryOptions[0].Filenames[0] = CustomFilenames("later.yml") + search = Paths(laterPath) + + result, err := Load[facadeDiscoveryConfig](source) + if err != nil || result.Config.Value != "selected" { + t.Fatalf("ordered required search after caller mutation = %#v, %v", result, err) + } + if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) { + t.Fatalf("ordered search sources = %#v", sources) + } + + _, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true})) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("required exhausted search error = %v, want *LoadError", err) + } + foundMissing := false + for _, diagnostic := range loadErr.Diagnostics() { + foundMissing = foundMissing || diagnostic.Code == CodeSourceMissing + } + if !foundMissing { + t.Fatalf("required exhausted search diagnostics = %#v", loadErr.Diagnostics()) + } +} + +func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) { + t.Parallel() + + root := t.TempDir() + mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n") + underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n") + result, err := LoadLayered( + Input{Source: SearchConfig("", Paths(mainPath))}, + LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{ + Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{ + Filenames: []FilenameGroup{CustomFilenames("base.yml")}, + })), + }}}}}, + ) + if err != nil || !reflect.DeepEqual(result.Config.Settings, map[string]string{"main": "value", "underlay": "value"}) { + t.Fatalf("layered search = %#v, %v", result, err) + } + sources := result.Sources() + if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase || + sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay { + t.Fatalf("layered search sources = %#v", sources) + } +} + +func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) { + t.Parallel() + + root := t.TempDir() + mainPath := writeFacadeImportFile(t, root, "main.yml", "value: main\n") + writeFacadeImportFile(t, root, "overlays/testing.yml", "value: overlay\n") + filesystem := &FilesystemContext{Root: root} + options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem} + + selected, err := Load(SearchConfig("", Paths(mainPath)), options) + if err != nil || selected.Config.Value != "overlay" { + t.Fatalf("selected search with root-only context = %#v, %v", selected, err) + } + sources := selected.Sources() + if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].ID != "source-1" || + sources[1].ID != "source-2" || sources[1].Layer != LayerOverlay { + t.Fatalf("selected search source evidence = %#v", sources) + } + mainContext, ok := selected.SourceContext("source-1") + if !ok || mainContext.BaseDir != "." { + t.Fatalf("selected file base context = %#v, %v", mainContext, ok) + } + + missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml"))) + searchedAbsent, err := Load(missing, options) + if err != nil { + t.Fatalf("exhausted search with root-only overlay context: %v", err) + } + explicitAbsent, err := Load(NoSource(), options) + if err != nil { + t.Fatalf("NoSource with root-only overlay context: %v", err) + } + if !reflect.DeepEqual(searchedAbsent.Config, explicitAbsent.Config) || + !reflect.DeepEqual(searchedAbsent.Sources(), explicitAbsent.Sources()) { + t.Fatalf("searched absence differs from NoSource: %#v / %#v", searchedAbsent, explicitAbsent) + } + left, leftOK := searchedAbsent.Metadata().Lookup(Keys("value")) + right, rightOK := explicitAbsent.Metadata().Lookup(Keys("value")) + if leftOK != rightOK || !reflect.DeepEqual(left, right) || !leftOK { + t.Fatalf("searched absence metadata = %#v, %v; NoSource metadata = %#v, %v", left, leftOK, right, rightOK) + } + if sources := searchedAbsent.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Layer != LayerOverlay { + t.Fatalf("absent search created unexpected source evidence = %#v", sources) + } +} + +func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) { + t.Parallel() + + root := t.TempDir() + inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n") + outsideRoot := t.TempDir() + outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n") + search := SearchConfig("", Paths(outside, inside)) + + _, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}}) + var loadErr *LoadError + if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeImportOutsideRoot) { + t.Fatalf("selected outside-root file error = %v, want containment failure", err) + } + + _, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}}) + if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) { + t.Fatalf("selected file with explicit base error = %v, want option failure", err) + } + + native := NewFilesystem("native", fstest.MapFS{}) + _, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{FS: &native, BaseDir: ".", Root: "."}}) + if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) { + t.Fatalf("selected file with native context error = %v, want option failure", err) + } +} + +func hasFacadeDiagnostic(err *LoadError, code Code) bool { + for _, diagnostic := range err.Diagnostics() { + if diagnostic.Code == code { + return true + } + } + return false +} + +func canonicalFacadePath(t *testing.T, path string) string { + t.Helper() + absolute, err := filepath.Abs(path) + if err != nil { + t.Fatal(err) + } + canonical, err := filepath.EvalSymlinks(absolute) + if err != nil { + t.Fatal(err) + } + return canonical +} diff --git a/source.go b/source.go index febe1c5..c06c746 100644 --- a/source.go +++ b/source.go @@ -8,7 +8,8 @@ import ( ) // Source is an opaque configuration input. Construct sources with File, -// Filesystem.File, Bytes, Reader, Mapping, or NoSource; the zero value is invalid. +// Filesystem.File, SearchConfig, Bytes, Reader, Mapping, or NoSource; the zero +// value is invalid. SearchConfig selects a host file when a load consumes it. type Source struct{ value engine.Source } // Filesystem is a reusable opaque namespace for a caller-owned fs.FS. Copies