From cbf031aedc055a94392dfe901f46f9c4d07dd7c3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 29 Sep 2026 18:22:45 +0000 Subject: [PATCH] Document and demonstrate native and layered configuration loading --- README.md | 26 ++--- docs/adr/0007-source-backed-underlays.md | 3 +- docs/adr/0008-complete-on-return-loading.md | 5 +- docs/config.md | 10 ++ docs/consumers/README.md | 26 ++++- docs/development.md | 6 +- docs/internal/overview.md | 25 ++--- docs/policy/architecture.md | 15 +-- docs/roadmap/deferred.md | 6 +- docs/roadmap/implementation.md | 10 ++ docs/roadmap/sources-and-layering.md | 9 +- examples/consumers/layered.go | 95 +++++++++++++++++++ examples/consumers/layered_test.go | 71 ++++++++++++++ .../layered-bundle/defaults/config.yml | 7 ++ .../defaults/messages/welcome.txt | 1 + .../testdata/layered-bundle/shared.yml | 2 + .../testdata/layered-main/config.yml | 3 + .../testdata/layered-main/invalid.yml | 1 + .../testdata/native-source/config.yml | 2 + layered.go | 11 ++- resource_context.go | 3 +- 21 files changed, 291 insertions(+), 46 deletions(-) create mode 100644 examples/consumers/layered.go create mode 100644 examples/consumers/layered_test.go create mode 100644 examples/consumers/testdata/layered-bundle/defaults/config.yml create mode 100644 examples/consumers/testdata/layered-bundle/defaults/messages/welcome.txt create mode 100644 examples/consumers/testdata/layered-bundle/shared.yml create mode 100644 examples/consumers/testdata/layered-main/config.yml create mode 100644 examples/consumers/testdata/layered-main/invalid.yml create mode 100644 examples/consumers/testdata/native-source/config.yml diff --git a/README.md b/README.md index bafae96..6af5c09 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ An opinionated Go library for applications that use YAML configuration files. Linux is release-validated; macOS support is best-effort. See the [platform support policy](docs/policy/release.md#platform-support) for details. -ConfigKit's initial loader accepts one strict YAML -source (or explicit defaults-only input), optionally traverses explicit path and glob -imports, strictly composes their disjoint definitions, applies typed defaults, and -can overlay one selected fixed overlay and check an application schema version. It -returns detached metadata and safe diagnostics. +ConfigKit's simple `Load` accepts one strict YAML source (or explicit +defaults-only input). `LoadLayered` additionally combines explicit lower-priority +source groups with an independently selected main input. Sources can be host +files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings. +Enabled path and glob imports compose strictly within their group; precedence +between groups combines mappings recursively. Both entry points return a complete, +validated configuration with detached metadata and safe diagnostics. Applications may perform audited defaulting and normalization through a resolver, then report domain errors and warnings through read-only semantic validation. Secret tags and a path predicate drive deterministic redacted effective YAML. Typed secret @@ -50,19 +52,19 @@ for completion and ownership details. Within a configuration tier, independent contributions compose strictly: shared mappings combine, while overlapping non-mapping definitions are errors. Between precedence tiers, mappings combine recursively, retaining omitted lower-priority -children; other values replace lower-priority values. Today, the main document -and its imports form the strictly composed base, with a selected overlay and -explicit CLI overrides above it and typed defaults below it. The -[configuration contract](docs/config.md#strict-document-composition) owns the -detailed combination rules and input-specific constraints. +children; other values replace lower-priority values. The main document and its +imports form one strictly composed tier. Explicit underlay groups sit below it +and above typed defaults; a selected overlay and explicit CLI overrides sit +above it. The [configuration contract](docs/config.md#strict-document-composition) +owns the detailed combination rules and input-specific constraints. The [architecture goals](docs/policy/architecture.md#success-criteria) explain these priorities and how genuine new needs can justify extending the shared model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. The [configuration contract](docs/config.md) describes implemented behavior. The -[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-, -Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance. +[consumer adoption guide](docs/consumers/README.md) provides maintained simple +native-filesystem and layered examples alongside the migration shapes. See the [development guide](docs/development.md) for repository orientation and the [release procedure](docs/release.md) for maintainer-only candidate and publication steps. diff --git a/docs/adr/0007-source-backed-underlays.md b/docs/adr/0007-source-backed-underlays.md index 4cd78f3..7bdf785 100644 --- a/docs/adr/0007-source-backed-underlays.md +++ b/docs/adr/0007-source-backed-underlays.md @@ -4,7 +4,8 @@ Proposed draft for review. The [filesystem sources and underlays roadmap](../roadmap/sources-and-layering.md) -owns implementation status, detailed target behavior, and remaining API choices. +records the target and implementation progress; the +[configuration contract](../config.md#layered-loading) owns current behavior. This extends [ADR 0001](0001-separate-composition-and-overlay.md) and preserves its distinction between strict composition and intentional overlay. diff --git a/docs/adr/0008-complete-on-return-loading.md b/docs/adr/0008-complete-on-return-loading.md index 59bfc25..fede186 100644 --- a/docs/adr/0008-complete-on-return-loading.md +++ b/docs/adr/0008-complete-on-return-loading.md @@ -4,9 +4,8 @@ Proposed draft for review. The [configuration contract](../config.md#completion-and-resource-lifetime) describes -the implemented loading lifecycle. The -[source-backed underlays roadmap](../roadmap/sources-and-layering.md) owns its -application to the planned source and resource-context APIs. +the implemented loading lifecycle and +[source resource contexts](../config.md#source-resource-contexts). ## Date diff --git a/docs/config.md b/docs/config.md index d1b5754..365d49d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -148,6 +148,10 @@ have index zero. Explicit empty groups and `NoSource` consume no source ID. Byte, source, node, and glob-entry budgets are shared across the load, while the existing per-source and import-depth bounds still apply. +The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input) +shows a main host file, two underlay tiers, embedded defaults, a mapping source, +and explicit resource access without a second configuration pass. + ### Source resource contexts `Result.SourceContext(id)` and callback-scoped `Resolution.SourceContext(id)` @@ -165,6 +169,12 @@ contextless synthetic inputs have no resource context. To open a relative resource, join `BaseDir` with the application resource name using slash-path operations and open that path through `FS`. +For a resource-valued field, query `result.Metadata().Lookup(fieldPath)`, inspect +its origin locations, choose a source ID only when the application's policy can +identify the intended contributor, then call `result.SourceContext(id)`. The +context belongs to that source even when the field survived from a lower-priority +underlay. A composite origin has no automatically selected resource base. + Native contexts borrow the caller's filesystem within the configured root. Host contexts retain a canonical root path, not an open root handle; each resource `Open` checks containment and opens a fresh handle that the returned diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 8a67ad7..265b587 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -7,7 +7,7 @@ validation, schema-version policy, CLI parsing, runtime matching and conversion, resource checks, remote acquisition, and service initialization. The maintained [`examples/consumers`](../../examples/consumers) package demonstrates -four sanitized shapes using only ConfigKit and the Go standard library: +sanitized shapes using only ConfigKit and the Go standard library: | Shape | Demonstrated fit | Application code deliberately retained | | --- | --- | --- | @@ -15,6 +15,8 @@ four sanitized shapes using only ConfigKit and the Go standard library: | Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` | | WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering | | Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file | +| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem | +| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource | The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the executable examples and ordinary tests. It contains no private application data, @@ -48,6 +50,27 @@ both without redefining their syntax. ConfigKit overlays are configuration overl they do not select or replace an application's LLM, model, pipeline, report, or execution profile concepts. +## Native filesystem and layered input + +[`LoadBundled`](../../examples/consumers/layered.go) keeps the simple `Load` shape: +it selects a file in an application-owned embedded filesystem and needs no +underlay setup. [`LoadCatalog`](../../examples/consumers/layered.go) uses +`LoadLayered` with an imported embedded-defaults group, a separate +`Mapping` group, and a host main file supplied by its caller. The groups +compose strictly within themselves and recursively across precedence boundaries; +the main file's explicit `false` and `0` replace lower values without being +mistaken for omissions. Its validator owns allowed modes and resource-name policy. + +[`ReadWelcome`](../../examples/consumers/layered.go) follows the effective field's +origin location to a source ID, queries `Result.SourceContext`, and opens the +resource relative to that context's `BaseDir`. It does not assume the main file +owns a retained underlay value. A composite origin needs an application choice; +ConfigKit does not guess a directory. The context query is passive, while later +resource reads observe the caller-owned filesystem or a freshly opened host root +and may fail independently of the completed configuration load. The +[configuration contract](../config.md#source-resource-contexts) owns exact +lifetime and precedence rules. + Known module catalogs should use canonical object entries with a module identifier and typed options. A small union struct is sufficient when the available option fields are known; validation selects which fields apply to each module. Runtime- @@ -126,6 +149,7 @@ focused behavioral owner: | Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture | | Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests | | `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` | +| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests | | Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests | The package-level import-boundary test covers the consumer examples too: supported diff --git a/docs/development.md b/docs/development.md index cb98157..b356c27 100644 --- a/docs/development.md +++ b/docs/development.md @@ -133,12 +133,16 @@ code changes. Use the component overview to find the corresponding implementatio | --- | --- | --- | | YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` | | File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` | +| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` | | Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` | +| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` | | Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` | | Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` | | Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` | +| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` | | Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` | -| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` | +| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` | +| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` | | Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` | | Development and release checker behavior | [Shell regression](../release_check_test.go) | `go test . -run 'TestCheckScripts\|TestDevelopmentCheck'` | | Guarded release publication and recovery | [Publication regression](../release_publish_test.go) | `go test . -run '^TestReleasePublication'` | diff --git a/docs/internal/overview.md b/docs/internal/overview.md index e2e3776..3a900d5 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -7,23 +7,26 @@ private state; public signatures and fields do not expose engine types. Implemented component owners: -- `internal/engine` owns source acquisition, composition, schema inspection, - decoding, lifecycle coordination, callback auditing, provenance/history, - secret resolution and bootstrap, and safe inspection rendering. It never - imports the root package. -- Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and - bootstrap reports are root-defined wrappers or records. Callback wrappers pass - only live engine context during a callback; `Result.Config` is detached and its - private inspection remains frozen independently. +- `internal/engine` owns host and native-filesystem source acquisition, + grouped strict composition and recursive precedence assembly, schema + inspection, decoding, lifecycle coordination, callback auditing, + provenance/history, passive source-context retention, secret resolution and + bootstrap, and safe inspection rendering. It never imports the root package. +- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records, + lifecycle callbacks, results, and bootstrap reports are root-defined wrappers + or records. Callback wrappers pass only live engine context during a callback; + `Result.Config` is detached and its private inspection remains frozen + independently. - Root conversion files explicitly cross the path, record, callback, secret, and error boundaries. There are no public aliases, anonymous engine embeddings, or public engine bridge methods. - `internal/document` owns the parser-independent ordered tree and cloning. `internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary. - Engine mechanism tests own source, composition, decoding, callback, provenance, - secret, history, rendering, and bootstrap behavior. Root tests own public API - compatibility, facade integration, documentation links, and dependency/public - boundary checks. Consumer examples remain under `examples/consumers`. + source-context, secret, history, rendering, and bootstrap behavior. Root tests + own public API compatibility, facade integration, documentation links, and + dependency/public boundary checks. Consumer examples remain under + `examples/consumers`. Dependency direction is root to engine to document/YAML support. The root may import only `internal/engine` among project internal production packages; engine diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index 6e68179..ea474ee 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -161,9 +161,11 @@ Tier precedence is explicit; traversal order within a tier establishes stable reporting, not permission to override. The distinction and its rationale are recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md). -The main document and its imports currently form the strictly composed base -tier. Input-specific constraints, including CLI path rules and opt-in version -ownership, remain part of the configuration contract. Consistent combination +The main document and its imports form a strictly composed tier. Explicit +underlay groups compose strictly within each group, then combine recursively +from lowest to highest beneath the main tier. Source acquisition kind does not +determine its precedence role. Input-specific constraints, including CLI path +rules and opt-in version ownership, remain part of the configuration contract. Consistent combination semantics do not require identical acquisition or lifetime behavior from every source kind. @@ -172,9 +174,10 @@ parser defaults. Processing order and diagnostics must be deterministic for the same inputs and options. Invalid input must not produce a successful usable configuration. Do not run application hooks on invalid typed configuration. -User-controlled precedence is application defaults, composed base, selected -overlay, then explicitly supplied CLI overrides. Applications own flag parsing; -ConfigKit owns overlay semantics and provenance. Domain resolution follows these +User-controlled precedence is application defaults, ordered underlays, composed +main input, selected overlay, then explicitly supplied CLI overrides. +Applications own flag parsing; ConfigKit owns overlay semantics and provenance. +Domain resolution follows these inputs and must remain recorded, not become a hidden override channel. Preserve node-based composition until the effective input is ready for typed diff --git a/docs/roadmap/deferred.md b/docs/roadmap/deferred.md index be454fd..bb7b62e 100644 --- a/docs/roadmap/deferred.md +++ b/docs/roadmap/deferred.md @@ -11,11 +11,13 @@ General environment-variable configuration overlays are not supported. Typed sec references and explicit directory bootstrap are the environment convention. Literal credential configuration and implicit secret-directory discovery are unsupported. -The initial release deliberately excludes: +The following remain deliberately excluded: - automatic `conf.d` discovery; - remote imports, arbitrary providers, and additional configuration formats; -- custom merge strategies, sequence merging, reset operators, and overlay inheritance; +- custom merge strategies, sequence merging, and reset operators; +- named overlay inheritance, tracked separately in the + [PromptKit inheritance request](feature-request-promptkit-inheritance.md); - parser-specific or custom structural-decoding extension APIs; - generic dynamic-subtree conversion and module-registry APIs; - automatic schema migrations; diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 52290ff..dde424c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -506,6 +506,16 @@ unchanged final tree. Benchmarks run with `-benchmem` and parameterized sizes. ## Stage 10: Complete consumer examples and architecture documentation +**Completed 2026-09-29.** Maintained executable examples now cover a simple +embedded filesystem source and a layered catalog with imported embedded defaults, +a mapping underlay, an independent host main file, explicit false/zero values, +domain validation, and resource access through the retained origin's source ID. +The configuration contract, public declaration comments, consumer/development +guides, README, component overview, architecture/deferred text, roadmap status, +and draft ADR status links reflect implemented behavior. Named inheritance remains +separate and deferred; the ADRs remain drafts. The everyday checker and full race +suite passed. No release preparation or publication was performed. + 1. Add a maintained simple native-filesystem example and a combined example with embedded defaults, two underlay tiers, independently located main input, programmatic contributions, and a relative resource from a retained origin. diff --git a/docs/roadmap/sources-and-layering.md b/docs/roadmap/sources-and-layering.md index 12c08ce..9944af8 100644 --- a/docs/roadmap/sources-and-layering.md +++ b/docs/roadmap/sources-and-layering.md @@ -1,8 +1,9 @@ # Filesystem Sources and Source-Backed Underlays -Status: agreed target for implementation; not implemented. This document owns -the desired behavior and design rationale. The [implementation plan](implementation.md) -owns API signatures, sequential work, and validation commands. +Status: implemented; draft ADRs remain pending review. This document preserves +the design target and rationale, including its historical baseline. The +[configuration contract](../config.md) owns current behavior, and the +[implementation plan](implementation.md) records sequential work and validation. ## Purpose and priorities @@ -36,7 +37,7 @@ The [named-inheritance request](feature-request-promptkit-inheritance.md) remain separate and optional. This roadmap neither implements it nor adds speculative infrastructure for it. -## Current baseline +## Planning baseline (historical) Planning baseline: `ec26591b275bd74f89120bdea69c5f6459dfcafb`, following v0.4.2. Current behavior is owned by the [configuration contract](../config.md), not by diff --git a/examples/consumers/layered.go b/examples/consumers/layered.go new file mode 100644 index 0000000..8ce64dc --- /dev/null +++ b/examples/consumers/layered.go @@ -0,0 +1,95 @@ +package consumers + +import ( + "embed" + "errors" + "io/fs" + "path" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +//go:embed testdata/native-source testdata/layered-bundle +var bundledFiles embed.FS + +// BundledConfig is a small configuration loaded directly from an embedded file. +type BundledConfig struct { + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` +} + +// LoadBundled demonstrates a native filesystem source without layered options. +func LoadBundled() (*configkit.Result[BundledConfig], error) { + files, err := fs.Sub(bundledFiles, "testdata/native-source") + if err != nil { + return nil, err + } + return configkit.Load[BundledConfig](configkit.NewFilesystem("bundled", files).File("config.yml")) +} + +// CatalogConfig keeps resource names and domain rules in the application. +type CatalogConfig struct { + Mode string `yaml:"mode"` + Enabled bool `yaml:"enabled"` + Workers int `yaml:"workers"` + Templates map[string]string `yaml:"templates"` + Labels map[string]string `yaml:"labels"` +} + +// LoadCatalog combines two underlay tiers beneath an independently located +// host main file. ConfigKit assembles and validates the complete result once. +func LoadCatalog(mainPath string) (*configkit.Result[CatalogConfig], error) { + files, err := fs.Sub(bundledFiles, "testdata/layered-bundle") + if err != nil { + return nil, err + } + bundle := configkit.NewFilesystem("catalog bundle", files) + return configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, + configkit.LayeredOptions[CatalogConfig]{ + Underlays: []configkit.Underlay{ + {Inputs: []configkit.Input{{Source: bundle.File("defaults/config.yml"), Imports: true}}}, + {Inputs: []configkit.Input{{Source: configkit.Mapping("local policy", map[string]any{ + "workers": 2, + "labels": map[string]any{"tenant": "example"}, + })}}}, + }, + Definition: configkit.Definition[CatalogConfig]{ + Defaults: func() CatalogConfig { return CatalogConfig{Mode: "demo"} }, + Validate: validateCatalog, + }, + }) +} + +func validateCatalog(configuration *CatalogConfig, validation *configkit.Validation) { + if configuration.Mode != "demo" && configuration.Mode != "production" { + validation.Error(configkit.Keys("mode"), "must be demo or production") + } + if configuration.Workers < 0 { + validation.Error(configkit.Keys("workers"), "must be nonnegative") + } + if name := configuration.Templates["welcome"]; name == "." || !fs.ValidPath(name) { + validation.Error(configkit.Keys("templates", "welcome"), "must be a relative resource name") + } +} + +// ReadWelcome follows the effective template field's one declaring source to +// its retained resource context. Opening and interpreting the resource remain +// application work; this does not reopen configuration or change metadata. +func ReadWelcome(result *configkit.Result[CatalogConfig]) (string, error) { + if result == nil { + return "", errors.New("catalog result is nil") + } + info, found := result.Metadata().Lookup(configkit.Keys("templates", "welcome")) + if !found || len(info.Origin.Locations) != 1 { + return "", errors.New("welcome template has no single declaring source") + } + context, found := result.SourceContext(info.Origin.Locations[0].SourceID) + if !found { + return "", errors.New("welcome template has no resource context") + } + data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Templates["welcome"])) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/examples/consumers/layered_test.go b/examples/consumers/layered_test.go new file mode 100644 index 0000000..f72cd61 --- /dev/null +++ b/examples/consumers/layered_test.go @@ -0,0 +1,71 @@ +package consumers_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" +) + +func Example_nativeFilesystem() { + result, err := consumers.LoadBundled() + if err != nil { + fmt.Println(err) + return + } + fmt.Println(result.Config.Name, result.Config.Enabled) + // Output: embedded false +} + +func Example_layeredCatalog() { + result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml")) + if err != nil { + fmt.Println(err) + return + } + welcome, err := consumers.ReadWelcome(result) + fmt.Println(result.Config.Mode, result.Config.Enabled, result.Config.Workers, + result.Config.Labels["origin"], result.Config.Labels["tenant"], strings.TrimSpace(welcome), err) + // Output: production false 0 bundle example Welcome from the bundle. +} + +func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) { + result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml")) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"enabled", "workers"} { + info, found := result.Metadata().Lookup(configkit.Keys(key)) + if !found || !info.Explicit || info.Origin.Layer != configkit.LayerBase { + t.Fatalf("%s presence = %#v, %v", key, info, found) + } + } + info, found := result.Metadata().Lookup(configkit.Keys("templates", "welcome")) + if !found || info.Origin.Layer != configkit.LayerUnderlay || len(info.Origin.Locations) != 1 { + t.Fatalf("welcome origin = %#v, %v", info, found) + } + context, found := result.SourceContext(info.Origin.Locations[0].SourceID) + if !found || context.BaseDir != "defaults" { + t.Fatalf("welcome context = %#v, %v", context, found) + } + welcome, err := consumers.ReadWelcome(result) + if err != nil || strings.TrimSpace(welcome) != "Welcome from the bundle." { + t.Fatalf("welcome = %q, %v", welcome, err) + } + if result.Config.Labels["tenant"] != "example" || result.Config.Labels["origin"] != "bundle" { + t.Fatalf("recursive underlay labels = %#v", result.Config.Labels) + } +} + +func TestCatalogValidationRejectsInvalidMode(t *testing.T) { + _, err := consumers.LoadCatalog(fixture("layered-main", "invalid.yml")) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || + loadError.Diagnostics()[0].Code != configkit.CodeValidateError || + loadError.Diagnostics()[0].Path.String() != "/mode" { + t.Fatalf("invalid mode error = %v", err) + } +} diff --git a/examples/consumers/testdata/layered-bundle/defaults/config.yml b/examples/consumers/testdata/layered-bundle/defaults/config.yml new file mode 100644 index 0000000..702485e --- /dev/null +++ b/examples/consumers/testdata/layered-bundle/defaults/config.yml @@ -0,0 +1,7 @@ +configkit: + imports: + - path: ../shared.yml +enabled: true +workers: 4 +templates: + welcome: messages/welcome.txt diff --git a/examples/consumers/testdata/layered-bundle/defaults/messages/welcome.txt b/examples/consumers/testdata/layered-bundle/defaults/messages/welcome.txt new file mode 100644 index 0000000..cfc376f --- /dev/null +++ b/examples/consumers/testdata/layered-bundle/defaults/messages/welcome.txt @@ -0,0 +1 @@ +Welcome from the bundle. diff --git a/examples/consumers/testdata/layered-bundle/shared.yml b/examples/consumers/testdata/layered-bundle/shared.yml new file mode 100644 index 0000000..f25b516 --- /dev/null +++ b/examples/consumers/testdata/layered-bundle/shared.yml @@ -0,0 +1,2 @@ +labels: + origin: bundle diff --git a/examples/consumers/testdata/layered-main/config.yml b/examples/consumers/testdata/layered-main/config.yml new file mode 100644 index 0000000..6a4d16d --- /dev/null +++ b/examples/consumers/testdata/layered-main/config.yml @@ -0,0 +1,3 @@ +mode: production +enabled: false +workers: 0 diff --git a/examples/consumers/testdata/layered-main/invalid.yml b/examples/consumers/testdata/layered-main/invalid.yml new file mode 100644 index 0000000..130ae0e --- /dev/null +++ b/examples/consumers/testdata/layered-main/invalid.yml @@ -0,0 +1 @@ +mode: unsupported diff --git a/examples/consumers/testdata/native-source/config.yml b/examples/consumers/testdata/native-source/config.yml new file mode 100644 index 0000000..3f65b10 --- /dev/null +++ b/examples/consumers/testdata/native-source/config.yml @@ -0,0 +1,2 @@ +name: embedded +enabled: false diff --git a/layered.go b/layered.go index d3fa855..ae148cd 100644 --- a/layered.go +++ b/layered.go @@ -26,10 +26,13 @@ type LayeredOptions[T any] struct { Overrides []Override } -// LoadLayered acquires the main input first, strictly composes each underlay, -// then applies underlays in order beneath main, a selected overlay, and CLI -// overrides. It performs defaults, decoding, resolution, and validation once. -// Zero or one options value is accepted; any error returns a nil result. +// LoadLayered acquires the main input first and strictly composes enabled +// imports with each input's tier. It strictly composes contributions within +// each underlay, then recursively combines underlays in listed order beneath +// main, a selected overlay, and CLI overrides. Defaults, decoding, resolution, +// and validation run once on the assembled input. Source IDs follow acquisition +// order, not precedence order. Zero or one options value is accepted; any error +// returns a nil result. func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T], error) { options := make([]engine.LayeredOptions[T], len(supplied)) for index := range supplied { diff --git a/resource_context.go b/resource_context.go index e3315a1..7a5b52c 100644 --- a/resource_context.go +++ b/resource_context.go @@ -4,7 +4,8 @@ import "io/fs" // ResourceContext provides a passive rooted filesystem view and a // slash-relative BaseDir for one acquired source. FS is borrowed: caller-owned -// filesystems remain under caller control, and host files are opened afresh. +// filesystems remain under caller control, while a host context opens a fresh +// contained root for each resource open rather than retaining a loading handle. // Querying this record performs no I/O. Resource contents may change after // loading; callers must close files they open through FS. type ResourceContext struct {