Expose import defaults and selection evidence
This commit is contained in:
@@ -43,12 +43,80 @@ func toEngineLocations(values []Location) []engine.Location {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
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 {
|
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)
|
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 {
|
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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
|
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||||
execution profile concepts.
|
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
|
## Shared source-version checks
|
||||||
|
|
||||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||||
|
|||||||
230
facade_import_defaults_test.go
Normal file
230
facade_import_defaults_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -52,6 +52,40 @@ type importDefaults struct {
|
|||||||
pattern []string
|
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 {
|
type resolvedImportPolicy struct {
|
||||||
required bool
|
required bool
|
||||||
follow bool
|
follow bool
|
||||||
|
|||||||
@@ -24,17 +24,18 @@ type Definition[T any] struct {
|
|||||||
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
Imports bool
|
||||||
Overlay string
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
SourceVersionMode sourceVersionMode
|
SourceVersionMode sourceVersionMode
|
||||||
importDefaults importDefaults
|
importDefaults importDefaults
|
||||||
destination Path
|
destination Path
|
||||||
placementMode placementMode
|
placementMode placementMode
|
||||||
sequencePolicy sequencePolicy
|
sequencePolicy sequencePolicy
|
||||||
deferAssembly bool
|
deferAssembly bool
|
||||||
|
importDefaultsValidated bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
|||||||
request.definition.sequenceRegistrations = nil
|
request.definition.sequenceRegistrations = nil
|
||||||
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
||||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
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
|
request.main.destination = mainOptions.destination
|
||||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||||
@@ -56,7 +57,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
|||||||
for groupIndex := range request.underlays {
|
for groupIndex := range request.underlays {
|
||||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
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
|
input.destination = inputOptions.destination
|
||||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
options = supplied[0]
|
options = supplied[0]
|
||||||
}
|
}
|
||||||
options.importDefaults = snapshotImportDefaults(options.importDefaults)
|
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 {
|
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,90 @@ type importSelectionEvidence struct {
|
|||||||
outcome importSelectionOutcome
|
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
|
// SourceInfoPlacementModeForFacade converts private placement evidence to its
|
||||||
// root-owned numeric representation.
|
// root-owned numeric representation.
|
||||||
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type Underlay struct{ Inputs []Input }
|
|||||||
type LayeredOptions[T any] struct {
|
type LayeredOptions[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
SourceVersionMode SourceVersionMode
|
SourceVersionMode SourceVersionMode
|
||||||
|
// ImportDefaults supplies one load-wide default policy for enabled main,
|
||||||
|
// underlay, and transitive declarations. Explicit declaration values win.
|
||||||
|
ImportDefaults ImportDefaults
|
||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
OverlayFilesystem *FilesystemContext
|
OverlayFilesystem *FilesystemContext
|
||||||
@@ -73,6 +76,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
|
|||||||
Overlay: options.Overlay,
|
Overlay: options.Overlay,
|
||||||
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
||||||
}
|
}
|
||||||
|
result = engine.LayeredOptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults))
|
||||||
if options.Underlays != nil {
|
if options.Underlays != nil {
|
||||||
result.Underlays = make([]engine.Underlay, len(options.Underlays))
|
result.Underlays = make([]engine.Underlay, len(options.Underlays))
|
||||||
for index, group := range options.Underlays {
|
for index, group := range options.Underlays {
|
||||||
|
|||||||
31
lifecycle.go
31
lifecycle.go
@@ -72,6 +72,28 @@ const (
|
|||||||
SourceVersionModeRequired
|
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.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
@@ -86,8 +108,12 @@ type Options[T any] struct {
|
|||||||
Destination Path
|
Destination Path
|
||||||
// PlacementMode defaults to mapping placement; unknown values and root item
|
// PlacementMode defaults to mapping placement; unknown values and root item
|
||||||
// placement fail before acquisition.
|
// placement fail before acquisition.
|
||||||
PlacementMode PlacementMode
|
PlacementMode PlacementMode
|
||||||
Imports bool
|
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
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
@@ -301,6 +327,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
|||||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||||
}
|
}
|
||||||
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
|
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
|
||||||
|
result = engine.OptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults))
|
||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
||||||
}
|
}
|
||||||
|
|||||||
46
records.go
46
records.go
@@ -210,6 +210,39 @@ type SourceInfo struct {
|
|||||||
// and failed loads, text is conservatively withheld; successful final source
|
// and failed loads, text is conservatively withheld; successful final source
|
||||||
// records reflect completed sensitivity classification.
|
// records reflect completed sensitivity classification.
|
||||||
VersionCheck *SourceVersionCheck
|
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
|
// SourceVersionCheck describes a source-local version declaration observed
|
||||||
@@ -295,9 +328,22 @@ func cloneSourceInfo(source SourceInfo) SourceInfo {
|
|||||||
source.Destination = source.Destination.clone()
|
source.Destination = source.Destination.clone()
|
||||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||||
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
||||||
|
source.ImportSelections = cloneImportSelections(source.ImportSelections)
|
||||||
return source
|
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 {
|
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -71,3 +71,34 @@ func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
|
|||||||
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user