Add maintained consumer migration examples

This commit is contained in:
2026-09-06 22:26:14 +00:00
parent 6ef240b1a0
commit a8bfb8848d
23 changed files with 855 additions and 95 deletions

View File

@@ -0,0 +1,331 @@
// Package consumers contains small, maintained application shapes that exercise
// ConfigKit without making consumer domain policy part of the library API.
package consumers
import (
"math"
"strconv"
"strings"
"time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// Artifact is a Narratio-shaped generated output declaration.
type Artifact struct {
Output string `yaml:"output"`
Timeout time.Duration `yaml:"timeout"`
}
// NarratioConfig demonstrates application defaults, partial imports, and
// application-owned model-profile and artifact semantics.
type NarratioConfig struct {
PollInterval time.Duration `yaml:"poll_interval"`
Timeout time.Duration `yaml:"timeout"`
ModelProfile string `yaml:"model_profile"`
Artifacts map[string]Artifact `yaml:"artifacts"`
}
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
return LoadNarratioProfile(path, "")
}
// LoadNarratioProfile selects a ConfigKit configuration overlay independently
// from the application's ModelProfile field.
func LoadNarratioProfile(path, profile string) (*configkit.Result[NarratioConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
Imports: true, Profile: profile,
Definition: configkit.Definition[NarratioConfig]{
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
Resolve: resolveNarratio,
Validate: validateNarratio,
},
})
}
func resolveNarratio(configuration *NarratioConfig, resolution *configkit.Resolution) error {
configkit.Default(resolution, configkit.Keys("timeout"), &configuration.Timeout,
configuration.PollInterval/10, "derived from poll interval", configkit.Keys("poll_interval"))
for name, artifact := range configuration.Artifacts {
path := configkit.Keys("artifacts", name)
configkit.Default(resolution, path.Key("timeout"), &artifact.Timeout,
configuration.Timeout, "inherited pipeline timeout", configkit.Keys("timeout"))
configkit.Normalize(resolution, path.Key("output"), &artifact.Output,
strings.TrimSpace(artifact.Output), "trimmed artifact output")
configuration.Artifacts[name] = artifact
}
return nil
}
func validateNarratio(configuration *NarratioConfig, validation *configkit.Validation) {
if configuration.PollInterval <= 0 {
validation.Error(configkit.Keys("poll_interval"), "must be positive")
}
if configuration.Timeout <= 0 {
validation.Error(configkit.Keys("timeout"), "must be positive")
}
for name, artifact := range configuration.Artifacts {
path := configkit.Keys("artifacts", name)
if artifact.Output == "" {
validation.Error(path.Key("output"), "must be nonempty")
}
if artifact.Timeout <= 0 {
validation.Error(path.Key("timeout"), "must be positive")
}
}
}
// ObjectBinding is a Notarius-shaped canonical object binding. Options remain
// dynamic because the application catalog selects their schema at runtime.
type ObjectBinding struct {
Object string `yaml:"object"`
Options map[string]any `yaml:"options"`
}
// NotariusConfig demonstrates string versions and dynamic catalog options.
type NotariusConfig struct {
Version string `yaml:"version"`
Bindings []ObjectBinding `yaml:"bindings"`
}
// LoadNotarius loads a Notarius-shaped configuration.
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
Definition: configkit.Definition[NotariusConfig]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("v1")},
Validate: validateNotarius,
},
})
}
func requireVersion(expected string) func(configkit.Version, *configkit.Validation) error {
return func(version configkit.Version, validation *configkit.Validation) error {
if !version.Present || version.Null || version.Text != expected {
validation.Error(configkit.Keys("version"), "unsupported configuration version")
}
return nil
}
}
func validateNotarius(configuration *NotariusConfig, validation *configkit.Validation) {
for index, binding := range configuration.Bindings {
path := configkit.Keys("bindings").Index(index)
if binding.Object != "summary" && binding.Object != "registry" {
validation.Error(path.Key("object"), "unsupported object binding")
}
for key, value := range binding.Options {
optionPath := path.Key("options").Key(key)
switch key {
case "window_units":
units, ok := value.(int64)
if !ok || units < 1 {
validation.Error(optionPath, "must be a positive integer")
}
case "format":
if _, ok := value.(string); !ok {
validation.Error(optionPath, "must be a string")
}
default:
validation.Error(optionPath, "unsupported object option")
}
}
}
}
// NotariusWindow converts ConfigKit's portable dynamic integer only at the
// application runtime boundary.
func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
if binding < 0 || binding >= len(configuration.Bindings) {
return 0, false
}
value, ok := configuration.Bindings[binding].Options["window_units"].(int64)
if !ok || value < 1 || (strconv.IntSize == 32 && value > math.MaxInt32) {
return 0, false
}
return int(value), true
}
// ModuleOptions is a WeatherReporter-shaped union of known module option fields.
type ModuleOptions struct {
WindowUnits *int `yaml:"window_units"`
Sections []string `yaml:"sections"`
}
// ModuleSpec uses the canonical object form instead of a string shorthand.
type ModuleSpec struct {
Module string `yaml:"module"`
Options ModuleOptions `yaml:"options"`
}
// ReportConfig keeps report-specific lists application-owned.
type ReportConfig struct {
Modules []ModuleSpec `yaml:"modules"`
PathTemplates []string `yaml:"path_templates"`
}
// WeatherConfig demonstrates simple defaults, reports, module options, and an
// optional typed secret reference.
type WeatherConfig struct {
Units string `yaml:"units"`
Reports map[string]ReportConfig `yaml:"reports"`
Token *configkit.Secret `yaml:"token"`
}
// LoadWeather loads a WeatherReporter-shaped source and explicit CLI records.
func LoadWeather(source configkit.Source, overrides ...configkit.Override) (*configkit.Result[WeatherConfig], error) {
return configkit.Load(source, configkit.Options[WeatherConfig]{
Overrides: overrides,
Definition: weatherDefinition(func() WeatherConfig {
return WeatherConfig{Units: "metric"}
}),
})
}
// LoadConstructedWeather runs the same rules over a detached constructed value.
func LoadConstructedWeather(configuration WeatherConfig) (*configkit.Result[WeatherConfig], error) {
return configkit.Load(configkit.NoSource(), configkit.Options[WeatherConfig]{
Definition: weatherDefinition(func() WeatherConfig { return configuration }),
})
}
func weatherDefinition(defaults func() WeatherConfig) configkit.Definition[WeatherConfig] {
return configkit.Definition[WeatherConfig]{
Defaults: defaults,
Resolve: func(configuration *WeatherConfig, resolution *configkit.Resolution) error {
for reportName, report := range configuration.Reports {
var modules []ModuleSpec
if report.Modules != nil {
modules = append(make([]ModuleSpec, 0, len(report.Modules)), report.Modules...)
}
for index, module := range modules {
if module.Options.Sections != nil {
sections := append([]string(nil), module.Options.Sections...)
for position := range sections {
sections[position] = strings.TrimSpace(sections[position])
}
module.Options.Sections = sections
}
modules[index] = module
}
configkit.Normalize(resolution, configkit.Keys("reports", reportName, "modules"),
&report.Modules, modules, "normalized module options")
configuration.Reports[reportName] = report
}
return nil
},
Validate: validateWeather,
}
}
func validateWeather(configuration *WeatherConfig, validation *configkit.Validation) {
if configuration.Units != "metric" && configuration.Units != "us" {
validation.Error(configkit.Keys("units"), "must be metric or us")
}
for reportName, report := range configuration.Reports {
path := configkit.Keys("reports", reportName)
if report.Modules != nil && len(report.Modules) == 0 {
validation.Error(path.Key("modules"), "must contain at least one module")
}
if report.PathTemplates != nil && len(report.PathTemplates) == 0 {
validation.Error(path.Key("path_templates"), "must contain at least one template")
}
for index, module := range report.Modules {
modulePath := path.Key("modules").Index(index)
switch module.Module {
case "chunker":
if module.Options.WindowUnits == nil || *module.Options.WindowUnits < 1 {
validation.Error(modulePath.Key("options").Key("window_units"), "must be a positive integer")
}
if module.Options.Sections != nil {
validation.Error(modulePath.Key("options").Key("sections"), "not supported by this module")
}
case "discussion":
if module.Options.WindowUnits != nil {
validation.Error(modulePath.Key("options").Key("window_units"), "not supported by this module")
}
if len(module.Options.Sections) == 0 {
validation.Error(modulePath.Key("options").Key("sections"), "must contain at least one section")
}
default:
validation.Error(modulePath.Key("module"), "unsupported module")
}
}
}
}
// SpeakerRule and SpeakerRules preserve declaration order for application matching.
type SpeakerRule struct {
Prefix string `yaml:"prefix"`
Name string `yaml:"name"`
}
type SpeakerRules struct {
Rules []SpeakerRule `yaml:"rules"`
}
// AutocorrectRule and AutocorrectRules preserve replacement order.
type AutocorrectRule struct {
Find string `yaml:"find"`
Replace string `yaml:"replace"`
}
type AutocorrectRules struct {
Rules []AutocorrectRule `yaml:"rules"`
}
// LoadSpeakerRules loads one independent Seriatim-shaped rule file.
func LoadSpeakerRules(path string) (*configkit.Result[SpeakerRules], error) {
return configkit.Load(configkit.File(path), configkit.Options[SpeakerRules]{
Definition: configkit.Definition[SpeakerRules]{Validate: validateSpeakerRules},
})
}
// LoadAutocorrectRules loads another independent ordered rule file.
func LoadAutocorrectRules(path string) (*configkit.Result[AutocorrectRules], error) {
return configkit.Load(configkit.File(path), configkit.Options[AutocorrectRules]{
Definition: configkit.Definition[AutocorrectRules]{Validate: validateAutocorrectRules},
})
}
func validateSpeakerRules(configuration *SpeakerRules, validation *configkit.Validation) {
seen := make(map[string]int)
for index, rule := range configuration.Rules {
path := configkit.Keys("rules").Index(index)
if rule.Prefix == "" {
validation.Error(path.Key("prefix"), "must be nonempty")
}
if previous, ok := seen[rule.Prefix]; ok {
validation.Error(path.Key("prefix"), "prefix must be unique",
configkit.Keys("rules").Index(previous).Key("prefix"))
}
seen[rule.Prefix] = index
}
}
func validateAutocorrectRules(configuration *AutocorrectRules, validation *configkit.Validation) {
for index, rule := range configuration.Rules {
path := configkit.Keys("rules").Index(index)
if rule.Find == "" {
validation.Error(path.Key("find"), "must be nonempty")
}
}
}
// SpeakerName is application runtime matching, not configuration infrastructure.
func SpeakerName(rules SpeakerRules, input string) (string, bool) {
for _, rule := range rules.Rules {
if strings.HasPrefix(input, rule.Prefix) {
return rule.Name, true
}
}
return "", false
}
// ApplyAutocorrect is application runtime replacement in declared order.
func ApplyAutocorrect(rules AutocorrectRules, input string) string {
for _, rule := range rules.Rules {
input = strings.ReplaceAll(input, rule.Find, rule.Replace)
}
return input
}

View File

@@ -0,0 +1,252 @@
package consumers_test
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_narratio() {
result, err := consumers.LoadNarratio(fixture("narratio", "pipeline.yml"))
artifact := result.Config.Artifacts["transcript"]
fmt.Println(result.Config.ModelProfile, artifact.Output, result.Config.Timeout, artifact.Timeout, err)
// Output: concise transcript.txt 6s 6s <nil>
}
func Example_notarius() {
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
window, ok := consumers.NotariusWindow(result.Config, 0)
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
// Output: v1 summary 12 true <nil>
}
func Example_weatherReporter() {
defaults, defaultsErr := consumers.LoadWeather(configkit.NoSource())
result, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
discussion := result.Config.Reports["daily"].Modules[1]
fmt.Println(defaults.Config.Units, result.Config.Units, discussion.Options.Sections[0], defaultsErr, err)
// Output: metric us short_term <nil> <nil>
}
func Example_seriatim() {
speakers, speakerErr := consumers.LoadSpeakerRules(fixture("seriatim", "speakers.yml"))
autocorrect, autocorrectErr := consumers.LoadAutocorrectRules(fixture("seriatim", "autocorrect.yml"))
name, found := consumers.SpeakerName(speakers.Config, "Guest: teh event occured")
text := consumers.ApplyAutocorrect(autocorrect.Config, "teh event occured")
fmt.Println(name, found, text, speakerErr, autocorrectErr)
// Output: visitor true the event occurred <nil> <nil>
}
func Example_weatherStartup() {
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"
restoreBlocked := preserveEnvironment(blockedName)
restoreToken := preserveEnvironment(tokenName)
defer restoreBlocked()
defer restoreToken()
_ = os.Setenv(blockedName, "")
_ = os.Unsetenv(tokenName)
directory, _ := os.MkdirTemp("", "configkit-weather-example-")
defer os.RemoveAll(directory)
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600)
report, bootstrapErr := configkit.LoadSecretsDir(directory)
var reportOutput bytes.Buffer
_, reportWriteErr := report.WriteTo(&reportOutput)
result, loadErr := consumers.LoadWeather(configkit.Bytes("weather.yml", []byte("units: metric\ntoken: "+tokenName+"\n")),
configkit.Override{Path: configkit.Keys("units"), Value: "us", Flag: "--units"})
var explanation bytes.Buffer
explainErr := result.WriteExplanation(&explanation, configkit.Keys("units"))
blocked, loaded := secretStatus(report, blockedName), secretStatus(report, tokenName)
value, present := os.LookupEnv(tokenName)
fmt.Printf("blocked=%s loaded=%s report=%t reference=%s explained=%t unchanged=%t errors=%t\n",
blocked, loaded, reportOutput.Len() > 0, result.Config.Token.EnvName(),
strings.Contains(explanation.String(), "--units"), present && value == "disposable-example-credential",
bootstrapErr != nil || reportWriteErr != nil || loadErr != nil || explainErr != nil)
// Output: blocked=skipped_existing loaded=loaded report=true reference=CONFIGKIT_WEATHER_EXAMPLE_TOKEN explained=true unchanged=true errors=false
}
func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
narratio, err := consumers.LoadNarratio(fixture("narratio", "pipeline.yml"))
if err != nil || narratio.Config.Artifacts["transcript"].Output != "transcript.txt" ||
narratio.Config.ModelProfile != "concise" || len(narratio.Sources()) != 2 {
t.Fatalf("Narratio shape = %#v, %v", narratio, err)
}
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
t.Fatalf("artifact origin = %#v, %v", origin, ok)
}
profiled, err := consumers.LoadNarratioProfile(fixture("narratio", "pipeline.yml"), "testing")
if err != nil || profiled.Config.PollInterval.String() != "2m0s" ||
profiled.Config.ModelProfile != "concise" || len(profiled.Sources()) != 3 {
t.Fatalf("configuration/application profiles = %#v, %v", profiled, err)
}
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
window, converted := consumers.NotariusWindow(notarius.Config, 0)
if err != nil || !converted || window != 12 {
t.Fatalf("Notarius shape = %#v, %v, %v", notarius, converted, err)
}
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
}
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
t.Fatalf("WeatherReporter shape = %#v, %v", weather, err)
}
speakers, err := consumers.LoadSpeakerRules(fixture("seriatim", "speakers.yml"))
if err != nil {
t.Fatal(err)
}
name, ok := consumers.SpeakerName(speakers.Config, "Host: introduction")
if !ok || name != "narrator" || speakers.Config.Rules[0].Prefix != "Host:" {
t.Fatalf("ordered speaker rules = %#v, %q, %v", speakers.Config.Rules, name, ok)
}
}
func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
tests := []struct {
name string
load func() error
code configkit.Code
path string
}{
{
name: "integer schema version",
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
code: configkit.CodeVersionType, path: "/version",
},
{
name: "module shorthand",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "shorthand-module.yml")))
return err
},
code: configkit.CodeDecodeType, path: "/reports/daily/modules/0",
},
{
name: "explicit empty modules",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "empty-modules.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/modules",
},
{
name: "explicit empty path templates",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "empty-paths.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/path_templates",
},
{
name: "explicit null required option",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "null-window.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/modules/0/options/window_units",
},
{
name: "strict unknown rule field",
load: func() error {
_, err := consumers.LoadSpeakerRules(fixture("seriatim", "unknown-field.yml"))
return err
},
code: configkit.CodeDecodeUnknownField, path: "/rules/0/color",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var loadError *configkit.LoadError
if err := test.load(); !errors.As(err, &loadError) {
t.Fatalf("error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if strings.Contains(diagnostics[0].Message, "wrong-before-profile") {
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
}
})
}
}
func TestWeatherLoadedAndConstructedRulesAgreeWithoutMutation(t *testing.T) {
loaded, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
if err != nil {
t.Fatal(err)
}
reconstructed, err := consumers.LoadConstructedWeather(loaded.Config)
if err != nil || !reflect.DeepEqual(reconstructed.Config, loaded.Config) {
t.Fatalf("loaded/constructed outcomes differ: %#v, %v", reconstructed, err)
}
constructed := consumers.WeatherConfig{Units: loaded.Config.Units, Reports: map[string]consumers.ReportConfig{}}
constructed.Reports["daily"] = consumers.ReportConfig{
Modules: []consumers.ModuleSpec{{Module: "discussion", Options: consumers.ModuleOptions{Sections: []string{" short_term "}}}},
PathTemplates: []string{"daily/{date}.md"},
}
wantInput := constructed.Reports["daily"].Modules[0].Options.Sections[0]
result, err := consumers.LoadConstructedWeather(constructed)
if err != nil {
t.Fatal(err)
}
if got := constructed.Reports["daily"].Modules[0].Options.Sections[0]; got != wantInput {
t.Fatalf("constructed options mutated from %q to %q", wantInput, got)
}
if result.Config.Reports["daily"].Modules[0].Options.Sections[0] != "short_term" {
t.Fatalf("normalized result = %#v", result.Config.Reports["daily"])
}
empty := consumers.WeatherConfig{Units: "metric", Reports: map[string]consumers.ReportConfig{
"daily": {Modules: []consumers.ModuleSpec{}},
}}
_, err = consumers.LoadConstructedWeather(empty)
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || loadError.Diagnostics()[0].Code != configkit.CodeValidateError {
t.Fatalf("constructed empty modules error = %v", err)
}
if !reflect.DeepEqual(empty.Reports["daily"].Modules, []consumers.ModuleSpec{}) {
t.Fatal("constructed empty module input was mutated")
}
}
func fixture(parts ...string) string {
_, file, _, _ := runtime.Caller(0)
values := append([]string{filepath.Dir(file), "testdata"}, parts...)
return filepath.Join(values...)
}
func preserveEnvironment(name string) func() {
value, present := os.LookupEnv(name)
return func() {
if present {
_ = os.Setenv(name, value)
} else {
_ = os.Unsetenv(name)
}
}
}
func secretStatus(report *configkit.SecretsReport, name string) configkit.SecretFileStatus {
for _, entry := range report.Entries() {
if entry.Name == name {
return entry.Status
}
}
return "missing"
}

View File

@@ -0,0 +1,3 @@
artifacts:
transcript:
output: ' transcript.txt '

View File

@@ -0,0 +1,5 @@
_configkit:
imports:
- path: artifacts.yml
poll_interval: 1m
model_profile: concise

View File

@@ -0,0 +1 @@
poll_interval: 2m

View File

@@ -0,0 +1,6 @@
version: v1
bindings:
- object: summary
options:
window_units: 12
format: markdown

View File

@@ -0,0 +1,2 @@
version: 1
bindings: []

View File

@@ -0,0 +1,5 @@
rules:
- find: teh
replace: the
- find: occured
replace: occurred

View File

@@ -0,0 +1,5 @@
rules:
- prefix: 'Host:'
name: narrator
- prefix: 'Guest:'
name: visitor

View File

@@ -0,0 +1,4 @@
rules:
- prefix: 'Host:'
name: narrator
color: blue

View File

@@ -0,0 +1,11 @@
units: us
reports:
daily:
modules:
- module: chunker
options:
window_units: 12
- module: discussion
options:
sections: [' short_term ']
path_templates: ['daily/{date}.md']

View File

@@ -0,0 +1,3 @@
reports:
daily:
modules: []

View File

@@ -0,0 +1,3 @@
reports:
daily:
path_templates: []

View File

@@ -0,0 +1,6 @@
reports:
daily:
modules:
- module: chunker
options:
window_units: null

View File

@@ -0,0 +1,3 @@
reports:
daily:
modules: [chunker]