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