// 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" TypeFloatArray ParamType = "float64[]" // waveform; value is a list of numbers ) func (t ParamType) valid() bool { switch t { case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray: 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"` Scope string `json:"scope,omitempty"` // access.Scope* visibility token Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility 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"` Scope string `json:"scope,omitempty"` // access.Scope* visibility token Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility 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) } case TypeFloatArray: arr, err := toFloatArray(v) if err != nil { return err } for i, f := range arr { if p.Min != nil && f < *p.Min { return fmt.Errorf("element %d value %v below minimum %v", i, f, *p.Min) } if p.Max != nil && f > *p.Max { return fmt.Errorf("element %d value %v above maximum %v", i, f, *p.Max) } } } return nil } // normalize coerces a JSON-decoded value into the canonical Go type the // datasource write path expects. Array parameters become []float64 (JSON yields // []any); scalar values are returned unchanged. func (p Parameter) normalize(v any) any { if p.Type == TypeFloatArray { if arr, err := toFloatArray(v); err == nil { return arr } } return v } // toFloatArray coerces a JSON-decoded array value to []float64. JSON // unmarshalling yields []any of float64, but a native []float64 is also // accepted. func toFloatArray(v any) ([]float64, error) { switch a := v.(type) { case []float64: return a, nil case []any: out := make([]float64, len(a)) for i, e := range a { f, err := toFloat(e) if err != nil { return nil, fmt.Errorf("element %d: %w", i, err) } out[i] = f } return out, nil default: return nil, fmt.Errorf("value %v is not an array", v) } } // 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) } }