diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d45dab4..b1d8595 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, and 5 are complete. +Status: in progress. Stages 1, 2, 3, 4, 5, and 6 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 @@ -322,7 +322,7 @@ Acceptance: shared absent-main behavior is complete for explicit `NoSource`. Run focused overlay/context/load tests, `go test -race ./internal/engine`, and the everyday check. Mark and commit. -## Stage 6 — Integrate Search Resolution Into Engine Loading +## Stage 6 — Integrate Search Resolution Into Engine Loading (Complete) 1. Add a private discovery source kind/state to engine `Source` and have the engine `SearchConfig` constructor capture the validated description. Add diff --git a/internal/engine/discovery.go b/internal/engine/discovery.go index 4c6dd18..c4d63b3 100644 --- a/internal/engine/discovery.go +++ b/internal/engine/discovery.go @@ -34,6 +34,19 @@ type SearchOptions struct { Required bool } +// SearchConfig creates a source that selects the first matching host file when +// consumed. Description and option errors are reported during loading. +func SearchConfig(application string, search Search, supplied ...SearchOptions) Source { + captured := Search{entries: cloneSearchEntries(search.entries), issues: append([]string(nil), search.issues...)} + source := Source{kind: sourceSearch, search: &captured, searchApp: application} + if len(supplied) > 1 { + source.searchIssues = []string{"SearchConfig accepts at most one options value"} + } else if len(supplied) == 1 { + source.searchRequired = supplied[0].Required + } + return source +} + type filenameGroupKind uint8 const ( diff --git a/internal/engine/discovery_load_test.go b/internal/engine/discovery_load_test.go new file mode 100644 index 0000000..264189b --- /dev/null +++ b/internal/engine/discovery_load_test.go @@ -0,0 +1,342 @@ +package engine + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" +) + +type discoveryLoadConfig struct { + Service struct { + Settings map[string]string `yaml:"settings"` + } `yaml:"service"` + Settings map[string]string `yaml:"settings"` + Value string `yaml:"value"` +} + +func TestSearchLoadMatchesExplicitFileThroughImportsAndContext(t *testing.T) { + t.Parallel() + + root := t.TempDir() + mainPath := writeImportFile(t, root, "app.yml", `configkit: + imports: [{path: fragments/extra.yml}] +settings: {main: selected} +`) + importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n") + search := SearchConfig("app", SearchDirectory(root)) + options := Options[discoveryLoadConfig]{ + Imports: true, Filesystem: &FilesystemContext{Root: root}, destination: Keys("service"), + } + searched, err := testLoad(search, options) + if err != nil { + t.Fatal(err) + } + explicit, err := testLoad(File(mainPath), options) + if err != nil { + t.Fatal(err) + } + assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, + Keys("service", "settings", "main"), Keys("service", "settings", "imported")) + + sources := searched.Sources() + if len(sources) != 2 || sources[0].Kind != SourceKindFile || sources[0].Path != canonicalTestPath(t, mainPath) || sources[1].Path != canonicalTestPath(t, importPath) { + t.Fatalf("searched source evidence = %#v", sources) + } +} + +func TestSearchLayeredMainAndUnderlayMatchExplicitFiles(t *testing.T) { + t.Parallel() + + root := t.TempDir() + mainPath := writeImportFile(t, root, "main.yml", "settings: {main: value}\n") + underlayPath := writeImportFile(t, root, "underlay/base.yml", `configkit: + imports: [{path: extra.yml}] +settings: {underlay: value} +`) + importPath := writeImportFile(t, root, "underlay/extra.yml", "settings: {imported: value}\n") + mainSearch := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("main.yml")}})) + underlaySearch := SearchConfig("", SearchDirectory(filepath.Join(root, "underlay"), DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("base.yml")}})) + load := func(main, underlay Source) (*Loaded[discoveryLoadConfig], error) { + return LoadLayered( + Input{Source: main, Imports: true}, testSecretBinding(), + LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: underlay, Imports: true}}}}}, + ) + } + searched, err := load(mainSearch, underlaySearch) + if err != nil { + t.Fatal(err) + } + explicit, err := load(File(mainPath), File(underlayPath)) + if err != nil { + t.Fatal(err) + } + assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, + Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported")) + + sources := searched.Inspection.Sources() + if len(sources) != 3 || sources[0].ID != "source-1" || sources[0].Layer != LayerBase || sources[1].ID != "source-2" || sources[1].Layer != LayerUnderlay || sources[2].Path != canonicalTestPath(t, importPath) || sources[2].UnderlayIndex != 1 { + t.Fatalf("layered search source evidence = %#v", sources) + } +} + +func TestOptionalSearchExhaustionMatchesNoSource(t *testing.T) { + t.Parallel() + + root := t.TempDir() + missing := filepath.Join(root, "missing.yml") + defaults := func() discoveryLoadConfig { + return discoveryLoadConfig{Value: "default"} + } + options := Options[discoveryLoadConfig]{ + Definition: Definition[discoveryLoadConfig]{Defaults: defaults}, + Imports: true, Filesystem: &FilesystemContext{Root: root}, + } + searched, err := testLoad(SearchConfig("", Paths(missing)), options) + if err != nil { + t.Fatal(err) + } + explicit, err := testLoad(NoSource(), options) + if err != nil { + t.Fatal(err) + } + assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value")) + if len(searched.Sources()) != 0 { + t.Fatalf("optional exhausted search created sources: %#v", searched.Sources()) + } +} + +func TestSearchSelectedFileFailuresDoNotFallBack(t *testing.T) { + t.Parallel() + + t.Run("malformed YAML", func(t *testing.T) { + first := writeImportFile(t, t.TempDir(), "first.yml", ": malformed\n") + writeImportFile(t, filepath.Dir(first), "later.yml", "value: valid\n") + loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(filepath.Dir(first), "later.yml"))), Options[discoveryLoadConfig]{}) + if loaded != nil || err == nil { + t.Fatalf("malformed selected source = %#v, %v", loaded, err) + } + }) + + t.Run("version check", func(t *testing.T) { + root := t.TempDir() + first := writeImportFile(t, root, "first.yml", "version: rejected\n") + writeImportFile(t, root, "later.yml", "version: accepted\n") + var checked []string + loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[discoveryLoadConfig]{ + SourceVersionMode: sourceVersionModeIfPresent, + Definition: Definition[discoveryLoadConfig]{Version: &VersionRule{ + Path: Keys("version"), + Check: func(version Version, _ *Validation) error { + checked = append(checked, version.Text) + if version.Text == "rejected" { + return errors.New("version rejected") + } + return nil + }, + }}, + }) + if loaded != nil || err == nil || !reflect.DeepEqual(checked, []string{"rejected"}) { + t.Fatalf("version failure search = %#v, %v; checked %#v", loaded, err, checked) + } + }) + + t.Run("decode", func(t *testing.T) { + root := t.TempDir() + first := writeImportFile(t, root, "first.yml", "value: not-an-integer\n") + writeImportFile(t, root, "later.yml", "value: 3\n") + type config struct { + Value int `yaml:"value"` + } + loaded, err := testLoad[config](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[config]{}) + if loaded != nil || err == nil { + t.Fatalf("decode failure search = %#v, %v", loaded, err) + } + }) +} + +func TestSearchValidationPrecedesCandidateProbing(t *testing.T) { + t.Parallel() + + root := t.TempDir() + first := writeImportFile(t, root, "first.yml", "value: selected\n") + _, err := testLoad[discoveryLoadConfig](SearchConfig("", Searches(Paths(first), Paths(""))), Options[discoveryLoadConfig]{}) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("invalid later search error = %v, want *LoadError", err) + } + if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid { + t.Fatalf("invalid later search component diagnostics = %#v", loadErr.Diagnostics()) + } + + malformed := writeImportFile(t, t.TempDir(), "main.yml", ": malformed\n") + loaded, err := LoadLayered( + Input{Source: SearchConfig("", Paths(malformed))}, testSecretBinding(), + LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: SearchConfig("", Paths(""))}}}}}, + ) + if loaded != nil || !errors.As(err, &loadErr) { + t.Fatalf("invalid later underlay search = %#v, %v", loaded, err) + } + for _, diagnostic := range loadErr.Diagnostics() { + if diagnostic.Code != CodeOptionsInvalid { + t.Fatalf("layered validation acquired main before rejecting underlay: %#v", loadErr.Diagnostics()) + } + } +} + +func TestSearchContextCompatibilityUsesSelectedOutcome(t *testing.T) { + t.Parallel() + + root := t.TempDir() + first := writeImportFile(t, root, "first.yml", "value: first\n") + writeImportFile(t, root, "later.yml", "value: later\n") + search := SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))) + options := Options[discoveryLoadConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}} + _, err := testLoad[discoveryLoadConfig](search, options) + var loadErr *LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("selected file with explicit base error = %v, want *LoadError", err) + } + if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid { + t.Fatalf("selected file context diagnostics = %#v", loadErr.Diagnostics()) + } +} + +func TestSearchSourceObservesFileChangesBetweenLoads(t *testing.T) { + t.Parallel() + + root := t.TempDir() + file := writeImportFile(t, root, "config.yml", "value: first\n") + source := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("config.yml")}})) + load := func() string { + loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{}) + if err != nil { + t.Fatal(err) + } + return loaded.Config.Value + } + if value := load(); value != "first" { + t.Fatalf("first search value = %q", value) + } + if err := os.WriteFile(file, []byte("value: second\n"), 0o600); err != nil { + t.Fatal(err) + } + if value := load(); value != "second" { + t.Fatalf("updated search value = %q", value) + } +} + +func TestSearchSourceObservesUserConfigEnvironmentChanges(t *testing.T) { + if runtime.GOOS == "windows" { + t.Setenv("APPDATA", filepath.Join(t.TempDir(), "first")) + } else { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "first")) + } + type config struct { + Value string `yaml:"value"` + } + firstBase := os.Getenv("XDG_CONFIG_HOME") + if runtime.GOOS == "windows" { + firstBase = os.Getenv("APPDATA") + } + first := writeImportFile(t, firstBase, "app/app.yml", "value: first\n") + secondBase := filepath.Join(t.TempDir(), "second") + second := writeImportFile(t, secondBase, "app/app.yml", "value: second\n") + source := SearchConfig("app", UserConfigDirectory()) + load := func() string { + loaded, err := testLoad[config](source, Options[config]{}) + if err != nil { + t.Fatal(err) + } + return loaded.Config.Value + } + if value := load(); value != "first" { + t.Fatalf("first user-config search value = %q (%s)", value, first) + } + if runtime.GOOS == "windows" { + t.Setenv("APPDATA", secondBase) + } else { + t.Setenv("XDG_CONFIG_HOME", secondBase) + } + if value := load(); value != "second" { + t.Fatalf("updated user-config search value = %q (%s)", value, second) + } + + // A reached dynamic base failure is terminal, but an earlier match must + // prevent the invalid environment value from being resolved at all. + direct := writeImportFile(t, t.TempDir(), "direct.yml", "value: direct\n") + if runtime.GOOS == "windows" { + t.Setenv("APPDATA", "relative") + } else { + t.Setenv("XDG_CONFIG_HOME", "relative") + } + early := SearchConfig("app", Searches(Paths(direct), UserConfigDirectory())) + loaded, err := testLoad[config](early, Options[config]{}) + if err != nil || loaded.Config.Value != "direct" { + t.Fatalf("early match with unavailable later base = %#v, %v", loaded, err) + } +} + +func TestSearchSourceObservesWorkingDirectoryChanges(t *testing.T) { + original, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(original); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + firstDir := t.TempDir() + secondDir := t.TempDir() + writeImportFile(t, firstDir, "app.yml", "value: first\n") + writeImportFile(t, secondDir, "app.yml", "value: second\n") + source := SearchConfig("app", WorkingDirectory()) + load := func() string { + loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{}) + if err != nil { + t.Fatal(err) + } + return loaded.Config.Value + } + if err := os.Chdir(firstDir); err != nil { + t.Fatal(err) + } + if value := load(); value != "first" { + t.Fatalf("first working-directory search value = %q", value) + } + if err := os.Chdir(secondDir); err != nil { + t.Fatal(err) + } + if value := load(); value != "second" { + t.Fatalf("updated working-directory search value = %q", value) + } +} + +func assertEquivalentDiscoveryLoads[T any](t *testing.T, leftConfig T, left *Inspection, rightConfig T, right *Inspection, presencePaths ...Path) { + t.Helper() + if !reflect.DeepEqual(leftConfig, rightConfig) { + t.Fatalf("search config %#v differs from explicit file config %#v", leftConfig, rightConfig) + } + leftSources, rightSources := left.Sources(), right.Sources() + if !reflect.DeepEqual(leftSources, rightSources) { + t.Fatalf("search sources %#v differ from explicit file sources %#v", leftSources, rightSources) + } + leftMetadata, rightMetadata := left.Metadata(), right.Metadata() + for _, path := range presencePaths { + leftInfo, leftPresent := leftMetadata.Lookup(path) + rightInfo, rightPresent := rightMetadata.Lookup(path) + if leftPresent != rightPresent || !reflect.DeepEqual(leftInfo, rightInfo) { + t.Fatalf("search metadata at %s = %#v, %v; explicit metadata = %#v, %v", path.String(), leftInfo, leftPresent, rightInfo, rightPresent) + } + } + for _, source := range leftSources { + leftContext, leftOK := left.SourceContext(source.ID) + rightContext, rightOK := right.SourceContext(source.ID) + if leftOK != rightOK || leftContext.BaseDir != rightContext.BaseDir || (leftContext.FS != nil) != (rightContext.FS != nil) { + t.Fatalf("search context for %s = %#v, %v; explicit context = %#v, %v", source.ID, leftContext, leftOK, rightContext, rightOK) + } + } +} diff --git a/internal/engine/filesystem.go b/internal/engine/filesystem.go index 30a671a..4067ed2 100644 --- a/internal/engine/filesystem.go +++ b/internal/engine/filesystem.go @@ -77,13 +77,9 @@ func validateAbsentHostFilesystemContext(context *FilesystemContext) *Diagnostic func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic { switch source.kind { case sourceNone: - if context.FS != nil { - if context.FS.namespace == nil || nilFilesystem(context.FS.namespace.filesystem) || context.BaseDir == "" || context.Root == "" || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) { - return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "native filesystem context requires a namespace, base, and root directory")} - } - } else if context.Root == "" || !filepath.IsAbs(context.Root) || context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) { - return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "absent-input filesystem root and base directories must be absolute host paths")} - } + return absentFilesystemOptionDiagnostics(context) + case sourceSearch: + return absentFilesystemOptionDiagnostics(context) case sourceFile: if context.FS != nil { return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")} @@ -116,6 +112,17 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di return nil } +func absentFilesystemOptionDiagnostics(context *FilesystemContext) []Diagnostic { + if context.FS != nil { + if context.FS.namespace == nil || nilFilesystem(context.FS.namespace.filesystem) || context.BaseDir == "" || context.Root == "" || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) { + return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "native filesystem context requires a namespace, base, and root directory")} + } + } else if context.Root == "" || !filepath.IsAbs(context.Root) || context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) { + return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "absent-input filesystem root and base directories must be absolute host paths")} + } + return nil +} + func nativeContext(source Source, context *FilesystemContext) bool { return source.kind == sourceFilesystem || context != nil && context.FS != nil } diff --git a/internal/engine/load.go b/internal/engine/load.go index a0192d6..7fccf49 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -202,6 +202,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo } accumulator.versionState = versionState input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState) + request.main.source = input.source accumulator.sources = input.sources accumulator.add(versionState.diagnostics...) reportedVersionDiagnostics := len(versionState.diagnostics) diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index a421c9c..a9c3ebc 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -8,6 +8,7 @@ import ( // assembledInput is the source/document handoff across schema and version checks. type assembledInput struct { + source Source parsed parsedSource document *document.Node composition *composedDocument @@ -101,20 +102,36 @@ func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T } func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) { + selectedSource := source + if source.kind == sourceSearch { + resolved, diagnostics := resolveSearchSource(source, source.search, source.searchApp, source.searchRequired, defaultDiscoveryDirectoryHelpers(), defaultDiscoveryHostProbe()) + if len(diagnostics) != 0 { + return assembledInput{source: source, versionState: versionState, accounting: accounting}, diagnostics + } + selectedSource = resolved + if options.Filesystem != nil { + if diagnostics := filesystemOptionDiagnostics(selectedSource, options.Filesystem); len(diagnostics) != 0 { + return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, diagnostics + } + } + if selectedSource.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil { + return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")} + } + } limits := defaultImportLimits() parseLimits := defaultParseLimits() parseLimits.source.remaining = limits.bytes - accounting.bytes - parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits) + parsed, diagnostics := parseSource(selectedSource, "source-"+strconv.Itoa(accounting.sources+1), parseLimits) if parsed.source != nil { parsed.source.Destination = destination.clone() parsed.source.placementMode = options.placementMode } - input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting} + input := assembledInput{source: selectedSource, parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting} if len(diagnostics) != 0 { input.sources = sourceInfos(parsed.source) // Mapping value issues already identify application content. Other early // failures describe source syntax or access, not a placed value. - if source.kind == sourceMapping && source.mapIssue != nil && len(destination.segments) != 0 { + if selectedSource.kind == sourceMapping && selectedSource.mapIssue != nil && len(destination.segments) != 0 { for index := range diagnostics { relative := diagnostics[index].Path segments := clonePathSegments(destination.segments, len(relative.segments)) @@ -200,11 +217,11 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d versionState.mainProcessing = &processed } } - if options.Imports && source.kind != sourceNone { + if options.Imports && selectedSource.kind != sourceNone { if options.deferAssembly { - input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true) + input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true) } else { - input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy) + input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy) } if len(diagnostics) != 0 { return input, diagnostics @@ -218,8 +235,8 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d if versionProcessing != nil { input.document = versionProcessing.payload } - if options.Filesystem != nil && (source.kind == sourceNone || !activeOverlay(options.Overlay)) { - if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil { + if options.Filesystem != nil && (selectedSource.kind == sourceNone || !activeOverlay(options.Overlay)) { + if diagnostic := validateFilesystemContext(selectedSource, options.Filesystem, parsed); diagnostic != nil { return input, []Diagnostic{*diagnostic} } } @@ -251,7 +268,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes { return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")} } - if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil { + if diagnostic := registerAcquiredSource(selectedSource, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil { return input, []Diagnostic{*diagnostic} } accounting.sources++ @@ -259,7 +276,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d accounting.nodes += rawNodes + placementNodes } if !activeOverlay(options.Overlay) || options.Imports { - contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources) + contexts, diagnostic := contextsForAcquiredSources(selectedSource, options.Filesystem, parsed.source, input.sources) if diagnostic != nil { return input, []Diagnostic{*diagnostic} } diff --git a/internal/engine/options.go b/internal/engine/options.go index 802be28..4b833a9 100644 --- a/internal/engine/options.go +++ b/internal/engine/options.go @@ -31,6 +31,16 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D if options.Filesystem != nil { diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...) } + if source.kind == sourceSearch { + for _, issue := range source.searchIssues { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue)) + } + if source.search == nil { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "configuration search is invalid")) + } else { + diagnostics = append(diagnostics, validateSearch(*source.search, source.searchApp)...) + } + } if source.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")) } diff --git a/internal/engine/source.go b/internal/engine/source.go index 5b7a89d..e66f57c 100644 --- a/internal/engine/source.go +++ b/internal/engine/source.go @@ -21,17 +21,21 @@ const ( // Source is an opaque configuration input. Construct sources with File, // Filesystem.File, Bytes, Reader, or NoSource; the zero value is invalid. type Source struct { - kind sourceKind - path string - label string - namespace *nativeNamespace - data []byte - reader *readerSource - mapping *document.Node - mapIssue *valueIssue - mapBytes int64 - mapNodes int - createErr sourceCreationError + kind sourceKind + path string + label string + namespace *nativeNamespace + data []byte + reader *readerSource + mapping *document.Node + mapIssue *valueIssue + mapBytes int64 + mapNodes int + createErr sourceCreationError + search *Search + searchApp string + searchRequired bool + searchIssues []string } type sourceKind uint8 @@ -44,6 +48,7 @@ const ( sourceReader sourceMapping sourceNone + sourceSearch ) type sourceCreationError uint8