diff --git a/conversion.go b/conversion.go index 9914f0b..0f1876f 100644 --- a/conversion.go +++ b/conversion.go @@ -43,10 +43,10 @@ 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, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)} + return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)} } func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { - return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)} + return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)} } func fromEngineOrigin(value engine.Origin) Origin { return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)} diff --git a/docs/config.md b/docs/config.md index 745cedf..b9ef3c5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,6 +111,43 @@ snapshot. ConfigKit does not roll back application callback effects or manage wo or resources created by application callbacks. The explicit secrets-directory bootstrap remains a separate operation invoked by the application. +## Layered loading + +`LoadLayered[T](main, options...)` accepts zero or one `LayeredOptions[T]` value. +Its `main` is an `Input` containing a source, import opt-in, and optional +filesystem context. A zero source is invalid; `Input{Source: NoSource()}` omits +the main document. Each `Underlay` contains inputs that compose strictly as +one tier, including each input's enabled imports. Empty groups contribute +nothing. Underlay groups are listed from lowest to highest priority. Repeating +a file within one group remains an import/repetition error; the same reusable +source may be selected again in another tier. + +ConfigKit acquires and composes the main input first, constructs the schema, +and checks the optional application version from the main tier only. Underlay +roots cannot claim that version path (`underlay.version`); imported fragments +retain `import.version` protection. It then acquires and composes underlay +groups in order, recursively combines the groups, and applies the main tier, +one selected overlay, and explicit CLI overrides. Scalar, sequence, and null +definitions replace lower values between tiers; mappings combine recursively. +Syntax and strict-composition errors inside a tier cannot be masked by a +higher-priority tier. Defaults, typed decoding, resolution, secret lookup, +validation, and publication run once on the final assembled input. + +An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly, +independent of the main source's namespace. Its name and root-relative lookup +follow the selected-overlay rules below; inactive overlays ignore the context. +The same path in a different filesystem namespace is not a repeated file. +Ordinary `Load` continues to infer its selected-overlay context from its source. + +Source IDs follow acquisition order—main and its depth-first imports, then each +underlay tier's inputs and depth-first imports, then overlay and CLI—even though +history follows precedence order. Source `Layer` describes precedence while +`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a +one-based `UnderlayIndex` giving their original tier position; all other records +have index zero. Explicit empty groups and `NoSource` consume no source ID. +Byte, source, node, and glob-entry budgets are shared across the load, while +the existing per-source and import-depth bounds still apply. + ### Application schema versions `VersionRule` declares a semantic path and non-nil checker. Before defaults or typed diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a698e96..2ce01c8 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -395,6 +395,16 @@ ordinary-load tests remain green. Run the everyday checker and engine race tests ## Stage 7: Expose layered loading and complete inspection integration +**Completed 2026-09-29.** The root facade now offers `Input`, `Underlay`, +`LayeredOptions`, and `LoadLayered` with copied options/context records. Explicit +host or native selected-overlay contexts work independently of the main source; +repetition uses native namespace identity rather than a display path alone. +Root layer/source conversions and explanation rendering include underlays. +Public tests cover mixed host/native/mapping tiers, recursive precedence, +empty/zero/false/null and sequence replacement, source order and tier indexes, +composite and displaced origins, resolution, redaction, and zero-underlay +equivalence. The everyday checker and full race suite passed. + 1. Add `Input`, `Underlay`, `LayeredOptions`, and `LoadLayered` root wrappers and conversions. Copy option slices/context records into private load state while preserving reusable namespace identities and single-consumption readers. diff --git a/facade_layered_test.go b/facade_layered_test.go new file mode 100644 index 0000000..7b15e94 --- /dev/null +++ b/facade_layered_test.go @@ -0,0 +1,243 @@ +package configkit_test + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "testing/fstest" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +type layeredFacadeConfig struct { + Settings map[string]string `yaml:"settings"` + Count int `yaml:"count"` + Enabled bool `yaml:"enabled"` + List []string `yaml:"list"` + Optional *string `yaml:"optional"` + Derived string `yaml:"derived"` +} + +func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) { + mainPath := filepath.Join(t.TempDir(), "main.yml") + if err := os.WriteFile(mainPath, []byte("settings: {shared: main, secret: new-canary}\ncount: 0\nenabled: false\nlist: []\noptional: null\n"), 0o600); err != nil { + t.Fatal(err) + } + lower := configkit.NewFilesystem("bundle", fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: defaults.yml}]}\nsettings: {shared: lower, native: retained, secret: old-canary}\nlist: [lower]\n")}, + "defaults.yml": &fstest.MapFile{Data: []byte("settings: {imported: retained}\n")}, + "overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {from_lower_namespace: retained}\n")}, + }) + selected := configkit.NewFilesystem("bundle", fstest.MapFS{ + "overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {shared: overlay}\n")}, + }) + checks := struct{ defaults, resolution, validation int }{} + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[layeredFacadeConfig]{ + Underlays: []configkit.Underlay{ + {Inputs: []configkit.Input{ + {Source: lower.File("main.yml"), Imports: true}, + {Source: configkit.Mapping("programmatic-low", map[string]any{"settings": map[string]any{"mapping": "retained"}, "count": 7, "enabled": true})}, + {Source: lower.File("overlays/testing.yml")}, + }}, + {Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic-high", map[string]any{ + "settings": map[string]any{"shared": "middle", "second": "retained"}, "list": []any{"middle"}, + })}}}, + }, + Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &selected}, + Overrides: []configkit.Override{{Path: configkit.Keys("settings", "shared"), Value: "cli", Flag: "--shared"}}, + Definition: configkit.Definition[layeredFacadeConfig]{ + Defaults: func() layeredFacadeConfig { checks.defaults++; return layeredFacadeConfig{} }, + Resolve: func(config *layeredFacadeConfig, resolution *configkit.Resolution) error { + checks.resolution++ + if source, ok := resolution.Source("source-2"); !ok || source.Layer != configkit.LayerUnderlay || source.UnderlayIndex != 1 || source.Kind != configkit.SourceKindFilesystem { + t.Errorf("resolver source = %#v, %v", source, ok) + } + configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native")) + return nil + }, + Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ }, + Secret: func(path configkit.Path) bool { return path.String() == "/settings/secret" }, + }, + }) + if err != nil { + t.Fatalf("layered load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + wantSettings := map[string]string{ + "shared": "cli", "secret": "new-canary", "native": "retained", "imported": "retained", + "mapping": "retained", "from_lower_namespace": "retained", "second": "retained", + } + if !reflect.DeepEqual(result.Config.Settings, wantSettings) || result.Config.Count != 0 || result.Config.Enabled || result.Config.List == nil || len(result.Config.List) != 0 || result.Config.Optional != nil || result.Config.Derived != "retained" { + t.Fatalf("configuration = %#v", result.Config) + } + if checks != (struct{ defaults, resolution, validation int }{1, 1, 1}) { + t.Fatalf("lifecycle calls = %#v", checks) + } + sources := result.Sources() + if len(sources) != 8 { + t.Fatalf("sources = %#v", sources) + } + wantLayers := []configkit.Layer{configkit.LayerBase, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI} + wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0} + wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI} + for index, source := range sources { + if source.ID != "source-"+strconv.Itoa(index+1) || source.Layer != wantLayers[index] || source.UnderlayIndex != wantIndexes[index] || source.Kind != wantKinds[index] { + t.Errorf("source %d = %#v", index, source) + } + } + if sources[4].Path != sources[6].Path || sources[4].Label != sources[6].Label { + t.Fatalf("namespace-collision fixture changed: %#v, %#v", sources[4], sources[6]) + } + sources[1].Label = "changed" + if result.Sources()[1].Label != "bundle/main.yml" { + t.Fatal("source records alias retained metadata") + } + for _, test := range []struct { + path string + want configkit.Layer + }{ + {"/settings/native", configkit.LayerUnderlay}, + {"/settings/shared", configkit.LayerCLI}, + {"/derived", configkit.LayerResolution}, + } { + path, err := configkit.ParsePath(test.path) + if err != nil { + t.Fatal(err) + } + info, ok := result.Metadata().Lookup(path) + if !ok || info.Origin.Layer != test.want { + t.Errorf("%s metadata = %#v, %v", test.path, info, ok) + } + } + container, ok := result.Metadata().Lookup(configkit.Keys("settings")) + if !ok || len(container.Origin.Locations) < 4 { + t.Fatalf("composite mapping origin = %#v, %v", container, ok) + } + explanation, ok := result.Explain(configkit.Keys("settings", "shared")) + if !ok || len(explanation.History) < 5 || explanation.Origin.Layer != configkit.LayerCLI { + t.Fatalf("shared history = %#v, %v", explanation, ok) + } + var effective, rendered bytes.Buffer + if err := result.WriteEffective(&effective); err != nil { + t.Fatal(err) + } + if err := result.WriteExplanation(&rendered, configkit.Keys("settings", "secret")); err != nil { + t.Fatal(err) + } + if strings.Contains(effective.String()+rendered.String(), "old-canary") || strings.Contains(effective.String()+rendered.String(), "new-canary") || !strings.Contains(rendered.String(), "redacted") { + t.Fatalf("secret rendering leaked: effective %q, explanation %q", effective.String(), rendered.String()) + } +} + +func TestFacadeLayeredDisplacedDescendantRemainsInspectable(t *testing.T) { + type config struct { + Choice string `yaml:"choice"` + } + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"choice": "flat"})}, configkit.LayeredOptions[config]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{ + "choice": map[string]any{"nested": "historical"}, + })}}}}, + }) + if err != nil || result.Config.Choice != "flat" { + t.Fatalf("replacement load = %#v, %v", result, err) + } + path := configkit.Keys("choice", "nested") + explanation, ok := result.Explain(path) + if !ok || explanation.Effective.Present || len(explanation.History) == 0 || explanation.History[0].Origin.Layer != configkit.LayerUnderlay { + t.Fatalf("deleted descendant = %#v, %v", explanation, ok) + } +} + +func TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad(t *testing.T) { + filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{ + "overlays/testing.yml": &fstest.MapFile{Data: []byte("value: selected\n")}, + }) + context := &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem} + source := configkit.Bytes("main", []byte("value: base\n")) + type config struct { + Value string `yaml:"value"` + } + ordinary, err := configkit.Load(source, configkit.Options[config]{Overlay: "testing", Filesystem: context}) + if err != nil { + t.Fatal(err) + } + layered, err := configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[config]{Overlay: "testing", OverlayFilesystem: context}) + if err != nil { + t.Fatal(err) + } + if ordinary.Config != layered.Config || !reflect.DeepEqual(ordinary.Sources(), layered.Sources()) { + t.Fatalf("ordinary = %#v, %#v; layered = %#v, %#v", ordinary.Config, ordinary.Sources(), layered.Config, layered.Sources()) + } + var first, second bytes.Buffer + if err := ordinary.WriteEffective(&first); err != nil { + t.Fatal(err) + } + if err := layered.WriteEffective(&second); err != nil { + t.Fatal(err) + } + if first.String() != second.String() { + t.Fatalf("effective ordinary %q, layered %q", first.String(), second.String()) + } + if ordinaryExplanation, ok := ordinary.Explain(configkit.Keys("value")); !ok { + t.Fatal("ordinary explanation missing") + } else if layeredExplanation, ok := layered.Explain(configkit.Keys("value")); !ok || !reflect.DeepEqual(ordinaryExplanation, layeredExplanation) { + t.Fatalf("explanations differ: %#v, %#v", ordinaryExplanation, layeredExplanation) + } +} + +func TestFacadeLayeredOptionAndVersionFailures(t *testing.T) { + type config struct { + Version string `yaml:"version"` + } + namespace := configkit.NewFilesystem("bundle", fstest.MapFS{ + "overlays/testing.yml": &fstest.MapFile{Data: []byte("{}\n")}, + }) + for _, test := range []struct { + name string + main configkit.Input + options []configkit.LayeredOptions[config] + want configkit.Code + }{ + {"zero source", configkit.Input{}, nil, configkit.CodeSourceInvalid}, + {"multiple options", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{}, {}}, configkit.CodeOptionsInvalid}, + {"missing overlay context", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{Overlay: "testing"}}, configkit.CodeOptionsInvalid}, + {"selected file already acquired", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{ + Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &namespace}, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: namespace.File("overlays/testing.yml")}}}}, + }}, configkit.CodeOverlayRepeated}, + {"underlay version", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{ + Definition: configkit.Definition[config]{Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}}, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"version": "forbidden"})}}}}, + }}, configkit.CodeUnderlayVersion}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := configkit.LoadLayered(test.main, test.options...) + loadError, ok := err.(*configkit.LoadError) + if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.want { + t.Fatalf("error = %#v, want %s", err, test.want) + } + }) + } +} + +func TestFacadeLayeredExplicitHostOverlayWithoutMain(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "overlays"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "overlays", "testing.yml"), []byte("value: selected\n"), 0o600); err != nil { + t.Fatal(err) + } + type config struct { + Value string `yaml:"value"` + } + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{ + Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: root, BaseDir: root}, + }) + if err != nil || result.Config.Value != "selected" || len(result.Sources()) != 1 || result.Sources()[0].Kind != configkit.SourceKindFile { + t.Fatalf("explicit host overlay = %#v, %v", result, err) + } +} diff --git a/internal/engine/explain_render.go b/internal/engine/explain_render.go index ef5b1b9..4724aae 100644 --- a/internal/engine/explain_render.go +++ b/internal/engine/explain_render.go @@ -38,7 +38,7 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error { rendered.WriteString(" - representation: ") if change.Origin.Operation == ChangeDecoding { rendered.WriteString("input to canonical typed\n") - } else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI { + } else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerUnderlay || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI { rendered.WriteString("policy-validated input\n") } else { rendered.WriteString("canonical typed\n") diff --git a/internal/engine/imports.go b/internal/engine/imports.go index b3867af..bb56e62 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -261,6 +261,9 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati label = s.access.label + "/" + canonical } info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Layer: LayerBase, Kind: kind, ImportedFrom: &location} + if kind == SourceKindFilesystem { + info.namespace = s.access.token + } s.sources = append(s.sources, info) data, skipped, diagnostic := s.readImport(canonical, sourceID, declaration.optional) if diagnostic != nil { diff --git a/internal/engine/layered.go b/internal/engine/layered.go new file mode 100644 index 0000000..63f6095 --- /dev/null +++ b/internal/engine/layered.go @@ -0,0 +1,50 @@ +package engine + +import "os" + +// Input selects one source and its independent import policy. +type Input struct { + Source Source + Imports bool + Filesystem *FilesystemContext +} + +// Underlay contains strictly composed contributions at one precedence tier. +type Underlay struct{ Inputs []Input } + +// LayeredOptions selects lower-priority tiers and final application rules. +type LayeredOptions[T any] struct { + Definition Definition[T] + Underlays []Underlay + Overlay string + OverlayFilesystem *FilesystemContext + Overrides []Override +} + +// LoadLayered loads a main input and lower-priority grouped inputs through one +// final decode, resolution, and validation lifecycle. +func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOptions[T]) (*Loaded[T], error) { + if len(supplied) > 1 { + return nil, (&diagnosticAccumulator{diagnostics: []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "LoadLayered accepts at most one options value")}}).loadError() + } + var options LayeredOptions[T] + if len(supplied) != 0 { + options = supplied[0] + } + request := loadRequest[T]{ + main: loadInput{source: main.Source, imports: main.Imports, filesystem: main.Filesystem}, + definition: options.Definition, overlay: options.Overlay, + overlayFilesystem: options.OverlayFilesystem, + overrides: append([]Override(nil), options.Overrides...), + underlays: make([]loadGroup, len(options.Underlays)), + } + for index, underlay := range options.Underlays { + request.underlays[index].inputs = make([]loadInput, len(underlay.Inputs)) + for inputIndex, input := range underlay.Inputs { + request.underlays[index].inputs[inputIndex] = loadInput{ + source: input.Source, imports: input.Imports, filesystem: input.Filesystem, + } + } + } + return loadGroupedWithSecretLookup(request, secrets, os.LookupEnv) +} diff --git a/internal/engine/records.go b/internal/engine/records.go index aeb7587..97db8bc 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -182,6 +182,7 @@ type SourceInfo struct { Kind SourceKind UnderlayIndex int ImportedFrom *Location + namespace *namespaceToken } // Diagnostic is a safe structured problem report. Message and source labels must diff --git a/internal/engine/selected_overlay.go b/internal/engine/selected_overlay.go index 611523b..c5a68ed 100644 --- a/internal/engine/selected_overlay.go +++ b/internal/engine/selected_overlay.go @@ -84,7 +84,9 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)} } for _, existing := range sources { - if existing.Path != "" && existing.Path == canonical { + if existing.Path != "" && existing.Path == canonical && + ((access.native == nil && existing.Kind == SourceKindFile) || + (access.native != nil && existing.Kind == SourceKindFilesystem && existing.namespace == access.token)) { return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)} } } @@ -105,6 +107,9 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum label = access.label + "/" + canonical } overlaySource := SourceInfo{ID: sourceID, Label: label, Path: canonical, Layer: LayerOverlay, Kind: kind} + if kind == SourceKindFilesystem { + overlaySource.namespace = access.token + } sources = append(cloneSourceInfos(sources), overlaySource) data, diagnostic := readOverlayFile(access, canonical, sourceID, limits.bytes-base.current.inputBytes) if diagnostic != nil { diff --git a/internal/engine/source.go b/internal/engine/source.go index 90a594f..402130d 100644 --- a/internal/engine/source.go +++ b/internal/engine/source.go @@ -238,6 +238,7 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead case sourceFilesystem: access := newNativeAccess(source.namespace.filesystem, ".", source.namespace.token) info.Path = source.path + info.namespace = source.namespace.token canonical, err := access.canonical(source.path) if err != nil { return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)} diff --git a/layered.go b/layered.go new file mode 100644 index 0000000..d3fa855 --- /dev/null +++ b/layered.go @@ -0,0 +1,74 @@ +package configkit + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" + +// Input selects one source, whether its imports are enabled, and any explicit +// filesystem context. A zero Source is invalid; use NoSource() for an omitted +// main input. Sources keep their documented reuse and reader ownership rules. +type Input struct { + Source Source + Imports bool + Filesystem *FilesystemContext +} + +// Underlay contains inputs that must compose strictly within one tier. Tiers +// appear in lowest-to-highest precedence order; an empty tier is a no-op. +type Underlay struct{ Inputs []Input } + +// LayeredOptions selects lower-priority tiers and final application rules. +// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay +// ignores it. Overrides remain explicit CLI records, not general mappings. +type LayeredOptions[T any] struct { + Definition Definition[T] + Underlays []Underlay + Overlay string + OverlayFilesystem *FilesystemContext + Overrides []Override +} + +// LoadLayered acquires the main input first, strictly composes each underlay, +// then applies underlays in order beneath main, a selected overlay, and CLI +// overrides. It performs defaults, decoding, resolution, and validation once. +// Zero or one options value is accepted; any error returns a nil result. +func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T], error) { + options := make([]engine.LayeredOptions[T], len(supplied)) + for index := range supplied { + options[index] = toEngineLayeredOptions(supplied[index]) + } + loaded, err := engine.LoadLayered(toEngineInput(main), secretBinding(), options...) + return fromEngineLoaded(loaded, err) +} + +func toEngineInput(input Input) engine.Input { + return engine.Input{ + Source: input.Source.toEngine(), Imports: input.Imports, + Filesystem: toEngineFilesystemContext(input.Filesystem), + } +} + +func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] { + result := engine.LayeredOptions[T]{ + Definition: toEngineDefinition(options.Definition), Overlay: options.Overlay, + OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem), + } + if options.Underlays != nil { + result.Underlays = make([]engine.Underlay, len(options.Underlays)) + for index, group := range options.Underlays { + if group.Inputs != nil { + result.Underlays[index].Inputs = make([]engine.Input, len(group.Inputs)) + for inputIndex, input := range group.Inputs { + result.Underlays[index].Inputs[inputIndex] = toEngineInput(input) + } + } + } + } + if options.Overrides != nil { + result.Overrides = make([]engine.Override, len(options.Overrides)) + for index, override := range options.Overrides { + result.Overrides[index] = engine.Override{ + Path: toEnginePath(override.Path), Value: override.Value, Flag: override.Flag, + } + } + } + return result +} diff --git a/lifecycle.go b/lifecycle.go index 82e1d29..960c444 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -87,6 +87,10 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { options[index] = toEngineOptions(supplied[index]) } loaded, err := engine.Load(source.toEngine(), secretBinding(), options...) + return fromEngineLoaded(loaded, err) +} + +func fromEngineLoaded[T any](loaded *engine.Loaded[T], err error) (*Result[T], error) { if err != nil { var loadError *engine.LoadError if errors.As(err, &loadError) { @@ -226,11 +230,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] { Overlay: options.Overlay, } if options.Filesystem != nil { - result.Filesystem = &engine.FilesystemContext{BaseDir: options.Filesystem.BaseDir, Root: options.Filesystem.Root} - if options.Filesystem.FS != nil { - filesystem := options.Filesystem.FS.value - result.Filesystem.FS = &filesystem - } + result.Filesystem = toEngineFilesystemContext(options.Filesystem) } if options.Overrides != nil { result.Overrides = make([]engine.Override, len(options.Overrides)) @@ -243,6 +243,18 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] { return result } +func toEngineFilesystemContext(context *FilesystemContext) *engine.FilesystemContext { + if context == nil { + return nil + } + result := &engine.FilesystemContext{BaseDir: context.BaseDir, Root: context.Root} + if context.FS != nil { + filesystem := context.FS.value + result.FS = &filesystem + } + return result +} + func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] { result := engine.Definition[T]{Defaults: definition.Defaults} if definition.Resolve != nil { diff --git a/records.go b/records.go index d848ef4..0807aa1 100644 --- a/records.go +++ b/records.go @@ -6,6 +6,7 @@ type Layer string const ( LayerDefault Layer = "default" LayerBase Layer = "base" + LayerUnderlay Layer = "underlay" LayerOverlay Layer = "overlay" LayerCLI Layer = "cli" LayerResolution Layer = "resolution" @@ -102,6 +103,7 @@ const ( CodeImportRepeated Code = "import.repeated" CodeImportLimitExceeded Code = "import.limit_exceeded" CodeImportVersion Code = "import.version" + CodeUnderlayVersion Code = "underlay.version" CodeOverlayInvalid Code = "overlay.invalid" CodeOverlayMissing Code = "overlay.missing" @@ -172,14 +174,16 @@ type Location struct { // SourceInfo describes a source discovered during a load. Kind identifies its // acquisition, independent of Layer. Path is canonical for a host file and // namespace-relative for a filesystem file; synthetic sources have no Path. -// ImportedFrom identifies the declaration that included this source. +// UnderlayIndex is the one-based original underlay position, or zero for other +// sources. ImportedFrom identifies the declaration that included this source. type SourceInfo struct { - ID string - Label string - Path string - Layer Layer - Kind SourceKind - ImportedFrom *Location + ID string + Label string + Path string + Layer Layer + Kind SourceKind + UnderlayIndex int + ImportedFrom *Location } // Diagnostic is a safe structured problem report. Message and source labels must