Add single-source loading and version checks
This commit is contained in:
25
README.md
25
README.md
@@ -2,9 +2,28 @@
|
|||||||
|
|
||||||
An opinionated Go library for applications that use YAML configuration files.
|
An opinionated Go library for applications that use YAML configuration files.
|
||||||
|
|
||||||
ConfigKit is in early implementation. The Go module and root package boundary are
|
ConfigKit is in early implementation. Its current loader accepts one strict YAML
|
||||||
established, but no usable configuration loader has been implemented yet.
|
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.
|
The [implementation plan](docs/roadmap/implementation.md) owns the pending stages.
|
||||||
See the [development guide](docs/development.md) for repository orientation.
|
See the [development guide](docs/development.md) for repository orientation.
|
||||||
|
|||||||
13
doc.go
13
doc.go
@@ -1,8 +1,9 @@
|
|||||||
// Package configkit provides foundational types for deterministic,
|
// Package configkit provides deterministic, application-owned configuration
|
||||||
// application-owned configuration loading in Go programs.
|
// loading for Go programs.
|
||||||
//
|
//
|
||||||
// Semantic paths, opaque source constructors, metadata and provenance records, safe
|
// Load currently supports one file, byte, reader, or explicit no-input source with
|
||||||
// diagnostics, and structured load errors are available. YAML input is validated
|
// strict YAML, typed defaults, schema-version checks, detached metadata, safe
|
||||||
// behind a private parser boundary according to docs/config.md. Typed decoding,
|
// diagnostics, and structured load errors. Composition, profiles, application
|
||||||
// composition, and loading operations are not available yet.
|
// resolution and validation, overrides, secrets, and effective rendering are not
|
||||||
|
// available yet. The configuration contract is documented in docs/config.md.
|
||||||
package configkit
|
package configkit
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Configuration Input Contract
|
# Configuration Input Contract
|
||||||
|
|
||||||
This document owns ConfigKit's currently implemented source-input, YAML syntax,
|
This document owns ConfigKit's currently implemented source-input, YAML syntax,
|
||||||
schema, typed-decoding, and ownership contracts. The parser, owned document
|
schema, typed-decoding, loading, metadata, and ownership contracts. The parser,
|
||||||
representation, and decoder are private; a public typed loading lifecycle has not
|
owned document representation, and decoder remain private behind the public loading
|
||||||
been implemented yet.
|
lifecycle.
|
||||||
|
|
||||||
## Sources
|
## 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. |
|
| `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. |
|
| `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. |
|
| `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
|
The zero `Source` is invalid. Constructor argument failures are retained inside the
|
||||||
opaque source and become safe structured diagnostics when the source is consumed.
|
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
|
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.
|
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
|
## YAML documents
|
||||||
|
|
||||||
The private YAML boundary currently accepts exactly one nonempty document whose root
|
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
|
codec failures are classified without exposing application error text or input
|
||||||
values.
|
values.
|
||||||
|
|
||||||
This decoder is currently an internal lifecycle foundation, not a public conversion
|
This decoder is available only through `Load`; ConfigKit exposes no generic document
|
||||||
API. Applications cannot call typed loading until `Load` is introduced.
|
conversion API.
|
||||||
|
|
||||||
## Typed projection and ownership
|
## Typed projection and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
This is the contributor entry point for ConfigKit. Read the task-specific owners
|
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
|
below before making changes. The repository is in early implementation: its public
|
||||||
Go module and documented root package, but no loading behavior or executable
|
loader currently covers one strict source, typed defaults, schema-version checks,
|
||||||
examples yet. The [component overview](internal/overview.md) owns the implementation
|
and base/default metadata. The [component overview](internal/overview.md) owns the
|
||||||
inventory.
|
implementation inventory.
|
||||||
|
|
||||||
## Initial orientation
|
## Initial orientation
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,15 @@ multi-error collection, and repeated-result isolation. Resolution, validation,
|
|||||||
consumer defaults, and all campaign/session algorithms remain assigned to later
|
consumer defaults, and all campaign/session algorithms remain assigned to later
|
||||||
work.
|
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
|
### Secrets bootstrap
|
||||||
|
|
||||||
Assigned to the explicit bootstrap operation:
|
Assigned to the explicit bootstrap operation:
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ module and package documentation boundary. Its implemented public primitives are
|
|||||||
enforcement, and structural comparison in `projection.go`; and
|
enforcement, and structural comparison in `projection.go`; and
|
||||||
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
||||||
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
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,
|
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
||||||
and ownership behavior. ConfigKit intentionally exposes no typed loading API yet;
|
single-source lifecycle, version checking, metadata, and ownership behavior.
|
||||||
the decoder and its initial metadata artifacts remain private until that lifecycle
|
|
||||||
exists.
|
|
||||||
|
|
||||||
The source-extraction inventory records the application foundations assigned to
|
The source-extraction inventory records the application foundations assigned to
|
||||||
future implementation work:
|
future implementation work:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status and ownership
|
## 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.
|
pending.
|
||||||
|
|
||||||
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
|
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
|
## 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),
|
Read: feature [lifecycle](initial.md#resolution-lifecycle),
|
||||||
[version hook](initial.md#schema-checking-and-secret-classification),
|
[version hook](initial.md#schema-checking-and-secret-classification),
|
||||||
|
|||||||
@@ -425,15 +425,16 @@ configuration is never returned as a successful, usable result.
|
|||||||
|
|
||||||
Source ownership, the supported YAML subset, scalar grammar, fixed input limits,
|
Source ownership, the supported YAML subset, scalar grammar, fixed input limits,
|
||||||
and safe failure behavior are implemented. The [current configuration input
|
and safe failure behavior are implemented. The [current configuration input
|
||||||
contract](../config.md) now owns those definitions. Typed scalar conversion into
|
contract](../config.md) now owns those definitions, including typed scalar
|
||||||
application values remains target work in [typed decoding](#typed-decoding).
|
conversion through the current single-source lifecycle.
|
||||||
|
|
||||||
## Paths and presence
|
## Paths and presence
|
||||||
|
|
||||||
Semantic path construction, JSON Pointer parsing, and current metadata query
|
Semantic path construction, JSON Pointer parsing, and base/default metadata
|
||||||
declarations are implemented. Their current contracts are owned by the Go
|
population are implemented. Their current contracts are owned by the Go
|
||||||
documentation in [`path.go`](../../path.go) and [`metadata.go`](../../metadata.go).
|
documentation in [`path.go`](../../path.go), [`metadata.go`](../../metadata.go),
|
||||||
The remaining presence behavior below is target lifecycle work.
|
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
|
Distinguish absent, explicitly null, explicitly zero/false/empty, and other
|
||||||
explicit values. An explicit value equal to its default remains explicit.
|
explicit values. An explicit value equal to its default remains explicit.
|
||||||
@@ -672,8 +673,11 @@ Automatic migration is deferred.
|
|||||||
|
|
||||||
## Consumer API design
|
## Consumer API design
|
||||||
|
|
||||||
The following is a proposed public API, not implemented Go reference documentation
|
The core declarations and single-source loading subset are implemented; their
|
||||||
or a frozen naming commitment. Declarations omit method bodies and private fields.
|
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`
|
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).
|
import using the module path selected in the [implementation plan](implementation.md#implementation-decisions).
|
||||||
They are illustrative target examples, not currently runnable examples. The
|
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
|
### 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
|
```go
|
||||||
func Load[T any](source Source, options ...Options[T]) (*Result[T], error)
|
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
|
### Paths and metadata
|
||||||
|
|
||||||
The path API, detached metadata records, and empty/unknown query behavior are now
|
The path API, detached metadata records, empty/unknown query behavior, and
|
||||||
implemented; [`path.go`](../../path.go), [`records.go`](../../records.go), and
|
base/default population are now implemented; [`path.go`](../../path.go),
|
||||||
[`metadata.go`](../../metadata.go) own their current Go contracts. Populating
|
[`records.go`](../../records.go), [`metadata.go`](../../metadata.go), and the
|
||||||
effective presence, origins, and history belongs to the loading stages. Callback
|
[metadata configuration contract](../config.md#base-default-metadata) own current
|
||||||
path checking and safe historical values remain target behavior below.
|
behavior. Layered callback history and safe historical values remain target behavior.
|
||||||
|
|
||||||
### Resolution and validation callbacks
|
### Resolution and validation callbacks
|
||||||
|
|
||||||
@@ -970,6 +981,12 @@ potentially sensitive configured values.
|
|||||||
|
|
||||||
### Schema checking and secret classification
|
### 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
|
```go
|
||||||
type VersionRule struct {
|
type VersionRule struct {
|
||||||
Path Path
|
Path Path
|
||||||
@@ -1030,6 +1047,12 @@ applications own secret-producing transformations not described by those records
|
|||||||
|
|
||||||
### Results, errors, and inspection
|
### 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
|
```go
|
||||||
type Result[T any] struct {
|
type Result[T any] struct {
|
||||||
Config T
|
Config T
|
||||||
|
|||||||
336
load.go
Normal file
336
load.go
Normal 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
95
load_metadata.go
Normal 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
300
load_test.go
Normal 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()
|
||||||
|
}
|
||||||
10
records.go
10
records.go
@@ -72,6 +72,16 @@ const (
|
|||||||
CodeDecodeText Code = "decode.text"
|
CodeDecodeText Code = "decode.text"
|
||||||
CodeDecodeUnknownField Code = "decode.unknown_field"
|
CodeDecodeUnknownField Code = "decode.unknown_field"
|
||||||
CodeDecodeArrayLength Code = "decode.array_length"
|
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
|
// Location identifies a position in a configuration source. Zero line and column
|
||||||
|
|||||||
101
validation.go
Normal file
101
validation.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user