Files
configkit/lifecycle.go

426 lines
17 KiB
Go
Raw Permalink Normal View History

2026-09-12 16:16:11 +00:00
package configkit
import (
"errors"
"go.maximumdirect.net/configkit/internal/engine"
2026-09-12 16:16:11 +00:00
)
// Definition supplies the defaults and application rules used when loading T.
// Treat a definition as read-only after sharing it. Sensitive is a pure predicate
// that can mark additional configuration paths for hiding in inspection output.
2026-09-12 16:16:11 +00:00
type Definition[T any] struct {
// Defaults returns starting values for settings omitted from input.
Defaults func() T
// Resolve derives or normalizes values and records those changes.
Resolve func(*T, *Resolution) error
// Validate reports application errors and warnings without changing values.
Validate func(*T, *Validation)
// Version chooses a version path and the application's acceptance rule.
Version *VersionRule
// Sensitive adds paths to those already hidden by configkit:"secret" tags.
Sensitive func(Path) bool
// SequenceIdentities declares the scalar fields used to combine records and
// resolve record inheritance at selected sequence destinations. Registration
// errors are reported before source acquisition. Before successful return,
// matching final sequences are also checked, including values from defaults,
// CLI overrides, and application resolution. Schema and validation govern
// path existence and shape; later caller mutations are outside this guarantee.
SequenceIdentities []SequenceIdentity
}
// PlacementMode selects how an input mapping is placed at its Destination.
// The zero value, PlacementModeMapping, preserves ordinary mapping placement.
// Unknown values and sequence-item placement at the root fail option validation.
type PlacementMode uint8
const (
// PlacementModeMapping places a source mapping at Destination.
PlacementModeMapping PlacementMode = iota
// PlacementModeSequenceItem wraps a source mapping as one item at Destination.
PlacementModeSequenceItem
)
// SourceVersionMode controls schema-version declarations across one complete
2026-10-15 16:23:39 +00:00
// load. The zero value preserves the existing main-only effective-path check
// and its protected-path behavior. Enabled modes check participating documents
// at source-local Definition.Version.Path coordinates and consume accepted
// non-main declarations. See the shared source-version contract in docs/config.md.
type SourceVersionMode uint8
const (
2026-10-27 15:01:54 +00:00
// SourceVersionModeMainOnly preserves the existing main-version check and
// rejects version-path contributions from imported, underlay, and overlay
// payloads.
2026-10-27 15:01:54 +00:00
SourceVersionModeMainOnly SourceVersionMode = iota
// SourceVersionModeIfPresent checks each participating document that has a
// declaration. A missing main declaration still reaches the checker; an
// absent non-main declaration skips it. Accepted non-main declarations are
// consumed before composition.
SourceVersionModeIfPresent
// SourceVersionModeRequired requires a declaration in each participating
// document. NoSource retains the existing absent-main checker behavior.
// Accepted non-main declarations are consumed before composition.
SourceVersionModeRequired
)
// ImportDefaults supplies load-wide defaults for enabled import declarations.
// Each field applies independently and an explicit declaration value takes
// precedence. Nil boolean fields are unspecified and leave the library defaults
2026-10-27 15:07:15 +00:00
// in effect: Required false, FollowSymlinks true, IncludeHidden true, and
// Recursive false.
// Pattern applies to directory searches; nil leaves filenames unrestricted,
// while a non-nil empty slice selects none. Defaults apply to main, underlay,
// and transitive imports, not selected overlays. Load calls snapshot the
// pointed-to booleans and Pattern slice before acquiring sources.
type ImportDefaults struct {
// Required defaults absence handling. For path, true requires every named
// file; for dir and glob, true requires at least one selected file.
Required *bool
2026-10-27 15:07:15 +00:00
// FollowSymlinks controls discovered symbolic links for directory and glob search.
FollowSymlinks *bool
// IncludeHidden controls discovery of hidden files and directories.
IncludeHidden *bool
// Recursive controls descendant traversal for directory searches.
Recursive *bool
// Pattern replaces the library's unrestricted filename matching for directory
// searches unless a declaration supplies its own pattern.
Pattern []string
}
2026-09-12 16:16:11 +00:00
// Options contains the application definition and per-load input selections.
// Imports enables traversal of declarations in a selected main document; an
// absent source has no main declarations to traverse. Overlay selects one fixed
// overlay and requires filesystem context for NoSource. Overrides contain only
// values explicitly supplied by an application CLI.
// Enabled source-version modes interpret Definition.Version.Path in each
// document's local coordinates. Placement projects checks and main protection
// through the input destination; item placement adds the source's item
// coordinate to that projection. See docs/config.md for the full contract.
2026-09-12 16:16:11 +00:00
type Options[T any] struct {
Definition Definition[T]
// Destination places the ordinary source beneath literal mapping keys. Its
// zero value means the application root. Invalid paths fail before acquisition.
Destination Path
// PlacementMode defaults to mapping placement; unknown values and root item
// placement fail before acquisition.
PlacementMode PlacementMode
Imports bool
// ImportDefaults supplies one load-wide default policy for every enabled
// declaration, including transitive imports. Each field is independently
// overridden by an explicit declaration value.
ImportDefaults ImportDefaults
Overlay string
Filesystem *FilesystemContext
Overrides []Override
SourceVersionMode SourceVersionMode
2026-09-12 16:16:11 +00:00
}
// FilesystemContext supplies roots for imports and overlays. With FS nil,
// BaseDir and Root use absolute host paths. For NoSource, Root is required and
// BaseDir may be omitted to use Root as the import base. With FS set, BaseDir
// and Root use slash-relative paths in that namespace ("." denotes its root).
// File sources derive BaseDir from the selected file; a native file's explicit
// Root limits its scope.
2026-09-12 16:16:11 +00:00
type FilesystemContext struct {
BaseDir string
Root string
FS *Filesystem
2026-09-12 16:16:11 +00:00
}
// 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 lets a resolver inspect supplied values and record derived defaults
// or normalization. Its input queries describe the state at callback entry,
// even after the resolver changes the configuration. Use the context only during
// the callback; do not retain it or use it concurrently.
2026-09-12 16:16:11 +00:00
type Resolution struct{ value *engine.Resolution }
// VersionRule declares the input path and application checker for a schema version.
2026-10-15 16:23:39 +00:00
// Default source-version mode interprets Path as the existing effective path;
// enabled modes interpret it in each participating document's local coordinates.
// At the selected path, strings and strict decimal integers supply their exact
// spelling as Version.Text. See docs/config.md for the complete contract.
2026-09-12 16:16:11 +00:00
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 lets an application report value-free errors and warnings during
// a version or validation callback. Use it only during the callback; do not
// retain the context or use it concurrently.
2026-09-12 16:16:11 +00:00
type Validation struct{ value *engine.Validation }
// Result contains a successful load's configuration and saved inspection state.
// The application owns Config and may change it. Inspection continues to describe
// the completed load; it does not follow later changes to Config. Separate loads
// return independently owned results.
2026-09-12 16:16:11 +00:00
type Result[T any] struct {
Config T
inspection *engine.Inspection
}
// Load reads the selected input and returns a configuration of type T with
// inspection metadata. NoSource deliberately omits the main document. Enabled
// imports and one selected overlay participate in loading. Sources combine first,
// then sibling extends relationships resolve before defaults and strict typed
// decoding. Load accepts at most one Options value and returns a nil result when
// any error is reported. See docs/config.md for the complete processing order.
2026-09-12 16:16:11 +00:00
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...)
return fromEngineLoaded(loaded, err)
}
func fromEngineLoaded[T any](loaded *engine.Loaded[T], err error) (*Result[T], error) {
2026-09-12 16:16:11 +00:00
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 provides queries for final input presence, value origins, and history.
// Records returned by its queries are copies that cannot change the saved load.
2026-09-12 16:16:11 +00:00
func (r *Result[T]) Metadata() Metadata {
if r == nil || r.inspection == nil {
return Metadata{}
}
return Metadata{value: r.inspection.Metadata()}
}
// Sources returns a copied list of sources used by the load. Changing the list
// or its nested records cannot change later queries.
2026-09-12 16:16:11 +00:00
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
}
// Source returns a detached record for an acquired source in this load. IDs are
// local to the load; the lookup does not reacquire the source or access the
// filesystem. Empty or unknown IDs return false.
func (r *Result[T]) Source(id string) (SourceInfo, bool) {
if r == nil || r.inspection == nil {
return SourceInfo{}, false
}
value, ok := r.inspection.Source(id)
if !ok {
return SourceInfo{}, false
}
return fromEngineSourceInfo(value), true
}
2026-09-12 16:16:11 +00:00
// 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 direct or inherited input defined path.
func (r *Resolution) IsExplicit(path Path) bool {
return r.engineContext().IsExplicit(toEnginePath(path))
}
2026-09-12 16:16:11 +00:00
// EntryInfo returns detached entry-state metadata for path. The entry state is
// the decoded configuration, including retained static defaults and CLI
// assignments, immediately before resolution starts. It is fixed while
// resolution records mutate the working configuration; unknown or
// shape-mismatched paths return false.
2026-09-12 16:16:11 +00:00
//
// EntryInfo is valid only during the resolution callback. See the configuration
2026-09-12 16:16:11 +00:00
// contract for source and provenance semantics.
func (r *Resolution) EntryInfo(path Path) (FieldInfo, bool) {
value, ok := r.engineContext().InputInfo(toEnginePath(path))
2026-09-12 16:16:11 +00:00
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)
2026-09-12 16:16:11 +00:00
return fromEngineSourceInfo(value), ok
}
// RecordDerivedDefault records a derived-default intent for a resolver mutation
// already performed by application code. A container path covers mutations to
// its descendants. This does not assign a value or suppress work for explicit
// input.
func (r *Resolution) RecordDerivedDefault(path Path, reason string, dependencies ...Path) {
r.engineContext().RecordDerivedDefault(toEnginePath(path), reason, toEnginePaths(dependencies)...)
}
// RecordNormalization records a normalization intent for a resolver mutation
// already performed by application code. A container path covers mutations to
// its descendants. This does not assign a value.
func (r *Resolution) RecordNormalization(path Path, reason string, dependencies ...Path) {
r.engineContext().RecordNormalization(toEnginePath(path), reason, toEnginePaths(dependencies)...)
2026-09-12 16:16:11 +00:00
}
// 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)...)
2026-09-12 16:16:11 +00:00
}
// 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)...)
2026-09-12 16:16:11 +00:00
}
// 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)...)
2026-09-12 16:16:11 +00:00
}
// 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)...)
2026-09-12 16:16:11 +00:00
}
// 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)...)
2026-09-12 16:16:11 +00:00
}
// 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
2026-09-12 16:16:11 +00:00
}
func toEngineOptions[T any](options Options[T]) engine.Options[T] {
result := engine.Options[T]{
Definition: toEngineDefinition(options.Definition),
Imports: options.Imports,
Overlay: options.Overlay,
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
ImportDefaults: toEngineImportDefaults(options.ImportDefaults),
Destination: toEnginePath(options.Destination.clone()),
PlacementMode: engine.PlacementMode(options.PlacementMode),
2026-09-12 16:16:11 +00:00
}
if options.Filesystem != nil {
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
2026-09-12 16:16:11 +00:00
}
result.Overrides = toEngineOverrides(options.Overrides)
2026-09-12 16:16:11 +00:00
return result
}
func toEngineFilesystemContext(context *FilesystemContext) *engine.FilesystemContext {
if context == nil {
return nil
}
result := &engine.FilesystemContext{BaseDir: context.BaseDir, Root: context.Root}
if context.FS != nil {
filesystem := context.FS.value
result.FS = &filesystem
}
return result
}
2026-09-12 16:16:11 +00:00
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
result := engine.Definition[T]{Defaults: definition.Defaults}
if definition.SequenceIdentities != nil {
registrations := make([]engine.SequenceIdentityRegistrationInput, len(definition.SequenceIdentities))
for index, identity := range definition.SequenceIdentities {
registrations[index] = toEngineSequenceIdentity(identity)
}
result = engine.DefinitionWithSequenceIdentityRegistrations(result, registrations)
}
2026-09-12 16:16:11 +00:00
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})
}
}
2026-09-12 16:16:11 +00:00
}
if definition.Sensitive != nil {
result.Secret = func(path engine.Path) bool { return definition.Sensitive(fromEnginePath(path)) }
2026-09-12 16:16:11 +00:00
}
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
}