396 lines
14 KiB
Go
396 lines
14 KiB
Go
// Package consumers contains small, maintained application shapes that exercise
|
|
// ConfigKit without making consumer domain policy part of the library API.
|
|
package consumers
|
|
|
|
import (
|
|
"math"
|
|
"path/filepath"
|
|
"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-backed 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("4")},
|
|
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
|
|
}
|
|
|
|
// NotariusPathConfig demonstrates application-owned interpretation of a
|
|
// path-valued configuration field. ConfigKit supplies provenance; this example
|
|
// selects its own relative-path bases.
|
|
type NotariusPathConfig struct {
|
|
Reference string `yaml:"reference"`
|
|
Sensitive string `yaml:"sensitive"`
|
|
}
|
|
|
|
// LoadNotariusPaths loads the path-bearing Notarius-shaped example. InvocationDir
|
|
// is the application-supplied base for relative CLI overrides; it is never read
|
|
// from the process working directory.
|
|
func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Override) (*configkit.Result[NotariusPathConfig], error) {
|
|
return configkit.Load(configkit.File(path), configkit.Options[NotariusPathConfig]{
|
|
Imports: true, Overrides: overrides,
|
|
Definition: configkit.Definition[NotariusPathConfig]{
|
|
Defaults: func() NotariusPathConfig { return NotariusPathConfig{Reference: "defaults/reference.txt"} },
|
|
Resolve: func(configuration *NotariusPathConfig, resolution *configkit.Resolution) error {
|
|
resolveNotariusReference(configuration, resolution, invocationDir)
|
|
return nil
|
|
},
|
|
Secret: func(path configkit.Path) bool { return path.String() == "/sensitive" },
|
|
},
|
|
})
|
|
}
|
|
|
|
func resolveNotariusReference(configuration *NotariusPathConfig, resolution *configkit.Resolution, invocationDir string) {
|
|
path := configkit.Keys("reference")
|
|
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
|
|
return
|
|
}
|
|
info, found := resolution.InputInfo(path)
|
|
if !found {
|
|
resolution.Warning(path, "relative reference has no entry provenance")
|
|
return
|
|
}
|
|
|
|
base := ""
|
|
if info.Origin.Layer == configkit.LayerCLI {
|
|
base = invocationDir
|
|
if base == "" {
|
|
resolution.Warning(path, "relative CLI reference has no invocation directory")
|
|
return
|
|
}
|
|
} else {
|
|
sourceID := "source-1" // ConfigKit's primary-source identity convention.
|
|
if info.Origin.Layer != configkit.LayerDefault {
|
|
if len(info.Origin.Locations) != 1 {
|
|
resolution.Warning(path, "relative reference has no unambiguous declaring source")
|
|
return
|
|
}
|
|
sourceID = info.Origin.Locations[0].SourceID
|
|
}
|
|
source, exists := resolution.Source(sourceID)
|
|
if !exists || source.Path == "" {
|
|
resolution.Warning(path, "relative reference has no declaring file")
|
|
return
|
|
}
|
|
base = filepath.Dir(source.Path)
|
|
}
|
|
configkit.Normalize(resolution, path, &configuration.Reference,
|
|
filepath.Join(base, configuration.Reference), "resolved relative reference from entry source")
|
|
}
|
|
|
|
// 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
|
|
}
|