diff --git a/README.md b/README.md index 3991019..0adfa1b 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ 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), applies typed defaults, optionally checks an application schema version, and returns detached metadata and safe diagnostics. -Applications may perform audited defaulting and normalization through a resolver. -Composition, profiles, semantic validation, overrides, secrets, and rendering are -still under development. +Applications may perform audited defaulting and normalization through a resolver, +then report domain errors and warnings through read-only semantic validation. +Composition, profiles, overrides, secrets, and rendering are still under development. ```go type Config struct { diff --git a/doc.go b/doc.go index 042b3b6..a2e5c46 100644 --- a/doc.go +++ b/doc.go @@ -3,8 +3,8 @@ // // Load currently supports one file, byte, reader, or explicit no-input source with // strict YAML, typed defaults, schema-version checks, detached metadata, safe -// diagnostics, recorded application resolution, and structured load errors. -// Composition, profiles, semantic validation, overrides, secrets, and effective +// diagnostics, recorded application resolution, read-only semantic validation, and +// structured load errors. Composition, profiles, overrides, secrets, and effective // rendering are not available yet. The configuration contract is documented in // docs/config.md. package configkit diff --git a/docs/config.md b/docs/config.md index f13a207..85197af 100644 --- a/docs/config.md +++ b/docs/config.md @@ -38,7 +38,8 @@ The current lifecycle is: ```text validate options → parse one source → reject reserved directives → build schema → check the optional application version → create and clone defaults - → strictly decode → snapshot configuration and metadata + → strictly decode → recorded application resolution → semantic validation + → snapshot configuration and metadata ``` `File`, `Bytes`, and `Reader` each contribute one base source with the deterministic @@ -46,17 +47,17 @@ ID `source-1`. `NoSource` skips parsing, supplies an absent version to a configu version checker, 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 and `VersionRule`. -Imports, active profiles, filesystem context, overrides, semantic validator -callbacks, and dynamic secret classification return an explicit +The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver, +and semantic validator. Imports, active profiles, filesystem context, overrides, +and dynamic secret classification return an explicit `options.unavailable` error 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. -Every source, options, schema, version, defaults, or decode failure returns a nil -result and a `*LoadError`. Successful results contain caller-owned `Config`, detached +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 `Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating the returned configuration or inspection records cannot alter ConfigKit's private snapshot. ConfigKit performs no logging and exposes no effective writer yet. @@ -122,6 +123,33 @@ retain history. ConfigKit privately snapshots each recorded before/after subtree later safe explanation; caller mutations of `Result.Config` cannot alter those snapshots. +### Semantic validation + +An optional `Definition.Validate` callback runs after successful resolution and +receives the resolved configuration plus a per-load `Validation` context. It can +collect multiple independent `validate.error` and `validate.warning` reports. +Warnings continue the lifecycle and are returned on success; any error returns a nil +result with the full safe diagnostic and source report. + +Primary and related paths must be valid for the application schema. Omitted declared +fields and missing string-map or dynamic keys are valid report targets; unknown +struct fields and malformed paths produce `validate.invalid_path`. ConfigKit attaches +the exact surviving input location when one exists, otherwise the nearest input +container location, or no primary location for defaults-only synthetic state. + +Validation is read-only. ConfigKit projects the configuration immediately before and +after the callback and returns `validate.mutation` at every detected net-change path, +including nested mutable state. A value that can no longer be projected is also a +mutation failure. No mutated or otherwise invalid configuration is published. +Callback panics remain programming failures, and retained contexts use the same +fixed closed-context panic as version and resolution callbacks. + +Only schema-bound exported state participates in loading, resolution audit, +validation audit, metadata, and snapshots. Ignored or unexported ordinary fields are +zero in returned detached values. Applications retain responsibility for domain +rules and runtime initialization; validators should not start clients or depend on +live services as a substitute for configuration validation. + ## YAML documents The private YAML boundary currently accepts exactly one nonempty document whose root diff --git a/docs/development.md b/docs/development.md index 043151a..9851b3b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ 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 one strict source, typed defaults, schema-version checks, -recorded application resolution, and metadata. The +recorded application resolution, semantic validation, and metadata. The [component overview](internal/overview.md) owns the implementation inventory. ## Initial orientation diff --git a/docs/internal/extraction.md b/docs/internal/extraction.md index 5bdd711..741f310 100644 --- a/docs/internal/extraction.md +++ b/docs/internal/extraction.md @@ -203,6 +203,16 @@ intent, coalesced repeated writes, private before/after snapshots, deterministic record ordering, and safe hook diagnostics. Semantic validation remains assigned to the next lifecycle work. +Validation implementation record (2026-09-06): ConfigKit generalized Narratio's +`TestNormalizeLoadAndValidate`, `TestRepresentativeLoadDefaultsAndValidation`, +strict registry decoding, and maintained extraction-example expectations into the +post-resolution `Validation` lifecycle and synthetic consumer examples. ConfigKit +keeps domain rules application-owned, resolves schema-valid missing/dynamic report +paths to nearest input locations, collects independent warnings/errors, and audits +the full typed projection to reject validator mutation. Live resources, registries, +campaign/session logic, commands, and application runtime initialization were not +extracted. + ### Secrets bootstrap Assigned to the explicit bootstrap operation: diff --git a/docs/internal/overview.md b/docs/internal/overview.md index a64d113..adccef7 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -22,11 +22,13 @@ module and package documentation boundary. Its implemented public primitives are `load_metadata.go`; and - recorded application defaulting and normalization in `resolution.go`, including explicitness queries, helper verification, structural mutation audit, coalesced - intent, private before/after snapshots, and resolution metadata integration. + intent, private before/after snapshots, and resolution metadata integration; and +- post-resolution schema-aware report collection and validator mutation detection in + `validation.go`, completing the public single-source callback lifecycle. The [configuration contract](../config.md) owns implemented input, schema, decoding, -single-source lifecycle, version checking, recorded resolution, metadata, and -ownership behavior. +single-source lifecycle, version checking, recorded resolution, semantic validation, +metadata, and ownership behavior. The source-extraction inventory records the application foundations assigned to future implementation work: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6e3d408..02b8e7a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -2,7 +2,7 @@ ## Status and ownership -Status: implementation in progress; Stages 1–8 are complete and later stages are +Status: implementation in progress; Stages 1–9 are complete and later stages are pending. Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in @@ -744,7 +744,37 @@ Acceptance: ## Stage 9 — Add semantic validation and finish the single-source lifecycle -Status: pending. +Status: complete. + +Completion evidence (2026-09-06): + +- Reused `Validation` for post-resolution application reports in + [`validation.go`](../../validation.go), with `validate.error`, + `validate.warning`, and `validate.invalid_path` classifications. Omitted declared + fields and missing string-map/dynamic keys remain valid report paths; unknown + struct fields and malformed primary or related paths fail safely. +- Attached exact or nearest input locations to primary and related reports, retained + independent errors and warnings, continued through warnings, and returned no + configuration on any semantic error. Version, decode, and resolution failures stop + before later hooks. +- Added before/after typed projection around the validator. Any net scalar, + container, or nested mutable-state change, and any newly unprojectable value, + returns `validate.mutation` without publishing the mutated object. Callback + contexts close after invocation and callback panics remain programming failures. +- Removed the semantic-validator options gate, finalized metadata only after + successful resolution/validation, and retained defensive configuration snapshot, + metadata, diagnostic, and source ownership. The + [current semantic-validation contract](../config.md#semantic-validation) owns the + completed single-source lifecycle and application/runtime-state boundary. +- Added compiling executable examples in [`example_test.go`](../../example_test.go) + for minimal loading, defaults-only operation, related-path validation, actionable + schema-version guidance, and resolution followed by validation. Focused tests cover + multiple reports, missing/dynamic paths, related/nearest locations, invalid paths, + validator mutation, hook ordering and stop points, warnings, and final value/ + explicitness/origin/history agreement. Verified `go test ./...`, + `go test -race ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and + `git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required; + imports and profiles remain deliberately unavailable. Read: feature [diagnostic callbacks](initial.md#resolution-and-validation-callbacks) and [schema reports](initial.md#schema-checking-and-secret-classification). diff --git a/docs/roadmap/initial.md b/docs/roadmap/initial.md index 8ce3818..ed69e23 100644 --- a/docs/roadmap/initial.md +++ b/docs/roadmap/initial.md @@ -582,10 +582,10 @@ independently captured resolved results are implemented. The [current ownership contract](../config.md#typed-projection-and-ownership) owns those mechanics. -Application semantic validation runs after successful resolution and collects -independent errors in a standard diagnostic form. Validators are read-only and -own domain requirements; checking live S3 buckets or starting runtime clients -is outside this lifecycle. Do not run later hooks on an invalid typed value. +Application semantic validation is implemented. The +[current semantic-validation contract](../config.md#semantic-validation) owns +schema-aware reporting, nearest locations, warning/error behavior, mutation audit, +callback lifetime, and application/runtime responsibility boundaries. ## Provenance, diagnostics, and redaction @@ -795,9 +795,10 @@ behavior. Layered callback history and safe historical values remain target beha ### Resolution and validation callbacks -The resolution declarations and behavior below are implemented; the -[current recorded-resolution contract](../config.md#recorded-application-resolution) -is authoritative. Semantic validation behavior in this section remains target work. +The resolution and semantic-validation declarations and behavior below are +implemented. The [current recorded-resolution contract](../config.md#recorded-application-resolution) +and [semantic-validation contract](../config.md#semantic-validation) are +authoritative. ```go func (r *Resolution) IsExplicit(path Path) bool diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..3cdaac0 --- /dev/null +++ b/example_test.go @@ -0,0 +1,96 @@ +package configkit_test + +import ( + "errors" + "fmt" + "strings" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func ExampleLoad() { + type Config struct { + Enabled bool `yaml:"enabled"` + Workers int `yaml:"workers"` + } + result, err := configkit.Load[Config](configkit.Bytes("example.yml", []byte("enabled: true\nworkers: 3\n"))) + fmt.Println(result.Config, err) + // Output: {true 3} +} + +func ExampleNoSource() { + type Config struct { + Workers int `yaml:"workers"` + } + result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{ + Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }}, + }) + fmt.Println(result.Config.Workers, len(result.Sources()), err) + // Output: 4 0 +} + +func ExampleValidation_Error() { + type Config struct { + First string `yaml:"first"` + Second string `yaml:"second"` + } + _, err := configkit.Load(configkit.Bytes("duplicates.yml", []byte("first: same\nsecond: same\n")), configkit.Options[Config]{ + Definition: configkit.Definition[Config]{Validate: func(configuration *Config, validation *configkit.Validation) { + if configuration.First == configuration.Second { + validation.Error(configkit.Keys("second"), "must differ from first", configkit.Keys("first")) + } + }}, + }) + var loadError *configkit.LoadError + errors.As(err, &loadError) + diagnostic := loadError.Diagnostics()[0] + fmt.Println(diagnostic.Code, diagnostic.Path, len(diagnostic.Related)) + // Output: validate.error /second 1 +} + +func ExampleVersionRule() { + type Config struct { + Version string `yaml:"version"` + } + _, err := configkit.Load(configkit.Bytes("version.yml", []byte("version: old\n")), configkit.Options[Config]{ + Definition: configkit.Definition[Config]{Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), + Check: func(version configkit.Version, validation *configkit.Validation) error { + if version.Text != "v1" { + validation.Error(configkit.Keys("version"), "unsupported version; migrate using the application guide") + } + return nil + }, + }}, + }) + var loadError *configkit.LoadError + errors.As(err, &loadError) + fmt.Println(loadError.Diagnostics()[0].Message) + // Output: unsupported version; migrate using the application guide +} + +func ExampleResolution() { + type Config struct { + Input int `yaml:"input"` + Derived int `yaml:"derived"` + } + var order []string + result, err := configkit.Load(configkit.Bytes("resolved.yml", []byte("input: 3\n")), configkit.Options[Config]{ + Definition: configkit.Definition[Config]{ + Resolve: func(configuration *Config, resolution *configkit.Resolution) error { + order = append(order, "resolve") + configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, + configuration.Input*2, "derived from input", configkit.Keys("input")) + return nil + }, + Validate: func(configuration *Config, validation *configkit.Validation) { + order = append(order, "validate") + if configuration.Derived < configuration.Input { + validation.Error(configkit.Keys("derived"), "must not be less than input") + } + }, + }, + }) + fmt.Println(strings.Join(order, " then "), result.Config.Derived, err) + // Output: resolve then validate 6 +} diff --git a/load.go b/load.go index 787b3cf..355b1ab 100644 --- a/load.go +++ b/load.go @@ -157,6 +157,13 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { resolutionSnapshots = resolved.snapshots applyResolutionMetadata(&metadata, resolved) } + if options.Definition.Validate != nil { + validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, parsed.document) + accumulator.add(validationDiagnostics...) + if hasErrors(validationDiagnostics) { + return nil, accumulator.loadError() + } + } return &Result[T]{ Config: configuration, metadata: metadata, @@ -177,9 +184,6 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D } var diagnostics []Diagnostic definition := options.Definition - if definition.Validate != nil { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "application validation is not available in this release boundary")) - } if definition.Secret != nil { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary")) } @@ -268,7 +272,7 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic { } state := &validationState{ active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning, - document: root, + invalidPath: CodeVersionError, document: root, } validation := &Validation{state: state} err := rule.Check(version, validation) diff --git a/load_test.go b/load_test.go index 2246368..1f442c5 100644 --- a/load_test.go +++ b/load_test.go @@ -222,7 +222,6 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) { code Code }{ {name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid}, - {name: "validator", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Validate: func(*config, *Validation) {}}}}, code: CodeOptionsUnavailable}, {name: "secret classifier", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Secret: func(Path) bool { return false }}}}, code: CodeOptionsUnavailable}, {name: "imports", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsUnavailable}, {name: "profile", source: validSource(), options: []Options[config]{{Profile: "testing"}}, code: CodeOptionsUnavailable}, diff --git a/records.go b/records.go index c5d5ca9..f79b2a6 100644 --- a/records.go +++ b/records.go @@ -92,6 +92,11 @@ const ( CodeResolveInvalidTarget Code = "resolve.invalid_target" CodeResolveInvalidValue Code = "resolve.invalid_value" CodeResolveUnrecordedChange Code = "resolve.unrecorded_change" + + CodeValidateError Code = "validate.error" + CodeValidateWarning Code = "validate.warning" + CodeValidateInvalidPath Code = "validate.invalid_path" + CodeValidateMutation Code = "validate.mutation" ) // Location identifies a position in a configuration source. Zero line and column diff --git a/validation.go b/validation.go index f8f316b..9cc32bb 100644 --- a/validation.go +++ b/validation.go @@ -1,6 +1,10 @@ package configkit -import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +import ( + "reflect" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) // Validation collects safe application diagnostics during a supported callback. // A callback must not retain the context or use it concurrently. @@ -12,6 +16,8 @@ type validationState struct { active bool errorCode Code warningCode Code + invalidPath Code + schema *schemaDescriptor document *document.Node diagnostics []Diagnostic } @@ -38,17 +44,49 @@ func (v *Validation) report(severity Severity, path Path, message string, relate } diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message} if !path.valid() { + invalidCode := v.state.invalidPath + if invalidCode == "" { + invalidCode = v.state.errorCode + } diagnostic.Severity = SeverityError - diagnostic.Code = v.state.errorCode + diagnostic.Code = invalidCode diagnostic.Path = Path{} diagnostic.Message = "application reported an invalid configuration path" + } else if v.state.schema != nil { + if _, ok := resolveSchemaPath(v.state.schema, path); !ok { + diagnostic.Severity = SeverityError + diagnostic.Code = v.state.invalidPath + diagnostic.Message = "application reported a path outside the configuration schema" + v.state.diagnostics = append(v.state.diagnostics, diagnostic) + return + } + if location, ok := nearestDocumentLocation(v.state.document, path); ok { + diagnostic.Primary = documentLocationPointer(location) + } } else if location, ok := nearestDocumentLocation(v.state.document, path); ok { diagnostic.Primary = documentLocationPointer(location) } for _, relatedPath := range related { if !relatedPath.valid() { + invalidCode := v.state.invalidPath + if invalidCode == "" { + invalidCode = v.state.errorCode + } + v.state.diagnostics = append(v.state.diagnostics, Diagnostic{ + Code: invalidCode, Severity: SeverityError, + Message: "application reported an invalid related configuration path", + }) continue } + if v.state.schema != nil { + if _, ok := resolveSchemaPath(v.state.schema, relatedPath); !ok { + v.state.diagnostics = append(v.state.diagnostics, Diagnostic{ + Code: v.state.invalidPath, Severity: SeverityError, Path: relatedPath.clone(), + Message: "application reported a related path outside the configuration schema", + }) + continue + } + } if location, ok := nearestDocumentLocation(v.state.document, relatedPath); ok { diagnostic.Related = append(diagnostic.Related, Location{ SourceID: location.SourceID, Line: location.Line, Column: location.Column, @@ -58,6 +96,32 @@ func (v *Validation) report(severity Severity, path Path, message string, relate v.state.diagnostics = append(v.state.diagnostics, diagnostic) } +func runValidation[T any](configuration *T, validator func(*T, *Validation), schema *schemaDescriptor, before, input *document.Node) []Diagnostic { + state := &validationState{ + active: true, errorCode: CodeValidateError, warningCode: CodeValidateWarning, + invalidPath: CodeValidateInvalidPath, schema: schema, document: input, + } + context := &Validation{state: state} + validator(configuration, context) + state.active = false + after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits()) + if issue != nil { + state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path)) + return canonicalDiagnostics(state.diagnostics) + } + for _, delta := range compareDocumentTrees(before, after) { + state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path)) + } + return canonicalDiagnostics(state.diagnostics) +} + +func validationMutationDiagnostic(path Path) Diagnostic { + return Diagnostic{ + Code: CodeValidateMutation, Severity: SeverityError, Path: path.clone(), + Message: "application validation mutated the configuration", + } +} + func nearestDocumentLocation(root *document.Node, path Path) (document.Location, bool) { if root == nil || !path.valid() { return document.Location{}, false diff --git a/validation_test.go b/validation_test.go new file mode 100644 index 0000000..ccd5cd7 --- /dev/null +++ b/validation_test.go @@ -0,0 +1,214 @@ +package configkit + +import ( + "errors" + "reflect" + "testing" +) + +func TestValidationCollectsSchemaAwareReportsAndNearestLocations(t *testing.T) { + t.Parallel() + + type config struct { + Primary string `yaml:"primary"` + Missing string `yaml:"missing"` + Dynamic map[string]any `yaml:"dynamic"` + } + result, err := Load(Bytes("validation.yml", []byte("primary: configured\ndynamic: {present: 1}\n")), Options[config]{Definition: Definition[config]{ + Validate: func(_ *config, validation *Validation) { + validation.Error(Keys("missing"), "is required", Keys("primary")) + validation.Error(Keys("dynamic", "missing"), "dynamic entry is required") + validation.Warning(Keys("primary"), "primary value is deprecated") + }, + }}) + if result != nil || err == nil { + t.Fatalf("result, err = %#v, %v", result, err) + } + var loadError *LoadError + if !errors.As(err, &loadError) { + t.Fatalf("error type = %T", err) + } + diagnostics := loadError.Diagnostics() + if len(diagnostics) != 3 { + t.Fatalf("diagnostics = %#v", diagnostics) + } + byPath := make(map[string]Diagnostic) + for _, diagnostic := range diagnostics { + byPath[diagnostic.Path.String()] = diagnostic + } + missing := byPath["/missing"] + if missing.Code != CodeValidateError || missing.Primary == nil || missing.Primary.Line != 1 || len(missing.Related) != 1 || missing.Related[0].Line != 1 { + t.Fatalf("missing diagnostic = %#v", missing) + } + dynamic := byPath["/dynamic/missing"] + if dynamic.Code != CodeValidateError || dynamic.Primary == nil || dynamic.Primary.Line != 2 { + t.Fatalf("dynamic diagnostic = %#v", dynamic) + } + if warning := byPath["/primary"]; warning.Code != CodeValidateWarning || warning.Severity != SeverityWarning { + t.Fatalf("warning = %#v", warning) + } + if len(loadError.Sources()) != 1 { + t.Fatalf("sources = %#v", loadError.Sources()) + } +} + +func TestValidationRejectsUnknownStructPaths(t *testing.T) { + t.Parallel() + + type config struct { + Known string `yaml:"known"` + } + result, err := Load(Bytes("validation-path.yml", []byte("known: value\n")), Options[config]{Definition: Definition[config]{ + Validate: func(_ *config, validation *Validation) { + validation.Error(Keys("unknown"), "must be configured") + }, + }}) + assertLoadDiagnostic(t, result, err, CodeValidateInvalidPath) +} + +func TestValidationMutationFailsWithoutPublishingResult(t *testing.T) { + t.Parallel() + + type config struct { + Value int `yaml:"value"` + Items map[string]int `yaml:"items"` + } + result, err := Load(Bytes("mutation.yml", []byte("value: 1\nitems: {one: 1}\n")), Options[config]{Definition: Definition[config]{ + Validate: func(configuration *config, _ *Validation) { + configuration.Value = 2 + configuration.Items["one"] = 2 + }, + }}) + if result != nil || err == nil { + t.Fatalf("result, err = %#v, %v", result, err) + } + var loadError *LoadError + if !errors.As(err, &loadError) { + t.Fatal(err) + } + var paths []string + for _, diagnostic := range loadError.Diagnostics() { + if diagnostic.Code != CodeValidateMutation || diagnostic.Message != "application validation mutated the configuration" { + t.Fatalf("diagnostic = %#v", diagnostic) + } + paths = append(paths, diagnostic.Path.String()) + } + if !reflect.DeepEqual(paths, []string{"/items/one", "/value"}) { + t.Fatalf("mutation paths = %#v", paths) + } +} + +func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) { + t.Parallel() + + type config struct { + Version string `yaml:"version"` + Value int `yaml:"value"` + } + t.Run("warnings continue", func(t *testing.T) { + var events []string + definition := Definition[config]{ + Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error { + events = append(events, "version") + validation.Warning(Keys("version"), "version warning") + return nil + }}, + Defaults: func() config { + events = append(events, "defaults") + return config{Value: 1} + }, + Resolve: func(_ *config, resolution *Resolution) error { + events = append(events, "resolve") + resolution.Warning(Keys("value"), "resolution warning") + return nil + }, + Validate: func(_ *config, validation *Validation) { + events = append(events, "validate") + validation.Warning(Keys("value"), "validation warning") + }, + } + result, err := Load(Bytes("order.yml", []byte("version: one\n")), Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(events, []string{"version", "defaults", "resolve", "validate"}) || len(result.Diagnostics()) != 3 { + t.Fatalf("events = %#v, diagnostics = %#v", events, result.Diagnostics()) + } + }) + + t.Run("version error stops later hooks", func(t *testing.T) { + var events []string + definition := Definition[config]{ + Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error { + events = append(events, "version") + validation.Error(Keys("version"), "unsupported version") + return nil + }}, + Defaults: func() config { events = append(events, "defaults"); return config{} }, + Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil }, + Validate: func(_ *config, _ *Validation) { events = append(events, "validate") }, + } + result, err := Load(Bytes("order-error.yml", []byte("version: one\n")), Options[config]{Definition: definition}) + assertLoadDiagnostic(t, result, err, CodeVersionError) + if !reflect.DeepEqual(events, []string{"version"}) { + t.Fatalf("events = %#v", events) + } + }) + + t.Run("resolution error stops validation", func(t *testing.T) { + var validated bool + result, err := Load(Bytes("resolution-order.yml", []byte("value: 1\nversion: one\n")), Options[config]{Definition: Definition[config]{ + Resolve: func(_ *config, resolution *Resolution) error { + resolution.Error(Keys("value"), "cannot resolve") + return nil + }, + Validate: func(_ *config, _ *Validation) { validated = true }, + }}) + assertLoadDiagnostic(t, result, err, CodeResolveError) + if validated { + t.Fatal("validation ran after resolution error") + } + }) + + t.Run("decode error stops application hooks", func(t *testing.T) { + var resolved, validated bool + result, err := Load(Bytes("decode-order.yml", []byte("value: wrong\nversion: one\n")), Options[config]{Definition: Definition[config]{ + Resolve: func(_ *config, _ *Resolution) error { resolved = true; return nil }, + Validate: func(_ *config, _ *Validation) { validated = true }, + }}) + assertLoadDiagnostic(t, result, err, CodeDecodeType) + if resolved || validated { + t.Fatalf("later hooks ran: resolve=%v validate=%v", resolved, validated) + } + }) +} + +func TestCompleteSingleSourceLifecycleKeepsMetadataAgreement(t *testing.T) { + t.Parallel() + + type config struct { + Input int `yaml:"input"` + Derived int `yaml:"derived"` + } + result, err := Load(Bytes("complete.yml", []byte("input: 3\n")), Options[config]{Definition: Definition[config]{ + Defaults: func() config { return config{Derived: 1} }, + Resolve: func(configuration *config, resolution *Resolution) error { + Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input")) + return nil + }, + Validate: func(configuration *config, validation *Validation) { + if configuration.Derived != 6 { + validation.Error(Keys("derived"), "derived value is inconsistent", Keys("input")) + } + }, + }}) + if err != nil { + t.Fatal(err) + } + input, _ := result.Metadata().Lookup(Keys("input")) + derived, _ := result.Metadata().Lookup(Keys("derived")) + history := result.Metadata().History(Keys("derived")) + if result.Config != (config{Input: 3, Derived: 6}) || !input.Explicit || input.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault || history[len(history)-1].Dependencies[0].String() != "/input" { + t.Fatalf("config/metadata = %#v / %#v / %#v / %#v", result.Config, input, derived, history) + } +}