From 2d8df22dd1cb9fde8e60853da3e2260731acf2bb Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 14 Oct 2026 13:59:37 +0000 Subject: [PATCH] Add mounted collection consumer examples --- docs/consumers/README.md | 28 +++ docs/roadmap/implementation.md | 23 ++- examples/consumers/destinations.go | 159 ++++++++++++++++++ examples/consumers/destinations_test.go | 116 +++++++++++++ examples/consumers/layered.go | 2 +- .../collections/weather/collection.yml | 4 + .../collections/weather/metadata.yml | 2 + .../collections/weather/profiles/compact.yml | 3 + .../collections/weather/profiles/weather.yml | 3 + .../collections/weather/prompts/brief.yml | 7 + .../collections/weather/prompts/custom.yml | 3 + .../collections/weather/prompts/welcome.txt | 1 + .../operators/weather-prompt-version.yml | 2 + 13 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 examples/consumers/destinations.go create mode 100644 examples/consumers/destinations_test.go create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/collection.yml create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml create mode 100644 examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt create mode 100644 examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 3f2ed6f..4303427 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -17,6 +17,7 @@ sanitized shapes using only ConfigKit and the Go standard library: | 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 | +| Mounted collections | Literal input destinations, peer prompt/profile fragments, local inheritance, imported collection metadata, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources | | Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset | | Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation | @@ -108,6 +109,32 @@ and may fail independently of the completed configuration load. The [configuration contract](../config.md#source-resource-contexts) owns exact lifetime and precedence rules. +## Input destinations and mounted collections + +The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go) +mounts one mapping source beneath literal collection/prompt keys and loads a +second input without a destination to show that root placement remains the +default. Consumers pass paths, not filenames or namespace strings for ConfigKit +to interpret. + +[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an +embedded collection manifest with a source-root import, separate prompt and +profile peers, and a higher-priority input that changes only one prompt's +ordinary `version` field. The main document alone supplies the application +schema version. A prompt's local `extends` relationship resolves after all +tiers, so `custom` inherits the final `brief` content and fields while retaining +its own profile selection. + +The resolver follows each effective `content_file` origin to its declaring +source context. This also works for `custom`'s inherited resource. The +application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go) +demonstrates the same lookup after loading; ConfigKit does not open or interpret +the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go) +checks source destinations and authored coordinates, presence, inherited values, +relative resources, and strict peer conflicts. The +[configuration contract](../config.md#input-destinations) owns shared placement, +import, source-location, and ownership behavior. + 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- @@ -187,6 +214,7 @@ focused behavioral owner: | 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 | +| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` | | Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` | | Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` | | Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9b7a6e0..8f5388e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -465,7 +465,7 @@ supported ConfigKit import path. ## Stage 11 — Add maintained simple and collection-style consumer examples -Status: planned. Scope: executable adoption examples with consumer-owned types. +Status: complete. Scope: executable adoption examples with consumer-owned types. 1. Add a minimal mounted-fragment example using `Input.Destination` in an ordinary layered load. Show literal keys and root-default compatibility @@ -483,9 +483,24 @@ Status: planned. Scope: executable adoption examples with consumer-owned types. from the consumer guide; add a small executable root example if useful for discovery without duplicating the complete scenario. -Validate: `go test ./examples/consumers`, applicable `Example` tests, local links, -and everyday check. Done: simple and representative consumers substantiate the -same configuration model and need no compensating structural pass. +Validated with `go test ./examples/consumers -count=1`, +`go test . -run '^Example' -count=1`, and `./scripts/check.sh` (go1.26.8 +linux/amd64); all passed, including local documentation links. + +`Example_inputDestination` demonstrates a literal mounted mapping path and an +ordinary root input without consumer-side parsing or merging. +`LoadCollectionBundle` and `TestCollectionBundleAssemblesMountedPeersAndResources` +exercise prompt/profile peer sources, imported collection metadata with an +ordinary version label, main-owned schema-version checking, local sibling +inheritance, and a higher-tier prompt version-only change that retains the rest +of its body. The resolver and `ReadCollectionPromptContent` follow direct and +inherited `content_file` provenance to the declaring native-filesystem context. +The test checks final values, explicit presence, source destinations and authored +coordinates, relative resources, and the inherited field's origin. +`TestCollectionPeerPromptConflictsRemainStrict` preserves expected conflict +behavior. The examples are linked from the consumer adoption guide. Done: simple +and representative consumers substantiate the same model without a compensating +structural pass. ## Stage 12 — Reconcile documentation and audit the complete implementation diff --git a/examples/consumers/destinations.go b/examples/consumers/destinations.go new file mode 100644 index 0000000..4656af2 --- /dev/null +++ b/examples/consumers/destinations.go @@ -0,0 +1,159 @@ +package consumers + +import ( + "errors" + "fmt" + "io/fs" + "path" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +// CollectionBundleConfig is a representative application-owned collection +// shape. Collection and prompt version labels remain ordinary domain fields. +type CollectionBundleConfig struct { + Version string `yaml:"version"` + Collections map[string]CollectionDefinition `yaml:"collections"` +} + +type CollectionDefinition struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + Prompts map[string]CollectionPrompt `yaml:"prompts"` + Profiles map[string]CollectionProfile `yaml:"profiles"` +} + +type CollectionPrompt struct { + Version string `yaml:"version"` + DefaultProfile string `yaml:"default_profile"` + ContentFile string `yaml:"content_file"` + ResourceText string `yaml:"resource_text"` + Messages []CollectionMessage `yaml:"messages"` +} + +type CollectionMessage struct { + Role string `yaml:"role"` + Content string `yaml:"content"` +} + +type CollectionProfile struct { + Format string `yaml:"format"` + Temperature float64 `yaml:"temperature"` +} + +// LoadCollectionBundle demonstrates one layered collection load. Its main +// document owns the application schema version; imported collection metadata, +// prompt/profile peers, and an operator prompt-version override use ordinary +// destinations and domain fields. +func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) { + files, err := fs.Sub(bundledFiles, "testdata/collection-bundle") + if err != nil { + return nil, err + } + bundle := configkit.NewFilesystem("collection bundle", files) + collectionDestination := configkit.Keys("collections", "weatherreporter") + promptDestination := collectionDestination.Key("prompts") + profileDestination := collectionDestination.Key("profiles") + return configkit.LoadLayered( + configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))}, + configkit.LayeredOptions[CollectionBundleConfig]{ + Definition: configkit.Definition[CollectionBundleConfig]{ + Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, _ *configkit.Validation) error { + if !version.Present || version.Text != "1" { + return errors.New("unsupported application schema version") + } + return nil + }}, + Resolve: resolveCollectionResources, + Validate: validateCollectionBundle, + }, + Underlays: []configkit.Underlay{ + {Inputs: []configkit.Input{ + {Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination}, + {Source: bundle.File("collections/weather/prompts/brief.yml"), Destination: promptDestination}, + {Source: bundle.File("collections/weather/prompts/custom.yml"), Destination: promptDestination}, + {Source: bundle.File("collections/weather/profiles/compact.yml"), Destination: profileDestination}, + {Source: bundle.File("collections/weather/profiles/weather.yml"), Destination: profileDestination}, + }}, + {Inputs: []configkit.Input{{ + Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination, + }}}, + }, + }, + ) +} + +func resolveCollectionResources(configuration *CollectionBundleConfig, resolution *configkit.Resolution) error { + for collectionID, collection := range configuration.Collections { + for promptID, prompt := range collection.Prompts { + if prompt.ContentFile == "" { + continue + } + contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file") + info, found := resolution.InputInfo(contentPath) + if !found || len(info.Origin.Locations) != 1 { + return fmt.Errorf("prompt resource has no single declaring source") + } + resourceSource, found := resolution.Source(info.Origin.Locations[0].SourceID) + if !found { + return fmt.Errorf("prompt resource source is unavailable") + } + context, found := resolution.SourceContext(resourceSource.ID) + if !found { + return fmt.Errorf("prompt resource context is unavailable") + } + data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile)) + if err != nil { + return fmt.Errorf("read prompt resource: %w", err) + } + prompt.ResourceText = string(data) + collection.Prompts[promptID] = prompt + resolution.Record( + configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"), + configkit.ChangeNormalization, "read source-relative prompt content", contentPath, + ) + } + configuration.Collections[collectionID] = collection + } + return nil +} + +func validateCollectionBundle(configuration *CollectionBundleConfig, validation *configkit.Validation) { + for collectionID, collection := range configuration.Collections { + for promptID, prompt := range collection.Prompts { + if prompt.DefaultProfile == "" { + continue + } + if _, found := collection.Profiles[prompt.DefaultProfile]; !found { + validation.Error(configkit.Keys("collections", collectionID, "prompts", promptID, "default_profile"), "must name a profile in this collection") + } + } + } +} + +// ReadCollectionPromptContent follows the effective content field's physical +// source, including when it was inherited from a peer prompt. +func ReadCollectionPromptContent(result *configkit.Result[CollectionBundleConfig], collectionID, promptID string) (string, error) { + if result == nil { + return "", errors.New("collection result is nil") + } + contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file") + info, found := result.Metadata().Lookup(contentPath) + if !found || len(info.Origin.Locations) != 1 { + return "", errors.New("prompt resource has no single declaring source") + } + context, found := result.SourceContext(info.Origin.Locations[0].SourceID) + if !found { + return "", errors.New("prompt resource context is unavailable") + } + prompt, found := result.Config.Collections[collectionID].Prompts[promptID] + if !found { + return "", errors.New("prompt is unavailable") + } + data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile)) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/examples/consumers/destinations_test.go b/examples/consumers/destinations_test.go new file mode 100644 index 0000000..492127c --- /dev/null +++ b/examples/consumers/destinations_test.go @@ -0,0 +1,116 @@ +package consumers_test + +import ( + "errors" + "fmt" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" +) + +func Example_inputDestination() { + mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.Mapping("weather prompt", map[string]any{ + "summarize.alerts": map[string]any{"version": "1"}, + }), + Destination: configkit.Keys("collections", "weather.reporter", "prompts"), + }) + if err != nil { + fmt.Println(err) + return + } + root, err := configkit.LoadLayered[map[string]any](configkit.Input{ + Source: configkit.Bytes("standalone", []byte("name: standalone\n")), + }) + if err != nil { + fmt.Println(err) + return + } + _, found := mounted.Config["collections"] + fmt.Println(found, root.Config["name"]) + // Output: true standalone +} + +func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) { + result, err := consumers.LoadCollectionBundle() + if err != nil { + t.Fatalf("LoadCollectionBundle: %v", err) + } + collection := result.Config.Collections["weatherreporter"] + if result.Config.Version != "1" || collection.Name != "Weather Reporter" || collection.Version != "2.4.0" { + t.Fatalf("main and collection versions = %q, %#v", result.Config.Version, collection) + } + brief := collection.Prompts["brief"] + custom := collection.Prompts["custom"] + if brief.Version != "2" || brief.DefaultProfile != "compact" || brief.ContentFile != "welcome.txt" || len(brief.Messages) != 1 { + t.Fatalf("version-only override replaced lower prompt fields: %#v", brief) + } + if custom.Version != "2" || custom.DefaultProfile != "weather" || custom.ContentFile != brief.ContentFile || + len(custom.Messages) != 1 || custom.ResourceText != "Welcome to the weather collection.\n" { + t.Fatalf("inherited prompt = %#v", custom) + } + weatherProfile := collection.Profiles["weather"] + if weatherProfile.Format != "text" || weatherProfile.Temperature != 0.4 { + t.Fatalf("inherited profile = %#v", weatherProfile) + } + resource, err := consumers.ReadCollectionPromptContent(result, "weatherreporter", "custom") + if err != nil || resource != custom.ResourceText { + t.Fatalf("inherited prompt resource = %q, %v", resource, err) + } + + versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version")) + if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 { + t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok) + } + collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version") + collectionVersion, ok := result.Metadata().Lookup(collectionVersionPath) + if !ok || !collectionVersion.Explicit || collectionVersion.Origin.Layer != configkit.LayerUnderlay || len(collectionVersion.Origin.Locations) != 1 { + t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok) + } + var metadataSource configkit.SourceInfo + var parentPromptSource configkit.SourceInfo + var promptVersionSource configkit.SourceInfo + for _, source := range result.Sources() { + switch source.Path { + case "collections/weather/metadata.yml": + metadataSource = source + case "collections/weather/prompts/brief.yml": + parentPromptSource = source + case "operators/weather-prompt-version.yml": + promptVersionSource = source + } + } + collectionVersionLocation := collectionVersion.Origin.Locations[0] + if metadataSource.ID == "" || collectionVersionLocation.SourceID != metadataSource.ID || + metadataSource.Destination.String() != "/collections/weatherreporter" || collectionVersionLocation.Line != 1 { + t.Fatalf("imported collection version source = %#v, %#v", metadataSource, collectionVersionLocation) + } + if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 { + t.Fatalf("prompt version source = %#v", promptVersionSource) + } + contentPath := configkit.Keys("collections", "weatherreporter", "prompts", "custom", "content_file") + contentInfo, ok := result.Metadata().Lookup(contentPath) + if !ok || !contentInfo.Explicit || len(contentInfo.Origin.Inheritance) != 1 || len(contentInfo.Origin.Locations) != 1 { + t.Fatalf("inherited content field metadata = %#v, %v", contentInfo, ok) + } + contentLocation := contentInfo.Origin.Locations[0] + if parentPromptSource.ID == "" || contentLocation.SourceID != parentPromptSource.ID || contentLocation.Line != 4 || + parentPromptSource.Destination.String() != "/collections/weatherreporter/prompts" { + t.Fatalf("inherited resource source coordinates = %#v, source %#v", contentLocation, parentPromptSource) + } +} + +func TestCollectionPeerPromptConflictsRemainStrict(t *testing.T) { + destination := configkit.Keys("collections", "weatherreporter", "prompts") + _, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{ + {Source: configkit.Bytes("peer-a", []byte("brief: {version: '1'}\n")), Destination: destination}, + {Source: configkit.Bytes("peer-b", []byte("brief: {version: '1'}\n")), Destination: destination}, + }}}, + }) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict { + t.Fatalf("duplicate peer prompt error = %v", err) + } +} diff --git a/examples/consumers/layered.go b/examples/consumers/layered.go index 8ce64dc..8454496 100644 --- a/examples/consumers/layered.go +++ b/examples/consumers/layered.go @@ -9,7 +9,7 @@ import ( configkit "gitea.maximumdirect.net/eric/ConfigKit" ) -//go:embed testdata/native-source testdata/layered-bundle +//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle var bundledFiles embed.FS // BundledConfig is a small configuration loaded directly from an embedded file. diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/collection.yml b/examples/consumers/testdata/collection-bundle/collections/weather/collection.yml new file mode 100644 index 0000000..5c2a448 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/collection.yml @@ -0,0 +1,4 @@ +configkit: + imports: + - path: metadata.yml +name: Weather Reporter diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml b/examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml new file mode 100644 index 0000000..40811a7 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml @@ -0,0 +1,2 @@ +version: "2.4.0" +description: Imported collection metadata. diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml b/examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml new file mode 100644 index 0000000..aae8982 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml @@ -0,0 +1,3 @@ +compact: + format: text + temperature: 0.2 diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml b/examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml new file mode 100644 index 0000000..d32fa21 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml @@ -0,0 +1,3 @@ +weather: + extends: compact + temperature: 0.4 diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml b/examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml new file mode 100644 index 0000000..3f0dee5 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml @@ -0,0 +1,7 @@ +brief: + version: "1" + default_profile: compact + content_file: welcome.txt + messages: + - role: user + content: Write a concise weather summary. diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml b/examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml new file mode 100644 index 0000000..9175357 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml @@ -0,0 +1,3 @@ +custom: + extends: brief + default_profile: weather diff --git a/examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt b/examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt new file mode 100644 index 0000000..03e7d33 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt @@ -0,0 +1 @@ +Welcome to the weather collection. diff --git a/examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml b/examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml new file mode 100644 index 0000000..0cccab9 --- /dev/null +++ b/examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml @@ -0,0 +1,2 @@ +brief: + version: "2"