diff --git a/conversion.go b/conversion.go index a6ab423..60595d2 100644 --- a/conversion.go +++ b/conversion.go @@ -43,12 +43,80 @@ func toEngineLocations(values []Location) []engine.Location { return result } func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo { - return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)} + return SourceInfo{ + ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), + PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), + DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), + Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, + ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck), + ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)), + } } func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { - result := engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)} + result := engine.SourceInfo{ + ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), + Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, + ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck), + } + result = engine.SourceInfoWithImportSelections(result, toEngineImportSelections(value.ImportSelections)) return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved) } + +func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection { + if values == nil { + return nil + } + result := make([]ImportSelection, len(values)) + for index, value := range values { + result[index] = ImportSelection{ + Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location), + Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome), + } + } + return result +} + +func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade { + if values == nil { + return nil + } + result := make([]engine.ImportSelectionForFacade, len(values)) + for index, value := range values { + result[index] = engine.ImportSelectionForFacade{ + Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location), + Selector: string(value.Selector), Outcome: string(value.Outcome), + } + } + return result +} + +func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults { + return engine.ImportDefaults{ + Required: cloneImportDefaultBool(value.Required), + Follow: cloneImportDefaultBool(value.Follow), + Hidden: cloneImportDefaultBool(value.Hidden), + Recursive: cloneImportDefaultBool(value.Recursive), + Pattern: cloneImportDefaultStrings(value.Pattern), + } +} + +func cloneImportDefaultBool(value *bool) *bool { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneImportDefaultStrings(values []string) []string { + if values == nil { + return nil + } + cloned := make([]string, len(values)) + copy(cloned, values) + return cloned +} + func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck { if value == nil { return nil diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 60e95b5..cd58920 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -55,6 +55,24 @@ both without redefining their syntax. ConfigKit overlays are configuration overl they do not select or replace an application's LLM, model, pipeline, report, or execution profile concepts. +## Import defaults and selection inspection + +`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one +load-wide policy to enabled main, underlay, and transitive imports. Each field +is resolved independently: a declaration's explicit value wins, then the +application default, then the library default. `Required`, `Follow`, `Hidden`, +and `Recursive` use nil pointers for unspecified values. `Pattern` applies to +directory searches: nil leaves filenames unrestricted, while a non-nil empty +slice selects no files. A declaration's own `pattern` replaces the application +default. The defaults do not enable imports or change selected-overlay behavior. + +`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each +declaring source's `ImportSelections`. These records identify the source-local +declaration path, its authored location and selector, and the `none` outcome; +they do not include candidate names or a guessed reason for absence. Returned +records are detached snapshots. The [configuration contract](../config.md#explicit-imports) +owns selector applicability and exact absence semantics. + ## Shared source-version checks [`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates diff --git a/facade_import_defaults_test.go b/facade_import_defaults_test.go new file mode 100644 index 0000000..6125f1a --- /dev/null +++ b/facade_import_defaults_test.go @@ -0,0 +1,230 @@ +package configkit + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" +) + +func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) { + type config struct { + Main string `yaml:"main"` + Transitive string `yaml:"transitive"` + Exception string `yaml:"exception"` + Underlay string `yaml:"underlay"` + IgnoredDeep string `yaml:"ignored_deep"` + } + directory := t.TempDir() + mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit: + imports: + - path: parent/parent.yml +main: main +`) + writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit: + imports: + - dir: generated + - dir: explicit + pattern: '*.yaml' + recursive: false +`) + transitivePath := writeFacadeImportFile(t, directory, "parent/generated/deep/transitive.cfg", "transitive: selected\n") + writeFacadeImportFile(t, directory, "parent/generated/ignored.yaml", "ignored_deep: wrong-pattern\n") + explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\n") + writeFacadeImportFile(t, directory, "parent/explicit/deep/ignored.yaml", "ignored_deep: wrong-depth\n") + writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n") + underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit: + imports: + - dir: underlay-files +`) + underlayImportPath := writeFacadeImportFile(t, directory, "underlay-files/underlay.cfg", "underlay: selected\n") + recursive := true + loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{ + ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: []string{"*.cfg"}}, + Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}}, + }) + if err != nil { + t.Fatalf("LoadLayered: %v", err) + } + if loaded.Config.Main != "main" || loaded.Config.Transitive != "selected" || loaded.Config.Exception != "declaration" || loaded.Config.Underlay != "selected" || loaded.Config.IgnoredDeep != "" { + t.Fatalf("configuration = %#v", loaded.Config) + } + paths := make(map[string]bool) + for _, source := range loaded.Sources() { + paths[source.Path] = true + } + for _, selected := range []string{transitivePath, explicitPath, underlayImportPath} { + if !paths[canonicalFacadeImportPath(t, selected)] { + t.Errorf("selected source %q missing from source records", selected) + } + } +} + +func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) { + type config struct { + Value string `yaml:"value"` + } + directory := t.TempDir() + mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit: + imports: + - dir: snippets +`) + writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n") + + unrestricted, err := Load(File(mainPath), Options[config]{Imports: true}) + if err != nil || unrestricted.Config.Value != "imported" { + t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err) + } + empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}}) + if err != nil || empty.Config.Value != "" { + t.Fatalf("empty pattern = %#v, %v", empty, err) + } + emptySources := empty.Sources() + if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone { + t.Fatalf("empty-pattern evidence = %#v", emptySources) + } + required := true + _, err = Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Required: &required, Pattern: []string{}}}) + var loadError *LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing { + t.Fatalf("required empty selection error = %T %v", err, err) + } + if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 { + t.Fatalf("required empty selection was recorded as optional: %#v", sources) + } + + recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit: + imports: + - dir: recursive-files +`) + writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n") + recursive := true + patterns := []string{"*.cfg"} + engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}}) + recursive = false + patterns[0] = "[" + loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions) + if err != nil || loadedEngine.Config.Value != "selected" { + t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err) + } +} + +func TestInvalidImportDefaultsPreflightBeforeOrdinaryAndLayeredReads(t *testing.T) { + type config struct { + Value string `yaml:"value"` + } + for _, layered := range []bool{false, true} { + input := strings.NewReader("value: read\n") + underlayInput := strings.NewReader("value: underlay\n") + defaultsCalled := false + resolveCalled := false + definition := Definition[config]{ + Defaults: func() config { defaultsCalled = true; return config{} }, + Resolve: func(*config, *Resolution) error { resolveCalled = true; return nil }, + } + defaults := ImportDefaults{Pattern: []string{"["}} + var err error + if layered { + _, err = LoadLayered(Input{Source: Reader("reader.yml", input)}, LayeredOptions[config]{ + Definition: definition, ImportDefaults: defaults, + Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayInput)}}}}, + }) + } else { + _, err = Load(Reader("reader.yml", input), Options[config]{Definition: definition, ImportDefaults: defaults}) + } + var loadError *LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid { + if loadError != nil { + t.Fatalf("layered=%v diagnostics = %#v", layered, loadError.Diagnostics()) + } + t.Fatalf("layered=%v error = %T %v", layered, err, err) + } + if input.Len() != len("value: read\n") || underlayInput.Len() != len("value: underlay\n") || defaultsCalled || resolveCalled { + t.Fatalf("layered=%v consumed input or called application hooks: remaining=%d underlay=%d defaults=%v resolve=%v", layered, input.Len(), underlayInput.Len(), defaultsCalled, resolveCalled) + } + } +} + +func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution(t *testing.T) { + type config struct { + Value string `yaml:"value"` + } + directory := t.TempDir() + mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit: + imports: + - dir: absent +value: loaded +`) + callbackPath := "" + loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { + first, ok := resolution.Source("source-1") + if !ok || len(first.ImportSelections) != 1 { + t.Fatalf("resolution source = %#v, %v", first, ok) + } + first.ImportSelections[0].Path = Keys("callback mutation") + second, ok := resolution.Source("source-1") + if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" { + t.Fatalf("resolution snapshot changed after mutation: %#v", second) + } + callbackPath = second.ImportSelections[0].Path.String() + return nil + }}}) + if err != nil { + t.Fatal(err) + } + if callbackPath != "/configkit/imports/0" { + t.Fatalf("callback path = %q", callbackPath) + } + sources := loaded.Sources() + if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory { + t.Fatalf("result source evidence = %#v", sources) + } + sources[0].ImportSelections[0].Path = Keys("result mutation") + if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" { + t.Fatalf("result snapshot changed after mutation: %q", got) + } + + failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit: + imports: + - dir: absent + - path: required-missing.yml + required: true +`) + _, err = Load(File(failurePath), Options[config]{Imports: true}) + var loadError *LoadError + if !errors.As(err, &loadError) { + t.Fatalf("failed load error = %T %v", err, err) + } + errorSources := loadError.Sources() + if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" { + t.Fatalf("error source evidence = %#v", errorSources) + } + errorSources[0].ImportSelections[0].Path = Keys("error mutation") + if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" { + t.Fatalf("error snapshot changed after mutation: %q", got) + } +} + +func writeFacadeImportFile(t *testing.T, root, name, contents string) string { + t.Helper() + filename := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return filename +} + +func canonicalFacadeImportPath(t *testing.T, filename string) string { + t.Helper() + canonical, err := filepath.EvalSymlinks(filename) + if err != nil { + t.Fatal(err) + } + return filepath.ToSlash(canonical) +} diff --git a/internal/engine/import_spec.go b/internal/engine/import_spec.go index f1c5c78..3af59dc 100644 --- a/internal/engine/import_spec.go +++ b/internal/engine/import_spec.go @@ -52,6 +52,40 @@ type importDefaults struct { pattern []string } +// ImportDefaults transports root-owned load defaults across the facade +// boundary. Load options snapshot this value before source acquisition. +type ImportDefaults struct { + // Required controls absence handling when non-nil. + Required *bool + // Follow controls symlink following when non-nil. + Follow *bool + // Hidden controls hidden-entry discovery when non-nil. + Hidden *bool + // Recursive controls descendant directory traversal when non-nil. + Recursive *bool + // Pattern filters directory-search filenames; nil and empty differ. + Pattern []string +} + +// OptionsWithImportDefaults copies root-owned defaults into ordinary options. +func OptionsWithImportDefaults[T any](options Options[T], value ImportDefaults) Options[T] { + options.importDefaults = privateImportDefaults(value) + return options +} + +// LayeredOptionsWithImportDefaults copies root-owned defaults into grouped options. +func LayeredOptionsWithImportDefaults[T any](options LayeredOptions[T], value ImportDefaults) LayeredOptions[T] { + options.importDefaults = privateImportDefaults(value) + return options +} + +func privateImportDefaults(value ImportDefaults) importDefaults { + return snapshotImportDefaults(importDefaults{ + required: value.Required, follow: value.Follow, hidden: value.Hidden, + recursive: value.Recursive, pattern: value.Pattern, + }) +} + type resolvedImportPolicy struct { required bool follow bool diff --git a/internal/engine/load.go b/internal/engine/load.go index fa571fd..c74c03c 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -24,17 +24,18 @@ type Definition[T any] struct { // Imports enables explicit import declarations; Overlay selects one fixed overlay; // Overrides contains only values explicitly supplied by an application CLI. type Options[T any] struct { - Definition Definition[T] - Imports bool - Overlay string - Filesystem *FilesystemContext - Overrides []Override - SourceVersionMode sourceVersionMode - importDefaults importDefaults - destination Path - placementMode placementMode - sequencePolicy sequencePolicy - deferAssembly bool + Definition Definition[T] + Imports bool + Overlay string + Filesystem *FilesystemContext + Overrides []Override + SourceVersionMode sourceVersionMode + importDefaults importDefaults + destination Path + placementMode placementMode + sequencePolicy sequencePolicy + deferAssembly bool + importDefaultsValidated bool } // FilesystemContext supplies filesystem roots for imports and overlays. diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index 74486b4..0288af7 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -48,7 +48,8 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi request.definition.sequenceRegistrations = nil mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{ Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), - SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode, importDefaults: request.importDefaults, + SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode, + importDefaults: request.importDefaults, importDefaultsValidated: true, }}) request.main.destination = mainOptions.destination diagnostics = append(diagnostics, mainDiagnostics...) @@ -56,7 +57,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi for groupIndex := range request.underlays { for inputIndex := range request.underlays[groupIndex].inputs { input := &request.underlays[groupIndex].inputs[inputIndex] - inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode, importDefaults: request.importDefaults}}) + inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{ + Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode, + importDefaults: request.importDefaults, importDefaultsValidated: true, + }}) input.destination = inputOptions.destination diagnostics = append(diagnostics, inputDiagnostics...) } diff --git a/internal/engine/options.go b/internal/engine/options.go index f0c2a19..e7b3a47 100644 --- a/internal/engine/options.go +++ b/internal/engine/options.go @@ -9,7 +9,10 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D options = supplied[0] } options.importDefaults = snapshotImportDefaults(options.importDefaults) - diagnostics := validateImportDefaults(options.importDefaults) + var diagnostics []Diagnostic + if !options.importDefaultsValidated { + diagnostics = validateImportDefaults(options.importDefaults) + } if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid")) } diff --git a/internal/engine/records.go b/internal/engine/records.go index c9906e3..cc70b25 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -207,6 +207,90 @@ type importSelectionEvidence struct { outcome importSelectionOutcome } +// ImportSelectionForFacade is a root-converted source-selection transport +// record. Its paths and enum spellings are converted by the facade. +type ImportSelectionForFacade struct { + Path Path // source-local import declaration path + Location Location // authored location and declaring source ID + Selector string // path, dir, or glob + Outcome string // none for a completed empty optional selection +} + +// SourceInfoImportSelectionsForFacade returns detached selection records for +// explicit root-owned conversion. +func SourceInfoImportSelectionsForFacade(source SourceInfo) []ImportSelectionForFacade { + if source.importSelections == nil { + return nil + } + result := make([]ImportSelectionForFacade, len(source.importSelections)) + for index, selection := range source.importSelections { + result[index] = ImportSelectionForFacade{ + Path: selection.path.clone(), Location: selection.location, + Selector: importSelectorString(selection.selector), + Outcome: importSelectionOutcomeString(selection.outcome), + } + } + return result +} + +// SourceInfoWithImportSelections copies converted root-owned records into +// private engine source evidence. +func SourceInfoWithImportSelections(source SourceInfo, selections []ImportSelectionForFacade) SourceInfo { + if selections == nil { + source.importSelections = nil + return source + } + source.importSelections = make([]importSelectionEvidence, len(selections)) + for index, selection := range selections { + source.importSelections[index] = importSelectionEvidence{ + path: selection.Path.clone(), location: selection.Location, + selector: importSelectorFromString(selection.Selector), + outcome: importSelectionOutcomeFromString(selection.Outcome), + } + } + return source +} + +func importSelectorString(value importSelector) string { + switch value { + case importSelectorPath: + return "path" + case importSelectorDirectory: + return "dir" + case importSelectorGlob: + return "glob" + default: + return "" + } +} + +func importSelectorFromString(value string) importSelector { + switch value { + case "path": + return importSelectorPath + case "dir": + return importSelectorDirectory + case "glob": + return importSelectorGlob + default: + return 0 + } +} + +func importSelectionOutcomeString(value importSelectionOutcome) string { + if value == importSelectionNone { + return "none" + } + return "" +} + +func importSelectionOutcomeFromString(value string) importSelectionOutcome { + if value == "none" { + return importSelectionNone + } + return 0 +} + // SourceInfoPlacementModeForFacade converts private placement evidence to its // root-owned numeric representation. func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 { diff --git a/layered.go b/layered.go index cfe878d..dd72c54 100644 --- a/layered.go +++ b/layered.go @@ -35,6 +35,9 @@ type Underlay struct{ Inputs []Input } type LayeredOptions[T any] struct { Definition Definition[T] SourceVersionMode SourceVersionMode + // ImportDefaults supplies one load-wide default policy for enabled main, + // underlay, and transitive declarations. Explicit declaration values win. + ImportDefaults ImportDefaults Underlays []Underlay Overlay string OverlayFilesystem *FilesystemContext @@ -73,6 +76,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti Overlay: options.Overlay, OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem), } + result = engine.LayeredOptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults)) if options.Underlays != nil { result.Underlays = make([]engine.Underlay, len(options.Underlays)) for index, group := range options.Underlays { diff --git a/lifecycle.go b/lifecycle.go index e9183f3..0098e0a 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -72,6 +72,28 @@ const ( SourceVersionModeRequired ) +// ImportDefaults supplies load-wide defaults for enabled import declarations. +// Each field applies independently and an explicit declaration value takes +// precedence. Nil boolean fields are unspecified and leave the library defaults +// in effect: Required false, Follow true, Hidden true, and Recursive false. +// Pattern applies to directory searches; nil leaves filenames unrestricted, +// while a non-nil empty slice selects none. Defaults apply to main, underlay, +// and transitive imports, not selected overlays. +type ImportDefaults struct { + // Required defaults absence handling. For path, true requires every named + // file; for dir and glob, true requires at least one selected file. + Required *bool + // Follow controls discovered symbolic links for directory and glob search. + Follow *bool + // Hidden controls discovery of hidden files and directories. + Hidden *bool + // Recursive controls descendant traversal for directory searches. + Recursive *bool + // Pattern replaces the library's unrestricted filename matching for directory + // searches unless a declaration supplies its own pattern. + Pattern []string +} + // Options contains the application definition and per-load input selections. // Imports enables explicit import declarations; Overlay selects one fixed overlay; // Overrides contains only values explicitly supplied by an application CLI. @@ -86,8 +108,12 @@ type Options[T any] struct { Destination Path // PlacementMode defaults to mapping placement; unknown values and root item // placement fail before acquisition. - PlacementMode PlacementMode - Imports bool + PlacementMode PlacementMode + Imports bool + // ImportDefaults supplies one load-wide default policy for every enabled + // declaration, including transitive imports. Each field is independently + // overridden by an explicit declaration value. + ImportDefaults ImportDefaults Overlay string Filesystem *FilesystemContext Overrides []Override @@ -301,6 +327,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] { SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)), } result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode)) + result = engine.OptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults)) if options.Filesystem != nil { result.Filesystem = toEngineFilesystemContext(options.Filesystem) } diff --git a/records.go b/records.go index a51ce2a..c5dad51 100644 --- a/records.go +++ b/records.go @@ -210,6 +210,39 @@ type SourceInfo struct { // and failed loads, text is conservatively withheld; successful final source // records reflect completed sensitivity classification. VersionCheck *SourceVersionCheck + // ImportSelections records completed optional declarations that selected no + // sources, in declaration order. Paths are local to this declaring source. + ImportSelections []ImportSelection +} + +// ImportSelector identifies the selector used by an import declaration. +type ImportSelector string + +const ( + // ImportSelectorPath identifies a named-file selector. + ImportSelectorPath ImportSelector = "path" + // ImportSelectorDirectory identifies a literal directory search. + ImportSelectorDirectory ImportSelector = "dir" + // ImportSelectorGlob identifies a path-glob selector. + ImportSelectorGlob ImportSelector = "glob" +) + +// ImportSelectionOutcome identifies the established result of import selection. +type ImportSelectionOutcome string + +const ( + // ImportSelectionNone means the optional declaration selected no sources. + ImportSelectionNone ImportSelectionOutcome = "none" +) + +// ImportSelection records an optional import declaration that selected no +// sources. Path is local to the declaring source; Location identifies its +// declaration mapping and source ID. +type ImportSelection struct { + Path Path + Location Location + Selector ImportSelector + Outcome ImportSelectionOutcome } // SourceVersionCheck describes a source-local version declaration observed @@ -295,9 +328,22 @@ func cloneSourceInfo(source SourceInfo) SourceInfo { source.Destination = source.Destination.clone() source.ImportedFrom = cloneLocation(source.ImportedFrom) source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck) + source.ImportSelections = cloneImportSelections(source.ImportSelections) return source } +func cloneImportSelections(selections []ImportSelection) []ImportSelection { + if selections == nil { + return nil + } + cloned := make([]ImportSelection, len(selections)) + for index, selection := range selections { + selection.Path = selection.Path.clone() + cloned[index] = selection + } + return cloned +} + func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck { if value == nil { return nil diff --git a/records_test.go b/records_test.go index a8e48b0..a1406a0 100644 --- a/records_test.go +++ b/records_test.go @@ -71,3 +71,34 @@ func TestSourceVersionCheckConversionsAndCopies(t *testing.T) { t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck) } } + +func TestImportSelectionConversionsAndCopies(t *testing.T) { + declaration, err := ParsePath("/configkit/imports/2") + if err != nil { + t.Fatal(err) + } + source := SourceInfo{ID: "source-4", ImportSelections: []ImportSelection{{ + Path: declaration, Location: Location{SourceID: "source-4", Line: 7, Column: 5}, + Selector: ImportSelectorGlob, Outcome: ImportSelectionNone, + }}} + internal := toEngineSourceInfo(source) + transport := engine.SourceInfoImportSelectionsForFacade(internal) + if len(transport) != 1 || transport[0].Path.String() != declaration.String() || transport[0].Location.SourceID != source.ID || transport[0].Selector != "glob" || transport[0].Outcome != "none" { + t.Fatalf("engine selection evidence = %#v", transport) + } + source.ImportSelections[0].Path = Keys("changed") + converted := fromEngineSourceInfo(internal) + if len(converted.ImportSelections) != 1 || converted.ImportSelections[0].Path.String() != declaration.String() || converted.ImportSelections[0].Location.Line != 7 || converted.ImportSelections[0].Selector != ImportSelectorGlob || converted.ImportSelections[0].Outcome != ImportSelectionNone { + t.Fatalf("converted selection evidence = %#v", converted.ImportSelections) + } + copy := cloneSourceInfo(converted) + copy.ImportSelections[0].Path = Keys("copy") + copy.ImportSelections[0].Selector = ImportSelectorPath + if converted.ImportSelections[0].Path.String() != declaration.String() || converted.ImportSelections[0].Selector != ImportSelectorGlob { + t.Fatalf("source selection evidence changed through copy: %#v", converted.ImportSelections) + } + empty := cloneSourceInfo(SourceInfo{ImportSelections: make([]ImportSelection, 0)}) + if empty.ImportSelections == nil { + t.Fatal("cloning collapsed a non-nil empty selection slice") + } +}