1package tools
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "charm.land/fantasy"
10 "github.com/charmbracelet/crush/internal/filetracker"
11 "github.com/charmbracelet/crush/internal/history"
12 "github.com/charmbracelet/crush/internal/permission"
13 "github.com/charmbracelet/crush/internal/pubsub"
14 "github.com/stretchr/testify/require"
15)
16
17func TestFindBestMatch_ExactMatch(t *testing.T) {
18 t.Parallel()
19
20 content := "func foo() {\n\treturn 1\n}\n"
21 oldString := "func foo() {\n\treturn 1\n}"
22
23 matched, found, isMultiple := findBestMatch(content, oldString)
24 require.True(t, found)
25 require.False(t, isMultiple)
26 require.Equal(t, oldString, matched)
27}
28
29func TestFindBestMatch_TrailingWhitespacePerLine(t *testing.T) {
30 t.Parallel()
31
32 // Content has no trailing spaces, but oldString has trailing spaces.
33 content := "func foo() {\n\treturn 1\n}\n"
34 oldString := "func foo() { \n\treturn 1 \n}"
35
36 matched, found, isMultiple := findBestMatch(content, oldString)
37 require.True(t, found)
38 require.False(t, isMultiple)
39 require.Equal(t, "func foo() {\n\treturn 1\n}", matched)
40}
41
42func TestFindBestMatch_TrailingNewline(t *testing.T) {
43 t.Parallel()
44
45 // Content has trailing newline, oldString doesn't.
46 content := "line1\nline2\n"
47 oldString := "line1\nline2"
48
49 matched, found, isMultiple := findBestMatch(content, oldString)
50 require.True(t, found)
51 require.False(t, isMultiple)
52 require.Equal(t, "line1\nline2", matched)
53}
54
55func TestFindBestMatch_MissingTrailingNewline(t *testing.T) {
56 t.Parallel()
57
58 // Content doesn't have trailing newline after match, but oldString does.
59 content := "line1\nline2"
60 oldString := "line1\nline2\n"
61
62 matched, found, isMultiple := findBestMatch(content, oldString)
63 require.True(t, found)
64 require.False(t, isMultiple)
65 require.Equal(t, "line1\nline2", matched)
66}
67
68func TestFindBestMatch_IndentationDifference(t *testing.T) {
69 t.Parallel()
70
71 // Content uses tabs, oldString uses spaces.
72 content := "func foo() {\n\treturn 1\n}\n"
73 oldString := "func foo() {\n return 1\n}"
74
75 matched, found, isMultiple := findBestMatch(content, oldString)
76 require.True(t, found)
77 require.False(t, isMultiple)
78 require.Equal(t, "func foo() {\n\treturn 1\n}", matched)
79}
80
81func TestFindBestMatch_DifferentIndentLevel(t *testing.T) {
82 t.Parallel()
83
84 // Content has 4-space indent, oldString has 2-space indent.
85 content := "func foo() {\n return 1\n}\n"
86 oldString := "func foo() {\n return 1\n}"
87
88 matched, found, isMultiple := findBestMatch(content, oldString)
89 require.True(t, found)
90 require.False(t, isMultiple)
91 require.Equal(t, "func foo() {\n return 1\n}", matched)
92}
93
94func TestFindBestMatch_CollapseBlankLines(t *testing.T) {
95 t.Parallel()
96
97 // Content has single blank line, oldString has multiple.
98 content := "line1\n\nline2\n"
99 oldString := "line1\n\n\n\nline2"
100
101 matched, found, isMultiple := findBestMatch(content, oldString)
102 require.True(t, found)
103 require.False(t, isMultiple)
104 require.Equal(t, "line1\n\nline2", matched)
105}
106
107func TestFindBestMatch_MultipleMatches(t *testing.T) {
108 t.Parallel()
109
110 content := "foo\nbar\nfoo\n"
111 oldString := "foo"
112
113 matched, found, isMultiple := findBestMatch(content, oldString)
114 require.True(t, found)
115 require.True(t, isMultiple)
116 require.Equal(t, "foo", matched)
117}
118
119func TestFindBestMatch_NoMatch(t *testing.T) {
120 t.Parallel()
121
122 content := "func foo() {\n\treturn 1\n}\n"
123 oldString := "func bar() {\n\treturn 2\n}"
124
125 _, found, _ := findBestMatch(content, oldString)
126 require.False(t, found)
127}
128
129func TestFindBestMatch_StripsViewLineNumbers(t *testing.T) {
130 t.Parallel()
131
132 content := "line1\nline2\nline3\n"
133 oldString := " 1|line1\n 2|line2\n 3|line3"
134
135 matched, found, isMultiple := findBestMatch(content, oldString)
136 require.True(t, found)
137 require.False(t, isMultiple)
138 require.Equal(t, "line1\nline2\nline3", matched)
139}
140
141func TestFindBestMatch_StripsMarkdownCodeFences(t *testing.T) {
142 t.Parallel()
143
144 content := "line1\nline2\n"
145 oldString := "```go\nline1\nline2\n```"
146
147 matched, found, isMultiple := findBestMatch(content, oldString)
148 require.True(t, found)
149 require.False(t, isMultiple)
150 require.Equal(t, "line1\nline2", matched)
151}
152
153func TestFindBestMatch_TrimsSurroundingBlankLines(t *testing.T) {
154 t.Parallel()
155
156 content := "line1\nline2\n"
157 oldString := "\n\nline1\nline2\n\n"
158
159 matched, found, isMultiple := findBestMatch(content, oldString)
160 require.True(t, found)
161 require.False(t, isMultiple)
162 require.Equal(t, "line1\nline2", matched)
163}
164
165func TestFindBestMatch_StripsZeroWidthCharacters(t *testing.T) {
166 t.Parallel()
167
168 content := "line1\nline2\n"
169 oldString := "line\u200b1\nline2"
170
171 matched, found, isMultiple := findBestMatch(content, oldString)
172 require.True(t, found)
173 require.False(t, isMultiple)
174 require.Equal(t, "line1\nline2", matched)
175}
176
177func TestFindBestMatch_ComplexIndentation(t *testing.T) {
178 t.Parallel()
179
180 content := `func example() {
181 if true {
182 doSomething()
183 }
184}
185`
186 // Model provided with wrong indentation (2 spaces instead of tabs).
187 oldString := `func example() {
188 if true {
189 doSomething()
190 }
191}`
192
193 matched, found, isMultiple := findBestMatch(content, oldString)
194 require.True(t, found)
195 require.False(t, isMultiple)
196 require.Contains(t, matched, "\t")
197}
198
199func TestApplyEditToContent_FuzzyMatch(t *testing.T) {
200 t.Parallel()
201
202 // Content uses tabs, edit uses spaces - should still match.
203 content := "func foo() {\n\treturn 1\n}\n"
204
205 newContent, err := applyEditToContent(content, MultiEditOperation{
206 OldString: "func foo() {\n return 1\n}",
207 NewString: "func foo() {\n\treturn 2\n}",
208 })
209 require.NoError(t, err)
210 require.Contains(t, newContent, "return 2")
211}
212
213func TestApplyEditToContent_FuzzyMatchTrailingSpaces(t *testing.T) {
214 t.Parallel()
215
216 content := "line 1\nline 2\nline 3\n"
217
218 // Edit has trailing spaces that don't exist in content.
219 newContent, err := applyEditToContent(content, MultiEditOperation{
220 OldString: "line 1 \nline 2 ",
221 NewString: "LINE 1\nLINE 2",
222 })
223 require.NoError(t, err)
224 require.Contains(t, newContent, "LINE 1")
225 require.Contains(t, newContent, "LINE 2")
226}
227
228func TestApplyEditToContent_FuzzyMatchReplaceAll(t *testing.T) {
229 t.Parallel()
230
231 content := "foo bar\nfoo baz\n"
232
233 // With replaceAll and fuzzy match (trailing space).
234 newContent, err := applyEditToContent(content, MultiEditOperation{
235 OldString: "foo ",
236 NewString: "FOO ",
237 ReplaceAll: true,
238 })
239 require.NoError(t, err)
240 require.Contains(t, newContent, "FOO bar")
241 require.Contains(t, newContent, "FOO baz")
242}
243
244func TestTrimTrailingWhitespacePerLine(t *testing.T) {
245 t.Parallel()
246
247 tests := []struct {
248 name string
249 input string
250 expected string
251 }{
252 {
253 name: "trailing spaces",
254 input: "line1 \nline2 \nline3",
255 expected: "line1\nline2\nline3",
256 },
257 {
258 name: "trailing tabs",
259 input: "line1\t\nline2\t\t\nline3",
260 expected: "line1\nline2\nline3",
261 },
262 {
263 name: "mixed trailing whitespace",
264 input: "line1 \t \nline2\t \nline3 ",
265 expected: "line1\nline2\nline3",
266 },
267 {
268 name: "no trailing whitespace",
269 input: "line1\nline2\nline3",
270 expected: "line1\nline2\nline3",
271 },
272 {
273 name: "preserves leading whitespace",
274 input: " line1 \n\tline2\t\n line3 ",
275 expected: " line1\n\tline2\n line3",
276 },
277 }
278
279 for _, tt := range tests {
280 t.Run(tt.name, func(t *testing.T) {
281 t.Parallel()
282 result := trimTrailingWhitespacePerLine(tt.input)
283 require.Equal(t, tt.expected, result)
284 })
285 }
286}
287
288func TestCollapseBlankLines(t *testing.T) {
289 t.Parallel()
290
291 tests := []struct {
292 name string
293 input string
294 expected string
295 }{
296 {
297 name: "multiple blank lines",
298 input: "line1\n\n\n\nline2",
299 expected: "line1\n\nline2",
300 },
301 {
302 name: "single blank line unchanged",
303 input: "line1\n\nline2",
304 expected: "line1\n\nline2",
305 },
306 {
307 name: "no blank lines",
308 input: "line1\nline2",
309 expected: "line1\nline2",
310 },
311 {
312 name: "many blank lines",
313 input: "line1\n\n\n\n\n\n\nline2",
314 expected: "line1\n\nline2",
315 },
316 }
317
318 for _, tt := range tests {
319 t.Run(tt.name, func(t *testing.T) {
320 t.Parallel()
321 result := collapseBlankLines(tt.input)
322 require.Equal(t, tt.expected, result)
323 })
324 }
325}
326
327// Integration tests that test the actual replaceContent and deleteContent
328// functions with real files.
329
330func newTestEditContext(t *testing.T) (editContext, string) {
331 t.Helper()
332 tmpDir := t.TempDir()
333 permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
334 files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
335 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
336 return editContext{ctx, permissions, files, tmpDir}, tmpDir
337}
338
339func TestEditTool_ReplaceContent_FuzzyIndentation(t *testing.T) {
340 t.Parallel()
341
342 edit, tmpDir := newTestEditContext(t)
343 testFile := filepath.Join(tmpDir, "test.go")
344
345 // File uses tabs for indentation.
346 content := "func foo() {\n\treturn 1\n}\n"
347 err := os.WriteFile(testFile, []byte(content), 0o644)
348 require.NoError(t, err)
349
350 // Simulate reading the file first.
351 filetracker.RecordRead(testFile)
352
353 // Model provides spaces instead of tabs.
354 oldString := "func foo() {\n return 1\n}"
355 newString := "func foo() {\n\treturn 2\n}"
356
357 resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
358 require.NoError(t, err)
359 require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
360
361 // Verify the file was updated.
362 result, err := os.ReadFile(testFile)
363 require.NoError(t, err)
364 require.Contains(t, string(result), "return 2")
365}
366
367func TestEditTool_ReplaceContent_FuzzyTrailingWhitespace(t *testing.T) {
368 t.Parallel()
369
370 edit, tmpDir := newTestEditContext(t)
371 testFile := filepath.Join(tmpDir, "test.txt")
372
373 // File has no trailing whitespace.
374 content := "line 1\nline 2\nline 3\n"
375 err := os.WriteFile(testFile, []byte(content), 0o644)
376 require.NoError(t, err)
377
378 filetracker.RecordRead(testFile)
379
380 // Model provides trailing spaces.
381 oldString := "line 1 \nline 2 "
382 newString := "LINE 1\nLINE 2"
383
384 resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
385 require.NoError(t, err)
386 require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
387
388 result, err := os.ReadFile(testFile)
389 require.NoError(t, err)
390 require.Contains(t, string(result), "LINE 1")
391 require.Contains(t, string(result), "LINE 2")
392}
393
394func TestEditTool_ReplaceContent_FuzzyTrailingNewline(t *testing.T) {
395 t.Parallel()
396
397 edit, tmpDir := newTestEditContext(t)
398 testFile := filepath.Join(tmpDir, "test.txt")
399
400 // File content.
401 content := "hello\nworld\n"
402 err := os.WriteFile(testFile, []byte(content), 0o644)
403 require.NoError(t, err)
404
405 filetracker.RecordRead(testFile)
406
407 // Model omits trailing newline.
408 oldString := "hello\nworld"
409 newString := "HELLO\nWORLD"
410
411 resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
412 require.NoError(t, err)
413 require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
414
415 result, err := os.ReadFile(testFile)
416 require.NoError(t, err)
417 require.Contains(t, string(result), "HELLO")
418 require.Contains(t, string(result), "WORLD")
419}
420
421func TestEditTool_ReplaceContent_ExactMatchStillWorks(t *testing.T) {
422 t.Parallel()
423
424 edit, tmpDir := newTestEditContext(t)
425 testFile := filepath.Join(tmpDir, "test.txt")
426
427 content := "func foo() {\n\treturn 1\n}\n"
428 err := os.WriteFile(testFile, []byte(content), 0o644)
429 require.NoError(t, err)
430
431 filetracker.RecordRead(testFile)
432
433 // Exact match should still work.
434 oldString := "func foo() {\n\treturn 1\n}"
435 newString := "func foo() {\n\treturn 2\n}"
436
437 resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
438 require.NoError(t, err)
439 require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
440
441 result, err := os.ReadFile(testFile)
442 require.NoError(t, err)
443 require.Contains(t, string(result), "return 2")
444}
445
446func TestEditTool_ReplaceContent_NoMatchStillFails(t *testing.T) {
447 t.Parallel()
448
449 edit, tmpDir := newTestEditContext(t)
450 testFile := filepath.Join(tmpDir, "test.txt")
451
452 content := "func foo() {\n\treturn 1\n}\n"
453 err := os.WriteFile(testFile, []byte(content), 0o644)
454 require.NoError(t, err)
455
456 filetracker.RecordRead(testFile)
457
458 // Completely wrong content should still fail.
459 oldString := "func bar() {\n\treturn 999\n}"
460 newString := "func baz() {\n\treturn 0\n}"
461
462 resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
463 require.NoError(t, err)
464 require.True(t, resp.IsError, "expected error for no match")
465 require.Contains(t, resp.Content, "not found")
466}
467
468func TestEditTool_DeleteContent_FuzzyIndentation(t *testing.T) {
469 t.Parallel()
470
471 edit, tmpDir := newTestEditContext(t)
472 testFile := filepath.Join(tmpDir, "test.go")
473
474 // File uses tabs.
475 content := "func foo() {\n\treturn 1\n}\n\nfunc bar() {\n\treturn 2\n}\n"
476 err := os.WriteFile(testFile, []byte(content), 0o644)
477 require.NoError(t, err)
478
479 filetracker.RecordRead(testFile)
480
481 // Model provides spaces instead of tabs.
482 oldString := "func foo() {\n return 1\n}\n\n"
483
484 resp, err := deleteContent(edit, testFile, oldString, false, fantasy.ToolCall{ID: "test"})
485 require.NoError(t, err)
486 require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
487
488 result, err := os.ReadFile(testFile)
489 require.NoError(t, err)
490 require.NotContains(t, string(result), "return 1")
491 require.Contains(t, string(result), "return 2")
492}
493
494func TestEditTool_ReplaceContent_ReplaceAllFuzzy(t *testing.T) {
495 t.Parallel()
496
497 edit, tmpDir := newTestEditContext(t)
498 testFile := filepath.Join(tmpDir, "test.txt")
499
500 content := "foo bar\nfoo baz\nfoo qux\n"
501 err := os.WriteFile(testFile, []byte(content), 0o644)
502 require.NoError(t, err)
503
504 filetracker.RecordRead(testFile)
505
506 // ReplaceAll with exact match.
507 oldString := "foo"
508 newString := "FOO"
509
510 resp, err := replaceContent(edit, testFile, oldString, newString, true, fantasy.ToolCall{ID: "test"})
511 require.NoError(t, err)
512 require.False(t, resp.IsError, "expected no error, got: %s", resp.Content)
513
514 result, err := os.ReadFile(testFile)
515 require.NoError(t, err)
516 require.Contains(t, string(result), "FOO bar")
517 require.Contains(t, string(result), "FOO baz")
518 require.Contains(t, string(result), "FOO qux")
519 require.NotContains(t, string(result), "foo")
520}
521
522func TestEditTool_ReplaceContent_MultipleMatchesFails(t *testing.T) {
523 t.Parallel()
524
525 edit, tmpDir := newTestEditContext(t)
526 testFile := filepath.Join(tmpDir, "test.txt")
527
528 content := "foo\nbar\nfoo\n"
529 err := os.WriteFile(testFile, []byte(content), 0o644)
530 require.NoError(t, err)
531
532 filetracker.RecordRead(testFile)
533
534 // Should fail because "foo" appears multiple times.
535 oldString := "foo"
536 newString := "FOO"
537
538 resp, err := replaceContent(edit, testFile, oldString, newString, false, fantasy.ToolCall{ID: "test"})
539 require.NoError(t, err)
540 require.True(t, resp.IsError, "expected error for multiple matches")
541 require.Contains(t, resp.Content, "multiple times")
542}