diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 95edb53..5ee9b00 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -16,6 +16,8 @@ release gate is part of this work. ## Stage 1 — Define the Go API and preflight rules +**Status:** Complete. Main-relative descriptions and layered preflight are in place. + In root and engine `layered.go`, add matching descriptions and carry them into `loadInput` and `loadRequest` in the engine. Use these public names: diff --git a/facade_main_relative_options_test.go b/facade_main_relative_options_test.go new file mode 100644 index 0000000..753bfcc --- /dev/null +++ b/facade_main_relative_options_test.go @@ -0,0 +1,45 @@ +package configkit_test + +import ( + "testing" + + configkit "go.maximumdirect.net/configkit" +) + +func TestMainRelativeOptions(t *testing.T) { + if configkit.MainRelative() == configkit.MainRelative() { + t.Fatal("binding descriptions must be distinct") + } + invalid := &configkit.FilesystemContext{BaseDir: "relative", Root: "relative"} + cases := []struct { + name string + main configkit.Input + underlay configkit.Input + overlay string + overlayContext, fallback *configkit.FilesystemContext + wantError bool + }{ + {name: "binding on main", main: configkit.Input{Source: configkit.NoSource(), MainRelative: configkit.MainRelative()}, wantError: true}, + {name: "binding on file", underlay: configkit.Input{Source: configkit.File("missing"), MainRelative: configkit.MainRelative()}, wantError: true}, + {name: "invalid fallback", underlay: configkit.Input{Source: configkit.NoSource(), MainRelative: &configkit.MainRelativeContext{Fallback: invalid}}, wantError: true}, + {name: "shadowed binding", underlay: configkit.Input{Source: configkit.NoSource(), Filesystem: &configkit.FilesystemContext{Root: t.TempDir()}, MainRelative: &configkit.MainRelativeContext{Fallback: invalid}}}, + {name: "inactive overlay fallback", fallback: invalid}, + {name: "invalid explicit overlay", overlay: "test", overlayContext: invalid, fallback: &configkit.FilesystemContext{Root: t.TempDir(), BaseDir: t.TempDir()}, wantError: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + main := tc.main + if tc.name != "binding on main" { + main.Source = configkit.NoSource() + } + opts := configkit.LayeredOptions[layeredFacadeConfig]{Overlay: tc.overlay, OverlayFilesystem: tc.overlayContext, OverlayFallback: tc.fallback} + if tc.name == "binding on file" || tc.name == "invalid fallback" || tc.name == "shadowed binding" { + opts.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{tc.underlay}}} + } + _, err := configkit.LoadLayered(main, opts) + if (err != nil) != tc.wantError { + t.Fatalf("error = %v, want error %v", err, tc.wantError) + } + }) + } +} diff --git a/internal/engine/layered.go b/internal/engine/layered.go index 5cb119c..8543a56 100644 --- a/internal/engine/layered.go +++ b/internal/engine/layered.go @@ -7,10 +7,16 @@ type Input struct { Source Source Imports bool Filesystem *FilesystemContext + MainRelative *MainRelativeContext Destination Path PlacementMode PlacementMode } +type MainRelativeContext struct { + Fallback *FilesystemContext + RequireBase bool +} + // Underlay contains strictly composed contributions at one precedence tier. type Underlay struct{ Inputs []Input } @@ -22,6 +28,7 @@ type LayeredOptions[T any] struct { Underlays []Underlay Overlay string OverlayFilesystem *FilesystemContext + OverlayFallback *FilesystemContext Overrides []Override } @@ -37,13 +44,14 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp } request := loadRequest[T]{ main: loadInput{ - source: main.Source, imports: main.Imports, filesystem: main.Filesystem, + source: main.Source, imports: main.Imports, filesystem: main.Filesystem, mainRelative: main.MainRelative, destination: main.Destination.clone(), placementMode: main.PlacementMode, }, definition: options.Definition, sourceVersionMode: options.SourceVersionMode, importDefaults: options.ImportDefaults, overlay: options.Overlay, overlayFilesystem: options.OverlayFilesystem, + overlayFallback: options.OverlayFallback, overrides: append([]Override(nil), options.Overrides...), underlays: make([]loadGroup, len(options.Underlays)), } @@ -51,7 +59,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp 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, + source: input.Source, imports: input.Imports, filesystem: input.Filesystem, mainRelative: input.MainRelative, destination: input.Destination.clone(), placementMode: input.PlacementMode, } } diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index c69d867..beb48e0 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -16,6 +16,7 @@ type loadRequest[T any] struct { effectiveVersion *VersionRule overlay string overlayFilesystem *FilesystemContext + overlayFallback *FilesystemContext overrides []Override ordinary bool } @@ -31,6 +32,7 @@ type loadInput struct { source Source imports bool filesystem *FilesystemContext + mainRelative *MainRelativeContext destination Path placementMode PlacementMode } @@ -57,6 +59,9 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec request.importDefaults = defaults request.sequencePolicy = policy request.definition.sequenceIdentityRegistrations = nil + if request.main.mainRelative != nil && request.main.filesystem == nil { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "main input cannot use main-relative context")) + } main, mainDiagnostics := normalizeInput(request.main, "") request.main = main diagnostics = append(diagnostics, mainDiagnostics...) @@ -64,6 +69,13 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec for groupIndex := range request.underlays { for inputIndex := range request.underlays[groupIndex].inputs { input := &request.underlays[groupIndex].inputs[inputIndex] + if input.mainRelative != nil && input.filesystem == nil { + if input.source.kind == sourceFile || input.source.kind == sourceFilesystem || input.source.kind == sourceSearch { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "file and search underlays cannot use main-relative context")) + } else if input.mainRelative.Fallback != nil { + diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, input.mainRelative.Fallback)...) + } + } normalized, inputDiagnostics := normalizeInput(*input, "") *input = normalized diagnostics = append(diagnostics, inputDiagnostics...) @@ -71,10 +83,10 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec } diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...) if activeOverlay(request.overlay) { - if request.overlayFilesystem == nil { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context")) - } else { + if request.overlayFilesystem != nil { diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFilesystem)...) + } else if request.overlayFallback != nil { + diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFallback)...) } } if len(diagnostics) != 0 { diff --git a/internal/engine/options.go b/internal/engine/options.go index 6705cd9..1b13991 100644 --- a/internal/engine/options.go +++ b/internal/engine/options.go @@ -29,7 +29,7 @@ func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagn if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")) } - if input.filesystem == nil && (input.source.kind == sourceBytes || input.source.kind == sourceReader || input.source.kind == sourceMapping) && (input.imports || activeOverlay(ordinaryOverlay)) { + if input.filesystem == nil && input.mainRelative == nil && (input.source.kind == sourceBytes || input.source.kind == sourceReader || input.source.kind == sourceMapping) && (input.imports || activeOverlay(ordinaryOverlay)) { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context")) } return input, canonicalDiagnostics(diagnostics) diff --git a/layered.go b/layered.go index bf29b00..582426a 100644 --- a/layered.go +++ b/layered.go @@ -19,8 +19,19 @@ type Input struct { PlacementMode PlacementMode Imports bool Filesystem *FilesystemContext + MainRelative *MainRelativeContext } +// MainRelativeContext selects the main input's resolved context for an underlay. +// Fallback is used when the main has no usable context; RequireBase requires one. +type MainRelativeContext struct { + Fallback *FilesystemContext + RequireBase bool +} + +// MainRelative returns a fresh default main-relative binding description. +func MainRelative() *MainRelativeContext { return &MainRelativeContext{} } + // 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 } @@ -42,6 +53,7 @@ type LayeredOptions[T any] struct { Underlays []Underlay Overlay string OverlayFilesystem *FilesystemContext + OverlayFallback *FilesystemContext Overrides []Override } @@ -68,6 +80,9 @@ func toEngineInput(input Input) engine.Input { Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination), PlacementMode: engine.PlacementMode(input.PlacementMode), } + if input.MainRelative != nil { + result.MainRelative = &engine.MainRelativeContext{Fallback: toEngineFilesystemContext(input.MainRelative.Fallback), RequireBase: input.MainRelative.RequireBase} + } return result } @@ -77,6 +92,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)), Overlay: options.Overlay, OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem), + OverlayFallback: toEngineFilesystemContext(options.OverlayFallback), ImportDefaults: toEngineImportDefaults(options.ImportDefaults), } if options.Underlays != nil {