package reconcile import ( "bytes" "encoding/json" "fmt" "strconv" "strings" "github.com/tailscale/hujson" ) // jsonFile ist eine JSON- oder JSONC-Datei, die über JSON-Patch geändert // wird. hujson erhält dabei Kommentare, Reihenfolge und die Form der übrigen // Inhalte. Eingefügte Werte rückt formatieren im Stil der Datei ein. type jsonFile struct { value hujson.Value // Einrückung der Datei, z. B. zwei Leerzeichen oder ein Tab indent string } func readJson(raw []byte, next bool) (*jsonFile, error) { if len(bytes.TrimSpace(raw)) == 0 { raw = []byte("{}") } v, err := hujson.Parse(raw) if err != nil { return nil, err } return &jsonFile{value: v, indent: detectIndent(raw)}, nil } func detectIndent(raw []byte) string { for _, row := range strings.Split(string(raw), "\n") { rest := strings.TrimLeft(row, " \t") if rest != "" && len(rest) < len(row) { return row[:len(row)-len(rest)] } } return " " } func (d *jsonFile) bytes() []byte { b := d.value.Pack() if !bytes.HasSuffix(b, []byte("\n")) { b = append(b, '\n') } return b } // read liefert den Wert unter path als Go-Wert, ok=false wenn er fehlt. func (d *jsonFile) read(path []string) (any, bool) { clone := d.value.Clone() clone.Standardize() var value any if err := json.Unmarshal(clone.Pack(), &value); err != nil { return nil, false } for _, s := range path { obj, ok := value.(map[string]any) if !ok { return nil, false } if value, ok = obj[s]; !ok { return nil, false } } return value, true } // empty sagt, ob die Datei nur noch ein leeres Objekt enthält. // OpenCodeSchema legt hugin in eine neue opencode.json. const OpenCodeSchema = "https://opencode.ai/config.json" // empty: ein leeres Objekt; ein $schema allein zählt nicht als Inhalt. func (d *jsonFile) empty() bool { value, ok := d.read(nil) obj, isObject := value.(map[string]any) delete(obj, "$schema") return ok && isObject && len(obj) == 0 } func (d *jsonFile) set(path []string, value json.RawMessage) error { if existing, ok := d.read(path); ok && canonical(existing) == canonicalRaw(value) { return nil } // Fehlende Zwischenobjekte nicht einzeln anlegen, sondern den Wert ab der // ersten fehlenden Ebene verschachtelt einfügen: ergibt saubere Einrückung. for i := 1; i < len(path); i++ { existing, ok := d.read(path[:i]) if !ok { for j := len(path) - 1; j >= i; j-- { key, _ := json.Marshal(path[j]) value = json.RawMessage("{" + string(key) + ":" + string(value) + "}") } path = path[:i] break } if _, isObject := existing.(map[string]any); !isObject { return fmt.Errorf("%s ist kein Objekt", pointer(path[:i])) } } if err := d.patch("add", path, value); err != nil { return err } return d.format(path[:len(path)-1], path[len(path)-1]) } func (d *jsonFile) remove(path []string) error { if _, ok := d.read(path); !ok { return nil } if err := d.patch("remove", path, nil); err != nil { return err } return d.removeEmptyParents(path) } // removeEmptyParents entfernt Objekte über pfad, die durch das Entfernen leer // geworden sind, z. B. "mcpServers": {} nach dem letzten Server. func (d *jsonFile) removeEmptyParents(path []string) error { for i := len(path) - 1; i > 0; i-- { value, ok := d.read(path[:i]) obj, isObject := value.(map[string]any) if !ok || !isObject || len(obj) > 0 { return nil } if err := d.patch("remove", path[:i], nil); err != nil { return err } } return nil } // addToList stellt sicher, dass wert in der Liste unter pfad steht. func (d *jsonFile) addToList(path []string, value json.RawMessage) error { existing, ok := d.read(path) if !ok { return d.set(path, json.RawMessage("["+string(value)+"]")) } items, isList := existing.([]any) if !isList { return fmt.Errorf("%s ist keine Liste", pointer(path)) } if index(items, value) >= 0 { return nil } if err := d.patch("add", append(append([]string{}, path...), "-"), value); err != nil { return err } return d.format(path, "") } func (d *jsonFile) removeFromList(path []string, value json.RawMessage) error { existing, _ := d.read(path) items, _ := existing.([]any) i := index(items, value) if i < 0 { return nil } if len(items) == 1 { // Eine leere Liste nützt niemandem: den Schlüssel ganz entfernen return d.remove(path) } return d.patch("remove", append(append([]string{}, path...), fmt.Sprint(i)), nil) } func index(items []any, value json.RawMessage) int { for i, entry := range items { if canonical(entry) == canonicalRaw(value) { return i } } return -1 } func (d *jsonFile) patch(op string, path []string, value json.RawMessage) error { step := map[string]any{"op": op, "path": pointer(path)} if value != nil { step["value"] = value } raw, err := json.Marshal([]any{step}) if err != nil { return err } return d.value.Patch(raw) } // pointer baut einen JSON-Pointer (RFC 6901) aus den Schlüsseln. func pointer(path []string) string { var b strings.Builder for _, s := range path { b.WriteString("/") b.WriteString(strings.NewReplacer("~", "~0", "/", "~1").Replace(s)) } return b.String() } func canonical(value any) string { b, _ := json.Marshal(value) return string(b) } func canonicalRaw(raw json.RawMessage) string { var value any if err := json.Unmarshal(raw, &value); err != nil { return string(raw) } return canonical(value) } // format rückt das eben gesetzte Element ein: den Member name eines // Objekts bzw. (name leer) das letzte Element einer Liste unter eltern. // Steht das Elternelement in einer Zeile, bleibt es einzeilig. func (d *jsonFile) format(parents []string, name string) error { node := d.value.Find(pointer(parents)) if node == nil { return nil } depth := len(parents) + 1 inside := "\n" + strings.Repeat(d.indent, depth) outside := "\n" + strings.Repeat(d.indent, depth-1) switch c := node.Value.(type) { case *hujson.Object: member := withName(c, name) if member == nil { return nil } var before []hujson.Extra // Abstand vor den übrigen Membern for i := range c.Members { if &c.Members[i] != member { before = append(before, c.Members[i].Name.BeforeExtra) } } multiline := multiline(before) switch { case multiline: member.Name.BeforeExtra = []byte(inside) case member == &c.Members[0]: member.Name.BeforeExtra = nil default: member.Name.BeforeExtra = []byte(" ") } if err := d.reindent(&member.Value, multiline, depth); err != nil { return err } member.Value.BeforeExtra = []byte(" ") member.Value.AfterExtra = nil if multiline && !comment(c.AfterExtra) { c.AfterExtra = []byte(outside) } case *hujson.Array: if len(c.Elements) == 0 { return nil } var before []hujson.Extra for _, e := range c.Elements[:len(c.Elements)-1] { before = append(before, e.BeforeExtra) } target := &c.Elements[len(c.Elements)-1] multiline := multiline(before) if err := d.reindent(target, multiline, depth); err != nil { return err } switch { case multiline: target.BeforeExtra = []byte(inside) if !comment(c.AfterExtra) { c.AfterExtra = []byte(outside) } case len(c.Elements) > 1: target.BeforeExtra = []byte(" ") default: target.BeforeExtra = nil } target.AfterExtra = nil } d.value.UpdateOffsets() return nil } // reindent ersetzt den Wert durch eine eingerückte (oder kompakte) Fassung. func (d *jsonFile) reindent(v *hujson.Value, multiline bool, depth int) error { clone := v.Clone() clone.Standardize() var buffer bytes.Buffer if multiline { if err := json.Indent(&buffer, bytes.TrimSpace(clone.Pack()), strings.Repeat(d.indent, depth), d.indent); err != nil { return err } } else if err := json.Compact(&buffer, clone.Pack()); err != nil { return err } next, err := hujson.Parse(buffer.Bytes()) if err != nil { return err } v.Value = next.Value return nil } func withName(obj *hujson.Object, name string) *hujson.ObjectMember { for i := range obj.Members { if n, _ := strconv.Unquote(string(obj.Members[i].Name.Value.(hujson.Literal))); n == name { return &obj.Members[i] } } return nil } // mehrzeilig: Die übrigen Elemente stehen auf eigenen Zeilen, oder es gibt // keine übrigen (dann wird ein neues Objekt bzw. eine neue Liste mehrzeilig). func multiline(before []hujson.Extra) bool { if len(before) == 0 { return true } for _, e := range before { if bytes.Contains(e, []byte("\n")) { return true } } return false } func comment(e hujson.Extra) bool { return bytes.Contains(e, []byte("/")) }