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