Files
ConfigKit/lifecycle.go

275 lines
9.5 KiB
Go

package configkit
import (
"errors"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// Definition contains reusable application rules for a configuration type.
// Secret is a pure predicate that can add sensitivity to semantic paths.
// 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.
// Imports enables explicit path and glob imports; Profile selects one fixed profile
// overlay; Overrides contains only values explicitly supplied by an application CLI.
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. Value uses
// parser-neutral built-in scalars, nil, []any, or map[string]any; Flag is a safe
// display name and must not contain a raw argument or value.
type Override struct {
Path Path
Value any
Flag string
}
// Resolution exposes entry-state input provenance, surviving-input presence, and
// audited application resolution changes. A resolver must not retain or
// concurrently use the context.
type Resolution struct{ value *engine.Resolution }
// VersionRule declares the input path and application checker for a schema version.
// At that path, string and decimal-integer input is supplied as Version.Text.
type VersionRule struct {
Path Path
Check func(Version, *Validation) error
}
// Version preserves the absent, null, and textual states of a schema-version input.
// Text is the exact spelling of accepted string or decimal-integer input.
type Version struct {
Present bool
Null bool
Text string
}
// Validation collects safe application diagnostics during a supported callback.
// A callback must not retain the context or use it concurrently.
type Validation struct{ value *engine.Validation }
// Result is a successful independently owned configuration load.
type Result[T any] struct {
Config T
inspection *engine.Inspection
}
// Load parses and strictly decodes zero or one selected source, explicitly enabled
// imports, and one selected profile. 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) {
options := make([]engine.Options[T], len(supplied))
for index := range supplied {
options[index] = toEngineOptions(supplied[index])
}
loaded, err := engine.Load(source.toEngine(), secretBinding(), options...)
if err != nil {
var loadError *engine.LoadError
if errors.As(err, &loadError) {
return nil, &LoadError{value: loadError}
}
return nil, &LoadError{}
}
return &Result[T]{Config: loaded.Config, inspection: loaded.Inspection}, nil
}
// Metadata returns detached effective presence, origin, and history records.
func (r *Result[T]) Metadata() Metadata {
if r == nil || r.inspection == nil {
return Metadata{}
}
return Metadata{value: r.inspection.Metadata()}
}
// Sources returns a detached list of sources used by the load.
func (r *Result[T]) Sources() []SourceInfo {
if r == nil || r.inspection == nil {
return nil
}
values := r.inspection.Sources()
if values == nil {
return nil
}
result := make([]SourceInfo, len(values))
for index := range values {
result[index] = fromEngineSourceInfo(values[index])
}
return result
}
// Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Result[T]) Diagnostics() []Diagnostic {
if r == nil || r.inspection == nil {
return nil
}
values := r.inspection.Diagnostics()
if values == nil {
return nil
}
result := make([]Diagnostic, len(values))
for index := range values {
result[index] = fromEngineDiagnostic(values[index])
}
return result
}
// IsExplicit reports whether surviving input explicitly defined path.
func (r *Resolution) IsExplicit(path Path) bool {
return r.engineContext().IsExplicit(toEnginePath(path))
}
// InputInfo returns detached entry-state metadata for path. The entry state is
// the decoded configuration, including retained static defaults, immediately
// before resolution starts. It is fixed while resolution records mutate the
// working configuration; unknown or shape-mismatched paths return false.
//
// InputInfo is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) {
value, ok := r.engineContext().InputInfo(toEnginePath(path))
return fromEngineFieldInfo(value), ok
}
// Source returns a detached record for a source acquired by this load. Empty or
// unknown IDs return false and do not perform input, filesystem, or credential
// access. A source path is present only when that acquired source has one.
//
// Source is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) Source(id string) (SourceInfo, bool) {
value, ok := r.engineContext().Source(id)
return fromEngineSourceInfo(value), ok
}
// Record declares the intent for a resolver mutation. A container path covers
// mutations to its descendants.
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
r.engineContext().Record(toEnginePath(path), engine.ChangeKind(kind), reason, toEnginePaths(dependencies)...)
}
// Error reports a safe, value-free resolution error at path.
func (r *Resolution) Error(path Path, message string, related ...Path) {
r.engineContext().Error(toEnginePath(path), message, toEnginePaths(related)...)
}
// Warning reports a safe, value-free resolution warning at path.
func (r *Resolution) Warning(path Path, message string, related ...Path) {
r.engineContext().Warning(toEnginePath(path), message, toEnginePaths(related)...)
}
// Default assigns and records value only when path was absent from surviving
// input. Static defaults are not considered explicit.
func Default[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
engine.Default(resolution.engineContext(), toEnginePath(path), target, value, reason, toEnginePaths(dependencies)...)
}
// Normalize assigns and records value regardless of input presence.
func Normalize[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
engine.Normalize(resolution.engineContext(), toEnginePath(path), target, value, reason, toEnginePaths(dependencies)...)
}
// 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.engineContext().Error(toEnginePath(path), message, toEnginePaths(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.engineContext().Warning(toEnginePath(path), message, toEnginePaths(related)...)
}
// Pass nil through so the engine remains the owner of the callback guard.
func (r *Resolution) engineContext() *engine.Resolution {
if r == nil {
return nil
}
return r.value
}
func (v *Validation) engineContext() *engine.Validation {
if v == nil {
return nil
}
return v.value
}
func toEngineOptions[T any](options Options[T]) engine.Options[T] {
result := engine.Options[T]{
Definition: toEngineDefinition(options.Definition),
Imports: options.Imports,
Profile: options.Profile,
}
if options.Filesystem != nil {
result.Filesystem = &engine.FilesystemContext{BaseDir: options.Filesystem.BaseDir, Root: options.Filesystem.Root}
}
if options.Overrides != nil {
result.Overrides = make([]engine.Override, len(options.Overrides))
for index := range options.Overrides {
result.Overrides[index] = engine.Override{
Path: toEnginePath(options.Overrides[index].Path), Value: options.Overrides[index].Value, Flag: options.Overrides[index].Flag,
}
}
}
return result
}
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
result := engine.Definition[T]{Defaults: definition.Defaults}
if definition.Resolve != nil {
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
return definition.Resolve(configuration, &Resolution{value: resolution})
}
}
if definition.Validate != nil {
result.Validate = func(configuration *T, validation *engine.Validation) {
definition.Validate(configuration, &Validation{value: validation})
}
}
if definition.Version != nil {
rule := definition.Version
result.Version = &engine.VersionRule{Path: toEnginePath(rule.Path)}
if rule.Check != nil {
result.Version.Check = func(version engine.Version, validation *engine.Validation) error {
return rule.Check(Version{Present: version.Present, Null: version.Null, Text: version.Text}, &Validation{value: validation})
}
}
}
if definition.Secret != nil {
result.Secret = func(path engine.Path) bool { return definition.Secret(fromEnginePath(path)) }
}
return result
}
func toEnginePaths(paths []Path) []engine.Path {
if paths == nil {
return nil
}
result := make([]engine.Path, len(paths))
for index := range paths {
result[index] = toEnginePath(paths[index])
}
return result
}