Add single-source loading and version checks

This commit is contained in:
2026-09-06 20:03:22 +00:00
parent 21e3f633bc
commit cc41653bfd
13 changed files with 1022 additions and 38 deletions

View File

@@ -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.

13
doc.go
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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),

View File

@@ -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

336
load.go Normal file
View File

@@ -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
}

95
load_metadata.go Normal file
View File

@@ -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
}

300
load_test.go Normal file
View File

@@ -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()
}

View File

@@ -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

101
validation.go Normal file
View File

@@ -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
}