mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
239 lines
6.1 KiB
Go
239 lines
6.1 KiB
Go
package tools
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/patriceckhart/zot/internal/core"
|
|
"github.com/patriceckhart/zot/internal/provider"
|
|
)
|
|
|
|
// EditTool applies one or more exact-match replacements to a file.
|
|
type EditTool struct {
|
|
CWD string
|
|
Sandbox *Sandbox
|
|
}
|
|
|
|
type editOp struct {
|
|
OldText string `json:"oldText"`
|
|
NewText string `json:"newText"`
|
|
}
|
|
|
|
type editArgs struct {
|
|
Path string `json:"path"`
|
|
Edits []editOp `json:"edits"`
|
|
}
|
|
|
|
const editSchema = `{
|
|
"type":"object",
|
|
"properties":{
|
|
"path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},
|
|
"edits":{
|
|
"type":"array",
|
|
"description":"One or more targeted replacements. Each oldText must match exactly once in the original file.",
|
|
"items":{
|
|
"type":"object",
|
|
"properties":{
|
|
"oldText":{"type":"string","description":"Exact text to replace. Must be unique in the file."},
|
|
"newText":{"type":"string","description":"Replacement text."}
|
|
},
|
|
"required":["oldText","newText"],
|
|
"additionalProperties":false
|
|
}
|
|
}
|
|
},
|
|
"required":["path","edits"],
|
|
"additionalProperties":false
|
|
}`
|
|
|
|
func (t *EditTool) Name() string { return "edit" }
|
|
func (t *EditTool) Description() string {
|
|
return "Edit an existing file via exact-match replacements. Preserves line endings and BOM."
|
|
}
|
|
func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
|
|
|
|
func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
|
|
var a editArgs
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
if a.Path == "" {
|
|
return core.ToolResult{}, fmt.Errorf("path is required")
|
|
}
|
|
if len(a.Edits) == 0 {
|
|
return core.ToolResult{}, fmt.Errorf("at least one edit is required")
|
|
}
|
|
path := resolvePath(t.CWD, a.Path)
|
|
if err := t.Sandbox.CheckPath(path); err != nil {
|
|
return core.ToolResult{}, err
|
|
}
|
|
|
|
orig, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return core.ToolResult{}, err
|
|
}
|
|
|
|
// Detect BOM and line endings.
|
|
var bom []byte
|
|
if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) {
|
|
bom = orig[:3]
|
|
orig = orig[3:]
|
|
}
|
|
nl := detectLineEnding(orig)
|
|
// Normalize to \n for matching.
|
|
body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
|
|
|
|
// Validate all edits first (against original content, not sequentially).
|
|
for i, e := range a.Edits {
|
|
if e.OldText == "" {
|
|
return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
|
|
}
|
|
if e.OldText == e.NewText {
|
|
return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
|
|
}
|
|
count := strings.Count(body, e.OldText)
|
|
if count == 0 {
|
|
return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s", i+1, a.Path)
|
|
}
|
|
if count > 1 {
|
|
return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
|
|
}
|
|
}
|
|
|
|
// Apply atomically (sorted by position so offsets stay valid as we splice).
|
|
type span struct {
|
|
start, end int
|
|
replacement string
|
|
}
|
|
spans := make([]span, 0, len(a.Edits))
|
|
for _, e := range a.Edits {
|
|
idx := strings.Index(body, e.OldText)
|
|
spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
|
|
}
|
|
// Check for overlaps.
|
|
for i := 0; i < len(spans); i++ {
|
|
for j := i + 1; j < len(spans); j++ {
|
|
if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
|
|
return core.ToolResult{}, fmt.Errorf("edits overlap; merge them into one edit")
|
|
}
|
|
}
|
|
}
|
|
// Sort ascending.
|
|
for i := 1; i < len(spans); i++ {
|
|
for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- {
|
|
spans[j-1], spans[j] = spans[j], spans[j-1]
|
|
}
|
|
}
|
|
var out strings.Builder
|
|
prev := 0
|
|
for _, s := range spans {
|
|
out.WriteString(body[prev:s.start])
|
|
out.WriteString(s.replacement)
|
|
prev = s.end
|
|
}
|
|
out.WriteString(body[prev:])
|
|
newBody := out.String()
|
|
|
|
// Restore line endings.
|
|
if nl == "\r\n" {
|
|
newBody = strings.ReplaceAll(newBody, "\n", "\r\n")
|
|
}
|
|
final := append([]byte{}, bom...)
|
|
final = append(final, []byte(newBody)...)
|
|
|
|
if err := os.WriteFile(path, final, 0o644); err != nil {
|
|
return core.ToolResult{}, err
|
|
}
|
|
|
|
diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
|
|
msg := fmt.Sprintf("applied %d edit(s) to %s", len(a.Edits), a.Path)
|
|
return core.ToolResult{
|
|
Content: []provider.Content{provider.TextBlock{Text: msg + "\n" + diff}},
|
|
Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
|
|
}, nil
|
|
}
|
|
|
|
func detectLineEnding(b []byte) string {
|
|
if bytes.Contains(b, []byte("\r\n")) {
|
|
return "\r\n"
|
|
}
|
|
return "\n"
|
|
}
|
|
|
|
// unifiedDiff is a minimal unified diff good enough for tool output.
|
|
func unifiedDiff(name, a, b string) string {
|
|
if a == b {
|
|
return ""
|
|
}
|
|
aLines := strings.Split(a, "\n")
|
|
bLines := strings.Split(b, "\n")
|
|
// Use simple LCS-based diff.
|
|
ops := diffLines(aLines, bLines)
|
|
var sb strings.Builder
|
|
fmt.Fprintf(&sb, "--- %s\n+++ %s\n", name, name)
|
|
for _, op := range ops {
|
|
switch op.kind {
|
|
case ' ':
|
|
fmt.Fprintf(&sb, " %s\n", op.line)
|
|
case '-':
|
|
fmt.Fprintf(&sb, "-%s\n", op.line)
|
|
case '+':
|
|
fmt.Fprintf(&sb, "+%s\n", op.line)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
type diffOp struct {
|
|
kind byte
|
|
line string
|
|
}
|
|
|
|
func diffLines(a, b []string) []diffOp {
|
|
// LCS table.
|
|
m, n := len(a), len(b)
|
|
dp := make([][]int, m+1)
|
|
for i := range dp {
|
|
dp[i] = make([]int, n+1)
|
|
}
|
|
for i := 0; i < m; i++ {
|
|
for j := 0; j < n; j++ {
|
|
if a[i] == b[j] {
|
|
dp[i+1][j+1] = dp[i][j] + 1
|
|
} else if dp[i+1][j] >= dp[i][j+1] {
|
|
dp[i+1][j+1] = dp[i+1][j]
|
|
} else {
|
|
dp[i+1][j+1] = dp[i][j+1]
|
|
}
|
|
}
|
|
}
|
|
// Backtrack.
|
|
var ops []diffOp
|
|
i, j := m, n
|
|
for i > 0 && j > 0 {
|
|
if a[i-1] == b[j-1] {
|
|
ops = append([]diffOp{{' ', a[i-1]}}, ops...)
|
|
i--
|
|
j--
|
|
} else if dp[i][j-1] >= dp[i-1][j] {
|
|
ops = append([]diffOp{{'+', b[j-1]}}, ops...)
|
|
j--
|
|
} else {
|
|
ops = append([]diffOp{{'-', a[i-1]}}, ops...)
|
|
i--
|
|
}
|
|
}
|
|
for i > 0 {
|
|
ops = append([]diffOp{{'-', a[i-1]}}, ops...)
|
|
i--
|
|
}
|
|
for j > 0 {
|
|
ops = append([]diffOp{{'+', b[j-1]}}, ops...)
|
|
j--
|
|
}
|
|
return ops
|
|
}
|