diff --git a/internal/engine/import_spec.go b/internal/engine/import_spec.go new file mode 100644 index 0000000..e776987 --- /dev/null +++ b/internal/engine/import_spec.go @@ -0,0 +1,425 @@ +package engine + +import ( + "path" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +type importSelector uint8 + +const ( + importSelectorPath importSelector = iota + 1 + importSelectorDirectory + importSelectorGlob +) + +type importFieldSite struct { + path Path + location Location +} + +// importDeclarationSpec preserves authored presence until discovery policy is resolved. +type importDeclarationSpec struct { + path Path + location Location + selector importSelector + paths []string + glob string + globSegments []string + required *bool + recursive *bool + follow *bool + hidden *bool + pattern []string + patternSet bool + exclude []string + excludeSet bool + depth int + depthSet bool + destination Path + placement placementMode + fieldSites map[string]importFieldSite +} + +// importDefaults are the engine's private copy of load-wide discovery defaults. +type importDefaults struct { + required *bool + follow *bool + hidden *bool + recursive *bool + pattern []string +} + +type resolvedImportPolicy struct { + required bool + follow bool + hidden bool + recursive bool + pattern []string + depth int + depthSet bool + exclude []string + destination Path + placement placementMode +} + +func snapshotImportDefaults(value importDefaults) importDefaults { + return importDefaults{ + required: cloneBool(value.required), + follow: cloneBool(value.follow), + hidden: cloneBool(value.hidden), + recursive: cloneBool(value.recursive), + pattern: cloneStrings(value.pattern), + } +} + +func cloneBool(value *bool) *bool { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneStrings(values []string) []string { + if values == nil { + return nil + } + return append(make([]string, 0, len(values)), values...) +} + +func validateImportDefaults(value importDefaults) []Diagnostic { + for _, pattern := range value.pattern { + if _, err := path.Match(pattern, ""); err != nil { + return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")} + } + } + return nil +} + +func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (resolvedImportPolicy, []Diagnostic) { + defaults = snapshotImportDefaults(defaults) + policy := resolvedImportPolicy{ + required: boolOr(spec.required, defaults.required, false), + follow: true, + hidden: true, + recursive: false, + destination: spec.destination.clone(), + placement: spec.placement, + depth: spec.depth, + depthSet: spec.depthSet, + exclude: cloneStrings(spec.exclude), + } + if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob { + policy.follow = boolOr(spec.follow, defaults.follow, true) + policy.hidden = boolOr(spec.hidden, defaults.hidden, true) + } + if spec.selector == importSelectorDirectory { + policy.recursive = boolOr(spec.recursive, defaults.recursive, false) + if spec.patternSet { + policy.pattern = cloneStrings(spec.pattern) + } else { + policy.pattern = cloneStrings(defaults.pattern) + } + } + if spec.depthSet && !policy.recursive { + site := spec.fieldSites["depth"] + location := site.location + return resolvedImportPolicy{}, []Diagnostic{{ + Code: CodeImportInvalid, Severity: SeverityError, + Path: site.path.clone(), Message: "depth requires effective recursive discovery", + Primary: &location, + }} + } + return policy, nil +} + +func boolOr(explicit, application *bool, fallback bool) bool { + if explicit != nil { + return *explicit + } + if application != nil { + return *application + } + return fallback +} + +// parseImportSpecificationDirective validates every declaration before callers select candidates. +func parseImportSpecificationDirective(node *document.Node, directivePath Path) ([]importDeclarationSpec, []Diagnostic) { + if node.Kind != document.Mapping { + return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)} + } + var specs []importDeclarationSpec + var diagnostics []Diagnostic + for _, entry := range node.Mapping { + fieldPath := directivePath.Key(entry.Key) + if entry.Key != "imports" { + diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)) + continue + } + if entry.Value.Kind != document.Sequence { + diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)) + continue + } + for index, declarationNode := range entry.Value.Sequence { + spec, issues := parseImportDeclarationSpec(declarationNode, fieldPath.Index(index)) + if len(issues) != 0 { + diagnostics = append(diagnostics, issues...) + continue + } + specs = append(specs, spec) + } + } + return specs, canonicalDiagnostics(diagnostics) +} + +func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (importDeclarationSpec, []Diagnostic) { + if node.Kind != document.Mapping { + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)} + } + spec := importDeclarationSpec{ + path: declarationPath.clone(), + location: publicLocation(node.Location), + placement: placementModeMapping, + fieldSites: make(map[string]importFieldSite, len(node.Mapping)), + } + selectors := 0 + selectorIndex := -1 + for index, entry := range node.Mapping { + if selectorForField(entry.Key) != 0 { + selectors++ + selectorIndex = index + } + } + if selectors != 1 { + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path, dir, or glob selector", node)} + } + selectorEntry := node.Mapping[selectorIndex] + spec.selector = selectorForField(selectorEntry.Key) + for _, entry := range node.Mapping { + fieldPath := declarationPath.Key(entry.Key) + spec.fieldSites[entry.Key] = importFieldSite{path: fieldPath.clone(), location: publicLocation(entry.Value.Location)} + if selectorForField(entry.Key) != 0 { + continue + } + if !importFieldApplies(entry.Key, spec.selector) { + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not supported by this selector", entry.Value)} + } + switch entry.Key { + case "required", "recursive", "follow", "hidden": + if entry.Value.Kind != document.Boolean { + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, entry.Key+" must be a boolean", entry.Value)} + } + value := entry.Value.Text == "true" + switch entry.Key { + case "required": + spec.required = &value + case "recursive": + spec.recursive = &value + case "follow": + spec.follow = &value + case "hidden": + spec.hidden = &value + } + case "pattern": + values, origins, ok, badIndex := parseStringSet(entry.Value) + if !ok { + return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "pattern", badIndex)} + } + if badIndex, ok := invalidWildcard(values); ok { + badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex]) + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "pattern contains an invalid wildcard expression", badNode)} + } + spec.pattern, spec.patternSet = values, true + case "exclude": + values, origins, ok, badIndex := parseStringSet(entry.Value) + if !ok { + return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "exclude", badIndex)} + } + if badIndex, ok := invalidWildcard(values); ok { + badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex]) + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "exclude contains an invalid wildcard expression", badNode)} + } + spec.exclude, spec.excludeSet = values, true + case "depth": + depth, ok := parseImportDepth(entry.Value) + if !ok { + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "depth must be a nonnegative integer", entry.Value)} + } + spec.depth, spec.depthSet = depth, true + case "destination": + destination, diagnostic := parseImportDestination(entry.Value, fieldPath) + if diagnostic != nil { + return importDeclarationSpec{}, []Diagnostic{*diagnostic} + } + spec.destination = destination + case "placement": + if entry.Value.Kind != document.String { + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)} + } + switch entry.Value.Text { + case "mapping": + spec.placement = placementModeMapping + case "sequence-item": + spec.placement = placementModeSequenceItem + default: + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)} + } + default: + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)} + } + } + + selectorPath := declarationPath.Key(selectorEntry.Key) + spec.fieldSites[selectorEntry.Key] = importFieldSite{path: selectorPath.clone(), location: publicLocation(selectorEntry.Value.Location)} + switch spec.selector { + case importSelectorPath, importSelectorDirectory: + values, origins, ok, badIndex := parseStringSet(selectorEntry.Value) + if !ok { + name := selectorEntry.Key + return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(selectorEntry.Value, selectorPath, name, badIndex)} + } + for index, value := range values { + if !validImportPath(value) { + badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index]) + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, selectorEntry.Key+" contains an invalid source path", badNode)} + } + } + spec.paths = values + case importSelectorGlob: + if selectorEntry.Value.Kind != document.String || !validImportPath(selectorEntry.Value.Text) { + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", selectorEntry.Value)} + } + segments, ok := validGlobPattern(selectorEntry.Value.Text) + if !ok { + return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob pattern is invalid", selectorEntry.Value)} + } + spec.glob, spec.globSegments = selectorEntry.Value.Text, segments + } + return spec, nil +} + +func selectorForField(field string) importSelector { + switch field { + case "path": + return importSelectorPath + case "dir": + return importSelectorDirectory + case "glob": + return importSelectorGlob + default: + return 0 + } +} + +func importFieldApplies(field string, selector importSelector) bool { + switch field { + case "required", "exclude", "destination", "placement": + return true + case "pattern", "recursive", "depth": + return selector == importSelectorDirectory + case "follow", "hidden": + return selector == importSelectorDirectory || selector == importSelectorGlob + default: + return false + } +} + +func parseStringSet(node *document.Node) ([]string, []int, bool, int) { + if node.Kind == document.String { + return []string{node.Text}, []int{-1}, true, -1 + } + if node.Kind != document.Sequence { + return nil, nil, false, -1 + } + values := make([]string, 0, len(node.Sequence)) + origins := make([]int, 0, len(node.Sequence)) + seen := make(map[string]struct{}, len(node.Sequence)) + for index, item := range node.Sequence { + if item.Kind != document.String { + return nil, nil, false, index + } + if _, exists := seen[item.Text]; exists { + continue + } + seen[item.Text] = struct{}{} + values = append(values, item.Text) + origins = append(origins, index) + } + return values, origins, true, -1 +} + +func stringSetDiagnostic(node *document.Node, fieldPath Path, field string, badIndex int) Diagnostic { + if badIndex >= 0 { + badPath, item := listMemberSite(node, fieldPath, badIndex) + return nodeImportDiagnostic(CodeImportInvalid, badPath, field+" members must be strings", item) + } + return nodeImportDiagnostic(CodeImportInvalid, fieldPath, field+" must be a string or a flat list of strings", node) +} + +func invalidWildcard(values []string) (int, bool) { + for index, value := range values { + if _, err := path.Match(value, ""); err != nil { + return index, true + } + } + return -1, false +} + +func listMemberSite(node *document.Node, fieldPath Path, index int) (Path, *document.Node) { + if node.Kind != document.Sequence || index < 0 { + return fieldPath, node + } + return fieldPath.Index(index), node.Sequence[index] +} + +func parseImportDepth(node *document.Node) (int, bool) { + if node.Kind != document.Number || node.Text == "" { + return 0, false + } + if node.Text == "-0" { + return 0, true + } + var value uint64 + maxInt := uint64(^uint(0) >> 1) + for index := 0; index < len(node.Text); index++ { + character := node.Text[index] + if character < '0' || character > '9' { + return 0, false + } + digit := uint64(character - '0') + if value > (maxInt-digit)/10 { + return int(maxInt), true + } + value = value*10 + digit + } + return int(value), true +} + +func parseImportDestination(node *document.Node, fieldPath Path) (Path, *Diagnostic) { + if node.Kind != document.Sequence { + diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must be a sequence of strings", node) + return Path{}, &diagnostic + } + relative := Path{segments: make([]pathSegment, 0, len(node.Sequence))} + for index, item := range node.Sequence { + memberPath := fieldPath.Index(index) + if item.Kind != document.String { + diagnostic := nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be strings", item) + return Path{}, &diagnostic + } + member := Path{segments: []pathSegment{{kind: pathSegmentKey, key: item.Text}}} + if _, err := normalizeDestination(member); err != nil { + diagnostic := nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be non-reserved mapping keys", item) + return Path{}, &diagnostic + } + relative.segments = append(relative.segments, pathSegment{kind: pathSegmentKey, key: item.Text}) + } + normalized, err := normalizeDestination(relative) + if err != nil { + diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must contain only non-reserved mapping keys", node) + return Path{}, &diagnostic + } + return normalized, nil +} diff --git a/internal/engine/import_spec_test.go b/internal/engine/import_spec_test.go new file mode 100644 index 0000000..8d520ec --- /dev/null +++ b/internal/engine/import_spec_test.go @@ -0,0 +1,243 @@ +package engine + +import ( + "errors" + "io" + "strings" + "testing" +) + +func TestParseImportDeclarationSpecNormalizesPresenceAndPlacement(t *testing.T) { + t.Parallel() + + specs, diagnostics := parseImportSpecsYAML(t, ` + - path: [one.yaml, one.yaml, two.yaml] + required: false + destination: [services] + placement: sequence-item + - dir: [] + recursive: true + pattern: [] + exclude: [] + - glob: config/*.yaml +`) + if len(diagnostics) != 0 { + t.Fatalf("diagnostics = %#v", diagnostics) + } + if len(specs) != 3 { + t.Fatalf("spec count = %d, want 3", len(specs)) + } + first := specs[0] + if first.selector != importSelectorPath || len(first.paths) != 2 || first.paths[0] != "one.yaml" || first.paths[1] != "two.yaml" { + t.Fatalf("path selector = %#v", first) + } + if first.required == nil || *first.required || first.destination.String() != "/services" || first.placement != placementModeSequenceItem { + t.Fatalf("explicit declaration values = %#v", first) + } + if first.path.String() != "/configkit/imports/0" || first.fieldSites["required"].path.String() != "/configkit/imports/0/required" { + t.Fatalf("stored paths = declaration %q, required %q", first.path.String(), first.fieldSites["required"].path.String()) + } + second := specs[1] + if second.selector != importSelectorDirectory || second.paths == nil || len(second.paths) != 0 || !second.patternSet || second.pattern == nil || len(second.pattern) != 0 || !second.excludeSet || second.exclude == nil { + t.Fatalf("empty list presence was lost: %#v", second) + } + third := specs[2] + if third.selector != importSelectorGlob || third.glob != "config/*.yaml" || len(third.globSegments) != 2 { + t.Fatalf("glob selector = %#v", third) + } +} + +func TestParseImportDeclarationSpecValidatesSelectorMatrixAndTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name, yaml, path string + }{ + {name: "missing selector", yaml: " - required: true\n", path: "/configkit/imports/0"}, + {name: "multiple selectors", yaml: " - path: one.yaml\n dir: fragments\n", path: "/configkit/imports/0"}, + {name: "removed optional", yaml: " - path: one.yaml\n optional: true\n", path: "/configkit/imports/0/optional"}, + {name: "inapplicable field", yaml: " - path: one.yaml\n pattern: '*.yaml'\n", path: "/configkit/imports/0/pattern"}, + {name: "selector null", yaml: " - path: null\n", path: "/configkit/imports/0/path"}, + {name: "empty path", yaml: " - path: ''\n", path: "/configkit/imports/0/path"}, + {name: "nested selector list", yaml: " - path: [[one.yaml]]\n", path: "/configkit/imports/0/path/0"}, + {name: "null selector member", yaml: " - dir: [fragments, null]\n", path: "/configkit/imports/0/dir/1"}, + {name: "empty glob", yaml: " - glob: ''\n", path: "/configkit/imports/0/glob"}, + {name: "recursive glob wildcard", yaml: " - glob: '**/*.yaml'\n", path: "/configkit/imports/0/glob"}, + {name: "null boolean", yaml: " - path: one.yaml\n required: null\n", path: "/configkit/imports/0/required"}, + {name: "bad pattern member with empty roots", yaml: " - dir: []\n pattern: ['*', '[']\n", path: "/configkit/imports/0/pattern/1"}, + {name: "bad exclusion member", yaml: " - glob: '*.yaml'\n exclude: ['[']\n", path: "/configkit/imports/0/exclude/0"}, + {name: "invalid destination", yaml: " - path: one.yaml\n destination: [configkit]\n", path: "/configkit/imports/0/destination/0"}, + {name: "invalid placement", yaml: " - path: one.yaml\n placement: sequence\n", path: "/configkit/imports/0/placement"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, diagnostics := parseImportSpecsYAML(t, test.yaml) + if len(diagnostics) == 0 { + t.Fatal("expected a declaration diagnostic") + } + if diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.path { + t.Fatalf("diagnostic = %#v, want %s at %s", diagnostics[0], CodeImportInvalid, test.path) + } + if diagnostics[0].Primary == nil || diagnostics[0].Primary.Line == 0 { + t.Fatalf("diagnostic has no source location: %#v", diagnostics[0]) + } + }) + } +} + +func TestParseImportDeclarationSpecValidatesDepthSyntaxAndPolicy(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + value string + valid bool + want int + }{ + {value: "0", valid: true, want: 0}, + {value: "-0", valid: true, want: 0}, + {value: "999999999999999999999999999999999999", valid: true, want: int(^uint(0) >> 1)}, + {value: "-1"}, + {value: "1.5"}, + {value: "1e3"}, + {value: "'2'"}, + {value: "true"}, + {value: "null"}, + } { + t.Run(test.value, func(t *testing.T) { + t.Parallel() + specs, diagnostics := parseImportSpecsYAML(t, " - dir: []\n recursive: true\n depth: "+test.value+"\n") + if !test.valid { + if len(diagnostics) == 0 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" { + t.Fatalf("diagnostics = %#v, want invalid depth", diagnostics) + } + return + } + if len(diagnostics) != 0 || len(specs) != 1 || specs[0].depth != test.want { + t.Fatalf("specs, diagnostics = %#v, %#v; want depth %d", specs, diagnostics, test.want) + } + }) + } + + specs, diagnostics := parseImportSpecsYAML(t, " - dir: []\n depth: 1\n") + if len(diagnostics) != 0 { + t.Fatalf("syntax-only parse diagnostics = %#v", diagnostics) + } + _, diagnostics = resolveImportPolicy(specs[0], importDefaults{}) + if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" { + t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics) + } + + recursive := true + policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{recursive: &recursive}) + if len(diagnostics) != 0 || !policy.recursive || policy.depth != 1 || !policy.depthSet { + t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics) + } +} + +func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) { + t.Parallel() + + omittedSpecs, diagnostics := parseImportSpecsYAML(t, " - dir: fragments\n") + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + omitted, diagnostics := resolveImportPolicy(omittedSpecs[0], importDefaults{}) + if len(diagnostics) != 0 || omitted.required || omitted.recursive || !omitted.follow || !omitted.hidden || omitted.pattern != nil { + t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics) + } + + specs, diagnostics := parseImportSpecsYAML(t, ` + - dir: fragments + required: false + recursive: false + follow: false + hidden: false + pattern: [] +`) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + appRequired, appRecursive, appFollow, appHidden := true, true, true, true + defaults := importDefaults{ + required: &appRequired, + recursive: &appRecursive, + follow: &appFollow, + hidden: &appHidden, + pattern: []string{"*.yaml"}, + } + policy, diagnostics := resolveImportPolicy(specs[0], defaults) + if len(diagnostics) != 0 { + t.Fatalf("policy diagnostics = %#v", diagnostics) + } + if policy.required || policy.recursive || policy.follow || policy.hidden || policy.pattern == nil || len(policy.pattern) != 0 { + t.Fatalf("explicit values did not override application defaults: %#v", policy) + } + + pathSpecs, diagnostics := parseImportSpecsYAML(t, " - path: file.yaml\n") + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + pathPolicy, diagnostics := resolveImportPolicy(pathSpecs[0], defaults) + if len(diagnostics) != 0 || pathPolicy.required != true || pathPolicy.recursive || pathPolicy.pattern != nil { + t.Fatalf("path policy applied unsupported defaults: %#v, %#v", pathPolicy, diagnostics) + } + defaulted, diagnostics := resolveImportPolicy(omittedSpecs[0], defaults) + if len(diagnostics) != 0 || len(defaulted.pattern) != 1 || defaulted.pattern[0] != "*.yaml" { + t.Fatalf("directory pattern default = %#v, diagnostics = %#v", defaulted.pattern, diagnostics) + } + + snapshot := snapshotImportDefaults(defaults) + *defaults.required = false + defaults.pattern[0] = "*.toml" + if snapshot.required == nil || !*snapshot.required || snapshot.pattern[0] != "*.yaml" { + t.Fatalf("snapshot shares mutable inputs: %#v", snapshot) + } + empty := snapshotImportDefaults(importDefaults{pattern: []string{}}) + if empty.pattern == nil || len(empty.pattern) != 0 { + t.Fatalf("snapshot lost explicit empty pattern: %#v", empty) + } + if snapshotImportDefaults(importDefaults{}).pattern != nil { + t.Fatal("snapshot changed omitted pattern into explicit empty") + } +} + +func TestInvalidImportDefaultPatternsFailBeforeReadingSource(t *testing.T) { + t.Parallel() + + reader := &countingImportSpecReader{Reader: strings.NewReader("not: read\n")} + _, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{ + importDefaults: importDefaults{pattern: []string{"["}}, + }) + var loadErr *LoadError + if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid { + t.Fatalf("load error = %T %v, want invalid-options LoadError", err, err) + } + if reader.reads != 0 { + t.Fatalf("reader consumed %d bytes before invalid defaults failed", reader.reads) + } +} + +func parseImportSpecsYAML(t *testing.T, declarations string) ([]importDeclarationSpec, []Diagnostic) { + t.Helper() + parsed, diagnostics := parseSource(Bytes("import specification fixture", []byte("configkit:\n imports:\n"+declarations)), "source-1", defaultParseLimits()) + if len(diagnostics) != 0 { + return nil, diagnostics + } + index := documentEntryIndex(parsed.document.Mapping, directiveKey) + if index < 0 { + t.Fatal("missing root directive in test fixture") + } + return parseImportSpecificationDirective(parsed.document.Mapping[index].Value, Keys(directiveKey)) +} + +type countingImportSpecReader struct { + io.Reader + reads int +} + +func (reader *countingImportSpecReader) Read(buffer []byte) (int, error) { + read, err := reader.Reader.Read(buffer) + reader.reads += read + return read, err +} diff --git a/internal/engine/load.go b/internal/engine/load.go index f96b065..e564685 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -30,6 +30,7 @@ type Options[T any] struct { Filesystem *FilesystemContext Overrides []Override SourceVersionMode sourceVersionMode + importDefaults importDefaults destination Path placementMode placementMode sequencePolicy sequencePolicy diff --git a/internal/engine/options.go b/internal/engine/options.go index 651fa64..f0c2a19 100644 --- a/internal/engine/options.go +++ b/internal/engine/options.go @@ -8,7 +8,8 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D if len(supplied) == 1 { options = supplied[0] } - var diagnostics []Diagnostic + options.importDefaults = snapshotImportDefaults(options.importDefaults) + diagnostics := validateImportDefaults(options.importDefaults) if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid")) }