Files
mrcharles c869ac1b06 config: keyproxy mpk refs, loop daemon knobs, redmine status map, gitea section
mpk: key refs resolve through the ukrrs/mopac-keyproxy hop ([keyproxy]
url + token_ref, POST /v1/resolve, short in-memory cache); env:/file:/
literal: refs keep working untouched, so the harness runs with or
without keyproxy up. [loop] gains poll_interval_secs + state_dir for the
self-host daemon; [redmine.status_map] (quoted TOML keys) drives status
transitions; [gitea] carries the optional REPORT-commit step, off by
default. All hosts live in harness.toml, none in code.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-28 22:05:41 -05:00

434 lines
9.7 KiB
Go

// Package config implements the harness.toml surface for the MOPAC harness.
//
// The TOML parser in toml.go is a deliberate stdlib-only subset: tables,
// bare keys, basic strings, integers, booleans, and (single- or multi-line)
// string/int/bool arrays. Anything richer is a parse error so misconfigured
// files fail loudly instead of silently.
package config
import (
"fmt"
"os"
"sort"
"strings"
)
// TOMLDoc is the parsed representation of harness.toml: nested tables are
// TOMLDoc values; leaves are string, int64, bool, or []any of those.
type TOMLDoc map[string]any
// Table returns the nested table at the given key path, or an empty doc.
func (d TOMLDoc) Table(keys ...string) TOMLDoc {
cur := any(d)
for _, k := range keys {
m, ok := cur.(TOMLDoc)
if !ok {
return TOMLDoc{}
}
cur = m[k]
}
if m, ok := cur.(TOMLDoc); ok {
return m
}
return TOMLDoc{}
}
func (d TOMLDoc) String(key string) (string, bool) {
v, ok := d[key].(string)
return v, ok
}
func (d TOMLDoc) Int(key string) (int64, bool) {
v, ok := d[key].(int64)
return v, ok
}
func (d TOMLDoc) Bool(key string) (bool, bool) {
v, ok := d[key].(bool)
return v, ok
}
func (d TOMLDoc) StringList(key string) ([]string, bool) {
raw, ok := d[key].([]any)
if !ok {
return nil, false
}
out := make([]string, 0, len(raw))
for _, e := range raw {
s, ok := e.(string)
if !ok {
return nil, false
}
out = append(out, s)
}
return out, true
}
// Keys returns the table's key names in sorted order.
func (d TOMLDoc) Keys() []string {
out := make([]string, 0, len(d))
for k := range d {
out = append(out, k)
}
sort.Strings(out)
return out
}
// ParseTOML parses the harness.toml subset.
func ParseTOML(src string) (TOMLDoc, error) {
lines, err := logicalLines(src)
if err != nil {
return nil, err
}
root := TOMLDoc{}
cur := root
for _, ln := range lines {
if strings.HasPrefix(ln.text, "[") {
path, err := parseTableName(ln.text, ln.no)
if err != nil {
return nil, err
}
cur, err = descend(root, path, ln.no)
if err != nil {
return nil, err
}
continue
}
if err := parseKeyValue(cur, ln.text, ln.no); err != nil {
return nil, err
}
}
return root, nil
}
type logicalLine struct {
no int
text string
}
// logicalLines joins physical lines into logical ones: comments are stripped
// (quote-aware), blank lines dropped, and unbalanced array brackets pull the
// next line in so multi-line arrays work. Strings may not span lines.
func logicalLines(src string) ([]logicalLine, error) {
var out []logicalLine
var buf strings.Builder
curNo := 0
depth := 0
for i, raw := range strings.Split(src, "\n") {
no := i + 1
line := stripComment(strings.TrimSuffix(raw, "\r"))
if buf.Len() == 0 {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
curNo = no
buf.WriteString(trimmed)
} else {
buf.WriteString(" ")
buf.WriteString(strings.TrimSpace(line))
}
inStr, esc := false, false
d := 0
for _, r := range line {
switch {
case esc:
esc = false
case inStr && r == '\\':
esc = true
case r == '"':
inStr = !inStr
case !inStr && r == '[':
d++
case !inStr && r == ']':
d--
}
}
if inStr {
return nil, fmt.Errorf("harness.toml:%d: unterminated string", no)
}
if depth+d < 0 {
return nil, fmt.Errorf("harness.toml:%d: unbalanced ']'", no)
}
depth += d
if depth == 0 {
out = append(out, logicalLine{no: curNo, text: buf.String()})
buf.Reset()
}
}
if depth != 0 {
return nil, fmt.Errorf("harness.toml:%d: unterminated array (missing ']')", curNo)
}
return out, nil
}
// stripComment removes a trailing # comment, ignoring # inside strings.
func stripComment(line string) string {
inStr, esc := false, false
for i, r := range line {
switch {
case esc:
esc = false
case inStr && r == '\\':
esc = true
case r == '"':
inStr = !inStr
case r == '#' && !inStr:
return line[:i]
}
}
return line
}
func parseTableName(text string, no int) ([]string, error) {
if !strings.HasSuffix(text, "]") {
return nil, fmt.Errorf("harness.toml:%d: malformed table header %q", no, text)
}
inner := strings.TrimSuffix(strings.TrimPrefix(text, "["), "]")
if inner == "" {
return nil, fmt.Errorf("harness.toml:%d: empty table name", no)
}
parts := strings.Split(inner, ".")
for _, p := range parts {
if !isBareKey(p) {
return nil, fmt.Errorf("harness.toml:%d: invalid table name segment %q", no, p)
}
}
return parts, nil
}
func descend(root TOMLDoc, path []string, no int) (TOMLDoc, error) {
cur := root
for _, k := range path {
if existing, ok := cur[k]; ok {
sub, ok := existing.(TOMLDoc)
if !ok {
return nil, fmt.Errorf("harness.toml:%d: key %q already has a value, cannot become table", no, k)
}
cur = sub
continue
}
sub := TOMLDoc{}
cur[k] = sub
cur = sub
}
return cur, nil
}
func parseKeyValue(m TOMLDoc, text string, no int) error {
eq := strings.IndexByte(text, '=')
if eq < 0 {
return fmt.Errorf("harness.toml:%d: expected key = value, got %q", no, text)
}
key := strings.TrimSpace(text[:eq])
if strings.HasPrefix(key, `"`) {
// Quoted key (e.g. `"In Progress" = "Done"` in a status map):
// the key must be exactly one quoted string.
q, err := parseStringValue(key, no)
if err != nil {
return fmt.Errorf("harness.toml:%d: invalid quoted key: %w", no, err)
}
key = q
} else if !isBareKey(key) {
return fmt.Errorf("harness.toml:%d: invalid key %q", no, key)
}
valStr := strings.TrimSpace(text[eq+1:])
if valStr == "" {
return fmt.Errorf("harness.toml:%d: missing value for key %q", no, key)
}
val, err := parseValue(valStr, no)
if err != nil {
return err
}
if _, exists := m[key]; exists {
return fmt.Errorf("harness.toml:%d: duplicate key %q", no, key)
}
m[key] = val
return nil
}
func isBareKey(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
default:
return false
}
}
return true
}
func parseValue(v string, no int) (any, error) {
switch {
case strings.HasPrefix(v, `"`):
return parseStringValue(v, no)
case strings.HasPrefix(v, "["):
return parseArrayValue(v, no)
default:
return parseScalar(v, no)
}
}
func parseScalar(v string, no int) (any, error) {
switch v {
case "true":
return true, nil
case "false":
return false, nil
}
n, err := parseTOMLInt(v)
if err != nil {
return nil, fmt.Errorf("harness.toml:%d: unsupported value %q (want string, int, bool, or array)", no, v)
}
return n, nil
}
func parseTOMLInt(v string) (int64, error) {
s := strings.ReplaceAll(v, "_", "")
return parseSignedInt(s)
}
func parseSignedInt(s string) (int64, error) {
var n int64
i := 0
neg := false
if i < len(s) && (s[i] == '+' || s[i] == '-') {
neg = s[i] == '-'
i++
}
if i >= len(s) {
return 0, fmt.Errorf("not an integer: %q", s)
}
for ; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0, fmt.Errorf("not an integer: %q", s)
}
n = n*10 + int64(s[i]-'0')
if n < 0 {
return 0, fmt.Errorf("integer overflow: %q", s)
}
}
if neg {
n = -n
}
return n, nil
}
func parseStringValue(v string, no int) (string, error) {
if len(v) < 2 {
return "", fmt.Errorf("harness.toml:%d: unterminated string %q", no, v)
}
var b strings.Builder
i := 1
for i < len(v) {
c := v[i]
if c == '"' {
if rest := strings.TrimSpace(v[i+1:]); rest != "" {
return "", fmt.Errorf("harness.toml:%d: unexpected trailing content after string: %q", no, rest)
}
return b.String(), nil
}
if c != '\\' {
b.WriteByte(c)
i++
continue
}
if i+1 >= len(v) {
return "", fmt.Errorf("harness.toml:%d: dangling escape in %q", no, v)
}
switch v[i+1] {
case '"':
b.WriteByte('"')
case '\\':
b.WriteByte('\\')
case 'n':
b.WriteByte('\n')
case 't':
b.WriteByte('\t')
case 'r':
b.WriteByte('\r')
default:
return "", fmt.Errorf("harness.toml:%d: unsupported escape \\%c", no, v[i+1])
}
i += 2
}
return "", fmt.Errorf("harness.toml:%d: unterminated string", no)
}
func parseArrayValue(v string, no int) ([]any, error) {
if !strings.HasSuffix(v, "]") {
return nil, fmt.Errorf("harness.toml:%d: unterminated array %q", no, v)
}
inner := strings.TrimSpace(v[1 : len(v)-1])
if inner == "" {
return []any{}, nil
}
var out []any
elems := splitTopLevel(inner, ',')
if len(elems) > 0 && strings.TrimSpace(elems[len(elems)-1]) == "" {
elems = elems[:len(elems)-1] // tolerate a trailing comma
}
for _, elem := range elems {
elem = strings.TrimSpace(elem)
if elem == "" {
return nil, fmt.Errorf("harness.toml:%d: empty array element in %q", no, v)
}
val, err := parseValue(elem, no)
if err != nil {
return nil, err
}
out = append(out, val)
}
return out, nil
}
// splitTopLevel splits on sep outside of double-quoted strings.
func splitTopLevel(s string, sep rune) []string {
var out []string
var cur strings.Builder
inStr, esc := false, false
for _, r := range s {
switch {
case esc:
esc = false
cur.WriteRune(r)
case inStr && r == '\\':
esc = true
cur.WriteRune(r)
case r == '"':
inStr = !inStr
cur.WriteRune(r)
case r == sep && !inStr:
out = append(out, cur.String())
cur.Reset()
default:
cur.WriteRune(r)
}
}
out = append(out, cur.String())
return out
}
// Load reads, parses, and validates harness.toml at path.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
doc, err := ParseTOML(string(data))
if err != nil {
return nil, fmt.Errorf("config: %w", err)
}
cfg := Default()
if err := cfg.apply(doc); err != nil {
return nil, fmt.Errorf("config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("config: %w", err)
}
return cfg, nil
}