Expose layered configuration loading and inspection

This commit is contained in:
2026-09-29 17:58:15 +00:00
parent 1cdf90d831
commit 5a77728a81
13 changed files with 456 additions and 16 deletions

View File

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

View File

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

View File

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

243
facade_layered_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

74
layered.go Normal file
View File

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

View File

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

View File

@@ -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,13 +174,15 @@ 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
UnderlayIndex int
ImportedFrom *Location
}