diff --git a/docs/adr/0019-explicit-configuration-discovery.md b/docs/adr/0019-explicit-configuration-discovery.md new file mode 100644 index 0000000..4e59704 --- /dev/null +++ b/docs/adr/0019-explicit-configuration-discovery.md @@ -0,0 +1,68 @@ +# ADR 0019: Keep Configuration Discovery Explicit and Inside Loading + +## Status + +Accepted. + +## Date + +2026-10-24 + +## Context + +Applications often need a small ordered convention for choosing a main +configuration file. Keeping that choice entirely in each application repeats +path selection and file-loading plumbing. Moving the convention into ConfigKit +without an explicit declaration would make ordinary loads search paths that a +consumer did not choose, obscuring both precedence and filesystem access. + +ConfigKit already owns synchronous source acquisition, parsing, composition, +decoding, validation, and inspection. A selected host file can therefore enter +that lifecycle as the existing file source, preserving its source identity, +resource context, and diagnostics. Optional exhaustion can use the same absent +main lifecycle as an explicitly absent source. + +## Decision + +Consumers choose and order the main-file conventions they want to use. They may +describe literal paths, directory filename groups, and supported directory +presets, and may mark the complete search as required. Search descriptions are +explicit inputs to loading; no preset or convention is activated implicitly. + +During synchronous loading, ConfigKit resolves the description to one real host +file or to absence before parsing. The selected file follows the existing source +acquisition and processing rules. A selected-file access, parse, version, +composition, decode, or validation failure is terminal and does not resume the +search. Optional exhaustion shares the absent-main lifecycle; required +exhaustion reports a missing-source diagnostic. No candidate-attempt history is +added to public metadata. + +This keeps convention choice with applications while moving repeated selection +and loading plumbing into the library's established lifecycle. It preserves +explicit control, ordinary source behavior, and complete-on-return ownership. + +## Alternatives Considered + +- **Keep all search and fallback loading in each application.** This preserves + consumer ownership but repeats the file-selection and load coordination that + ConfigKit already owns, and risks divergent handling of selected-file errors. +- **Search conventional locations implicitly during every load.** This reduces + setup, but activates filesystem access and precedence the consumer did not + select and makes ordinary source behavior less predictable. +- **Return candidate paths for the application to load one by one.** This keeps + selection outside the load lifecycle and encourages repeated complete loads, + potentially rerunning defaults, callbacks, and validation during fallback. + +## Consequences + +The public search description must be immutable and root-owned, with private +engine conversion and selection. The contract documents candidate ordering, +selection, exhaustion, context, and provenance; the API declaration documents +the public symbols. Dynamic bases and file availability are observed when each +load consumes the description. Applications remain responsible for selecting +conventions and for their schema and domain requirements. + +The [configuration contract](../config.md) will own implemented behavior, the +[component overview](../internal/overview.md) will describe implementation +ownership, and the [implementation plan](../roadmap/implementation.md) tracks +delivery progress. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a67f91f..72035c3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Configuration Discovery Implementation Plan -Status: ready for implementation. No stages are complete. +Status: in progress. Stage 1 is 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 @@ -190,7 +190,7 @@ from attempted directories. Layered overlays still require their independent or context descriptors are acceptable; they must not become an acquired main document, source ID, or explicit-presence contribution. -## Stage 1 — Define Immutable Search Descriptions And Validation +## Stage 1 — Define Immutable Search Descriptions And Validation (Complete) Read [the facade ADR](../adr/0006-public-facade.md), the roadmap, and the source, options, filesystem, and lifecycle owners named in the component overview. diff --git a/internal/engine/discovery.go b/internal/engine/discovery.go new file mode 100644 index 0000000..db381dc --- /dev/null +++ b/internal/engine/discovery.go @@ -0,0 +1,266 @@ +package engine + +import ( + "path/filepath" + "runtime" + "strings" +) + +// Search is an immutable ordered description of possible host-file inputs. +// It does not access the filesystem until a later loading integration consumes it. +type Search struct { + entries []searchEntry + issues []string +} + +// FilenameGroup is an immutable ordered group used by a directory search. +type FilenameGroup struct { + kind filenameGroupKind + names []string + issues []string +} + +// DirectorySearchOptions controls the filename groups used by SearchDirectory. +// A nil Filenames slice selects the default groups; a non-nil empty slice selects none. +type DirectorySearchOptions struct { + Filenames []FilenameGroup +} + +// SearchOptions controls whether a later source consumption requires a match. +type SearchOptions struct { + Required bool +} + +type filenameGroupKind uint8 + +const ( + filenameGroupEmpty filenameGroupKind = iota + filenameGroupNamed + filenameGroupGeneric + filenameGroupCustom +) + +type searchEntryKind uint8 + +const ( + searchEntryPath searchEntryKind = iota + 1 + searchEntryDirectory +) + +type directoryBaseKind uint8 + +const ( + directoryBaseLiteral directoryBaseKind = iota + directoryBaseUnixLocal + directoryBaseUnixEtc + directoryBaseUserConfig + directoryBaseHome + directoryBaseWorking +) + +type searchEntry struct { + kind searchEntryKind + path string + baseKind directoryBaseKind + directory string + appendSlug bool + slugPrefix string + filenames []FilenameGroup +} + +// NamedFilenames selects .yml followed by .yaml. +func NamedFilenames() FilenameGroup { + return FilenameGroup{kind: filenameGroupNamed} +} + +// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml. +func GenericFilenames() FilenameGroup { + return FilenameGroup{kind: filenameGroupGeneric} +} + +// CustomFilenames selects literal filename components in the supplied order. +func CustomFilenames(names ...string) FilenameGroup { + group := FilenameGroup{kind: filenameGroupCustom, names: append([]string(nil), names...)} + for _, name := range group.names { + if !validFilenameComponent(name) { + group.issues = append(group.issues, "custom filename must be a usable host filename component") + } + } + return group +} + +// SearchDirectory searches the supplied directory using the selected filename groups. +// With omitted options or nil Filenames, it selects Named then Generic filenames. +func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search { + search := Search{} + if len(supplied) > 1 { + search.issues = append(search.issues, "directory search accepts at most one options value") + } + var groups []FilenameGroup + if len(supplied) == 0 || supplied[0].Filenames == nil { + groups = []FilenameGroup{NamedFilenames(), GenericFilenames()} + } else { + groups = cloneFilenameGroups(supplied[0].Filenames) + } + search.entries = append(search.entries, searchEntry{ + kind: searchEntryDirectory, baseKind: directoryBaseLiteral, + directory: directory, filenames: groups, + }) + return search +} + +// Paths describes literal host-file candidates in the supplied order. +func Paths(paths ...string) Search { + search := Search{entries: make([]searchEntry, 0, len(paths))} + for _, path := range append([]string(nil), paths...) { + search.entries = append(search.entries, searchEntry{kind: searchEntryPath, path: path}) + } + return search +} + +// Searches concatenates descriptions in order and snapshots their entries. +func Searches(searches ...Search) Search { + result := Search{} + for _, search := range append([]Search(nil), searches...) { + result.entries = append(result.entries, cloneSearchEntries(search.entries)...) + result.issues = append(result.issues, search.issues...) + } + return result +} + +// UnixSystemDirectories describes the conventional Unix system directories. +func UnixSystemDirectories() Search { + namedGeneric := []FilenameGroup{NamedFilenames(), GenericFilenames()} + return Search{entries: []searchEntry{ + {kind: searchEntryDirectory, baseKind: directoryBaseUnixLocal, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)}, + {kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)}, + {kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, filenames: []FilenameGroup{NamedFilenames()}}, + }} +} + +// UserConfigDirectory describes the Go user-configuration directory searches. +func UserConfigDirectory() Search { + return Search{entries: []searchEntry{ + {kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, appendSlug: true, filenames: defaultFilenameGroups()}, + {kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, filenames: []FilenameGroup{NamedFilenames()}}, + }} +} + +// HomeDirectory describes the Go home-directory searches. +func HomeDirectory() Search { + return Search{entries: []searchEntry{ + {kind: searchEntryDirectory, baseKind: directoryBaseHome, appendSlug: true, slugPrefix: ".", filenames: defaultFilenameGroups()}, + {kind: searchEntryDirectory, baseKind: directoryBaseHome, filenames: []FilenameGroup{NamedFilenames()}}, + }} +} + +// WorkingDirectory describes application-named files in the current directory. +func WorkingDirectory() Search { + return Search{entries: []searchEntry{{ + kind: searchEntryDirectory, baseKind: directoryBaseWorking, + filenames: []FilenameGroup{NamedFilenames()}, + }}} +} + +func defaultFilenameGroups() []FilenameGroup { + return []FilenameGroup{NamedFilenames(), GenericFilenames()} +} + +func cloneFilenameGroups(groups []FilenameGroup) []FilenameGroup { + if groups == nil { + return nil + } + cloned := make([]FilenameGroup, len(groups)) + for i, group := range groups { + cloned[i] = FilenameGroup{ + kind: group.kind, names: append([]string(nil), group.names...), + issues: append([]string(nil), group.issues...), + } + } + return cloned +} + +func cloneSearchEntries(entries []searchEntry) []searchEntry { + if entries == nil { + return nil + } + cloned := make([]searchEntry, len(entries)) + for i, entry := range entries { + cloned[i] = entry + cloned[i].filenames = cloneFilenameGroups(entry.filenames) + } + return cloned +} + +func validateSearch(search Search, application string) []Diagnostic { + var diagnostics []Diagnostic + for _, issue := range search.issues { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue)) + } + slugNeeded := false + for _, entry := range search.entries { + switch entry.kind { + case searchEntryPath: + if entry.path == "" { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search path must not be empty")) + } + case searchEntryDirectory: + if entry.baseKind == directoryBaseLiteral && entry.directory == "" { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "directory search path must not be empty")) + } + if entry.appendSlug { + slugNeeded = true + } + for _, group := range entry.filenames { + switch group.kind { + case filenameGroupEmpty, filenameGroupGeneric: + case filenameGroupNamed: + slugNeeded = true + case filenameGroupCustom: + default: + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "filename group is invalid")) + } + for _, issue := range group.issues { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue)) + } + } + default: + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search description is invalid")) + } + } + if slugNeeded && !validFilenameComponent(application) { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "application name must be a usable host filename component")) + } + return canonicalDiagnostics(diagnostics) +} + +func validFilenameComponent(value string) bool { + if value == "" || value == "." || value == ".." || strings.IndexByte(value, 0) >= 0 { + return false + } + if filepath.IsAbs(value) || filepath.VolumeName(value) != "" || filepath.Base(value) != value { + return false + } + if runtime.GOOS == "windows" { + return validWindowsFilenameComponent(value) + } + return true +} + +func validWindowsFilenameComponent(value string) bool { + if strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") { + return false + } + for _, character := range value { + if character < 32 { + return false + } + } + base := strings.ToUpper(strings.TrimSuffix(value, filepath.Ext(value))) + switch base { + case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³": + return false + default: + return true + } +} diff --git a/internal/engine/discovery_test.go b/internal/engine/discovery_test.go new file mode 100644 index 0000000..a257a32 --- /dev/null +++ b/internal/engine/discovery_test.go @@ -0,0 +1,152 @@ +package engine + +import ( + "runtime" + "testing" +) + +func TestSearchDescriptionEmptyAndSlugRequirements(t *testing.T) { + t.Parallel() + + if diagnostics := validateSearch(Search{}, ""); len(diagnostics) != 0 { + t.Fatalf("zero search diagnostics = %#v", diagnostics) + } + if diagnostics := validateSearch(Searches(), ""); len(diagnostics) != 0 { + t.Fatalf("empty concatenation diagnostics = %#v", diagnostics) + } + if diagnostics := validateSearch(Paths(), ""); len(diagnostics) != 0 { + t.Fatalf("empty path list diagnostics = %#v", diagnostics) + } + if diagnostics := validateSearch(Paths("relative/config.yml"), ""); len(diagnostics) != 0 { + t.Fatalf("literal path unexpectedly requires a slug: %#v", diagnostics) + } + generic := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{GenericFilenames()}}) + if diagnostics := validateSearch(generic, ""); len(diagnostics) != 0 { + t.Fatalf("generic-only directory unexpectedly requires a slug: %#v", diagnostics) + } + if diagnostics := validateSearch(SearchDirectory("configs"), ""); len(diagnostics) == 0 || diagnostics[0].Code != CodeOptionsInvalid { + t.Fatalf("default named directory accepted an empty slug: %#v", diagnostics) + } + if diagnostics := validateSearch(UnixSystemDirectories(), "myapp"); len(diagnostics) != 0 { + t.Fatalf("valid preset slug diagnostics = %#v", diagnostics) + } +} + +func TestSearchDescriptionSnapshotsInputs(t *testing.T) { + t.Parallel() + + paths := []string{"first.yml", "second.yml"} + pathSearch := Paths(paths...) + paths[0] = "changed.yml" + + customNames := []string{"one.yml", "two.yml"} + group := CustomFilenames(customNames...) + customNames[0] = "changed.yml" + groups := []FilenameGroup{group} + options := DirectorySearchOptions{Filenames: groups} + directorySearch := SearchDirectory("configs", options) + groups[0] = GenericFilenames() + options.Filenames[0] = NamedFilenames() + + searches := []Search{pathSearch, directorySearch} + combined := Searches(searches...) + searches[0] = Search{} + + if got := combined.entries[0].path; got != "first.yml" { + t.Fatalf("snapshotted path = %q", got) + } + gotNames := combined.entries[2].filenames[0].names + if len(gotNames) != 2 || gotNames[0] != "one.yml" || gotNames[1] != "two.yml" { + t.Fatalf("snapshotted custom names = %#v", gotNames) + } + if diagnostics := validateSearch(combined, ""); len(diagnostics) != 0 { + t.Fatalf("snapshot description diagnostics = %#v", diagnostics) + } +} + +func TestDirectoryFilenameDefaultsAndExplicitEmpty(t *testing.T) { + t.Parallel() + + omitted := SearchDirectory("configs") + if len(omitted.entries) != 1 || len(omitted.entries[0].filenames) != 2 || omitted.entries[0].filenames[0].kind != filenameGroupNamed || omitted.entries[0].filenames[1].kind != filenameGroupGeneric { + t.Fatalf("omitted groups = %#v", omitted.entries) + } + explicitNil := SearchDirectory("configs", DirectorySearchOptions{}) + if len(explicitNil.entries[0].filenames) != 2 { + t.Fatalf("nil filename selection did not use defaults: %#v", explicitNil.entries[0].filenames) + } + explicitEmpty := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{}}) + if explicitEmpty.entries[0].filenames == nil || len(explicitEmpty.entries[0].filenames) != 0 { + t.Fatalf("explicit empty selection lost its distinction: %#v", explicitEmpty.entries[0].filenames) + } + if len(validateSearch(explicitEmpty, "")) != 0 { + t.Fatal("explicit empty filename selection should need no slug") + } +} + +func TestSearchDescriptionArityAndErrorConcatenation(t *testing.T) { + t.Parallel() + + tooMany := SearchDirectory("configs", DirectorySearchOptions{}, DirectorySearchOptions{}) + if diagnostics := validateSearch(tooMany, "app"); len(diagnostics) == 0 { + t.Fatal("multiple option values should be invalid") + } + invalid := Paths("") + combined := Searches(Paths("good.yml"), invalid) + if diagnostics := validateSearch(combined, ""); len(diagnostics) == 0 { + t.Fatal("concatenation lost a constructor error") + } + if diagnostics := validateSearch(Paths("good.yml", ""), ""); len(diagnostics) == 0 { + t.Fatal("later invalid path was hidden by an earlier component") + } +} + +func TestSearchDescriptionPreservesFilenameGroupOrder(t *testing.T) { + t.Parallel() + + groups := []FilenameGroup{GenericFilenames(), NamedFilenames(), CustomFilenames("override.yml")} + search := SearchDirectory("configs", DirectorySearchOptions{Filenames: groups}) + got := search.entries[0].filenames + if len(got) != 3 || got[0].kind != filenameGroupGeneric || got[1].kind != filenameGroupNamed || got[2].names[0] != "override.yml" { + t.Fatalf("filename group order = %#v", got) + } + if got := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames()}}).entries[0].filenames[0].names; len(got) != 0 { + t.Fatalf("empty custom group = %#v", got) + } +} + +func TestFilenameComponentValidation(t *testing.T) { + t.Parallel() + + for _, value := range []string{"", ".", "..", "nested/name", "bad\x00name"} { + if validFilenameComponent(value) { + t.Errorf("validFilenameComponent(%q) = true", value) + } + } + if runtime.GOOS == "windows" { + for _, value := range []string{"CON", "nul.txt", "name.", "bad:name"} { + if validFilenameComponent(value) { + t.Errorf("Windows component %q unexpectedly accepted", value) + } + } + } else { + for _, value := range []string{"name with spaces", "naïve"} { + if !validFilenameComponent(value) { + t.Errorf("host-valid component %q rejected", value) + } + } + } + if diagnostics := validateSearch(SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("bad/name")}}), ""); len(diagnostics) == 0 { + t.Fatal("invalid custom filename was not retained for validation") + } + for _, value := range []string{"CON", "nul.txt", "COM¹.log", "LPT³.txt", "name.", "bad:name", "bad\x01name"} { + if validWindowsFilenameComponent(value) { + t.Errorf("Windows component %q unexpectedly accepted", value) + } + } + for _, value := range []string{"name with spaces", "naïve"} { + if !validWindowsFilenameComponent(value) { + t.Errorf("valid Windows component %q rejected", value) + } + } +}