From 05812a6ad39e2ed3efc41ee462a5ea40c89c318e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 12 Sep 2026 16:07:38 +0000 Subject: [PATCH] Add public facade value wrappers --- docs/roadmap/implementation.md | 20 ++ internal/facade/conversion.go | 91 ++++++++ internal/facade/diagnostic.go | 44 ++++ internal/facade/facade_external_test.go | 43 ++++ internal/facade/facade_test.go | 52 +++++ internal/facade/path.go | 33 +++ internal/facade/records.go | 286 ++++++++++++++++++++++++ internal/facade/secret.go | 52 +++++ internal/facade/source.go | 25 +++ 9 files changed, 646 insertions(+) create mode 100644 internal/facade/conversion.go create mode 100644 internal/facade/diagnostic.go create mode 100644 internal/facade/facade_external_test.go create mode 100644 internal/facade/facade_test.go create mode 100644 internal/facade/path.go create mode 100644 internal/facade/records.go create mode 100644 internal/facade/secret.go create mode 100644 internal/facade/source.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 96a63ae..2772306 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -323,6 +323,26 @@ Acceptance: candidate wrapper tests and engine tests pass. Run `go test ./internal/facade ./internal/engine` and the everyday check. Lifecycle entry points are added in Stage 6; do not add nonfunctional stubs here. +### Completion + +Status: complete (2026-09-12). + +Created the temporary `internal/facade` package (named `configkit`) with +root-owned `Path`, `Source`, and `Secret` wrappers; root-owned scalar and +provenance records; explicit private conversions; `LoadError`; and +`WriteDiagnostics`. Public fields and signatures contain only facade types. +`Path` carries its engine value privately without text round-tripping, `Source` +retains the engine's source value so copied reader sources preserve their shared +claim, and the facade builds the exact-type secret binding locally. No lifecycle +entry point or placeholder was added. + +Candidate tests cover semantic path distinctions, source byte snapshotting and +copied-reader ownership, external-package type ownership and interfaces, and +safe secret formatting/JSON. Linux validation with Go 1.26.7 passed +`go test ./internal/facade ./internal/engine` and `./scripts/check.sh`. There +were no deviations. The next handoff separates engine publication from its +generic result before lifecycle delegation begins. + ## Stage 5 — Separate engine publication and inspection Status: not started. diff --git a/internal/facade/conversion.go b/internal/facade/conversion.go new file mode 100644 index 0000000..9253c2e --- /dev/null +++ b/internal/facade/conversion.go @@ -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 +} diff --git a/internal/facade/diagnostic.go b/internal/facade/diagnostic.go new file mode 100644 index 0000000..027e426 --- /dev/null +++ b/internal/facade/diagnostic.go @@ -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)) +} diff --git a/internal/facade/facade_external_test.go b/internal/facade/facade_external_test.go new file mode 100644 index 0000000..5597243 --- /dev/null +++ b/internal/facade/facade_external_test.go @@ -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) + } +} diff --git a/internal/facade/facade_test.go b/internal/facade/facade_test.go new file mode 100644 index 0000000..d60fac9 --- /dev/null +++ b/internal/facade/facade_test.go @@ -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") + } +} diff --git a/internal/facade/path.go b/internal/facade/path.go new file mode 100644 index 0000000..939fbd9 --- /dev/null +++ b/internal/facade/path.go @@ -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} } diff --git a/internal/facade/records.go b/internal/facade/records.go new file mode 100644 index 0000000..a9738f3 --- /dev/null +++ b/internal/facade/records.go @@ -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 +} diff --git a/internal/facade/secret.go b/internal/facade/secret.go new file mode 100644 index 0000000..6a033de --- /dev/null +++ b/internal/facade/secret.go @@ -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}) + }} +} diff --git a/internal/facade/source.go b/internal/facade/source.go new file mode 100644 index 0000000..154edff --- /dev/null +++ b/internal/facade/source.go @@ -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 }