2026-10-11 21:48:49 +00:00
|
|
|
package ckconfig
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-11 21:49:50 +00:00
|
|
|
"bytes"
|
2026-10-11 21:48:49 +00:00
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-10-11 21:49:50 +00:00
|
|
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
2026-10-11 21:48:49 +00:00
|
|
|
"go.yaml.in/yaml/v4"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func decodeSafeSnapshot(snapshot string) (*yaml.Node, error) {
|
|
|
|
|
var document yaml.Node
|
|
|
|
|
decoder := yaml.NewDecoder(strings.NewReader(snapshot))
|
|
|
|
|
if err := decoder.Decode(&document); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decode safe YAML snapshot: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if document.Kind != yaml.DocumentNode || len(document.Content) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("decode safe YAML snapshot: document has no value")
|
|
|
|
|
}
|
|
|
|
|
return document.Content[0], nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func decodeJSONPointer(pointer string) ([]string, error) {
|
|
|
|
|
if pointer == "" {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
if pointer[0] != '/' {
|
|
|
|
|
return nil, fmt.Errorf("decode JSON pointer %q: path must start with /", pointer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts := strings.Split(pointer[1:], "/")
|
|
|
|
|
for index, part := range parts {
|
|
|
|
|
var decoded strings.Builder
|
|
|
|
|
decoded.Grow(len(part))
|
|
|
|
|
for offset := 0; offset < len(part); offset++ {
|
|
|
|
|
if part[offset] != '~' {
|
|
|
|
|
decoded.WriteByte(part[offset])
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if offset+1 >= len(part) {
|
|
|
|
|
return nil, fmt.Errorf("decode JSON pointer %q: incomplete escape in token %d", pointer, index)
|
|
|
|
|
}
|
|
|
|
|
offset++
|
|
|
|
|
switch part[offset] {
|
|
|
|
|
case '0':
|
|
|
|
|
decoded.WriteByte('~')
|
|
|
|
|
case '1':
|
|
|
|
|
decoded.WriteByte('/')
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("decode JSON pointer %q: invalid escape in token %d", pointer, index)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
parts[index] = decoded.String()
|
|
|
|
|
}
|
|
|
|
|
return parts, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func placeYAMLValue(category *yaml.Node, path []string, value *yaml.Node) (*yaml.Node, error) {
|
|
|
|
|
if value == nil {
|
|
|
|
|
return nil, fmt.Errorf("place YAML value: value node is nil")
|
|
|
|
|
}
|
|
|
|
|
if len(path) == 0 {
|
|
|
|
|
return value, nil
|
|
|
|
|
}
|
|
|
|
|
if category == nil || category.Kind != yaml.MappingNode {
|
|
|
|
|
return nil, fmt.Errorf("place YAML value at %q: category ancestor is not a mapping", pointerFromTokens(path[:1]))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current := category
|
|
|
|
|
for index, token := range path {
|
|
|
|
|
keyIndex := mappingKeyIndex(current, token)
|
|
|
|
|
if index == len(path)-1 {
|
|
|
|
|
if keyIndex >= 0 {
|
|
|
|
|
current.Content[keyIndex+1] = value
|
|
|
|
|
} else {
|
|
|
|
|
current.Content = append(current.Content, stringKey(token), value)
|
|
|
|
|
}
|
|
|
|
|
return category, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if keyIndex < 0 {
|
|
|
|
|
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
|
|
|
current.Content = append(current.Content, stringKey(token), child)
|
|
|
|
|
current = child
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
child := current.Content[keyIndex+1]
|
|
|
|
|
if child.Kind != yaml.MappingNode {
|
|
|
|
|
return nil, fmt.Errorf("place YAML value at %q: ancestor %q is not a mapping", pointerFromTokens(path), pointerFromTokens(path[:index+1]))
|
|
|
|
|
}
|
|
|
|
|
current = child
|
|
|
|
|
}
|
|
|
|
|
return category, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mappingKeyIndex(mapping *yaml.Node, key string) int {
|
|
|
|
|
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
|
|
|
|
candidate := mapping.Content[index]
|
|
|
|
|
if candidate.Kind == yaml.ScalarNode && candidate.Tag == "!!str" && candidate.Value == key {
|
|
|
|
|
return index
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func stringKey(value string) *yaml.Node {
|
|
|
|
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pointerFromTokens(tokens []string) string {
|
|
|
|
|
if len(tokens) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
var pointer strings.Builder
|
|
|
|
|
for _, token := range tokens {
|
|
|
|
|
pointer.WriteByte('/')
|
|
|
|
|
pointer.WriteString(strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1"))
|
|
|
|
|
}
|
|
|
|
|
return pointer.String()
|
|
|
|
|
}
|
2026-10-11 21:49:50 +00:00
|
|
|
|
|
|
|
|
func renderDiffReport(differences []configkit.Difference) ([]byte, error) {
|
|
|
|
|
additions := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
|
|
|
changes := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
|
|
|
deletions := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
|
|
|
|
|
|
|
|
for _, difference := range differences {
|
|
|
|
|
path := difference.Path.String()
|
|
|
|
|
switch {
|
|
|
|
|
case !difference.Left.Present && difference.Right.Present:
|
|
|
|
|
tokens, err := decodeJSONPointer(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
additions, err = placeYAMLValue(additions, tokens, value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
case difference.Left.Present && difference.Right.Present:
|
|
|
|
|
tokens, err := decodeJSONPointer(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
changes, err = placeYAMLValue(changes, tokens, value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
case difference.Left.Present && !difference.Right.Present:
|
|
|
|
|
deletions.Content = append(deletions.Content, stringKey(path))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
|
|
|
|
|
stringKey("additions"), additions,
|
|
|
|
|
stringKey("changes"), changes,
|
|
|
|
|
stringKey("deletions"), deletions,
|
|
|
|
|
}}
|
|
|
|
|
document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
if err := encodeYAML(&output, document); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("encode configuration diff report: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return output.Bytes(), nil
|
|
|
|
|
}
|