返回 DeepSeek-Reasonix
write_tool_test.go
根目录 / internal / tool / builtin / write_tool_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 )
12
13 // --- write_file extended tests ---
14
15 func TestWriteFileCreatesParentDirs(t *testing.T) {
16 dir := t.TempDir()
17 f := filepath.Join(dir, "a", "b", "c", "file.txt")
18 runTool(t, writeFile{}, map[string]any{"path": f, "content": "nested"})
19 got, _ := os.ReadFile(f)
20 if string(got) != "nested" {
21 t.Errorf("content = %q", got)
22 }
23 }
24
25 func TestWriteFileOverwrites(t *testing.T) {
26 f := filepath.Join(t.TempDir(), "x.txt")
27 os.WriteFile(f, []byte("old"), 0o644)
28 runTool(t, writeFile{}, map[string]any{"path": f, "content": "new"})
29 got, _ := os.ReadFile(f)
30 if string(got) != "new" {
31 t.Errorf("after overwrite = %q", got)
32 }
33 }
34
35 func TestWriteFileSameContentNoOp(t *testing.T) {
36 f := filepath.Join(t.TempDir(), "x.txt")
37 os.WriteFile(f, []byte("same"), 0o644)
38 out, err := writeFile{}.Execute(context.Background(), argsJSON(t, map[string]any{"path": f, "content": "same"}))
39 if err != nil {
40 t.Fatalf("Execute: %v", err)
41 }
42 if !strings.Contains(out, "already contains the exact content") {
43 t.Fatalf("same-content write should return a no-op signal, got %q", out)
44 }
45 got, _ := os.ReadFile(f)
46 if string(got) != "same" {
47 t.Errorf("content changed = %q", got)
48 }
49 }
50
51 func TestWriteFileEmptyContent(t *testing.T) {
52 f := filepath.Join(t.TempDir(), "empty.txt")
53 runTool(t, writeFile{}, map[string]any{"path": f, "content": ""})
54 got, _ := os.ReadFile(f)
55 if len(got) != 0 {
56 t.Errorf("expected empty file, got %d bytes", len(got))
57 }
58 }
59
60 func TestWriteFileMissingPath(t *testing.T) {
61 _, err := writeFile{}.Execute(context.Background(), argsJSON(t, map[string]any{"content": "x"}))
62 if err == nil {
63 t.Fatal("expected error for missing path")
64 }
65 }
66
67 func TestWriteFileMissingContent(t *testing.T) {
68 f := filepath.Join(t.TempDir(), "x.txt")
69 // Missing content field should write empty file (content defaults to "").
70 runTool(t, writeFile{}, map[string]any{"path": f})
71 got, _ := os.ReadFile(f)
72 if len(got) != 0 {
73 t.Errorf("missing content should write empty file, got %d bytes", len(got))
74 }
75 }
76
77 func TestWriteFileInvalidArgs(t *testing.T) {
78 _, err := writeFile{}.Execute(context.Background(), json.RawMessage(`{invalid`))
79 if err == nil {
80 t.Fatal("expected error for invalid JSON")
81 }
82 }
83
84 // --- move_file tests ---
85
86 func TestMoveFileMovesIntoParentDir(t *testing.T) {
87 dir := t.TempDir()
88 src := filepath.Join(dir, "a.md")
89 dst := filepath.Join(dir, "docs", "a.md")
90 if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
91 t.Fatal(err)
92 }
93
94 out := runTool(t, moveFile{}, map[string]any{"source_path": src, "destination_path": dst})
95 if !strings.Contains(out, "moved") {
96 t.Fatalf("move_file output = %q, want moved", out)
97 }
98 if _, err := os.Stat(src); !os.IsNotExist(err) {
99 t.Fatalf("source still exists or stat failed: %v", err)
100 }
101 got, err := os.ReadFile(dst)
102 if err != nil {
103 t.Fatal(err)
104 }
105 if string(got) != "hello" {
106 t.Fatalf("destination content = %q, want hello", got)
107 }
108 }
109
110 func TestMoveFileRejectsDestinationExists(t *testing.T) {
111 dir := t.TempDir()
112 src := filepath.Join(dir, "a.md")
113 dst := filepath.Join(dir, "b.md")
114 os.WriteFile(src, []byte("a"), 0o644)
115 os.WriteFile(dst, []byte("b"), 0o644)
116
117 if _, err := (moveFile{}).Execute(context.Background(), argsJSON(t, map[string]any{"source_path": src, "destination_path": dst})); err == nil {
118 t.Fatal("expected error for existing destination")
119 }
120 }
121
122 func TestMoveFileRejectsEscape(t *testing.T) {
123 dir := t.TempDir()
124 outside := t.TempDir()
125 src := filepath.Join(dir, "a.md")
126 if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
127 t.Fatal(err)
128 }
129
130 if _, err := (moveFile{roots: []string{dir}}).Execute(context.Background(), argsJSON(t, map[string]any{
131 "source_path": src,
132 "destination_path": filepath.Join(outside, "a.md"),
133 })); err == nil {
134 t.Fatal("expected error for destination outside workspace")
135 }
136 if _, err := os.Stat(src); err != nil {
137 t.Fatalf("source should remain after refused move: %v", err)
138 }
139 }
140
141 func TestMoveFileSamePathRequiresExistingFile(t *testing.T) {
142 missing := filepath.Join(t.TempDir(), "missing.md")
143 _, err := (moveFile{}).Execute(context.Background(), argsJSON(t, map[string]any{
144 "source_path": missing,
145 "destination_path": missing,
146 }))
147 if err == nil {
148 t.Fatal("expected error for missing source even when source and destination match")
149 }
150 }
151
152 func TestMoveFileAllowsCaseOnlyRename(t *testing.T) {
153 dir := t.TempDir()
154 src := filepath.Join(dir, "caseonly.txt")
155 dst := filepath.Join(dir, "CASEONLY.txt")
156 if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
157 t.Fatal(err)
158 }
159 srcInfo, err := os.Stat(src)
160 if err != nil {
161 t.Fatal(err)
162 }
163 dstInfo, err := os.Stat(dst)
164 if os.IsNotExist(err) {
165 t.Skip("filesystem is case-sensitive")
166 }
167 if err != nil {
168 t.Fatal(err)
169 }
170 if !os.SameFile(srcInfo, dstInfo) {
171 t.Skip("source and destination do not resolve to the same file")
172 }
173
174 runTool(t, moveFile{}, map[string]any{"source_path": src, "destination_path": dst})
175 got, err := os.ReadFile(dst)
176 if err != nil {
177 t.Fatal(err)
178 }
179 if string(got) != "hello" {
180 t.Fatalf("destination content = %q, want hello", got)
181 }
182 }
183
184 func TestMoveFileFallsBackWhenSameFileDestinationRenameFails(t *testing.T) {
185 dir := t.TempDir()
186 src := filepath.Join(dir, "a.md")
187 dst := filepath.Join(dir, "same-file.md")
188 if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
189 t.Fatal(err)
190 }
191 if err := os.Link(src, dst); err != nil {
192 t.Skipf("hard links unavailable: %v", err)
193 }
194
195 oldRename := renameFile
196 renameFile = func(oldpath, newpath string) error {
197 if oldpath == src && newpath == dst {
198 return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: os.ErrExist}
199 }
200 return oldRename(oldpath, newpath)
201 }
202 t.Cleanup(func() { renameFile = oldRename })
203
204 runTool(t, moveFile{}, map[string]any{"source_path": src, "destination_path": dst})
205 if _, err := os.Stat(src); !os.IsNotExist(err) {
206 t.Fatalf("source still exists or stat failed: %v", err)
207 }
208 got, err := os.ReadFile(dst)
209 if err != nil {
210 t.Fatal(err)
211 }
212 if string(got) != "hello" {
213 t.Fatalf("destination content = %q, want hello", got)
214 }
215 }
216
217 func TestMoveFileFallsBackForCrossDeviceRename(t *testing.T) {
218 dir := t.TempDir()
219 src := filepath.Join(dir, "a.md")
220 dst := filepath.Join(dir, "docs", "a.md")
221 if err := os.WriteFile(src, []byte("hello"), 0o640); err != nil {
222 t.Fatal(err)
223 }
224
225 oldRename := renameFile
226 renameFile = func(oldpath, newpath string) error {
227 return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: errors.New("invalid cross-device link")}
228 }
229 t.Cleanup(func() { renameFile = oldRename })
230
231 out := runTool(t, moveFile{}, map[string]any{"source_path": src, "destination_path": dst})
232 if !strings.Contains(out, "moved") {
233 t.Fatalf("move_file output = %q, want moved", out)
234 }
235 if _, err := os.Stat(src); !os.IsNotExist(err) {
236 t.Fatalf("source still exists or stat failed: %v", err)
237 }
238 got, err := os.ReadFile(dst)
239 if err != nil {
240 t.Fatal(err)
241 }
242 if string(got) != "hello" {
243 t.Fatalf("destination content = %q, want hello", got)
244 }
245 }
246
247 // --- edit_file extended tests ---
248
249 func TestEditFileNotFound(t *testing.T) {
250 f := filepath.Join(t.TempDir(), "missing.txt")
251 _, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
252 "path": f, "old_string": "x", "new_string": "y",
253 }))
254 if err == nil {
255 t.Fatal("expected error for missing file")
256 }
257 }
258
259 func TestEditFileOldStringNotFound(t *testing.T) {
260 f := filepath.Join(t.TempDir(), "a.txt")
261 os.WriteFile(f, []byte("hello world"), 0o644)
262 _, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
263 "path": f, "old_string": "nonexistent", "new_string": "x",
264 }))
265 if err == nil {
266 t.Fatal("expected error for old_string not found")
267 }
268 if !strings.Contains(err.Error(), "Re-read the current file") {
269 t.Fatalf("not-found error should include recovery hint, got: %v", err)
270 }
271 // File should be unchanged.
272 got, _ := os.ReadFile(f)
273 if string(got) != "hello world" {
274 t.Errorf("file modified despite error: %q", got)
275 }
276 }
277
278 func TestEditFileNotUniqueReportsMatchingLines(t *testing.T) {
279 f := filepath.Join(t.TempDir(), "map.html")
280 separator := " // ═══════════════════════════════════════"
281 body := strings.Join([]string{
282 separator,
283 "const a = 1;",
284 separator,
285 "const b = 2;",
286 separator,
287 "",
288 }, "\n")
289 os.WriteFile(f, []byte(body), 0o644)
290
291 _, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
292 "path": f, "old_string": separator, "new_string": "// section",
293 }))
294 if err == nil {
295 t.Fatal("expected not-unique error")
296 }
297 for _, want := range []string{"not unique", "matching lines include 1, 3, 5", "repeated separator lines"} {
298 if !strings.Contains(err.Error(), want) {
299 t.Errorf("error should mention %q: %v", want, err)
300 }
301 }
302 }
303
304 func TestEditFileDelete(t *testing.T) {
305 f := filepath.Join(t.TempDir(), "a.txt")
306 os.WriteFile(f, []byte("remove this line\nkeep this\n"), 0o644)
307 out := runTool(t, editFile{}, map[string]any{
308 "path": f, "old_string": "remove this line\n", "new_string": "",
309 })
310 for _, want := range []string{"Actual replacement receipt after write:", "-remove this line", "+<empty>"} {
311 if !strings.Contains(out, want) {
312 t.Fatalf("delete result should contain %q in actual post-write receipt:\n%s", want, out)
313 }
314 }
315 if strings.Contains(out, " keep this") {
316 t.Fatalf("actual receipt should not auto-upload unchanged neighboring lines:\n%s", out)
317 }
318 got, _ := os.ReadFile(f)
319 if string(got) != "keep this\n" {
320 t.Errorf("after delete = %q", got)
321 }
322 }
323
324 func TestEditFileActualDiffIsBounded(t *testing.T) {
325 f := filepath.Join(t.TempDir(), "large.txt")
326 old := strings.Repeat("old line with enough content to grow the diff\n", 300)
327 newText := strings.Repeat("new line with enough content to grow the diff\n", 300)
328 os.WriteFile(f, []byte(old), 0o644)
329
330 out := runTool(t, editFile{}, map[string]any{
331 "path": f, "old_string": old, "new_string": newText,
332 })
333 if len(out) > maxPostWriteReceiptBytes+1024 {
334 t.Fatalf("bounded edit result is too large: %d bytes", len(out))
335 }
336 if !strings.Contains(out, postWriteSpanTruncated) {
337 t.Fatalf("bounded edit result should disclose truncation:\n%s", out)
338 }
339 }
340
341 func TestEditFileReceiptDoesNotExposeUnchangedSameLineContent(t *testing.T) {
342 f := filepath.Join(t.TempDir(), "private.txt")
343 const privateMarker = "PRIVATE_SAME_LINE_CONTEXT_6504"
344 seed := "customer_note=" + privateMarker + " enabled=false\n"
345 os.WriteFile(f, []byte(seed), 0o600)
346
347 out := runTool(t, editFile{}, map[string]any{
348 "path": f, "old_string": "false", "new_string": "true",
349 })
350 if strings.Contains(out, privateMarker) || strings.Contains(out, "customer_note") || strings.Contains(out, "enabled=") {
351 t.Fatalf("replacement receipt exposed unchanged same-line content:\n%s", out)
352 }
353 for _, want := range []string{"-false", "+true"} {
354 if !strings.Contains(out, want) {
355 t.Fatalf("replacement receipt should contain %q:\n%s", want, out)
356 }
357 }
358 got, err := os.ReadFile(f)
359 if err != nil {
360 t.Fatal(err)
361 }
362 want := "customer_note=" + privateMarker + " enabled=true\n"
363 if string(got) != want {
364 t.Fatalf("file = %q, want %q", got, want)
365 }
366 }
367
368 func TestEditFileMissingOldString(t *testing.T) {
369 f := filepath.Join(t.TempDir(), "a.txt")
370 os.WriteFile(f, []byte("content"), 0o644)
371 _, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
372 "path": f, "new_string": "x",
373 }))
374 if err == nil {
375 t.Fatal("expected error for missing old_string")
376 }
377 }
378
379 func TestEditFileMissingPath(t *testing.T) {
380 _, err := editFile{}.Execute(context.Background(), argsJSON(t, map[string]any{
381 "old_string": "x", "new_string": "y",
382 }))
383 if err == nil {
384 t.Fatal("expected error for missing path")
385 }
386 }
387
388 func TestEditFileInvalidArgs(t *testing.T) {
389 _, err := editFile{}.Execute(context.Background(), json.RawMessage(`{invalid`))
390 if err == nil {
391 t.Fatal("expected error for invalid JSON")
392 }
393 }
394
395 // --- multi_edit extended tests ---
396
397 func TestMultiEditEmptyEdits(t *testing.T) {
398 f := filepath.Join(t.TempDir(), "a.txt")
399 os.WriteFile(f, []byte("content"), 0o644)
400 _, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
401 "path": f, "edits": []map[string]any{},
402 }))
403 if err == nil {
404 t.Fatal("expected error for empty edits")
405 }
406 }
407
408 func TestMultiEditMissingPath(t *testing.T) {
409 _, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
410 "edits": []map[string]any{{"old_string": "x", "new_string": "y"}},
411 }))
412 if err == nil {
413 t.Fatal("expected error for missing path")
414 }
415 }
416
417 func TestMultiEditStepNotFound(t *testing.T) {
418 f := filepath.Join(t.TempDir(), "a.txt")
419 os.WriteFile(f, []byte("alpha\nbeta\n"), 0o644)
420 _, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
421 "path": f,
422 "edits": []map[string]any{
423 {"old_string": "alpha", "new_string": "ALPHA"},
424 {"old_string": "nonexistent", "new_string": "x"},
425 },
426 }))
427 if err == nil {
428 t.Fatal("expected error for missing edit step")
429 }
430 for _, want := range []string{"edit 2", "Re-read the current file"} {
431 if !strings.Contains(err.Error(), want) {
432 t.Fatalf("multi_edit error should mention %q, got: %v", want, err)
433 }
434 }
435 // File should be unchanged (atomicity).
436 got, _ := os.ReadFile(f)
437 if string(got) != "alpha\nbeta\n" {
438 t.Errorf("file modified despite error: %q", got)
439 }
440 }
441
442 func TestMultiEditReplaceAll(t *testing.T) {
443 f := filepath.Join(t.TempDir(), "a.txt")
444 os.WriteFile(f, []byte("foo bar foo baz foo"), 0o644)
445 runTool(t, multiEdit{}, map[string]any{
446 "path": f,
447 "edits": []map[string]any{
448 {"old_string": "foo", "new_string": "qux", "replace_all": true},
449 },
450 })
451 got, _ := os.ReadFile(f)
452 if string(got) != "qux bar qux baz qux" {
453 t.Errorf("after replace_all = %q", got)
454 }
455 }
456
457 func TestMultiEditReplaceAllNotFound(t *testing.T) {
458 f := filepath.Join(t.TempDir(), "a.txt")
459 os.WriteFile(f, []byte("hello"), 0o644)
460 _, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
461 "path": f,
462 "edits": []map[string]any{
463 {"old_string": "nonexistent", "new_string": "x", "replace_all": true},
464 },
465 }))
466 if err == nil {
467 t.Fatal("expected error for replace_all with no matches")
468 }
469 }
470
471 func TestMultiEditMissingOldString(t *testing.T) {
472 f := filepath.Join(t.TempDir(), "a.txt")
473 os.WriteFile(f, []byte("content"), 0o644)
474 _, err := multiEdit{}.Execute(context.Background(), argsJSON(t, map[string]any{
475 "path": f,
476 "edits": []map[string]any{
477 {"new_string": "x"},
478 },
479 }))
480 if err == nil {
481 t.Fatal("expected error for missing old_string in edit step")
482 }
483 }
484
485 func TestMultiEditInvalidArgs(t *testing.T) {
486 _, err := multiEdit{}.Execute(context.Background(), json.RawMessage(`{invalid`))
487 if err == nil {
488 t.Fatal("expected error for invalid JSON")
489 }
490 }
491
492 func TestMultiEditChained(t *testing.T) {
493 f := filepath.Join(t.TempDir(), "code.go")
494 os.WriteFile(f, []byte("package old\n\nfunc Old() {\n\tOld()\n}\n"), 0o644)
495 runTool(t, multiEdit{}, map[string]any{
496 "path": f,
497 "edits": []map[string]any{
498 {"old_string": "package old", "new_string": "package new"},
499 {"old_string": "Old", "new_string": "New", "replace_all": true},
500 },
501 })
502 got, _ := os.ReadFile(f)
503 want := "package new\n\nfunc New() {\n\tNew()\n}\n"
504 if string(got) != want {
505 t.Errorf("after chained edits = %q\nwant %q", got, want)
506 }
507 }
508
509 // --- confine tests ---
510
511 func TestConfineRejectsEscape(t *testing.T) {
512 dir := t.TempDir()
513 err := confine([]string{dir}, filepath.Join(dir, "..", "outside", "file.txt"))
514 if err == nil {
515 t.Fatal("expected error for path escaping workspace")
516 }
517 }
518
519 func TestConfineAllowsInside(t *testing.T) {
520 dir := t.TempDir()
521 // confine uses realPath which resolves symlinks, so we need to resolve too.
522 real, _ := filepath.EvalSymlinks(dir)
523 target := filepath.Join(real, "inside", "file.txt")
524 err := confine([]string{real}, target)
525 if err != nil {
526 t.Errorf("should allow path inside workspace: %v", err)
527 }
528 }
529
530 func TestConfineEmptyRootsAllowsAll(t *testing.T) {
531 err := confine(nil, "/any/path")
532 if err != nil {
533 t.Errorf("empty roots should allow all: %v", err)
534 }
535 }
536
537 // --- resolveIn tests ---
538
539 func TestResolveInAbsolute(t *testing.T) {
540 abs := filepath.Join(t.TempDir(), "absolute", "path")
541 got := resolveIn("/workdir", abs)
542 if got != abs {
543 t.Errorf("resolveIn absolute = %q, want %q", got, abs)
544 }
545 }
546
547 func TestResolveInRelative(t *testing.T) {
548 got := resolveIn("/workdir", "relative/path")
549 if got != filepath.Join("/workdir", "relative/path") {
550 t.Errorf("resolveIn relative = %q", got)
551 }
552 }
553
554 func TestResolveInEmptyWorkDir(t *testing.T) {
555 got := resolveIn("", "relative/path")
556 if got != "relative/path" {
557 t.Errorf("resolveIn empty workdir = %q", got)
558 }
559 }
560
560 lines GO