// Package confmgr implements the configuration manager: versioned, git-style // configuration Sets (schemas of parameters bound to target signals) and // configuration Instances (concrete values for a set), persisted as versioned // JSON files. Applying an instance writes each value to its target signal. package confmgr import ( "fmt" "math" "slices" "strconv" "strings" ) // ParamType enumerates the value kinds a parameter may hold. type ParamType string const ( TypeFloat ParamType = "float64" TypeInt ParamType = "int64" TypeBool ParamType = "bool" TypeString ParamType = "string" TypeEnum ParamType = "enum" ) func (t ParamType) valid() bool { switch t { case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum: return true } return false } // Parameter is one entry in a configuration set's schema. It names a target // signal (DS + Signal), a value type, an optional default, and validation // metadata. Parameters may be grouped for presentation via Group/Subgroup. type Parameter struct { Key string `json:"key"` // unique within the set Label string `json:"label,omitempty"` // human-friendly name Group string `json:"group,omitempty"` // top-level grouping Subgroup string `json:"subgroup,omitempty"` DS string `json:"ds"` // target data source Signal string `json:"signal"` // target signal name Type ParamType `json:"type"` // value kind Default any `json:"default,omitempty"` Mandatory bool `json:"mandatory,omitempty"` Min *float64 `json:"min,omitempty"` // numeric lower bound Max *float64 `json:"max,omitempty"` // numeric upper bound EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum Unit string `json:"unit,omitempty"` Description string `json:"description,omitempty"` } // ConfigSet is the schema half of the two-tier model: an ordered list of // parameters bound to target signals. It is versioned git-style. type ConfigSet struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Version int `json:"version"` Tag string `json:"tag,omitempty"` Owner string `json:"owner,omitempty"` Parameters []Parameter `json:"parameters"` } // ConfigInstance is the value half: concrete values for a set's parameters, // keyed by parameter key. SetID pins the schema it belongs to; SetVersion pins // the schema revision (0 means "track current"). It is versioned git-style. type ConfigInstance struct { ID string `json:"id"` Name string `json:"name"` SetID string `json:"setId"` SetVersion int `json:"setVersion,omitempty"` // 0 = current set version Version int `json:"version"` Tag string `json:"tag,omitempty"` Owner string `json:"owner,omitempty"` Values map[string]any `json:"values"` } // Validate checks structural invariants of a config set: non-empty name, // unique non-empty parameter keys, valid types, a target signal per parameter, // and well-formed enum/default metadata. func (s ConfigSet) Validate() error { if strings.TrimSpace(s.Name) == "" { return fmt.Errorf("config set name must not be empty") } seen := make(map[string]bool, len(s.Parameters)) for i, p := range s.Parameters { if strings.TrimSpace(p.Key) == "" { return fmt.Errorf("parameter %d: key must not be empty", i) } if seen[p.Key] { return fmt.Errorf("duplicate parameter key %q", p.Key) } seen[p.Key] = true if !p.Type.valid() { return fmt.Errorf("parameter %q: invalid type %q", p.Key, p.Type) } if strings.TrimSpace(p.DS) == "" || strings.TrimSpace(p.Signal) == "" { return fmt.Errorf("parameter %q: target ds and signal are required", p.Key) } if p.Type == TypeEnum && len(p.EnumValues) == 0 { return fmt.Errorf("parameter %q: enum type requires enumValues", p.Key) } if p.Min != nil && p.Max != nil && *p.Min > *p.Max { return fmt.Errorf("parameter %q: min %v greater than max %v", p.Key, *p.Min, *p.Max) } if p.Default != nil { if err := p.checkValue(p.Default); err != nil { return fmt.Errorf("parameter %q default: %w", p.Key, err) } } } return nil } // param returns the parameter with the given key, or false. func (s ConfigSet) param(key string) (Parameter, bool) { for _, p := range s.Parameters { if p.Key == key { return p, true } } return Parameter{}, false } // ValidateAgainst checks an instance against its set: every value references a // known parameter and is type/range valid; every mandatory parameter without a // default has a value. func (inst ConfigInstance) ValidateAgainst(set ConfigSet) error { for key, v := range inst.Values { p, ok := set.param(key) if !ok { return fmt.Errorf("value for unknown parameter %q", key) } if err := p.checkValue(v); err != nil { return fmt.Errorf("parameter %q: %w", key, err) } } for _, p := range set.Parameters { if !p.Mandatory { continue } if _, ok := inst.Values[p.Key]; ok { continue } if p.Default == nil { return fmt.Errorf("mandatory parameter %q has no value", p.Key) } } return nil } // Resolve returns the effective value for a parameter: the instance value when // present, otherwise the parameter default. The second result is false when no // value is available (optional parameter, no default). func (inst ConfigInstance) Resolve(p Parameter) (any, bool) { if v, ok := inst.Values[p.Key]; ok { return v, true } if p.Default != nil { return p.Default, true } return nil, false } // checkValue verifies a value matches the parameter's type and constraints. func (p Parameter) checkValue(v any) error { switch p.Type { case TypeFloat, TypeInt: f, err := toFloat(v) if err != nil { return err } if p.Type == TypeInt && f != math.Trunc(f) { return fmt.Errorf("value %v is not an integer", f) } if p.Min != nil && f < *p.Min { return fmt.Errorf("value %v below minimum %v", f, *p.Min) } if p.Max != nil && f > *p.Max { return fmt.Errorf("value %v above maximum %v", f, *p.Max) } case TypeBool: if _, ok := v.(bool); !ok { return fmt.Errorf("value %v is not a bool", v) } case TypeString: if _, ok := v.(string); !ok { return fmt.Errorf("value %v is not a string", v) } case TypeEnum: s, ok := v.(string) if !ok { return fmt.Errorf("enum value %v is not a string", v) } if !slices.Contains(p.EnumValues, s) { return fmt.Errorf("value %q is not an allowed enum value", s) } } return nil } // toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling // yields float64 for numbers, but values may also arrive as int or string. func toFloat(v any) (float64, error) { switch n := v.(type) { case float64: return n, nil case float32: return float64(n), nil case int: return float64(n), nil case int64: return float64(n), nil case string: f, err := strconv.ParseFloat(n, 64) if err != nil { return 0, fmt.Errorf("value %q is not numeric", n) } return f, nil default: return 0, fmt.Errorf("value %v is not numeric", v) } }