edit_fuzzy_test.go

  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}