Add public facade value wrappers

This commit is contained in:
2026-09-12 16:07:38 +00:00
parent da02a19f2c
commit 05812a6ad3
9 changed files with 646 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
func fromEngineLocation(value engine.Location) Location {
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
}
func toEngineLocation(value Location) engine.Location {
return engine.Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
}
func fromEngineLocationPointer(value *engine.Location) *Location {
if value == nil {
return nil
}
result := fromEngineLocation(*value)
return &result
}
func toEngineLocationPointer(value *Location) *engine.Location {
if value == nil {
return nil
}
result := toEngineLocation(*value)
return &result
}
func fromEngineLocations(values []engine.Location) []Location {
if values == nil {
return nil
}
result := make([]Location, len(values))
for i := range values {
result[i] = fromEngineLocation(values[i])
}
return result
}
func toEngineLocations(values []Location) []engine.Location {
if values == nil {
return nil
}
result := make([]engine.Location, len(values))
for i := range values {
result[i] = toEngineLocation(values[i])
}
return result
}
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
}
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
}
func fromEngineOrigin(value engine.Origin) Origin {
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
}
func toEngineOrigin(value Origin) engine.Origin {
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
}
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
}
func toEngineDiagnostic(value Diagnostic) engine.Diagnostic {
return engine.Diagnostic{Code: engine.Code(value.Code), Severity: engine.Severity(value.Severity), Path: toEnginePath(value.Path), Message: value.Message, Primary: toEngineLocationPointer(value.Primary), Related: toEngineLocations(value.Related)}
}
func toEngineDiagnostics(values []Diagnostic) []engine.Diagnostic {
if values == nil {
return nil
}
result := make([]engine.Diagnostic, len(values))
for i := range values {
result[i] = toEngineDiagnostic(values[i])
}
return result
}
func fromEngineFieldInfo(value engine.FieldInfo) FieldInfo {
return FieldInfo{Explicit: value.Explicit, Null: value.Null, Origin: fromEngineOrigin(value.Origin)}
}
func fromEngineChange(value engine.Change) Change {
result := Change{Path: fromEnginePath(value.Path), Origin: fromEngineOrigin(value.Origin), Reasons: append([]string(nil), value.Reasons...)}
if value.Previous != nil {
result.Previous = make([]Origin, len(value.Previous))
for i := range value.Previous {
result.Previous[i] = fromEngineOrigin(value.Previous[i])
}
}
if value.Dependencies != nil {
result.Dependencies = make([]Path, len(value.Dependencies))
for i := range value.Dependencies {
result.Dependencies[i] = fromEnginePath(value.Dependencies[i])
}
}
return result
}

View File

@@ -0,0 +1,44 @@
package configkit
import (
"io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// LoadError reports safe structured diagnostics for a failed configuration load.
type LoadError struct{ value *engine.LoadError }
func (e *LoadError) Error() string {
if e == nil || e.value == nil {
return "configkit: configuration load failed"
}
return e.value.Error()
}
func (e *LoadError) Diagnostics() []Diagnostic {
if e == nil || e.value == nil {
return nil
}
values := e.value.Diagnostics()
result := make([]Diagnostic, len(values))
for i := range values {
result[i] = fromEngineDiagnostic(values[i])
}
return result
}
func (e *LoadError) Sources() []SourceInfo {
if e == nil || e.value == nil {
return nil
}
values := e.value.Sources()
result := make([]SourceInfo, len(values))
for i := range values {
result[i] = fromEngineSourceInfo(values[i])
}
return result
}
// WriteDiagnostics writes diagnostics in canonical order.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
return engine.WriteDiagnostics(output, toEngineDiagnostics(diagnostics))
}

View File

@@ -0,0 +1,43 @@
package configkit_test
import (
"encoding/json"
"fmt"
"reflect"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit/internal/facade"
)
var (
_ error = (*configkit.LoadError)(nil)
_ fmt.Formatter = configkit.Secret{}
_ fmt.Stringer = configkit.Path{}
_ json.Marshaler = configkit.Secret{}
)
func TestFacadeValuesRemainRootOwned(t *testing.T) {
t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit/internal/facade"
for _, candidate := range []reflect.Type{
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
reflect.TypeFor[configkit.LoadError](),
} {
if candidate.PkgPath() != packagePath {
t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath())
}
}
secret := configkit.SecretEnv("SAFE_NAME")
formatted := fmt.Sprintf("%#v", secret)
encoded, err := json.Marshal(secret)
if formatted == "" || err != nil || string(encoded) != `"SAFE_NAME"` {
t.Fatalf("secret rendering = %q, %s, %v", formatted, encoded, err)
}
if got := configkit.Keys("a/b~").Index(0).String(); got != "/a~1b~0/0" {
t.Fatalf("path = %q", got)
}
if got := (&configkit.LoadError{}).Error(); got != "configkit: configuration load failed" {
t.Fatalf("zero LoadError = %q", got)
}
}

View File

@@ -0,0 +1,52 @@
package configkit
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
data := []byte("value: retained\n")
source := Bytes("snapshot.yml", data)
data[7] = 'X'
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
if err != nil || result.Config.Value != "retained" {
t.Fatalf("snapshotted source = %#v, %v", result, err)
}
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
first := readerSource
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
t.Fatal(err)
}
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
var loadError *engine.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
t.Fatalf("copied reader source error = %#v", err)
}
}
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
t.Parallel()
parsed, err := ParsePath("/0")
if err != nil {
t.Fatal(err)
}
keyed, indexed := Keys("0"), Path{}.Index(0)
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
}
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
}
}

33
internal/facade/path.go Normal file
View File

@@ -0,0 +1,33 @@
package configkit
import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// Path identifies a configuration value using YAML field names. Its textual form
// is a JSON Pointer. Paths are immutable; the zero value is the root path.
type Path struct {
value engine.Path
}
// Keys constructs a path of mapping keys. Keys with no arguments returns the root path.
func Keys(keys ...string) Path { return Path{value: engine.Keys(keys...)} }
// ParsePath parses a JSON Pointer while preserving context-dependent numeric tokens.
func ParsePath(pointer string) (Path, error) {
value, err := engine.ParsePath(pointer)
return Path{value: value}, err
}
// Key returns a new path with key appended as a mapping-key segment.
func (p Path) Key(key string) Path { return Path{value: p.value.Key(key)} }
// Index returns a new path with index appended as a sequence-index segment.
func (p Path) Index(index int) Path { return Path{value: p.value.Index(index)} }
// String returns the path's canonical JSON Pointer spelling.
func (p Path) String() string { return p.value.String() }
func (p Path) clone() Path { return Path{value: p.value} }
func toEnginePath(path Path) engine.Path { return path.value }
func fromEnginePath(path engine.Path) Path { return Path{value: path} }

286
internal/facade/records.go Normal file
View File

@@ -0,0 +1,286 @@
package configkit
// Layer identifies the precedence layer responsible for a configuration value.
type Layer string
const (
LayerDefault Layer = "default"
LayerBase Layer = "base"
LayerProfile Layer = "profile"
LayerCLI Layer = "cli"
LayerResolution Layer = "resolution"
)
// ChangeKind identifies a recorded operation. The zero value is
// used for input and default origins.
type ChangeKind string
const (
// ChangeDecoding records the built-in transition to canonical typed values.
// It is inspection metadata, not an operation accepted by Resolution.Record.
ChangeDecoding ChangeKind = "decoding"
ChangeDerivedDefault ChangeKind = "derived_default"
ChangeNormalization ChangeKind = "normalization"
)
// Severity classifies a diagnostic.
type Severity string
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
)
// Code is a stable, machine-readable diagnostic classification.
type Code string
const (
CodeSourceInvalid Code = "source.invalid"
CodeSourceMissing Code = "source.missing"
CodeSourcePermission Code = "source.permission"
CodeSourceRead Code = "source.read"
CodeSourceNotRegular Code = "source.not_regular"
CodeSourceLimitExceeded Code = "source.limit_exceeded"
CodeSourceReaderUsed Code = "source.reader_used"
CodeYAMLInvalid Code = "yaml.invalid"
CodeYAMLEmpty Code = "yaml.empty"
CodeYAMLMultipleDocuments Code = "yaml.multiple_documents"
CodeYAMLRootType Code = "yaml.root_type"
CodeYAMLDuplicateKey Code = "yaml.duplicate_key"
CodeYAMLTag Code = "yaml.tag"
CodeYAMLAnchor Code = "yaml.anchor"
CodeYAMLAlias Code = "yaml.alias"
CodeYAMLMergeKey Code = "yaml.merge_key"
CodeYAMLScalar Code = "yaml.scalar"
CodeYAMLDepthLimit Code = "yaml.depth_limit"
CodeYAMLNodeLimit Code = "yaml.node_limit"
CodeSchemaRoot Code = "schema.root"
CodeSchemaUnsupportedType Code = "schema.unsupported_type"
CodeSchemaAmbiguousField Code = "schema.ambiguous_field"
CodeSchemaTag Code = "schema.tag"
CodeSchemaTextCodec Code = "schema.text_codec"
CodeSchemaInline Code = "schema.inline"
CodeSchemaCycle Code = "schema.cycle"
CodeSchemaProjectionLimit Code = "schema.projection_limit"
CodeSchemaCodec Code = "schema.codec"
CodeSchemaNonFinite Code = "schema.non_finite"
CodeSchemaDynamicType Code = "schema.dynamic_type"
CodeDecodeType Code = "decode.type"
CodeDecodeNull Code = "decode.null"
CodeDecodeRange Code = "decode.range"
CodeDecodeDuration Code = "decode.duration"
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"
CodeImportInvalid Code = "import.invalid"
CodeImportMissing Code = "import.missing"
CodeImportPermission Code = "import.permission"
CodeImportRead Code = "import.read"
CodeImportOutsideRoot Code = "import.outside_root"
CodeImportCycle Code = "import.cycle"
CodeImportRepeated Code = "import.repeated"
CodeImportLimitExceeded Code = "import.limit_exceeded"
CodeImportVersion Code = "import.version"
CodeProfileInvalid Code = "profile.invalid"
CodeProfileMissing Code = "profile.missing"
CodeProfileOutsideRoot Code = "profile.outside_root"
CodeProfileRepeated Code = "profile.repeated"
CodeProfileDirective Code = "profile.directive"
CodeProfileVersion Code = "profile.version"
CodeProfileLimitExceeded Code = "profile.limit_exceeded"
CodeComposeInvalid Code = "compose.invalid"
CodeComposeConflict Code = "compose.conflict"
CodeVersionType Code = "version.type"
CodeVersionError Code = "version.error"
CodeVersionWarning Code = "version.warning"
CodeVersionHookFailed Code = "version.hook_failed"
CodeResolveError Code = "resolve.error"
CodeResolveWarning Code = "resolve.warning"
CodeResolveHookFailed Code = "resolve.hook_failed"
CodeResolveInvalidPath Code = "resolve.invalid_path"
CodeResolveInvalidKind Code = "resolve.invalid_kind"
CodeResolveInvalidRecord Code = "resolve.invalid_record"
CodeResolveInvalidTarget Code = "resolve.invalid_target"
CodeResolveInvalidValue Code = "resolve.invalid_value"
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
CodeValidateError Code = "validate.error"
CodeValidateWarning Code = "validate.warning"
CodeValidateInvalidPath Code = "validate.invalid_path"
CodeValidateMutation Code = "validate.mutation"
CodeSecretsInvalidPath Code = "secrets.invalid_path"
CodeSecretsMissing Code = "secrets.missing"
CodeSecretsPermission Code = "secrets.permission"
CodeSecretsRead Code = "secrets.read"
CodeSecretsNotRegular Code = "secrets.not_regular"
CodeSecretsInvalidName Code = "secrets.invalid_name"
CodeSecretsLimitExceeded Code = "secrets.limit_exceeded"
CodeSecretsInvalidValue Code = "secrets.invalid_value"
CodeSecretsApplyFailed Code = "secrets.apply_failed"
CodeSecretRequired Code = "secret.required"
CodeSecretInvalidName Code = "secret.invalid_name"
CodeSecretUnset Code = "secret.unset"
CodeSecretEmpty Code = "secret.empty"
CodeOverrideInvalidPath Code = "override.invalid_path"
CodeOverrideInvalidLabel Code = "override.invalid_label"
CodeOverrideInvalidValue Code = "override.invalid_value"
CodeOverrideDuplicate Code = "override.duplicate"
CodeOverrideOverlap Code = "override.overlap"
CodeOverrideDirective Code = "override.directive"
CodeOverrideVersion Code = "override.version"
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
)
// Location identifies a position in a configuration source. Zero line and column
// values mean that coordinates are unavailable or synthetic.
type Location struct {
SourceID string
Line int
Column int
}
// SourceInfo describes a source discovered during a load. Path is a canonical
// local path when one exists. ImportedFrom identifies the declaration that
// included this source.
type SourceInfo struct {
ID string
Label string
Path string
Layer Layer
ImportedFrom *Location
}
// Diagnostic is a safe structured problem report. Message and source labels must
// never contain configuration values or other secrets.
type Diagnostic struct {
Code Code
Severity Severity
Path Path
Message string
Primary *Location
Related []Location
}
// Origin describes where an effective definition came from. Multiple locations
// represent a composite origin; resolution origins are synthetic and identify their
// operation instead of a file location.
type Origin struct {
Layer Layer
Locations []Location
Operation ChangeKind
}
// FieldInfo describes an effective configuration path.
type FieldInfo struct {
Explicit bool
Null bool
Origin Origin
}
// Change describes a definition or application-resolution operation. Path can name
// an ancestor operation affecting the queried descendant. Historical values are
// intentionally not exposed by this record.
type Change struct {
Path Path
Origin Origin
Previous []Origin
Reasons []string
Dependencies []Path
}
func cloneLocation(location *Location) *Location {
if location == nil {
return nil
}
cloned := *location
return &cloned
}
func cloneLocations(locations []Location) []Location {
if locations == nil {
return nil
}
return append([]Location(nil), locations...)
}
func cloneSourceInfo(source SourceInfo) SourceInfo {
source.ImportedFrom = cloneLocation(source.ImportedFrom)
return source
}
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
if sources == nil {
return nil
}
cloned := make([]SourceInfo, len(sources))
for i, source := range sources {
cloned[i] = cloneSourceInfo(source)
}
return cloned
}
func cloneOrigin(origin Origin) Origin {
origin.Locations = cloneLocations(origin.Locations)
return origin
}
func cloneFieldInfo(info FieldInfo) FieldInfo {
info.Origin = cloneOrigin(info.Origin)
return info
}
func cloneChange(change Change) Change {
change.Path = change.Path.clone()
change.Origin = cloneOrigin(change.Origin)
if change.Previous != nil {
previous := make([]Origin, len(change.Previous))
for i, origin := range change.Previous {
previous[i] = cloneOrigin(origin)
}
change.Previous = previous
}
change.Reasons = append([]string(nil), change.Reasons...)
if change.Dependencies != nil {
dependencies := make([]Path, len(change.Dependencies))
for i, path := range change.Dependencies {
dependencies[i] = path.clone()
}
change.Dependencies = dependencies
}
return change
}
func cloneDiagnostic(diagnostic Diagnostic) Diagnostic {
diagnostic.Path = diagnostic.Path.clone()
diagnostic.Primary = cloneLocation(diagnostic.Primary)
diagnostic.Related = cloneLocations(diagnostic.Related)
return diagnostic
}
func cloneDiagnostics(diagnostics []Diagnostic) []Diagnostic {
if diagnostics == nil {
return nil
}
cloned := make([]Diagnostic, len(diagnostics))
for i, diagnostic := range diagnostics {
cloned[i] = cloneDiagnostic(diagnostic)
}
return cloned
}

52
internal/facade/secret.go Normal file
View File

@@ -0,0 +1,52 @@
package configkit
import (
"encoding/json"
"fmt"
"io"
"reflect"
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// Secret is an opaque reference to an environment variable. Its name is public
// configuration; its resolved value is available only through Value.
type Secret struct {
name, value string
specified, resolved bool
}
// SecretEnv constructs an unresolved environment-backed secret reference.
func SecretEnv(name string) Secret { return Secret{name: name, specified: true} }
// EnvName returns the public environment-variable name.
func (s Secret) EnvName() string { return s.name }
// Value returns the resolved credential, or an empty string when unresolved.
func (s Secret) Value() string {
if !s.resolved {
return ""
}
return s.value
}
// IsResolved reports whether this reference resolved to a nonempty value during its configuration load.
func (s Secret) IsResolved() bool { return s.resolved }
// Format renders only the public reference name for every formatting verb.
func (s Secret) Format(state fmt.State, _ rune) {
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
}
// MarshalJSON emits only the public environment-variable name.
func (s Secret) MarshalJSON() ([]byte, error) { return json.Marshal(s.name) }
func secretBinding() engine.SecretBinding {
return engine.SecretBinding{Type: reflect.TypeFor[Secret](), Read: func(value reflect.Value) engine.SecretState {
secret := value.Interface().(Secret)
return engine.SecretState{Name: secret.name, Value: secret.value, Specified: secret.specified, Resolved: secret.resolved}
}, Make: func(state engine.SecretState) reflect.Value {
return reflect.ValueOf(Secret{name: state.Name, value: state.Value, specified: state.Specified, resolved: state.Resolved})
}}
}

25
internal/facade/source.go Normal file
View File

@@ -0,0 +1,25 @@
package configkit
import (
"io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// Source is an opaque configuration input. Construct sources with File, Bytes,
// Reader, or NoSource; the zero value is invalid.
type Source struct{ value engine.Source }
// File returns a source that reopens path for every load attempt.
func File(path string) Source { return Source{value: engine.File(path)} }
// Bytes snapshots data immediately.
func Bytes(label string, data []byte) Source { return Source{value: engine.Bytes(label, data)} }
// Reader returns a single-use source. Copies share the same single-use claim.
func Reader(label string, input io.Reader) Source { return Source{value: engine.Reader(label, input)} }
// NoSource explicitly selects defaults-only input.
func NoSource() Source { return Source{value: engine.NoSource()} }
func (s Source) toEngine() engine.Source { return s.value }