diff --git a/README.md b/README.md index 34b0855..df6c8d9 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ An opinionated Go library for applications that use YAML configuration files. ConfigKit is in early implementation. Its current loader accepts one strict YAML source (or explicit defaults-only input), optionally traverses explicit path and glob imports, strictly composes their disjoint definitions, applies typed defaults, and -can check an application schema version. It returns detached metadata and safe -diagnostics. +can overlay one selected fixed profile and check an application schema version. It +returns detached metadata and safe diagnostics. Applications may perform audited defaulting and normalization through a resolver, then report domain errors and warnings through read-only semantic validation. -Profiles, overrides, secrets, and rendering are still under development. +Overrides, secrets, and rendering are still under development. ```go type Config struct { diff --git a/compose.go b/compose.go index b6feb79..c2fcb97 100644 --- a/compose.go +++ b/compose.go @@ -6,9 +6,12 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" // precedence layers. Claims remain ordered by source and declaration traversal; // origins retain every source that contributes to a composite mapping. type composedDocument struct { - root *document.Node - claims []compositionClaim - origins map[string]Origin + root *document.Node + claims []compositionClaim + origins map[string]Origin + overlays []overlayRecord + inputBytes int64 + inputNodes int } type compositionClaim struct { diff --git a/docs/config.md b/docs/config.md index 3ebd288..b0e060e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -20,28 +20,29 @@ The zero `Source` is invalid. Constructor argument failures are retained inside opaque source and become safe structured diagnostics when the source is consumed. File sources can observe file changes between consumptions; byte sources cannot. -Each source is limited to 8 MiB. An imported load is limited to 64 MiB across all -accepted sources, 1,024 accepted sources, 64 active import edges, and 1,000,000 -input nodes in aggregate. Glob expansion separately permits 1,000,000 visited -directory entries, including nonmatches. Reads request at most the remaining -allowance plus one byte so excess data is rejected rather than silently truncated. -Data returned together with a read error is not accepted as partial input. +Each source is limited to 8 MiB. A load using imports or an active profile is limited +to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import +edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits +1,000,000 visited directory entries, including nonmatches. Reads request at most the +remaining allowance plus one byte so excess data is rejected rather than silently +truncated. Data returned together with a read error is not accepted as partial input. ## Single-source loading `Load[T](source, options...)` accepts zero or one `Options[T]`. The configuration root `T` must satisfy the application schema contract below. With no options it starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied, -runs exactly once after successful source parsing, schema construction, and optional -schema-version checking. Its result is detached before input is applied. +runs exactly once after successful source parsing, schema construction, optional +schema-version checking, and selected-profile loading. Its result is detached before +input is applied. The current lifecycle is: ```text validate options → parse the selected source → traverse/compose enabled imports - → build schema → check the optional application version → create and clone defaults - → strictly decode → recorded application resolution → semantic validation - → snapshot configuration and metadata + → build schema → check the optional application version → apply a selected profile + → create and clone defaults → strictly decode → recorded application resolution + → semantic validation → snapshot configuration and metadata ``` `File`, `Bytes`, and `Reader` each contribute the first base source with deterministic @@ -51,13 +52,12 @@ contributes no source, and continues through defaults, decode, and projection. A missing file remains an error; it is never interpreted as no input. The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver, -semantic validator, explicit path and glob imports, and filesystem context. Active -profiles, overrides, and dynamic secret classification return `options.unavailable` -because their lifecycle stages are not implemented. The reserved profile spelling -`default` selects no active profile. `NoSource` -additionally rejects imports, active profiles, and filesystem context as invalid -combinations. Input containing a reserved `_configkit` mapping key is rejected while -imports are disabled. +semantic validator, explicit path and glob imports, one selected profile, and +filesystem context. Overrides and dynamic secret classification return +`options.unavailable` because their lifecycle stages are not implemented. `NoSource` +rejects imports, active profiles, and filesystem context as invalid combinations. +Input containing a reserved `_configkit` mapping key is rejected while imports are +disabled. Every source, options, schema, version, defaults, decode, resolution, or validation failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached @@ -79,13 +79,16 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed` diagnostic and suppresses the returned error text. Warnings alone are retained on a successful result. Callback panics remain programming failures. -### Base/default metadata +### Layered input/default metadata `Result.Metadata` describes every final canonical path. Exact surviving input paths -are explicit and have a base origin; omitted paths have a default origin. An explicit -parent mapping does not make omitted children explicit. Null state and available -source coordinates are retained. `History` records the Go-zero or application -default baseline followed by exact base definitions, including equal-valued input. +are explicit and have a base or profile origin; omitted paths have a default origin. +An explicit parent mapping does not make omitted children explicit. Null state and +available source coordinates are retained. `History` records the Go-zero or application +default baseline followed by exact base or profile definitions, including +equal-valued input. Profile replacement removes displaced descendants from effective +metadata while retaining their base definition and the parent replacement operation +in history. All metadata queries and returned nested records are detached. ### Recorded application resolution @@ -181,6 +184,43 @@ traversal order. Traversal order never authorizes replacement. Composition clone the resulting tree and its ordered claim records rather than mutating or aliasing an input document. +## Selected profiles + +`Options.Profile` selects at most one overlay by name. Names must match +`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name +`default` select no profile, and `profiles/default.yml` is never loaded implicitly. +Every other name resolves only to `/profiles/.yml`, with +no extension fallback, case normalization, inheritance, or profile stack. A selected +missing profile is an error. + +The profile is a separately policy-validated YAML mapping. It is loaded after the +base import graph is composed and its schema version is checked, but before defaults +and typed decoding. Profile bytes, nodes, and source records count toward the same +aggregate limits as base inputs. Its canonical path must remain within the configured +root and must not duplicate a file already present in the base graph. + +Overlay is intentionally distinct from strict composition: + +| Existing lower node | Incoming profile node | Result | +| --- | --- | --- | +| Absent | Value | Retain the profile value | +| Value | Absent | Retain the lower value | +| Mapping | Mapping | Recurse by mapping key | +| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node | + +Scalars, sequences, and null are atomic. Thus a profile sequence replaces rather +than extends a base sequence, `[]` clears it, null is an explicit replacement, and +cross-kind replacement is allowed when the final application schema accepts it. +Replacing a subtree removes all displaced descendants from effective presence and +origin data; history retains the displaced definitions and identifies the parent +replacement. Surviving profile paths have profile origins, while recursively shared +mapping containers retain their contributing locations. + +A profile may not contain `_configkit` anywhere, contribute the configured schema +version, or replace an ancestor of that version with a scalar or null. These rules +apply even if the supplied version text would be unchanged. Profiles cannot mask a +base parse, composition, or version failure because those checks run first. + ## Explicit imports Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root diff --git a/docs/development.md b/docs/development.md index c76b2ab..75f8c44 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,8 @@ This is the contributor entry point for ConfigKit. Read the task-specific owners below before making changes. The repository is in early implementation: its public loader currently covers strict selected sources, explicit path/glob import graphs, -composition, typed defaults, schema-version checks, recorded application resolution, +composition, selected profile overlays, typed defaults, schema-version checks, +recorded application resolution, semantic validation, and metadata. The [component overview](internal/overview.md) owns the implementation inventory. diff --git a/docs/internal/extraction.md b/docs/internal/extraction.md index 6f8ad90..d9395ef 100644 --- a/docs/internal/extraction.md +++ b/docs/internal/extraction.md @@ -155,6 +155,17 @@ Assigned to selected-profile overlay: declared defaults, inheritance, arbitrary profile paths, or validating every unselected overlay. +Implementation record (2026-09-06): `overlayDocuments`, `mergeOverlayMappings`, +`loadProfileDocument`, and `profileDirectiveDiagnostics` adapt the assigned merge, +selection, loading, and ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`, +`TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults`, profile selection/error, +directive/version, containment, and budget cases adapt the assigned tests. ConfigKit +uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement, +retains displaced descendant history, rejects any profile directive or version-path +contribution, and counts the profile in aggregate budgets. Narratio's declared +default, multi-overlay validation, inheritance-like application conventions, and +null/kind restrictions were not retained. + ### Effective inspection and provenance Assigned to layered metadata, redaction, rendering, and explanation: diff --git a/docs/internal/overview.md b/docs/internal/overview.md index da55aec..3764b5f 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -13,6 +13,8 @@ module and package documentation boundary. Its implemented public primitives are - a private strict composer in `compose.go` that joins only disjoint document-tree definitions, retains ordered claims and composite mapping origins, reports every conflicting location, and never aliases an input tree; +- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay + with displacement records in `overlay.go`; - a per-operation private reflection descriptor graph in `schema.go` for supported types, YAML field binding, inlining, secret tags, recursion, and semantic paths; - descriptor-driven canonical projection, detached typed cloning, cycle/limit @@ -31,7 +33,7 @@ module and package documentation boundary. Its implemented public primitives are `validation.go`, completing the public callback lifecycle after composed input. The [configuration contract](../config.md) owns implemented input, schema, decoding, -loading lifecycle, path imports, composition, version checking, recorded resolution, +loading lifecycle, path imports, composition, profile overlay, version checking, recorded resolution, semantic validation, metadata, and ownership behavior. The source-extraction inventory records the application foundations assigned to diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 917c2c1..64a9d03 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -2,7 +2,7 @@ ## Status and ownership -Status: implementation in progress; Stages 1–9 are complete and later stages are +Status: implementation in progress; Stages 1–13 are complete and later stages are pending. Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in @@ -982,7 +982,31 @@ Acceptance: ## Stage 13 — Implement profile overlays -Status: pending. +Status: complete. + +Completion evidence (2026-09-06): + +- Added a private non-aliasing recursive overlay that merges mappings by key and + atomically replaces scalars, sequences, empty sequences, nulls, and cross-kind + subtrees. Replacement deletes stale descendant origins and records every displaced + definition plus the parent operation for later history. +- Added exact selected-profile loading from `profiles/.yml`, with constrained + names, inactive empty/`default` selection, canonical root containment, base-graph + duplicate rejection, deterministic profile source ownership, and shared aggregate + source/byte/node limits. No path selection, extension fallback, inheritance, or + implicit default file is supported. +- Applied profiles only after base import composition and version checking and before + defaults and strict decoding. Profiles reject `_configkit` at any depth, direct + version contributions, and scalar/null replacements of version ancestors; base + parse and version failures therefore cannot be masked. +- Tests cover recursive siblings, scalar/full/empty-sequence/null/cross-kind + replacement, lower defaults, profile source and origin attribution, removed-child + and stale-index history, selection errors, fixed extension, directives, version + placement, base aliases, parse ordering, filesystem context, symlink escape, input + budgets, and non-aliasing. The + [current profile contract](../config.md#selected-profiles) owns the behavior. + Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`, + and `git diff --check` with Go 1.26.7 on linux/amd64. Read: feature [profiles](initial.md#profiles), composition/overlay table. diff --git a/docs/roadmap/initial.md b/docs/roadmap/initial.md index 17d7397..d85dbb6 100644 --- a/docs/roadmap/initial.md +++ b/docs/roadmap/initial.md @@ -450,28 +450,9 @@ origin is a resolution operation, with input origins retained separately. ## Composition and overlay -Strict base composition is implemented and now owned by the -[current configuration contract](../config.md#strict-document-composition). -Intentional profile overlay remains target behavior. Its exhaustive operation table -is below; “value” includes scalar, sequence, mapping, and null, while absent means no -node rather than null. - -| Existing/lower node | Incoming/higher node | Overlay | -| --- | --- | --- | -| Absent | Absent | Absent | -| Absent | Value | Incoming | -| Value | Absent | Existing | -| Mapping | Mapping | Recurse by key | -| Any other pair of present nodes | Any other pair of present nodes | Replace entire existing node | - -A mapping can replace a lower scalar, sequence, or null during overlay, subject to -the final schema. Sequences are atomic and the higher sequence replaces the entire -lower sequence in an overlay. `[]` clears a sequence. There is no append, union, -index merge, or mapping-clear operator. - -Replacing a parent must not leave effective metadata for removed children. -Sequence histories describe whole-sequence replacement; index origins refer to -the surviving sequence, not an attempted correspondence with previous indices. +Strict base composition and selected-profile overlay are implemented and owned by +the [current composition contract](../config.md#strict-document-composition) and +[profile contract](../config.md#selected-profiles). ## Explicit imports and source identity @@ -481,18 +462,9 @@ filesystem context, and budgets are implemented and now owned by the ## Profiles -Load only the composed base unless the application selects a profile. The API -accepts a name matching `[a-zA-Z0-9_-]+`, not an arbitrary path. The empty name -and reserved lowercase name `default` both mean no overlay; a literal -`profiles/default.yml` is never loaded. Other names resolve exactly to -`/profiles/.yml`, without extension fallback or case -normalization. A selected missing profile is an error. - -A profile is one policy-validated YAML mapping applied using the overlay table. -It must not contain `_configkit`, introduce imports, or alter the application's -declared schema-version path. Reject these directives even if their values -would be unchanged. There is no profile inheritance or multiple-profile stack. -Applications own flags and commands that select this behavior. +Selected profiles are implemented and owned by the +[current profile contract](../config.md#selected-profiles). Applications continue +to own the flags and commands that choose a profile name. ## Static defaults and null @@ -597,8 +569,8 @@ Automatic migration is deferred. ## Consumer API design -The core declarations, callback lifecycle, strict composition, and explicit path -imports are implemented; their current contract is owned by +The core declarations, callback lifecycle, strict composition, explicit imports, +and selected profiles are implemented; their current contract is owned by [configuration input](../config.md) and Go reference comments. Remaining declarations and behavior in this section are target design, not implemented Go reference documentation or a frozen naming commitment. Declarations omit method bodies and @@ -614,7 +586,7 @@ and verification against consumer models. `Load`, `Definition`, `Options`, `FilesystemContext`, `Override`, `VersionRule`, `Version`, `Validation`, and `Result` are declared. The current loader supports the callback and path-import subset described by the [current contract](../config.md); -later profile, override, secret, and rendering behavior below remains target work and +later override, secret, and rendering behavior below remains target work and unavailable options fail explicitly rather than being ignored. ```go @@ -660,8 +632,8 @@ ConfigKit does not synchronize application-owned captured state. `Source`, its constructors, and import filesystem context are implemented; [`source.go`](../../source.go) and the [configuration input contract](../config.md#sources) own their current behavior. Applications must use non-secret source labels because -labels appear in diagnostics and source reports. Profile-related filesystem behavior -remains target work. Filesystem options do not change application field path meanings. +labels appear in diagnostics and source reports. Filesystem options do not change +application field path meanings. `NoSource()` explicitly selects defaults-only loading. It contributes no document or file source entry and marks no fields explicit by itself; CLI overrides still diff --git a/imports.go b/imports.go index 14d801d..a128f47 100644 --- a/imports.go +++ b/imports.go @@ -114,6 +114,10 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)} } composed, diagnostics := composeDocuments(state.documents...) + if composed != nil { + composed.inputBytes = state.usedBytes + composed.inputNodes = state.usedNodes + } return composed, cloneSourceInfos(state.sources), diagnostics } diff --git a/load.go b/load.go index 0b5144e..0b394ac 100644 --- a/load.go +++ b/load.go @@ -17,8 +17,8 @@ type Definition[T any] struct { } // Options contains the application definition and per-load input selections. -// Imports enables explicit path and glob imports; later precedence layers remain -// unavailable until their owning features are implemented. +// Imports enables explicit path and glob imports; Profile selects one fixed profile +// overlay. Explicit overrides remain unavailable until implemented. type Options[T any] struct { Definition Definition[T] Imports bool @@ -94,9 +94,9 @@ func (r *Result[T]) Diagnostics() []Diagnostic { return cloneDiagnostics(r.diagnostics) } -// Load parses and strictly decodes zero or one selected source plus explicitly -// enabled imports. It accepts at most one Options value and returns no result when -// any error is reported. +// Load parses and strictly decodes zero or one selected source, explicitly enabled +// imports, and one selected profile. It accepts at most one Options value and +// returns no result when any error is reported. func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { accumulator := diagnosticAccumulator{} options, diagnostics := selectOptions(source, supplied) @@ -127,7 +127,7 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { if parsed.source != nil { accumulator.sources = append(accumulator.sources, *parsed.source) } - if options.Filesystem != nil { + if options.Filesystem != nil && !activeProfile(options.Profile) { if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil { accumulator.add(*diagnostic) return nil, accumulator.loadError() @@ -153,6 +153,26 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { if hasErrors(versionDiagnostics) { return nil, accumulator.loadError() } + if activeProfile(options.Profile) { + if composition == nil { + composition, diagnostics = composeDocuments(inputDocument) + if len(diagnostics) != 0 { + accumulator.add(diagnostics...) + return nil, accumulator.loadError() + } + composition.inputBytes = parsed.bytes + composition.inputNodes = countInputNodes(parsed.document) + } + composition, accumulator.sources, diagnostics = loadProfileDocument( + source, options.Filesystem, parsed, composition, accumulator.sources, + options.Profile, options.Definition.Version, defaultImportLimits(), + ) + if len(diagnostics) != 0 { + accumulator.add(diagnostics...) + return nil, accumulator.loadError() + } + inputDocument = composition.root + } defaults := reflect.Zero(reflect.TypeFor[T]()) if options.Definition.Defaults != nil { @@ -171,6 +191,9 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { configuration := decoded.value.Interface().(T) metadata := metadataFromDecoded(decoded) + if composition != nil { + applyOverlayMetadata(&metadata, composition.overlays) + } final := decoded.final var resolutionSnapshots []resolutionSnapshot if options.Definition.Resolve != nil { @@ -213,8 +236,8 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D if definition.Secret != nil { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary")) } - if options.Profile != "" && options.Profile != "default" { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "configuration profiles are not available in this release boundary")) + if !validProfileName(options.Profile) { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen")) } if options.Filesystem != nil { diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...) @@ -228,6 +251,9 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile")) } + if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context")) + } if definition.Version != nil { if !definition.Version.Path.valid() { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid")) diff --git a/load_metadata.go b/load_metadata.go index 895f295..bf55580 100644 --- a/load_metadata.go +++ b/load_metadata.go @@ -34,6 +34,43 @@ func metadataFromDecoded(decoded decodedConfiguration) Metadata { return metadata } +func applyOverlayMetadata(metadata *Metadata, records []overlayRecord) { + for _, record := range records { + for _, displaced := range record.displaced { + pointer := displaced.path.String() + history := metadata.history[pointer] + profileIndex := -1 + for index := len(history) - 1; index >= 0; index-- { + if history[index].Origin.Layer == LayerProfile { + profileIndex = index + break + } + } + baseChange := Change{Path: displaced.path.clone(), Origin: cloneOrigin(displaced.origin)} + if len(history) != 0 && history[len(history)-1].Origin.Layer == LayerDefault { + baseChange.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)} + } + if profileIndex >= 0 { + history = append(history[:profileIndex], append([]Change{baseChange}, history[profileIndex:]...)...) + } else { + history = append(history, baseChange) + } + + change := cloneChange(record.change) + change.Previous = []Origin{cloneOrigin(displaced.origin)} + if profileIndex >= 0 { + // The effective-input projection created a leaf-shaped profile + // record. Replace it with the actual parent operation. + profileIndex++ + history[profileIndex] = change + } else { + history = append(history, change) + } + metadata.history[pointer] = history + } + } +} + func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) { pointer := path.String() _, explicit := decoded.presence[pointer] diff --git a/load_test.go b/load_test.go index efb91ad..0ed9529 100644 --- a/load_test.go +++ b/load_test.go @@ -224,7 +224,6 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) { {name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid}, {name: "secret classifier", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Secret: func(Path) bool { return false }}}}, code: CodeOptionsUnavailable}, {name: "imports without filesystem context", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsInvalid}, - {name: "profile", source: validSource(), options: []Options[config]{{Profile: "testing"}}, code: CodeOptionsUnavailable}, {name: "incomplete filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid}, {name: "overrides", source: validSource(), options: []Options[config]{{Overrides: []Override{{Path: Keys("name"), Value: "other", Flag: "--name"}}}}, code: CodeOptionsUnavailable}, {name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid}, diff --git a/overlay.go b/overlay.go new file mode 100644 index 0000000..21c20af --- /dev/null +++ b/overlay.go @@ -0,0 +1,165 @@ +package configkit + +import ( + "slices" + "strings" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +type overlayRecord struct { + change Change + displaced []displacedDefinition +} + +type displacedDefinition struct { + path Path + origin Origin +} + +func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) { + if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping { + return nil, []Diagnostic{{ + Code: CodeProfileInvalid, Severity: SeverityError, + Message: "profile overlay requires mapping document roots", + }} + } + result := &composedDocument{ + root: document.Clone(base.root), + claims: cloneCompositionClaims(base.claims), + origins: cloneOriginIndex(base.origins), + overlays: cloneOverlayRecords(base.overlays), + inputBytes: base.inputBytes, + inputNodes: base.inputNodes, + } + mergeOverlayOrigin(result.origins, Path{}, overlay.Location) + mergeOverlayMappings(result, result.root, overlay, Path{}) + return result, nil +} + +func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path) { + for _, incoming := range overlay.Mapping { + path := current.Key(incoming.Key) + index := documentEntryIndex(target.Mapping, incoming.Key) + if index < 0 { + cloned := incoming + cloned.Value = document.Clone(incoming.Value) + target.Mapping = append(target.Mapping, cloned) + collectOverlayOrigins(result.origins, incoming.Value, path) + continue + } + + existing := target.Mapping[index].Value + if existing.Kind == document.Mapping && incoming.Value.Kind == document.Mapping { + mergeOverlayOrigin(result.origins, path, incoming.Value.Location) + mergeOverlayMappings(result, existing, incoming.Value, path) + continue + } + + displaced := displacedOrigins(existing, result.origins, path) + previous := cloneOrigin(result.origins[path.String()]) + deleteOriginSubtree(result.origins, path) + target.Mapping[index] = incoming + target.Mapping[index].Value = document.Clone(incoming.Value) + collectOverlayOrigins(result.origins, incoming.Value, path) + result.overlays = append(result.overlays, overlayRecord{ + change: Change{ + Path: path.clone(), Origin: profileOrigin(incoming.Value.Location), + Previous: previousOrigins(previous), + }, + displaced: displaced, + }) + } +} + +func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path) { + origins[path.String()] = profileOrigin(node.Location) + switch node.Kind { + case document.Mapping: + for _, entry := range node.Mapping { + collectOverlayOrigins(origins, entry.Value, path.Key(entry.Key)) + } + case document.Sequence: + for index, child := range node.Sequence { + collectOverlayOrigins(origins, child, path.Index(index)) + } + } +} + +func mergeOverlayOrigin(origins map[string]Origin, path Path, location document.Location) { + pointer := path.String() + origin := cloneOrigin(origins[pointer]) + origin.Layer = LayerProfile + origin.Locations = append(origin.Locations, publicLocation(location)) + origins[pointer] = origin +} + +func profileOrigin(location document.Location) Origin { + return Origin{Layer: LayerProfile, Locations: []Location{publicLocation(location)}} +} + +func previousOrigins(origin Origin) []Origin { + if origin.Layer == "" { + return nil + } + return []Origin{origin} +} + +func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition { + var displaced []displacedDefinition + collectDisplacedOrigins(&displaced, node, origins, path, Origin{}) + slices.SortFunc(displaced, func(left, right displacedDefinition) int { + return strings.Compare(left.path.String(), right.path.String()) + }) + return displaced +} + +func collectDisplacedOrigins(displaced *[]displacedDefinition, node *document.Node, origins map[string]Origin, path Path, inherited Origin) { + origin, ok := origins[path.String()] + if !ok { + origin = cloneOrigin(inherited) + origin.Locations = []Location{publicLocation(node.Location)} + } + *displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origin)}) + switch node.Kind { + case document.Mapping: + for _, entry := range node.Mapping { + collectDisplacedOrigins(displaced, entry.Value, origins, path.Key(entry.Key), origin) + } + case document.Sequence: + for index, child := range node.Sequence { + collectDisplacedOrigins(displaced, child, origins, path.Index(index), origin) + } + } +} + +func deleteOriginSubtree(origins map[string]Origin, path Path) { + prefix := path.String() + for pointer := range origins { + if pointer == prefix || strings.HasPrefix(pointer, prefix+"/") { + delete(origins, pointer) + } + } +} + +func cloneOriginIndex(origins map[string]Origin) map[string]Origin { + cloned := make(map[string]Origin, len(origins)) + for pointer, origin := range origins { + cloned[pointer] = cloneOrigin(origin) + } + return cloned +} + +func cloneOverlayRecords(records []overlayRecord) []overlayRecord { + cloned := make([]overlayRecord, len(records)) + for index, record := range records { + cloned[index].change = cloneChange(record.change) + cloned[index].displaced = make([]displacedDefinition, len(record.displaced)) + for displacedIndex, displaced := range record.displaced { + cloned[index].displaced[displacedIndex] = displacedDefinition{ + path: displaced.path.clone(), origin: cloneOrigin(displaced.origin), + } + } + } + return cloned +} diff --git a/overlay_test.go b/overlay_test.go new file mode 100644 index 0000000..991363b --- /dev/null +++ b/overlay_test.go @@ -0,0 +1,78 @@ +package configkit + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees(t *testing.T) { + baseRoot := parseCompositionDocument(t, "source-1", `shared: + kept: base + changed: old +sequence: [one, two] +nullable: value +to_map: scalar +to_scalar: {child: old} +`) + base, diagnostics := composeDocuments(baseRoot) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + overlay := parseCompositionDocument(t, "source-2", `shared: + changed: new + added: profile +sequence: [] +nullable: null +to_map: {child: new} +to_scalar: scalar +`) + baseBefore := document.Clone(base.root) + overlayBefore := document.Clone(overlay) + + result, diagnostics := overlayDocuments(base, overlay) + if len(diagnostics) != 0 { + t.Fatalf("overlayDocuments() diagnostics = %#v", diagnostics) + } + checks := []struct { + path Path + kind document.Kind + text string + }{ + {path: Keys("shared", "kept"), kind: document.String, text: "base"}, + {path: Keys("shared", "changed"), kind: document.String, text: "new"}, + {path: Keys("shared", "added"), kind: document.String, text: "profile"}, + {path: Keys("sequence"), kind: document.Sequence}, + {path: Keys("nullable"), kind: document.Null}, + {path: Keys("to_map"), kind: document.Mapping}, + {path: Keys("to_map", "child"), kind: document.String, text: "new"}, + {path: Keys("to_scalar"), kind: document.String, text: "scalar"}, + } + for _, check := range checks { + node, ok := documentAtPath(result.root, check.path) + if !ok || node.Kind != check.kind || node.Text != check.text { + t.Errorf("node %s = %#v, %v", check.path.String(), node, ok) + } + } + if _, exists := result.origins["/sequence/0"]; exists { + t.Fatal("replaced empty sequence retained a stale index origin") + } + if _, exists := result.origins["/to_scalar/child"]; exists { + t.Fatal("parent replacement retained a stale child origin") + } + shared := result.origins["/shared"] + if shared.Layer != LayerProfile || len(shared.Locations) != 2 { + t.Fatalf("shared mapping origin = %#v", shared) + } + if len(result.overlays) != 5 { + t.Fatalf("overlay records = %#v, want five replacements", result.overlays) + } + if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) { + t.Fatal("overlay mutated an input document") + } + result.root.Mapping[0].Value.Mapping[0].Value.Text = "changed" + if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) { + t.Fatal("overlay result aliases an input document") + } +} diff --git a/profile.go b/profile.go new file mode 100644 index 0000000..567a962 --- /dev/null +++ b/profile.go @@ -0,0 +1,179 @@ +package configkit + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml" +) + +func activeProfile(name string) bool { + return name != "" && name != "default" +} + +func validProfileName(name string) bool { + if !activeProfile(name) { + return true + } + for _, character := range name { + if (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '_' || character == '-' { + continue + } + return false + } + return true +} + +func loadProfileDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) { + rootPath, _, _, diagnostic := importFilesystem(source, context, main) + if diagnostic != nil { + return nil, cloneSourceInfos(sources), []Diagnostic{*diagnostic} + } + profilePath := filepath.Join(rootPath, "profiles", name+".yml") + canonical, err := canonicalExistingPath(profilePath) + if err != nil { + code := CodeProfileInvalid + message := "selected profile could not be resolved" + if errors.Is(err, fs.ErrNotExist) { + code, message = CodeProfileMissing, "selected profile does not exist" + } else if errors.Is(err, fs.ErrPermission) { + code, message = CodeImportPermission, "selected profile cannot be accessed" + } + return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)} + } + if !containedPath(rootPath, canonical) { + return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile resolves outside the configuration root", nil)} + } + for _, existing := range sources { + if existing.Path != "" && existing.Path == canonical { + return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileRepeated, "selected profile is already present in the base source graph", nil)} + } + } + if len(sources)+1 > limits.sources { + return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the source-count limit", nil)} + } + + root, err := os.OpenRoot(rootPath) + if err != nil { + value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected profile", Location{}) + value.Primary = nil + return nil, cloneSourceInfos(sources), []Diagnostic{value} + } + relative, err := filepath.Rel(rootPath, canonical) + if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + _ = root.Close() + return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)} + } + + sourceID := "source-" + strconv.Itoa(len(sources)+1) + profileLocation := &Location{SourceID: sourceID} + profileSource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("profiles", name+".yml")), Path: canonical, Layer: LayerProfile} + sources = append(cloneSourceInfos(sources), profileSource) + data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes) + if diagnostic != nil { + _ = root.Close() + return nil, sources, []Diagnostic{*diagnostic} + } + profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes}) + if len(issues) != 0 { + _ = root.Close() + return nil, sources, importParseDiagnostics(sourceID, issues) + } + nodeCount := countInputNodes(profileRoot) + if base.inputNodes+nodeCount > limits.nodes { + _ = root.Close() + return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)} + } + if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 { + _ = root.Close() + return nil, sources, diagnostics + } + if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil { + location := publicLocation(versionNode.Location) + _ = root.Close() + return nil, sources, []Diagnostic{{ + Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(), + Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location, + }} + } + if err := root.Close(); err != nil { + return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)} + } + + profiled, diagnostics := overlayDocuments(base, profileRoot) + if profiled != nil { + profiled.inputBytes = base.inputBytes + int64(len(data)) + profiled.inputNodes = base.inputNodes + nodeCount + } + return profiled, sources, diagnostics +} + +func readProfileFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) { + limit := min(int64(maxSourceBytes), remaining) + if limit < 0 { + limit = 0 + } + file, err := root.Open(relative) + if err != nil { + value := classifySourceError(err, sourceID) + return nil, &value + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil { + value := classifySourceError(err, sourceID) + return nil, &value + } + if !info.Mode().IsRegular() { + value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file") + return nil, &value + } + if info.Size() > limit { + value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID}) + return nil, &value + } + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil { + value := classifySourceError(err, sourceID) + return nil, &value + } + if int64(len(data)) > limit { + value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID}) + return nil, &value + } + return data, nil +} + +func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic { + if node.Kind == document.Mapping { + for _, entry := range node.Mapping { + childPath := current.Key(entry.Key) + if entry.Key == "_configkit" { + location := publicLocation(entry.KeyLocation) + return []Diagnostic{{ + Code: CodeProfileDirective, Severity: SeverityError, Path: childPath, + Message: "profiles cannot contain configuration directives", Primary: &location, + }} + } + if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { + return diagnostics + } + } + } else if node.Kind == document.Sequence { + for index, child := range node.Sequence { + if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 { + return diagnostics + } + } + } + return nil +} diff --git a/profile_test.go b/profile_test.go new file mode 100644 index 0000000..ccde257 --- /dev/null +++ b/profile_test.go @@ -0,0 +1,267 @@ +package configkit + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" +) + +type profileTestConfig struct { + Settings struct { + Kept string `yaml:"kept"` + Changed string `yaml:"changed"` + Added string `yaml:"added"` + } `yaml:"settings"` + Values []string `yaml:"values"` + Nullable *string `yaml:"nullable"` + Value any `yaml:"value"` + Schema map[string]string `yaml:"schema"` +} + +func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `_configkit: + imports: [{path: base.yml}] +settings: + kept: main + changed: base +values: [base, stale] +nullable: base +value: {child: base} +`) + basePath := writeImportFile(t, directory, "base.yml", "settings: {added: imported}\n") + profilePath := writeImportFile(t, directory, "profiles/testing.yml", `settings: + changed: profile +values: [profile] +nullable: null +value: scalar +`) + + result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{ + Imports: true, + Profile: "testing", + Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig { + defaultValue := "default" + return profileTestConfig{ + Values: []string{"default"}, Nullable: &defaultValue, + Settings: struct { + Kept string `yaml:"kept"` + Changed string `yaml:"changed"` + Added string `yaml:"added"` + }{Kept: "default", Changed: "default", Added: "default"}, + } + }}, + }) + if err != nil { + t.Fatal(err) + } + if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "profile" || result.Config.Settings.Added != "imported" { + t.Fatalf("settings = %#v", result.Config.Settings) + } + if !reflect.DeepEqual(result.Config.Values, []string{"profile"}) || result.Config.Nullable != nil || result.Config.Value != "scalar" { + t.Fatalf("profile values = values %#v nullable %#v value %#v", result.Config.Values, result.Config.Nullable, result.Config.Value) + } + sources := result.Sources() + wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, profilePath)} + gotPaths := make([]string, len(sources)) + for index, source := range sources { + gotPaths[index] = source.Path + } + if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerProfile { + t.Fatalf("sources = %#v", sources) + } + changed, _ := result.Metadata().Lookup(Keys("settings", "changed")) + kept, _ := result.Metadata().Lookup(Keys("settings", "kept")) + if changed.Origin.Layer != LayerProfile || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase { + t.Fatalf("origins = changed %#v kept %#v", changed, kept) + } + if _, ok := result.Metadata().Lookup(Keys("value", "child")); ok { + t.Fatal("removed child remains effective") + } + childHistory := result.Metadata().History(Keys("value", "child")) + if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerProfile { + t.Fatalf("removed child history = %#v", childHistory) + } + if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok { + t.Fatal("removed sequence index remains effective") + } + indexHistory := result.Metadata().History(Keys("values").Index(1)) + if len(indexHistory) < 2 || indexHistory[len(indexHistory)-1].Path.String() != "/values" { + t.Fatalf("removed sequence history = %#v", indexHistory) + } +} + +func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n") + writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n") + writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n") + + for _, name := range []string{"", "default"} { + result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name}) + if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 { + t.Fatalf("inactive profile %q = %#v, %v", name, result, err) + } + } + selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"}) + if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 { + t.Fatalf("selected profile = %#v, %v", selected, err) + } + + for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} { + t.Run(name, func(t *testing.T) { + requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: name}, CodeOptionsInvalid) + }) + } + requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "missing"}, CodeProfileMissing) + writeImportFile(t, directory, "profiles/fallback.yaml", "settings: {changed: fallback}\n") + requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "fallback"}, CodeProfileMissing) +} + +func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) { + versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error { + if !version.Present || version.Text != "v1" { + return errors.New("unexpected version") + } + return nil + }} + for _, test := range []struct { + name string + profile string + code Code + }{ + {name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective}, + {name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective}, + {name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion}, + {name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion}, + } { + t.Run(test.name, func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n") + writeImportFile(t, directory, "profiles/testing.yml", test.profile) + requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code) + }) + } + + t.Run("mapping sibling is allowed", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n") + writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n") + result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}) + if err != nil || result.Config.Schema["other"] != "allowed" { + t.Fatalf("mapping sibling = %#v, %v", result, err) + } + }) + + t.Run("profile aliases imported base", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n") + writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n") + requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated) + }) +} + +func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) { + t.Run("base parse precedes profile", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "settings: [\n") + writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: profile}\n") + requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid) + }) + + t.Run("profile parse", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "{}\n") + writeImportFile(t, directory, "profiles/testing.yml", "settings: [\n") + loadError := requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid) + if loadError.Diagnostics()[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 { + t.Fatalf("profile parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources()) + } + }) + + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + t.Run("symlink escape", func(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + outsideProfile := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n") + if err := os.Mkdir(filepath.Join(root, "profiles"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideProfile, filepath.Join(root, "profiles/testing.yml")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + mainPath := writeImportFile(t, root, "main.yml", "{}\n") + requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeProfileOutsideRoot) + }) + } + + t.Run("byte source context", func(t *testing.T) { + directory := t.TempDir() + writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n") + result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{ + Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}, + }) + if err != nil || result.Config.Settings.Changed != "selected" { + t.Fatalf("byte profile = %#v, %v", result, err) + } + requireProfileLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[profileTestConfig]{Profile: "testing"}, CodeOptionsInvalid) + }) +} + +func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) { + for _, test := range []struct { + name string + limits func(parsedSource, int) importLimits + }{ + {name: "source count", limits: func(_ parsedSource, _ int) importLimits { + return importLimits{sources: 1, bytes: maxTotalBytes, nodes: maxDocumentNodes} + }}, + {name: "aggregate bytes", limits: func(main parsedSource, profileBytes int) importLimits { + return importLimits{sources: 2, bytes: main.bytes + int64(profileBytes) - 1, nodes: maxDocumentNodes} + }}, + {name: "aggregate nodes", limits: func(main parsedSource, _ int) importLimits { + return importLimits{sources: 2, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1} + }}, + } { + t.Run(test.name, func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", "{}\n") + profileData := "settings: {changed: selected}\n" + writeImportFile(t, directory, "profiles/testing.yml", profileData) + main, diagnostics := parseSource(File(mainPath), "source-1", defaultParseLimits()) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + base, diagnostics := composeDocuments(main.document) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + base.inputBytes = main.bytes + base.inputNodes = countInputNodes(main.document) + _, _, diagnostics = loadProfileDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(profileData))) + if len(diagnostics) == 0 || diagnostics[0].Code != CodeProfileLimitExceeded { + t.Fatalf("diagnostics = %#v", diagnostics) + } + }) + } +} + +func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError { + t.Helper() + result, err := Load[profileTestConfig](source, options) + if result != nil || err == nil { + t.Fatalf("result, error = %#v, %v", result, err) + } + var loadError *LoadError + if !errors.As(err, &loadError) { + t.Fatalf("error type = %T", err) + } + diagnostics := loadError.Diagnostics() + if len(diagnostics) == 0 || diagnostics[0].Code != code { + t.Fatalf("diagnostics = %#v, want %s", diagnostics, code) + } + return loadError +} diff --git a/records.go b/records.go index 9582d2e..7f33072 100644 --- a/records.go +++ b/records.go @@ -87,6 +87,14 @@ const ( CodeImportLimitExceeded Code = "import.limit_exceeded" CodeImportVersion Code = "import.version" + CodeProfileInvalid Code = "profile.invalid" + CodeProfileMissing Code = "profile.missing" + CodeProfileOutsideRoot Code = "profile.outside_root" + CodeProfileRepeated Code = "profile.repeated" + CodeProfileDirective Code = "profile.directive" + CodeProfileVersion Code = "profile.version" + CodeProfileLimitExceeded Code = "profile.limit_exceeded" + CodeComposeInvalid Code = "compose.invalid" CodeComposeConflict Code = "compose.conflict"