Define immutable configuration searches

This commit is contained in:
2026-10-24 15:39:59 +00:00
parent 424c89780e
commit e2634c8815
4 changed files with 488 additions and 2 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 <application>.yml followed by <application>.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
}
}

View File

@@ -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)
}
}
}