Files
commandkit/integrations/ckconfig/diff_report.go

195 lines
5.8 KiB
Go

package ckconfig
import (
"bytes"
"fmt"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"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
}
// yamlValuePlacer indexes each mapping once while assembling one report.
// Content remains the ordered representation used by the YAML encoder.
type yamlValuePlacer struct {
keys map[*yaml.Node]map[string]int
}
func (placer *yamlValuePlacer) placeValue(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 {
keys := placer.mappingKeys(current)
keyIndex, exists := keys[token]
if index == len(path)-1 {
if exists {
current.Content[keyIndex+1] = value
} else {
keys[token] = len(current.Content)
current.Content = append(current.Content, stringKey(token), value)
}
return category, nil
}
if !exists {
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
keys[token] = len(current.Content)
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 (placer *yamlValuePlacer) mappingKeys(mapping *yaml.Node) map[string]int {
if keys, exists := placer.keys[mapping]; exists {
return keys
}
if placer.keys == nil {
placer.keys = make(map[*yaml.Node]map[string]int)
}
keys := make(map[string]int, len(mapping.Content)/2)
for index := 0; index+1 < len(mapping.Content); index += 2 {
candidate := mapping.Content[index]
if candidate.Kind == yaml.ScalarNode && candidate.Tag == "!!str" {
if _, exists := keys[candidate.Value]; !exists {
keys[candidate.Value] = index
}
}
}
placer.keys[mapping] = keys
return keys
}
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()
}
func renderDiffReport(differences []configkit.Difference) ([]byte, error) {
var placer yamlValuePlacer
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 = placer.placeValue(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 = placer.placeValue(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
}