// Package settings edits llmgw's configuration files key by key, for the // settings screens: what each key is comes from the generated reference, so // every setting the code reads is there without anyone listing it, and the // file is edited as YAML nodes, so its comments and order survive. package settings import ( "bytes" "fmt" "os" "reflect" "strconv" "strings" "time" "go.yaml.in/yaml/v3" "gitea.mynodes.xyz/ilker/llmgw/internal/docgen" ) // Kind is how a key is shown and edited. type Kind int // The kinds of key. const ( Text Kind = iota Int Float Bool Duration TextList // []string, []int: one value a line TextMap // map[string]string and the like: key = value lines Block // a nested block: a submenu BlockList // a list of blocks: a submenu of entries BlockMap // a map of blocks: a submenu of named entries ) // Key is one key of a block, as the screens need it. type Key struct { docgen.Field Kind Kind // Block is the block it opens, for Block, BlockList and BlockMap. Block *docgen.Block } // Blocks finds a block by ID in the generated reference. func Find(id string) (*docgen.Block, bool) { for i := range docgen.Reference { if docgen.Reference[i].ID() == id { return &docgen.Reference[i], true } } return nil, false } // Keys are a block's keys with their kinds. func Keys(b *docgen.Block) []Key { out := make([]Key, 0, len(b.Fields)) for _, f := range b.Fields { k := Key{Field: f} k.Kind, k.Block = kindOf(b.Package, f) out = append(out, k) } return out } func kindOf(pkg string, f docgen.Field) (Kind, *docgen.Block) { if nb, ok := Find(f.Nested); ok { return Block, nb } t := strings.TrimPrefix(f.Type, "*") switch t { case "string": return Text, nil case "int", "int64", "uint", "uint32": return Int, nil case "float64", "float32": return Float, nil case "bool": return Bool, nil case "time.Duration": return Duration, nil } if elem, ok := strings.CutPrefix(t, "[]"); ok { if b, ok := Find(qualify(pkg, elem)); ok { return BlockList, b } return TextList, nil } if strings.HasPrefix(t, "map[") { elem := t[strings.Index(t, "]")+1:] if b, ok := Find(qualify(pkg, elem)); ok { return BlockMap, b } return TextMap, nil } return Text, nil } // qualify is a type as a block ID: Endpoint in config is config.Endpoint. func qualify(pkg, typ string) string { if strings.Contains(typ, ".") { return typ } return pkg + "." + typ } // Root is the block a file is: server.yaml is config.Server, agent.yaml the // agent's Config. func Root(file string) (*docgen.Block, error) { id := "config.Server" if strings.Contains(file, "agent") { id = "agent.Config" } b, ok := Find(id) if !ok { return nil, fmt.Errorf("no block %s in the reference", id) } return b, nil } // Doc is a configuration file, open for editing. type Doc struct { Path string root *yaml.Node // the document's top mapping doc yaml.Node // Changed counts edits not yet saved. Changed int // cluster holds the keys that are not the machine's own, when the // cluster keeps them elsewhere (the store); isLocal says which are. cluster *Doc isLocal func([]string) bool } // Layer has the keys isLocal does not claim read and written in cluster // instead of this document: one screen over a machine's file and the // cluster's settings. func (d *Doc) Layer(cluster *Doc, isLocal func([]string) bool) { d.cluster, d.isLocal = cluster, isLocal } // Cluster is the layered cluster document, nil when there is none. func (d *Doc) Cluster() *Doc { return d.cluster } // Pending counts edits not yet saved, in this document and its layer. func (d *Doc) Pending() int { if d.cluster != nil { return d.Changed + d.cluster.Changed } return d.Changed } // at is the document a path is kept in. func (d *Doc) at(path []string) *Doc { if d.cluster != nil && len(path) > 0 && !d.isLocal(path) { return d.cluster } return d } // Open reads a file; a missing one is an empty document. func Open(path string) (*Doc, error) { b, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return nil, err } return parseDoc(path, b) } // FromBytes reads a document held somewhere other than a file: the // cluster's settings, kept in the store. It has no Path, so it is not Saved. func FromBytes(name string, b []byte) (*Doc, error) { d, err := parseDoc(name, b) if err != nil { return nil, err } d.Path = "" return d, nil } func parseDoc(path string, b []byte) (*Doc, error) { d := &Doc{Path: path} if len(bytes.TrimSpace(b)) > 0 { if err := yaml.Unmarshal(b, &d.doc); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } } if d.doc.Kind == 0 { d.doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}} } d.root = d.doc.Content[0] if d.root.Kind != yaml.MappingNode { return nil, fmt.Errorf("%s is not a mapping at the top", path) } return d, nil } // Bytes is the document as it would be written. func (d *Doc) Bytes() ([]byte, error) { var b bytes.Buffer enc := yaml.NewEncoder(&b) enc.SetIndent(2) if err := enc.Encode(&d.doc); err != nil { return nil, err } return b.Bytes(), enc.Close() } // Node is the node at a path of keys and list indexes, or nil. func (d *Doc) Node(path []string) *yaml.Node { if t := d.at(path); t != d { return t.Node(path) } n := d.root for _, p := range path { n = child(n, p) if n == nil { return nil } } return n } func child(n *yaml.Node, key string) *yaml.Node { switch n.Kind { case yaml.MappingNode: for i := 0; i+1 < len(n.Content); i += 2 { if n.Content[i].Value == key { return n.Content[i+1] } } case yaml.SequenceNode: if i, err := strconv.Atoi(key); err == nil && i >= 0 && i < len(n.Content) { return n.Content[i] } } return nil } // ensure is the node at a path, made as mappings where there is none. func (d *Doc) ensure(path []string) *yaml.Node { n := d.root for _, p := range path { c := child(n, p) if c == nil { if n.Kind != yaml.MappingNode { return nil } c = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: p}, c) } n = c } return n } // Set writes one key's value: a scalar, a list of lines or a map of // "key = value" lines, by its kind. It checks the value first. func (d *Doc) Set(path []string, k Kind, value string) error { if t := d.at(path); t != d { return t.Set(path, k, value) } if len(path) == 0 { return fmt.Errorf("no key") } n, err := encode(k, value) if err != nil { return err } return d.place(path, n) } // SetYAML writes one key's value given as YAML: a scalar, [a, b], {k: v}, // or a whole block. func (d *Doc) SetYAML(path []string, value string) error { if t := d.at(path); t != d { return t.SetYAML(path, value) } if len(path) == 0 { return fmt.Errorf("no key") } var doc yaml.Node if err := yaml.Unmarshal([]byte(value), &doc); err != nil { return fmt.Errorf("%q is not YAML: %w", value, err) } n := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: ""} if len(doc.Content) > 0 { n = doc.Content[0] } return d.place(path, n) } // SetNode writes a node, as read from another document, at path. func (d *Doc) SetNode(path []string, n *yaml.Node) error { if t := d.at(path); t != d { return t.SetNode(path, n) } if len(path) == 0 { return fmt.Errorf("no key") } c := *n return d.place(path, &c) } func (d *Doc) place(path []string, n *yaml.Node) error { parent := d.ensure(path[:len(path)-1]) if parent == nil { return fmt.Errorf("%s is not a block", strings.Join(path[:len(path)-1], ".")) } key := path[len(path)-1] if parent.Kind == yaml.SequenceNode { i, _ := strconv.Atoi(key) parent.Content[i] = n } else if c := child(parent, key); c != nil { // Kept in place, with whatever comment it had. n.HeadComment, n.LineComment, n.FootComment = c.HeadComment, c.LineComment, c.FootComment *c = *n } else { parent.Content = append(parent.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, n) } d.Changed++ return nil } // Unset removes a key, so its default applies. func (d *Doc) Unset(path []string) { if t := d.at(path); t != d { t.Unset(path) return } if len(path) == 0 { return } parent := d.Node(path[:len(path)-1]) if parent == nil { return } key := path[len(path)-1] switch parent.Kind { case yaml.MappingNode: for i := 0; i+1 < len(parent.Content); i += 2 { if parent.Content[i].Value == key { parent.Content = append(parent.Content[:i], parent.Content[i+2:]...) d.Changed++ return } } case yaml.SequenceNode: if i, err := strconv.Atoi(key); err == nil && i < len(parent.Content) { parent.Content = append(parent.Content[:i], parent.Content[i+1:]...) d.Changed++ } } } // Append adds an empty block to the list at path, and returns its index. func (d *Doc) Append(path []string) int { if t := d.at(path); t != d { return t.Append(path) } parent := d.ensure(path[:len(path)-1]) key := path[len(path)-1] seq := child(parent, key) if seq == nil || seq.Kind != yaml.SequenceNode { seq = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} if c := child(parent, key); c != nil { *c = *seq seq = c } else { parent.Content = append(parent.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, seq) } } seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}) d.Changed++ return len(seq.Content) - 1 } // AddEntry adds an empty block under a name to the map at path. func (d *Doc) AddEntry(path []string, name string) error { if t := d.at(path); t != d { return t.AddEntry(path, name) } m := d.ensure(path) if m == nil || m.Kind != yaml.MappingNode { return fmt.Errorf("%s is not a map", strings.Join(path, ".")) } if child(m, name) != nil { return fmt.Errorf("%s is there already", name) } m.Content = append(m.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}, &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}) d.Changed++ return nil } // Len is how many entries a list or map at path has. func (d *Doc) Len(path []string) int { n := d.Node(path) switch { case n == nil: return 0 case n.Kind == yaml.SequenceNode: return len(n.Content) case n.Kind == yaml.MappingNode: return len(n.Content) / 2 } return 0 } // MapKeys are a map's keys, in the file's order. func (d *Doc) MapKeys(path []string) []string { n := d.Node(path) if n == nil || n.Kind != yaml.MappingNode { return nil } var out []string for i := 0; i+1 < len(n.Content); i += 2 { out = append(out, n.Content[i].Value) } return out } // Text is a key's value as it is edited: a scalar as written, a list one // item a line, a map "key = value" a line. ok is false for a key the file // does not set. func (d *Doc) Text(path []string) (string, bool) { n := d.Node(path) if n == nil { return "", false } switch n.Kind { case yaml.ScalarNode: return n.Value, true case yaml.SequenceNode: var l []string for _, c := range n.Content { l = append(l, c.Value) } return strings.Join(l, "\n"), true case yaml.MappingNode: var l []string for i := 0; i+1 < len(n.Content); i += 2 { l = append(l, n.Content[i].Value+" = "+n.Content[i+1].Value) } return strings.Join(l, "\n"), true } return "", true } func encode(k Kind, value string) (*yaml.Node, error) { v := strings.TrimSpace(value) scalar := func(tag string) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: v} } switch k { case Int: if _, err := strconv.ParseInt(v, 10, 64); err != nil { return nil, fmt.Errorf("%q is not a whole number", v) } return scalar("!!int"), nil case Float: if _, err := strconv.ParseFloat(v, 64); err != nil { return nil, fmt.Errorf("%q is not a number", v) } return scalar("!!float"), nil case Bool: b, err := strconv.ParseBool(v) if err != nil { return nil, fmt.Errorf("%q is not true or false", v) } return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(b)}, nil case Duration: if _, err := time.ParseDuration(v); err != nil { return nil, fmt.Errorf("%q is not a duration, like 30s, 5m or 2h", v) } return scalar("!!str"), nil case TextList: seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} for _, l := range splitLines(value) { seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: l}) } return seq, nil case TextMap: m := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} for _, l := range splitLines(value) { k, val, ok := strings.Cut(l, "=") if !ok { return nil, fmt.Errorf("%q is not key = value", l) } m.Content = append(m.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: strings.TrimSpace(k)}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: strings.TrimSpace(val)}) } return m, nil case Block, BlockList, BlockMap: return nil, fmt.Errorf("a block is edited key by key") } return scalar("!!str"), nil } func splitLines(s string) []string { var out []string for _, l := range strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == ',' }) { if l = strings.TrimSpace(l); l != "" { out = append(out, l) } } return out } // Save checks the document with check — a loader that reads and validates // a file — and writes it, the old file kept beside it. Nothing is written // when the check fails. func (d *Doc) Save(check func(path string) error) (string, error) { b, err := d.Bytes() if err != nil { return "", err } tmp := d.Path + ".check" if err := os.WriteFile(tmp, b, 0o600); err != nil { return "", err } defer os.Remove(tmp) if check != nil { if err := check(tmp); err != nil { return "", err } } mode := os.FileMode(0o644) backup := "" if info, err := os.Stat(d.Path); err == nil { mode = info.Mode().Perm() old, err := os.ReadFile(d.Path) if err != nil { return "", err } backup = fmt.Sprintf("%s.bak-%s", d.Path, time.Now().Format("20060102-150405")) if err := os.WriteFile(backup, old, mode); err != nil { return "", err } } if err := os.WriteFile(d.Path+".new", b, mode); err != nil { return "", err } if err := os.Rename(d.Path+".new", d.Path); err != nil { return "", err } d.Changed = 0 return backup, nil } // Effective is the value a loaded configuration has at a path of koanf // keys — what applies, default included — as text. func Effective(cfg any, path []string) (string, bool) { v := reflect.ValueOf(cfg) for _, p := range path { for v.Kind() == reflect.Pointer { if v.IsNil() { return "", false } v = v.Elem() } switch v.Kind() { case reflect.Struct: found := false t := v.Type() for i := 0; i < t.NumField(); i++ { if tag, _, _ := strings.Cut(t.Field(i).Tag.Get("koanf"), ","); tag == p { v, found = v.Field(i), true break } } if !found { return "", false } case reflect.Slice: i, err := strconv.Atoi(p) if err != nil || i >= v.Len() { return "", false } v = v.Index(i) case reflect.Map: mv := v.MapIndex(reflect.ValueOf(p)) if !mv.IsValid() { return "", false } v = mv default: return "", false } } for v.Kind() == reflect.Pointer { if v.IsNil() { return "", true } v = v.Elem() } switch v.Kind() { case reflect.Slice: var l []string for i := 0; i < v.Len(); i++ { l = append(l, fmt.Sprint(v.Index(i).Interface())) } return strings.Join(l, ", "), true case reflect.Struct, reflect.Map: return "", true } if d, ok := v.Interface().(time.Duration); ok { return d.String(), true } return fmt.Sprint(v.Interface()), true }