1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "os"
9 "strings"
10
11 "github.com/patriceckhart/zot/packages/core"
12 "github.com/patriceckhart/zot/packages/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 = `{"type":"object","properties":{"path":{"type":"string","description":"File to modify, given as an absolute path or one relative to the working directory."},"edits":{"type":"array","description":"A set of non-overlapping substitutions, all located in the file content as it existed before this call.","items":{"type":"object","properties":{"oldText":{"type":"string","description":"A verbatim excerpt from the file being modified. Include enough surrounding text to identify exactly one location, without intersecting another edit."},"newText":{"type":"string","description":"Content that will replace the matched excerpt."}},"required":["oldText","newText"]}}},"required":["path","edits"]}`
32
33func (t *EditTool) Name() string { return "edit" }
34func (t *EditTool) Description() string {
35 return "Apply exact substitutions to an existing file. Inspect that file before editing and take every oldText directly from its current contents. Use short excerpts that identify one location; choose write when replacing most or all of a file."
36}
37func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
38
39type editPlan struct {
40 path string
41 final []byte
42 result core.ToolResult
43}
44
45// Preview validates the edit against the current file and returns the exact
46// diff without writing anything. The confirmation UI shows this result before
47// the user decides whether Execute may apply it.
48func (t *EditTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
49 plan, err := t.plan(raw)
50 if err != nil {
51 return core.ToolResult{}, err
52 }
53 return plan.result, nil
54}
55
56func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
57 plan, err := t.plan(raw)
58 if err != nil {
59 return core.ToolResult{}, err
60 }
61 if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil {
62 return core.ToolResult{}, err
63 }
64 return plan.result, nil
65}
66
67func (t *EditTool) plan(raw json.RawMessage) (editPlan, error) {
68 var a editArgs
69 if err := json.Unmarshal(raw, &a); err != nil {
70 return editPlan{}, fmt.Errorf("invalid args: %w", err)
71 }
72 if a.Path == "" {
73 return editPlan{}, fmt.Errorf("path is required")
74 }
75 if len(a.Edits) == 0 {
76 return editPlan{}, fmt.Errorf("at least one edit is required")
77 }
78 path := resolvePath(t.CWD, a.Path)
79 if err := t.Sandbox.CheckWritePath(path); err != nil {
80 return editPlan{}, err
81 }
82
83 orig, err := os.ReadFile(path)
84 if err != nil {
85 return editPlan{}, err
86 }
87
88 // Detect BOM and line endings.
89 var bom []byte
90 if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) {
91 bom = orig[:3]
92 orig = orig[3:]
93 }
94 nl := detectLineEnding(orig)
95 // Normalize to \n for matching.
96 body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
97
98 // Validate all edits first (against original content, not sequentially).
99 for i, e := range a.Edits {
100 if e.OldText == "" {
101 return editPlan{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
102 }
103 if e.OldText == e.NewText {
104 return editPlan{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
105 }
106 count := strings.Count(body, e.OldText)
107 if count == 0 {
108 return editPlan{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)
109 }
110 if count > 1 {
111 return editPlan{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
112 }
113 }
114
115 // Apply atomically (sorted by position so offsets stay valid as we splice).
116 type span struct {
117 start, end int
118 replacement string
119 }
120 spans := make([]span, 0, len(a.Edits))
121 for _, e := range a.Edits {
122 idx := strings.Index(body, e.OldText)
123 spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
124 }
125 // Check for overlaps.
126 for i := 0; i < len(spans); i++ {
127 for j := i + 1; j < len(spans); j++ {
128 if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
129 return editPlan{}, fmt.Errorf("edits overlap; merge them into one edit")
130 }
131 }
132 }
133 // Sort ascending.
134 for i := 1; i < len(spans); i++ {
135 for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- {
136 spans[j-1], spans[j] = spans[j], spans[j-1]
137 }
138 }
139 var out strings.Builder
140 prev := 0
141 for _, s := range spans {
142 out.WriteString(body[prev:s.start])
143 out.WriteString(s.replacement)
144 prev = s.end
145 }
146 out.WriteString(body[prev:])
147 newBody := out.String()
148
149 // Restore line endings.
150 if nl == "\r\n" {
151 newBody = strings.ReplaceAll(newBody, "\n", "\r\n")
152 }
153 final := append([]byte{}, bom...)
154 final = append(final, []byte(newBody)...)
155
156 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
157 // The tool-call header renders the path above the result, so the
158 // result body is just the context diff. Details carries metadata for
159 // programmatic consumers and confirmation previews.
160 result := core.ToolResult{
161 Content: []provider.Content{provider.TextBlock{Text: diff}},
162 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
163 }
164 return editPlan{path: path, final: final, result: result}, nil
165}
166
167func detectLineEnding(b []byte) string {
168 if bytes.Contains(b, []byte("\r\n")) {
169 return "\r\n"
170 }
171 return "\n"
172}
173
174// diffContextLines is the number of unchanged lines kept on each
175// side of an edit when rendering the diff. 3 is the git-diff
176// default and balances readability with transcript size.
177const diffContextLines = 3
178
179// unifiedDiff emits a context diff for the edit tool's result.
180//
181// Shape: each output row is either
182// - " <line>" unchanged context
183// - "-<line>" deletion (from a)
184// - "+<line>" addition (to b)
185// - "..." context break between hunks
186//
187// The legacy "--- name / +++ name" header is omitted because the
188// tool-call header above the result already shows the path. Only
189// lines within diffContextLines of a +/- row are kept; longer
190// runs of unchanged content collapse into a single "..." row so
191// a one-line edit in a thousand-line file produces a short
192// transcript.
193func unifiedDiff(name, a, b string) string {
194 if a == b {
195 return ""
196 }
197 aLines := strings.Split(a, "\n")
198 bLines := strings.Split(b, "\n")
199 ops := diffLines(aLines, bLines)
200
201 // Mark ops that sit within diffContextLines of any +/- op.
202 keep := make([]bool, len(ops))
203 for i, op := range ops {
204 if op.kind == '+' || op.kind == '-' {
205 keep[i] = true
206 for d := 1; d <= diffContextLines; d++ {
207 if i-d >= 0 {
208 keep[i-d] = true
209 }
210 if i+d < len(ops) {
211 keep[i+d] = true
212 }
213 }
214 }
215 }
216
217 var sb strings.Builder
218 prevKept := false
219 anyOutput := false
220 for i, op := range ops {
221 if !keep[i] {
222 if prevKept {
223 sb.WriteString("...\n")
224 prevKept = false
225 }
226 continue
227 }
228 if !prevKept && anyOutput {
229 sb.WriteString("...\n")
230 }
231 switch op.kind {
232 case ' ':
233 fmt.Fprintf(&sb, " %s\n", op.line)
234 case '-':
235 fmt.Fprintf(&sb, "-%s\n", op.line)
236 case '+':
237 fmt.Fprintf(&sb, "+%s\n", op.line)
238 }
239 prevKept = true
240 anyOutput = true
241 }
242 _ = name // header dropped; kept in signature for call-site stability
243 return sb.String()
244}
245
246type diffOp struct {
247 kind byte
248 line string
249}
250
251func diffLines(a, b []string) []diffOp {
252 // LCS table.
253 m, n := len(a), len(b)
254 dp := make([][]int, m+1)
255 for i := range dp {
256 dp[i] = make([]int, n+1)
257 }
258 for i := 0; i < m; i++ {
259 for j := 0; j < n; j++ {
260 if a[i] == b[j] {
261 dp[i+1][j+1] = dp[i][j] + 1
262 } else if dp[i+1][j] >= dp[i][j+1] {
263 dp[i+1][j+1] = dp[i+1][j]
264 } else {
265 dp[i+1][j+1] = dp[i][j+1]
266 }
267 }
268 }
269 // Backtrack.
270 var ops []diffOp
271 i, j := m, n
272 for i > 0 && j > 0 {
273 if a[i-1] == b[j-1] {
274 ops = append([]diffOp{{' ', a[i-1]}}, ops...)
275 i--
276 j--
277 } else if dp[i][j-1] >= dp[i-1][j] {
278 ops = append([]diffOp{{'+', b[j-1]}}, ops...)
279 j--
280 } else {
281 ops = append([]diffOp{{'-', a[i-1]}}, ops...)
282 i--
283 }
284 }
285 for i > 0 {
286 ops = append([]diffOp{{'-', a[i-1]}}, ops...)
287 i--
288 }
289 for j > 0 {
290 ops = append([]diffOp{{'+', b[j-1]}}, ops...)
291 j--
292 }
293 return ops
294}