1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "os"
9 "strings"
10
11 "github.com/patriceckhart/zot/internal/core"
12 "github.com/patriceckhart/zot/internal/provider"
13)
14
15// EditTool applies one or more exact-match replacements to a file.
16type EditTool struct {
17 CWD string
18 Sandbox *Sandbox
19}
20
21type editOp struct {
22 OldText string `json:"oldText"`
23 NewText string `json:"newText"`
24}
25
26type editArgs struct {
27 Path string `json:"path"`
28 Edits []editOp `json:"edits"`
29}
30
31const editSchema = `{
32 "type":"object",
33 "properties":{
34 "path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},
35 "edits":{
36 "type":"array",
37 "description":"One or more targeted replacements. Each oldText must match exactly once in the original file.",
38 "items":{
39 "type":"object",
40 "properties":{
41 "oldText":{"type":"string","description":"Exact text to replace. Must be unique in the file."},
42 "newText":{"type":"string","description":"Replacement text."}
43 },
44 "required":["oldText","newText"],
45 "additionalProperties":false
46 }
47 }
48 },
49 "required":["path","edits"],
50 "additionalProperties":false
51}`
52
53func (t *EditTool) Name() string { return "edit" }
54func (t *EditTool) Description() string {
55 return "Edit an existing file via exact-match replacements. Preserves line endings and BOM."
56}
57func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
58
59func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
60 var a editArgs
61 if err := json.Unmarshal(raw, &a); err != nil {
62 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
63 }
64 if a.Path == "" {
65 return core.ToolResult{}, fmt.Errorf("path is required")
66 }
67 if len(a.Edits) == 0 {
68 return core.ToolResult{}, fmt.Errorf("at least one edit is required")
69 }
70 path := resolvePath(t.CWD, a.Path)
71 if err := t.Sandbox.CheckPath(path); err != nil {
72 return core.ToolResult{}, err
73 }
74
75 orig, err := os.ReadFile(path)
76 if err != nil {
77 return core.ToolResult{}, err
78 }
79
80 // Detect BOM and line endings.
81 var bom []byte
82 if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) {
83 bom = orig[:3]
84 orig = orig[3:]
85 }
86 nl := detectLineEnding(orig)
87 // Normalize to \n for matching.
88 body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
89
90 // Validate all edits first (against original content, not sequentially).
91 for i, e := range a.Edits {
92 if e.OldText == "" {
93 return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
94 }
95 if e.OldText == e.NewText {
96 return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
97 }
98 count := strings.Count(body, e.OldText)
99 if count == 0 {
100 return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s", i+1, a.Path)
101 }
102 if count > 1 {
103 return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
104 }
105 }
106
107 // Apply atomically (sorted by position so offsets stay valid as we splice).
108 type span struct {
109 start, end int
110 replacement string
111 }
112 spans := make([]span, 0, len(a.Edits))
113 for _, e := range a.Edits {
114 idx := strings.Index(body, e.OldText)
115 spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
116 }
117 // Check for overlaps.
118 for i := 0; i < len(spans); i++ {
119 for j := i + 1; j < len(spans); j++ {
120 if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
121 return core.ToolResult{}, fmt.Errorf("edits overlap; merge them into one edit")
122 }
123 }
124 }
125 // Sort ascending.
126 for i := 1; i < len(spans); i++ {
127 for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- {
128 spans[j-1], spans[j] = spans[j], spans[j-1]
129 }
130 }
131 var out strings.Builder
132 prev := 0
133 for _, s := range spans {
134 out.WriteString(body[prev:s.start])
135 out.WriteString(s.replacement)
136 prev = s.end
137 }
138 out.WriteString(body[prev:])
139 newBody := out.String()
140
141 // Restore line endings.
142 if nl == "\r\n" {
143 newBody = strings.ReplaceAll(newBody, "\n", "\r\n")
144 }
145 final := append([]byte{}, bom...)
146 final = append(final, []byte(newBody)...)
147
148 if err := os.WriteFile(path, final, 0o644); err != nil {
149 return core.ToolResult{}, err
150 }
151
152 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
153 msg := fmt.Sprintf("applied %d edit(s) to %s", len(a.Edits), a.Path)
154 return core.ToolResult{
155 Content: []provider.Content{provider.TextBlock{Text: msg + "\n" + diff}},
156 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
157 }, nil
158}
159
160func detectLineEnding(b []byte) string {
161 if bytes.Contains(b, []byte("\r\n")) {
162 return "\r\n"
163 }
164 return "\n"
165}
166
167// unifiedDiff is a minimal unified diff good enough for tool output.
168func unifiedDiff(name, a, b string) string {
169 if a == b {
170 return ""
171 }
172 aLines := strings.Split(a, "\n")
173 bLines := strings.Split(b, "\n")
174 // Use simple LCS-based diff.
175 ops := diffLines(aLines, bLines)
176 var sb strings.Builder
177 fmt.Fprintf(&sb, "--- %s\n+++ %s\n", name, name)
178 for _, op := range ops {
179 switch op.kind {
180 case ' ':
181 fmt.Fprintf(&sb, " %s\n", op.line)
182 case '-':
183 fmt.Fprintf(&sb, "-%s\n", op.line)
184 case '+':
185 fmt.Fprintf(&sb, "+%s\n", op.line)
186 }
187 }
188 return sb.String()
189}
190
191type diffOp struct {
192 kind byte
193 line string
194}
195
196func diffLines(a, b []string) []diffOp {
197 // LCS table.
198 m, n := len(a), len(b)
199 dp := make([][]int, m+1)
200 for i := range dp {
201 dp[i] = make([]int, n+1)
202 }
203 for i := 0; i < m; i++ {
204 for j := 0; j < n; j++ {
205 if a[i] == b[j] {
206 dp[i+1][j+1] = dp[i][j] + 1
207 } else if dp[i+1][j] >= dp[i][j+1] {
208 dp[i+1][j+1] = dp[i+1][j]
209 } else {
210 dp[i+1][j+1] = dp[i][j+1]
211 }
212 }
213 }
214 // Backtrack.
215 var ops []diffOp
216 i, j := m, n
217 for i > 0 && j > 0 {
218 if a[i-1] == b[j-1] {
219 ops = append([]diffOp{{' ', a[i-1]}}, ops...)
220 i--
221 j--
222 } else if dp[i][j-1] >= dp[i-1][j] {
223 ops = append([]diffOp{{'+', b[j-1]}}, ops...)
224 j--
225 } else {
226 ops = append([]diffOp{{'-', a[i-1]}}, ops...)
227 i--
228 }
229 }
230 for i > 0 {
231 ops = append([]diffOp{{'-', a[i-1]}}, ops...)
232 i--
233 }
234 for j > 0 {
235 ops = append([]diffOp{{'+', b[j-1]}}, ops...)
236 j--
237 }
238 return ops
239}