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.
|
||||
|
||||
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
13
doc.go
@@ -1,8 +1,9 @@
|
||||
// Package configkit provides foundational types for deterministic,
|
||||
// application-owned configuration loading in Go programs.
|
||||
// Package configkit provides deterministic, application-owned configuration
|
||||
// loading for Go programs.
|
||||
//
|
||||
// Semantic paths, opaque source constructors, metadata and provenance records, safe
|
||||
// diagnostics, and structured load errors are available. YAML input is validated
|
||||
// behind a private parser boundary according to docs/config.md. Typed decoding,
|
||||
// composition, and loading operations are not available yet.
|
||||
// Load currently supports one file, byte, reader, or explicit no-input source with
|
||||
// strict YAML, typed defaults, schema-version checks, detached metadata, safe
|
||||
// diagnostics, and structured load errors. Composition, profiles, application
|
||||
// resolution and validation, overrides, secrets, and effective rendering are not
|
||||
// available yet. The configuration contract is documented in docs/config.md.
|
||||
package configkit
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
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"
|
||||
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
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