From cc41653bfdc5d1065f95c8784df77476a0507949 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 6 Sep 2026 20:03:22 +0000 Subject: [PATCH] Add single-source loading and version checks --- README.md | 25 ++- doc.go | 13 +- docs/config.md | 71 ++++++- docs/development.md | 8 +- docs/internal/extraction.md | 9 + docs/internal/overview.md | 9 +- docs/roadmap/implementation.md | 34 +++- docs/roadmap/initial.md | 49 +++-- load.go | 336 +++++++++++++++++++++++++++++++++ load_metadata.go | 95 ++++++++++ load_test.go | 300 +++++++++++++++++++++++++++++ records.go | 10 + validation.go | 101 ++++++++++ 13 files changed, 1022 insertions(+), 38 deletions(-) create mode 100644 load.go create mode 100644 load_metadata.go create mode 100644 load_test.go create mode 100644 validation.go diff --git a/README.md b/README.md index 7dc4253..def1e8b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,28 @@ An opinionated Go library for applications that use YAML configuration files. -ConfigKit is in early implementation. The Go module and root package boundary are -established, but no usable configuration loader has been implemented yet. +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. +Composition, profiles, application callbacks, overrides, secrets, and rendering are +still under development. -The [initial target design](docs/roadmap/initial.md) describes planned behavior. +```go +type Config struct { + Enabled bool `yaml:"enabled"` + Workers int `yaml:"workers"` +} + +func ReadConfig(path string) (Config, error) { + result, err := configkit.Load[Config](configkit.File(path)) + if err != nil { + return Config{}, err + } + return result.Config, nil +} +``` + +The [configuration contract](docs/config.md) describes implemented behavior. The +[initial target design](docs/roadmap/initial.md) describes planned behavior. The [implementation plan](docs/roadmap/implementation.md) owns the pending stages. See the [development guide](docs/development.md) for repository orientation. diff --git a/doc.go b/doc.go index f917262..6c72498 100644 --- a/doc.go +++ b/doc.go @@ -1,8 +1,9 @@ -// Package configkit provides foundational types for deterministic, -// application-owned configuration loading in Go programs. +// Package configkit provides deterministic, application-owned configuration +// loading for Go programs. // -// Semantic paths, opaque source constructors, metadata and provenance records, safe -// diagnostics, and structured load errors are available. YAML input is validated -// behind a private parser boundary according to docs/config.md. Typed decoding, -// composition, and loading operations are not available yet. +// Load currently supports one file, byte, reader, or explicit no-input source with +// strict YAML, typed defaults, schema-version checks, detached metadata, safe +// diagnostics, and structured load errors. Composition, profiles, application +// resolution and validation, 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 9479a40..cd8776c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,9 +1,9 @@ # Configuration Input Contract This document owns ConfigKit's currently implemented source-input, YAML syntax, -schema, typed-decoding, and ownership contracts. The parser, owned document -representation, and decoder are private; a public typed loading lifecycle has not -been implemented yet. +schema, typed-decoding, loading, metadata, and ownership contracts. The parser, +owned document representation, and decoder remain private behind the public loading +lifecycle. ## Sources @@ -14,7 +14,7 @@ ConfigKit exposes opaque sources through these constructors: | `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. | | `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. | | `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. | -| `NoSource()` | Represents an explicit absence of input and creates no source record. Its defaults-only lifecycle behavior will become available with `Load`. | +| `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. | The zero `Source` is invalid. Constructor argument failures are retained inside the opaque source and become safe structured diagnostics when the source is consumed. @@ -25,6 +25,65 @@ and override input when those layers are implemented. 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. + +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 +``` + +`File`, `Bytes`, and `Reader` each contribute one base source with the deterministic +ID `source-1`. `NoSource` skips parsing, supplies an absent version to a configured +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, resolver and semantic +validator callbacks, 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 +`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. + +### Application schema versions + +`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed +decoding, the checker receives a `Version` preserving absent, explicit null, or +string input. Any other input kind is a `version.type` error and the checker does not +run. ConfigKit does not interpret the text or impose a universal version path. + +The checker reports safe, value-free errors and warnings through `Validation.Error` +and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or +nearest input locations. Reported errors fail loading. If the checker returns an +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 + +`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. +All metadata queries and returned nested records are detached. + ## YAML documents The private YAML boundary currently accepts exactly one nonempty document whose root @@ -144,8 +203,8 @@ Paired text scalars decode only from strings through `encoding.TextUnmarshaler`, codec failures are classified without exposing application error text or input values. -This decoder is currently an internal lifecycle foundation, not a public conversion -API. Applications cannot call typed loading until `Load` is introduced. +This decoder is available only through `Load`; ConfigKit exposes no generic document +conversion API. ## Typed projection and ownership diff --git a/docs/development.md b/docs/development.md index 49472ee..f176848 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,10 +1,10 @@ # Development This is the contributor entry point for ConfigKit. Read the task-specific owners -below before making changes. The repository is in early implementation: it has a -Go module and documented root package, but no loading behavior or executable -examples yet. The [component overview](internal/overview.md) owns the implementation -inventory. +below before making changes. The repository is in early implementation: its public +loader currently covers one strict source, typed defaults, schema-version checks, +and base/default 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 3e3c282..831609e 100644 --- a/docs/internal/extraction.md +++ b/docs/internal/extraction.md @@ -183,6 +183,15 @@ multi-error collection, and repeated-result isolation. Resolution, validation, consumer defaults, and all campaign/session algorithms remain assigned to later work. +Lifecycle integration record (2026-09-06): the public single-source `Load` boundary +combines the already extracted labeled-source and strict-decoding mechanisms with +Narratio's fresh-default and presence expectations. ConfigKit adds an application- +defined pre-decode string-version check, defaults-only `NoSource` operation, +detached base/default metadata and source reports, and explicit gates for every +later lifecycle option. No application loader or production line was copied. +Narratio's resolution, validation, import/profile, campaign/session, and command +behavior remains assigned to its later owning work. + ### Secrets bootstrap Assigned to the explicit bootstrap operation: diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 2d8332c..edce2d7 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -16,12 +16,13 @@ module and package documentation boundary. Its implemented public primitives are enforcement, and structural comparison in `projection.go`; and - strict schema-driven decoding over detached defaults in `decode.go`, including mapping/sequence semantics, null and numeric policy, safe codec errors, presence, - baseline/final trees, and initial default/input origins. + baseline/final trees, and initial default/input origins; and +- public single-source lifecycle coordination in `load.go`, version-report contexts + in `validation.go`, and detached base/default metadata assembly in + `load_metadata.go`. The [configuration contract](../config.md) owns implemented input, schema, decoding, -and ownership behavior. ConfigKit intentionally exposes no typed loading API yet; -the decoder and its initial metadata artifacts remain private until that lifecycle -exists. +single-source lifecycle, version checking, 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 0abbcb9..709d377 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -2,7 +2,7 @@ ## Status and ownership -Status: implementation in progress; Stages 1–6 are complete and later stages are +Status: implementation in progress; Stages 1–7 are complete and later stages are pending. Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in @@ -619,7 +619,37 @@ Acceptance: ## Stage 7 — Assemble single-source loading and schema-version checks -Status: pending. +Status: complete. + +Completion evidence (2026-09-06): + +- Added the public generic `Load`, `Definition`, `Options`, `Result`, version, + filesystem-context, override, validation-context, and reserved resolution + declarations in [`load.go`](../../load.go) and [`validation.go`](../../validation.go). + The active lifecycle validates options, parses one source, rejects reserved + directives, builds the schema, checks an optional application version, creates + fresh defaults, decodes, and captures detached state. +- Added absent/null/string version handling, non-string rejection, exact/nearest + primary and related input locations, safe application reports, warning retention, + and generic value-free hook failure only when no explicit error was reported. + Defaults run only after version success and exactly once for a continuing load. +- Populated effective base/default `Metadata` and chronological definition history + in [`load_metadata.go`](../../load_metadata.go), with explicit and null state, + synthetic default origins, base coordinates, equal-valued input records, and + defensive result/source/diagnostic copies. `NoSource` performs the same schema, + version, defaults, decode, and projection lifecycle without a source record. +- Added explicit `options.unavailable` gates for resolution, semantic validation, + imports, active profiles, filesystem context, overrides, and secret classification; + invalid `NoSource` combinations and `_configkit` directives fail rather than being + ignored. The [current single-source contract](../config.md#single-source-loading), + package documentation, README, and component inventory own the implemented API. +- Integration tests cover File/Bytes/Reader/NoSource, schema-version states and + warnings, related locations, raw hook-error suppression, fresh-default ordering, + metadata agreement and detachment, source lists, unavailable options, reserved + directives, and nil results for source/schema/version/decode failures. 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; resolution and semantic validation remain later work. Read: feature [lifecycle](initial.md#resolution-lifecycle), [version hook](initial.md#schema-checking-and-secret-classification), diff --git a/docs/roadmap/initial.md b/docs/roadmap/initial.md index ee9520e..9f8fb66 100644 --- a/docs/roadmap/initial.md +++ b/docs/roadmap/initial.md @@ -425,15 +425,16 @@ configuration is never returned as a successful, usable result. Source ownership, the supported YAML subset, scalar grammar, fixed input limits, and safe failure behavior are implemented. The [current configuration input -contract](../config.md) now owns those definitions. Typed scalar conversion into -application values remains target work in [typed decoding](#typed-decoding). +contract](../config.md) now owns those definitions, including typed scalar +conversion through the current single-source lifecycle. ## Paths and presence -Semantic path construction, JSON Pointer parsing, and current metadata query -declarations are implemented. Their current contracts are owned by the Go -documentation in [`path.go`](../../path.go) and [`metadata.go`](../../metadata.go). -The remaining presence behavior below is target lifecycle work. +Semantic path construction, JSON Pointer parsing, and base/default metadata +population are implemented. Their current contracts are owned by the Go +documentation in [`path.go`](../../path.go), [`metadata.go`](../../metadata.go), +and the [single-source configuration contract](../config.md#base-default-metadata). +Layered replacement and resolution behavior below remains target lifecycle work. Distinguish absent, explicitly null, explicitly zero/false/empty, and other explicit values. An explicit value equal to its default remains explicit. @@ -672,8 +673,11 @@ Automatic migration is deferred. ## Consumer API design -The following is a proposed public API, not implemented Go reference documentation -or a frozen naming commitment. Declarations omit method bodies and private fields. +The core declarations and single-source loading subset are implemented; their +current contract is owned by [configuration input](../config.md#single-source-loading) +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 private fields. Consumer snippets assume the indicated standard-library imports and a `configkit` import using the module path selected in the [implementation plan](implementation.md#implementation-decisions). They are illustrative target examples, not currently runnable examples. The @@ -682,6 +686,13 @@ and verification against consumer models. ### Entry point and reusable application definition +`Load`, `Definition`, `Options`, `FilesystemContext`, `Override`, `VersionRule`, +`Version`, `Validation`, and `Result` are declared. The current loader accepts only +the defaults and version-checking subset described by the +[single-source contract](../config.md#single-source-loading); later-feature fields +fail explicitly rather than being ignored. The complete lifecycle and option +behavior below remains the target as its owning stages land. + ```go func Load[T any](source Source, options ...Options[T]) (*Result[T], error) @@ -795,11 +806,11 @@ func ReadWorkerDefaults() (*configkit.Result[Config], error) { ### Paths and metadata -The path API, detached metadata records, and empty/unknown query behavior are now -implemented; [`path.go`](../../path.go), [`records.go`](../../records.go), and -[`metadata.go`](../../metadata.go) own their current Go contracts. Populating -effective presence, origins, and history belongs to the loading stages. Callback -path checking and safe historical values remain target behavior below. +The path API, detached metadata records, empty/unknown query behavior, and +base/default population are now implemented; [`path.go`](../../path.go), +[`records.go`](../../records.go), [`metadata.go`](../../metadata.go), and the +[metadata configuration contract](../config.md#base-default-metadata) own current +behavior. Layered callback history and safe historical values remain target behavior. ### Resolution and validation callbacks @@ -970,6 +981,12 @@ potentially sensitive configured values. ### Schema checking and secret classification +`VersionRule`, `Version`, and the version-checking use of `Validation` are +implemented. The [current version contract](../config.md#application-schema-versions) +owns absent/null/string handling, report locations, warnings, failure behavior, and +raw-error suppression. Secret classification and later composed-source placement +checks remain target work. + ```go type VersionRule struct { Path Path @@ -1030,6 +1047,12 @@ applications own secret-producing transformations not described by those records ### Results, errors, and inspection +`Result.Config`, `Metadata`, `Sources`, and `Diagnostics`, together with +`LoadError` and `WriteDiagnostics`, are implemented at the single-source boundary. +The [current loading contract](../config.md#single-source-loading) and Go reference +comments own their behavior. Effective rendering and richer layered inspection +below remain target work. + ```go type Result[T any] struct { Config T diff --git a/load.go b/load.go new file mode 100644 index 0000000..88f6fd3 --- /dev/null +++ b/load.go @@ -0,0 +1,336 @@ +package configkit + +import ( + "reflect" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +// Definition contains reusable application rules for a configuration type. +// Definitions should be treated as read-only after being shared. +type Definition[T any] struct { + Defaults func() T + Resolve func(*T, *Resolution) error + Validate func(*T, *Validation) + Version *VersionRule + Secret func(Path) bool +} + +// Options contains the application definition and per-load input selections. +// The current single-source boundary rejects features scheduled for later work. +type Options[T any] struct { + Definition Definition[T] + Imports bool + Profile string + Filesystem *FilesystemContext + Overrides []Override +} + +// FilesystemContext supplies filesystem roots for imports and profiles. +type FilesystemContext struct { + BaseDir string + Root string +} + +// Override describes one explicitly supplied application override. +type Override struct { + Path Path + Value any + Flag string +} + +// Resolution is the application resolution callback context. Resolution callbacks +// are rejected until the recorded-resolution lifecycle is available. +type Resolution struct { + state *resolutionState +} + +type resolutionState struct{} + +// VersionRule declares the input path and application checker for a schema version. +type VersionRule struct { + Path Path + Check func(Version, *Validation) error +} + +// Version preserves the absent, null, and string states of a schema-version input. +type Version struct { + Present bool + Null bool + Text string +} + +// Result is a successful independently owned configuration load. +type Result[T any] struct { + Config T + + metadata Metadata + sources []SourceInfo + diagnostics []Diagnostic + final *document.Node +} + +// Metadata returns detached effective presence, origin, and history records. +func (r *Result[T]) Metadata() Metadata { + if r == nil { + return Metadata{} + } + return cloneMetadata(r.metadata) +} + +// Sources returns a detached list of sources used by the load. +func (r *Result[T]) Sources() []SourceInfo { + if r == nil { + return nil + } + return cloneSourceInfos(r.sources) +} + +// Diagnostics returns detached nonfatal diagnostics from the load. +func (r *Result[T]) Diagnostics() []Diagnostic { + if r == nil { + return nil + } + return cloneDiagnostics(r.diagnostics) +} + +// Load parses and strictly decodes zero or one source. 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) + if len(diagnostics) != 0 { + accumulator.add(diagnostics...) + return nil, accumulator.loadError() + } + + parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits()) + if parsed.source != nil { + accumulator.sources = append(accumulator.sources, *parsed.source) + } + if len(diagnostics) != 0 { + accumulator.add(diagnostics...) + return nil, accumulator.loadError() + } + if parsed.document != nil { + directiveDiagnostics := disabledDirectiveDiagnostics(parsed.document, Path{}) + if len(directiveDiagnostics) != 0 { + accumulator.add(directiveDiagnostics...) + return nil, accumulator.loadError() + } + } + + schema, schemaIssue := buildSchema(reflect.TypeFor[T]()) + if schemaIssue != nil { + accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) + return nil, accumulator.loadError() + } + + versionDiagnostics := checkVersion(options.Definition.Version, parsed.document) + accumulator.add(versionDiagnostics...) + if hasErrors(versionDiagnostics) { + return nil, accumulator.loadError() + } + + defaults := reflect.Zero(reflect.TypeFor[T]()) + if options.Definition.Defaults != nil { + defaults = reflect.ValueOf(options.Definition.Defaults()) + } + decoded, decodeIssues := decodeConfiguration(schema, defaults, parsed.document) + if len(decodeIssues) != 0 { + for _, issue := range decodeIssues { + accumulator.add(diagnosticFromDecodeIssue(issue)) + } + return nil, accumulator.loadError() + } + + configuration := decoded.value.Interface().(T) + return &Result[T]{ + Config: configuration, + metadata: metadataFromDecoded(decoded), + sources: cloneSourceInfos(accumulator.sources), + diagnostics: canonicalDiagnostics(accumulator.diagnostics), + final: document.Clone(decoded.final), + }, nil +} + +func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) { + if len(supplied) > 1 { + return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")} + } + var options Options[T] + if len(supplied) == 1 { + options = supplied[0] + } + var diagnostics []Diagnostic + definition := options.Definition + if definition.Resolve != nil { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "application resolution is not available in this release boundary")) + } + 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")) + } + if options.Imports { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "configuration imports are 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 options.Filesystem != nil { + code := CodeOptionsUnavailable + message := "filesystem context is not available in this release boundary" + if source.kind == sourceNone { + code = CodeOptionsInvalid + message = "NoSource cannot use filesystem context" + } + diagnostics = append(diagnostics, optionDiagnostic(code, message)) + } + if len(options.Overrides) != 0 { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "configuration overrides are not available in this release boundary")) + } + if source.kind == sourceNone && options.Imports { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports")) + } + if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile")) + } + if definition.Version != nil { + if !definition.Version.Path.valid() { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid")) + } + if definition.Version.Check == nil { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker")) + } + } + return options, canonicalDiagnostics(diagnostics) +} + +func optionDiagnostic(code Code, message string) Diagnostic { + return Diagnostic{Code: code, Severity: SeverityError, Message: message} +} + +func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { + var diagnostics []Diagnostic + if node.Kind == document.Mapping { + for _, entry := range node.Mapping { + childPath := path.Key(entry.Key) + if entry.Key == "_configkit" { + location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column} + diagnostics = append(diagnostics, Diagnostic{ + Code: CodeImportDisabled, Severity: SeverityError, Path: childPath, + Message: "configuration directives require imports to be enabled", Primary: &location, + }) + } + diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...) + } + } else if node.Kind == document.Sequence { + for index, child := range node.Sequence { + diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...) + } + } + return diagnostics +} + +func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic { + if rule == nil { + return nil + } + version := Version{} + node, present := documentAtPath(root, rule.Path) + if present { + version.Present = true + switch node.Kind { + case document.Null: + version.Null = true + case document.String: + version.Text = node.Text + default: + location, _ := nearestDocumentLocation(root, rule.Path) + primary := documentLocationPointer(location) + return []Diagnostic{{ + Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(), + Message: "schema version must be a string or null", Primary: primary, + }} + } + } + state := &validationState{ + active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning, + document: root, + } + validation := &Validation{state: state} + err := rule.Check(version, validation) + state.active = false + if err != nil && !hasErrors(state.diagnostics) { + location, _ := nearestDocumentLocation(root, rule.Path) + state.diagnostics = append(state.diagnostics, Diagnostic{ + Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(), + Message: "schema-version check failed", Primary: documentLocationPointer(location), + }) + } + return canonicalDiagnostics(state.diagnostics) +} + +func documentAtPath(root *document.Node, path Path) (*document.Node, bool) { + if root == nil || !path.valid() { + return nil, false + } + current := root + for _, segment := range path.segments { + switch current.Kind { + case document.Mapping: + if segment.kind == pathSegmentIndex { + return nil, false + } + found := false + for _, entry := range current.Mapping { + if entry.Key == segment.key { + current = entry.Value + found = true + break + } + } + if !found { + return nil, false + } + case document.Sequence: + index, ok := schemaIndex(segment) + if !ok || index >= len(current.Sequence) { + return nil, false + } + current = current.Sequence[index] + default: + return nil, false + } + } + return current, true +} + +func documentLocationPointer(location document.Location) *Location { + if !locationKnown(location) { + return nil + } + return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column} +} + +func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic { + return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message} +} + +func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic { + return Diagnostic{ + Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message, + Primary: documentLocationPointer(issue.location), + } +} + +func hasErrors(diagnostics []Diagnostic) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Severity == SeverityError { + return true + } + } + return false +} diff --git a/load_metadata.go b/load_metadata.go new file mode 100644 index 0000000..94c7fe9 --- /dev/null +++ b/load_metadata.go @@ -0,0 +1,95 @@ +package configkit + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +func metadataFromDecoded(decoded decodedConfiguration) Metadata { + metadata := Metadata{ + fields: make(map[string]FieldInfo), + history: make(map[string][]Change), + } + baseline := make(map[string]*document.Node) + indexDocument(decoded.baseline, Path{}, baseline) + for pointer := range baseline { + path, err := ParsePath(pointer) + if err != nil { + continue + } + metadata.history[pointer] = append(metadata.history[pointer], Change{ + Path: path, Origin: Origin{Layer: LayerDefault}, + }) + } + for pointer, input := range decoded.presence { + path, err := ParsePath(pointer) + if err != nil { + continue + } + origin := Origin{Layer: LayerBase, Locations: []Location{{ + SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column, + }}} + change := Change{Path: path, Origin: origin} + if _, ok := baseline[pointer]; ok { + change.Previous = []Origin{{Layer: LayerDefault}} + } + metadata.history[pointer] = append(metadata.history[pointer], change) + } + collectFieldInfo(decoded.final, Path{}, decoded, metadata.fields) + return metadata +} + +func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) { + pointer := path.String() + _, explicit := decoded.presence[pointer] + fields[pointer] = FieldInfo{ + Explicit: explicit, + Null: node.Kind == document.Null, + Origin: cloneOrigin(decoded.origins[pointer]), + } + switch node.Kind { + case document.Mapping: + for _, entry := range node.Mapping { + collectFieldInfo(entry.Value, path.Key(entry.Key), decoded, fields) + } + case document.Sequence: + for index, child := range node.Sequence { + collectFieldInfo(child, path.Index(index), decoded, fields) + } + } +} + +func indexDocument(node *document.Node, path Path, index map[string]*document.Node) { + if node == nil { + return + } + index[path.String()] = node + switch node.Kind { + case document.Mapping: + for _, entry := range node.Mapping { + indexDocument(entry.Value, path.Key(entry.Key), index) + } + case document.Sequence: + for position, child := range node.Sequence { + indexDocument(child, path.Index(position), index) + } + } +} + +func cloneMetadata(metadata Metadata) Metadata { + if metadata.fields == nil && metadata.history == nil { + return Metadata{} + } + cloned := Metadata{ + fields: make(map[string]FieldInfo, len(metadata.fields)), + history: make(map[string][]Change, len(metadata.history)), + } + for path, info := range metadata.fields { + cloned.fields[path] = cloneFieldInfo(info) + } + for path, changes := range metadata.history { + clonedChanges := make([]Change, len(changes)) + for index, change := range changes { + clonedChanges[index] = cloneChange(change) + } + cloned.history[path] = clonedChanges + } + return cloned +} diff --git a/load_test.go b/load_test.go new file mode 100644 index 0000000..5c0acc8 --- /dev/null +++ b/load_test.go @@ -0,0 +1,300 @@ +package configkit + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" +) + +func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) { + t.Parallel() + + type config struct { + Name string `yaml:"name"` + } + input := []byte("name: configured\n") + temporary := t.TempDir() + filename := filepath.Join(temporary, "config.yml") + if err := os.WriteFile(filename, input, 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + source func() Source + want string + wantSources int + }{ + {name: "file", source: func() Source { return File(filename) }, want: "configured", wantSources: 1}, + {name: "bytes", source: func() Source { return Bytes("memory.yml", input) }, want: "configured", wantSources: 1}, + {name: "reader", source: func() Source { return Reader("reader.yml", strings.NewReader(string(input))) }, want: "configured", wantSources: 1}, + {name: "no source", source: NoSource, want: "default", wantSources: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var defaultsCalls atomic.Int32 + result, err := Load(test.source(), Options[config]{Definition: Definition[config]{ + Defaults: func() config { + defaultsCalls.Add(1) + return config{Name: "default"} + }, + }}) + if err != nil { + t.Fatal(err) + } + if result.Config.Name != test.want || defaultsCalls.Load() != 1 || len(result.Sources()) != test.wantSources { + t.Fatalf("result = %#v, calls = %d, sources = %#v", result.Config, defaultsCalls.Load(), result.Sources()) + } + }) + } +} + +func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) { + t.Parallel() + + type config struct { + SchemaVersion *string `yaml:"schema_version"` + } + versionPath := Keys("schema_version") + tests := []struct { + name string + source Source + want Version + wantLine int + wantErr Code + returnOnly bool + }{ + {name: "absent", source: Bytes("absent.yml", []byte("{}\n")), want: Version{}, wantLine: 1}, + {name: "no source absent", source: NoSource(), want: Version{}}, + {name: "null", source: Bytes("null.yml", []byte("schema_version: null\n")), want: Version{Present: true, Null: true}, wantLine: 1}, + {name: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1}, + {name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), wantErr: CodeVersionType}, + {name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + called := false + rule := &VersionRule{Path: versionPath, Check: func(version Version, validation *Validation) error { + called = true + if test.returnOnly { + return errors.New("CANARY_VERSION_ERROR") + } + if version != test.want { + t.Errorf("version = %#v, want %#v", version, test.want) + } + validation.Warning(versionPath, "review schema compatibility", Keys("missing_related")) + return nil + }} + result, err := Load(test.source, Options[config]{Definition: Definition[config]{Version: rule}}) + if test.wantErr != "" { + 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) != 1 || diagnostics[0].Code != test.wantErr || strings.Contains(diagnostics[0].Message, "CANARY") { + t.Fatalf("diagnostics = %#v", diagnostics) + } + if test.wantErr == CodeVersionType && called { + t.Fatal("checker ran for a non-string version") + } + return + } + if err != nil { + t.Fatal(err) + } + diagnostics := result.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning || diagnostics[0].Severity != SeverityWarning { + t.Fatalf("diagnostics = %#v", diagnostics) + } + if test.wantLine == 0 && (diagnostics[0].Primary != nil || len(diagnostics[0].Related) != 0) { + t.Fatalf("synthetic diagnostic locations = %#v", diagnostics[0]) + } + if test.wantLine != 0 && (diagnostics[0].Primary == nil || diagnostics[0].Primary.Line != test.wantLine || len(diagnostics[0].Related) != 1) { + t.Fatalf("diagnostic locations = %#v", diagnostics[0]) + } + }) + } +} + +func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) { + t.Parallel() + + type config struct { + Name string `yaml:"name"` + } + var defaultsCalls atomic.Int32 + result, err := Load(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{ + Defaults: func() config { + defaultsCalls.Add(1) + return config{Name: "default"} + }, + Version: &VersionRule{Path: Keys("schema_version"), Check: func(_ Version, validation *Validation) error { + validation.Error(Keys("schema_version"), "schema_version is required") + return errors.New("CANARY_RETURNED_ERROR") + }}, + }}) + if result != nil || err == nil || defaultsCalls.Load() != 0 { + t.Fatalf("result = %#v, err = %v, calls = %d", result, err, defaultsCalls.Load()) + } + var loadError *LoadError + if !errors.As(err, &loadError) { + t.Fatalf("error type = %T", err) + } + diagnostics := loadError.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionError || strings.Contains(diagnostics[0].Message, "CANARY") { + t.Fatalf("diagnostics = %#v", diagnostics) + } +} + +func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) { + t.Parallel() + + type nested struct { + Kept string `yaml:"kept"` + Set string `yaml:"set"` + } + type config struct { + Nested nested `yaml:"nested"` + Nullable map[string]string `yaml:"nullable"` + } + result, err := Load(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{ + Defaults: func() config { + return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}} + }, + }}) + if err != nil { + t.Fatal(err) + } + metadata := result.Metadata() + kept, ok := metadata.Lookup(Keys("nested", "kept")) + if !ok || kept.Explicit || kept.Origin.Layer != LayerDefault { + t.Fatalf("kept = %#v, %v", kept, ok) + } + set, ok := metadata.Lookup(Keys("nested", "set")) + if !ok || !set.Explicit || set.Origin.Layer != LayerBase || len(set.Origin.Locations) != 1 { + t.Fatalf("set = %#v, %v", set, ok) + } + nullable, ok := metadata.Lookup(Keys("nullable")) + if !ok || !nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerBase { + t.Fatalf("nullable = %#v, %v", nullable, ok) + } + if metadata.IsExplicit(Keys("nested", "kept")) || !metadata.IsExplicit(Keys("nested", "set")) { + t.Fatal("explicit presence disagrees with lookup") + } + if history := metadata.History(Keys("nested", "set")); len(history) != 2 || history[0].Origin.Layer != LayerDefault || history[1].Origin.Layer != LayerBase || len(history[1].Previous) != 1 { + t.Fatalf("history = %#v", history) + } + + set.Origin.Locations[0].Line = 99 + history := metadata.History(Keys("nested", "set")) + history[1].Origin.Locations[0].Line = 99 + sources := result.Sources() + sources[0].Label = "changed" + diagnostics := result.Diagnostics() + if diagnostics != nil { + diagnostics[0].Message = "changed" + } + fresh, _ := result.Metadata().Lookup(Keys("nested", "set")) + if fresh.Origin.Locations[0].Line == 99 || result.Sources()[0].Label != "metadata.yml" || result.Diagnostics() != nil { + t.Fatal("result inspection aliases internal records") + } +} + +func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) { + t.Parallel() + + type config struct { + Name string `yaml:"name"` + } + validSource := func() Source { return Bytes("options.yml", []byte("name: value\n")) } + tests := []struct { + name string + source Source + options []Options[config] + code Code + }{ + {name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid}, + {name: "resolver", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Resolve: func(*config, *Resolution) error { return nil }}}}, code: CodeOptionsUnavailable}, + {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}, + {name: "filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsUnavailable}, + {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}, + {name: "no-source filesystem", source: NoSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := Load(test.source, test.options...) + if result != nil || err == nil { + t.Fatalf("result, err = %#v, %v", result, err) + } + var loadError *LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.code { + t.Fatalf("error = %#v", err) + } + }) + } + + result, err := Load[config](Bytes("directive.yml", []byte("nested:\n _configkit: {}\n"))) + if result != nil || err == nil { + t.Fatalf("result, err = %#v, %v", result, err) + } + var loadError *LoadError + if !errors.As(err, &loadError) { + t.Fatal(err) + } + diagnostic := loadError.Diagnostics()[0] + if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/_configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 { + t.Fatalf("diagnostic = %#v", diagnostic) + } +} + +func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) { + t.Parallel() + + type invalidSchema struct { + Channel chan int `yaml:"channel"` + } + type validConfig struct { + Known int `yaml:"known"` + } + tests := []struct { + name string + run func() (any, error) + code Code + }{ + {name: "source", run: func() (any, error) { return Load[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing}, + {name: "schema", run: func() (any, error) { return Load[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType}, + {name: "decode", run: func() (any, error) { return Load[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := test.run() + if !isNilInterface(result) || err == nil { + t.Fatalf("result, err = %#v, %v", result, err) + } + var loadError *LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != test.code { + t.Fatalf("error = %#v", err) + } + }) + } +} + +func isNilInterface(value any) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + return reflected.Kind() == reflect.Pointer && reflected.IsNil() +} diff --git a/records.go b/records.go index 8c7bac0..e25bf69 100644 --- a/records.go +++ b/records.go @@ -72,6 +72,16 @@ const ( CodeDecodeText Code = "decode.text" CodeDecodeUnknownField Code = "decode.unknown_field" CodeDecodeArrayLength Code = "decode.array_length" + + CodeOptionsInvalid Code = "options.invalid" + CodeOptionsUnavailable Code = "options.unavailable" + + CodeImportDisabled Code = "import.disabled" + + CodeVersionType Code = "version.type" + CodeVersionError Code = "version.error" + CodeVersionWarning Code = "version.warning" + CodeVersionHookFailed Code = "version.hook_failed" ) // Location identifies a position in a configuration source. Zero line and column diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..ecf40c5 --- /dev/null +++ b/validation.go @@ -0,0 +1,101 @@ +package configkit + +import "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. +type Validation struct { + state *validationState +} + +type validationState struct { + active bool + errorCode Code + warningCode Code + document *document.Node + diagnostics []Diagnostic +} + +// Error reports a value-free application error at path. Related paths are +// resolved to source locations when possible. +func (v *Validation) Error(path Path, message string, related ...Path) { + v.report(SeverityError, path, message, related) +} + +// Warning reports a value-free application warning at path. Related paths are +// resolved to source locations when possible. +func (v *Validation) Warning(path Path, message string, related ...Path) { + v.report(SeverityWarning, path, message, related) +} + +func (v *Validation) report(severity Severity, path Path, message string, related []Path) { + if v == nil || v.state == nil || !v.state.active { + return + } + code := v.state.errorCode + if severity == SeverityWarning { + code = v.state.warningCode + } + diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message} + if !path.valid() { + diagnostic.Severity = SeverityError + diagnostic.Code = v.state.errorCode + diagnostic.Path = Path{} + diagnostic.Message = "application reported an invalid configuration path" + } else if location, ok := nearestDocumentLocation(v.state.document, path); ok { + diagnostic.Primary = documentLocationPointer(location) + } + for _, relatedPath := range related { + if !relatedPath.valid() { + 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, + }) + } + } + v.state.diagnostics = append(v.state.diagnostics, diagnostic) +} + +func nearestDocumentLocation(root *document.Node, path Path) (document.Location, bool) { + if root == nil || !path.valid() { + return document.Location{}, false + } + current := root + nearest := current.Location + for _, segment := range path.segments { + switch current.Kind { + case document.Mapping: + if segment.kind == pathSegmentIndex { + return nearest, locationKnown(nearest) + } + found := false + for _, entry := range current.Mapping { + if entry.Key == segment.key { + current = entry.Value + nearest = current.Location + found = true + break + } + } + if !found { + return nearest, locationKnown(nearest) + } + case document.Sequence: + index, ok := schemaIndex(segment) + if !ok || index >= len(current.Sequence) { + return nearest, locationKnown(nearest) + } + current = current.Sequence[index] + nearest = current.Location + default: + return nearest, locationKnown(nearest) + } + } + return nearest, locationKnown(nearest) +} + +func locationKnown(location document.Location) bool { + return location.SourceID != "" || location.Line != 0 || location.Column != 0 +}