@@ -274,6 +274,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
+ normalizedNewContent := newContent
if isCrlf {
newContent, _ = fsext.ToWindowsLineEndings(newContent)
}
@@ -300,7 +301,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
}
}
// Store the new version
- _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent)
if err != nil {
slog.Error("Error creating file history version", "error", err)
}
@@ -311,7 +312,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
fantasy.NewTextResponse("Content deleted from file: "+filePath),
EditResponseMetadata{
OldContent: oldContent,
- NewContent: newContent,
+ NewContent: normalizedNewContent,
Additions: additions,
Removals: removals,
},
@@ -405,6 +406,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
+ normalizedNewContent := newContent
if isCrlf {
newContent, _ = fsext.ToWindowsLineEndings(newContent)
}
@@ -431,7 +433,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
}
}
// Store the new version
- _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent)
if err != nil {
slog.Error("Error creating file history version", "error", err)
}
@@ -442,7 +444,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
fantasy.NewTextResponse("Content replaced in file: "+filePath),
EditResponseMetadata{
OldContent: oldContent,
- NewContent: newContent,
+ NewContent: normalizedNewContent,
Additions: additions,
Removals: removals,
}), nil
@@ -340,6 +340,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
+ normalizedNewContent := currentContent
if isCrlf {
currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
}
@@ -367,7 +368,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
}
// Store the new version
- _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, normalizedNewContent)
if err != nil {
slog.Error("Error creating file history version", "error", err)
}
@@ -385,7 +386,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
fantasy.NewTextResponse(message),
MultiEditResponseMetadata{
OldContent: oldContent,
- NewContent: currentContent,
+ NewContent: normalizedNewContent,
Additions: additions,
Removals: removals,
EditsApplied: editsApplied,
@@ -0,0 +1,131 @@
+package diff
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGenerateDiff(t *testing.T) {
+ t.Parallel()
+
+ before := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, World!\")\n\tfmt.Println(\"Line 2\")\n}\n"
+ after := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, Go!\")\n\tfmt.Println(\"Line 2\")\n}\n"
+
+ t.Run("LF_before_LF_after", func(t *testing.T) {
+ t.Parallel()
+
+ diff, additions, removals := GenerateDiff(before, after, "main.go")
+
+ require.Equal(t, 1, additions)
+ require.Equal(t, 1, removals)
+ require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")")
+ require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")")
+ })
+
+ t.Run("CRLF_before_LF_after", func(t *testing.T) {
+ t.Parallel()
+
+ crlfBefore := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, World!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n"
+ diff, additions, removals := GenerateDiff(crlfBefore, after, "main.go")
+
+ require.Equal(t, 1, additions, "CRLF before should not inflate counts")
+ require.Equal(t, 1, removals, "CRLF before should not inflate counts")
+ require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")")
+ require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")")
+ })
+
+ t.Run("LF_before_CRLF_after", func(t *testing.T) {
+ t.Parallel()
+
+ crlfAfter := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, Go!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n"
+ diff, additions, removals := GenerateDiff(before, crlfAfter, "main.go")
+
+ require.Equal(t, 1, additions, "CRLF after should not inflate counts")
+ require.Equal(t, 1, removals, "CRLF after should not inflate counts")
+ require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")")
+ require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")")
+ })
+
+ t.Run("CRLF_before_CRLF_after", func(t *testing.T) {
+ t.Parallel()
+
+ crlfBefore := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, World!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n"
+ crlfAfter := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, Go!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n"
+ diff, additions, removals := GenerateDiff(crlfBefore, crlfAfter, "main.go")
+
+ require.Equal(t, 1, additions)
+ require.Equal(t, 1, removals)
+ require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")")
+ require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")")
+ })
+
+ t.Run("mixed_line_endings", func(t *testing.T) {
+ t.Parallel()
+
+ mixedBefore := "line1\r\nline2\nline3\r\nline4\n"
+ mixedAfter := "line1\nline2\nchanged\nline4\n"
+ diff, additions, removals := GenerateDiff(mixedBefore, mixedAfter, "test.txt")
+
+ require.Equal(t, 1, additions)
+ require.Equal(t, 1, removals)
+ require.Contains(t, diff, "-line3")
+ require.Contains(t, diff, "+changed")
+ })
+
+ t.Run("identical_content_different_endings", func(t *testing.T) {
+ t.Parallel()
+
+ lfContent := "line1\nline2\nline3\n"
+ crlfContent := "line1\r\nline2\r\nline3\r\n"
+ diff, additions, removals := GenerateDiff(lfContent, crlfContent, "test.txt")
+
+ require.Equal(t, 0, additions, "identical content with different line endings should produce no diff")
+ require.Equal(t, 0, removals, "identical content with different line endings should produce no diff")
+ require.Empty(t, diff)
+ })
+
+ t.Run("tabs_are_not_normalized", func(t *testing.T) {
+ t.Parallel()
+
+ tabContent := "\tfoo\n"
+ spaceContent := " foo\n"
+ diff, additions, removals := GenerateDiff(tabContent, spaceContent, "test.txt")
+
+ require.Equal(t, 1, additions, "tab vs space should be a real diff")
+ require.Equal(t, 1, removals, "tab vs space should be a real diff")
+ require.NotEmpty(t, diff)
+ })
+
+ t.Run("empty_before", func(t *testing.T) {
+ t.Parallel()
+
+ diff, additions, removals := GenerateDiff("", "line1\nline2\n", "new.txt")
+
+ require.Equal(t, 2, additions)
+ require.Equal(t, 0, removals)
+ require.Contains(t, diff, "+line1")
+ require.Contains(t, diff, "+line2")
+ })
+
+ t.Run("empty_after", func(t *testing.T) {
+ t.Parallel()
+
+ diff, additions, removals := GenerateDiff("line1\nline2\n", "", "deleted.txt")
+
+ require.Equal(t, 0, additions)
+ require.Equal(t, 2, removals)
+ require.Contains(t, diff, "-line1")
+ require.Contains(t, diff, "-line2")
+ })
+
+ t.Run("leading_slash_trimmed", func(t *testing.T) {
+ t.Parallel()
+
+ diff, _, _ := GenerateDiff("a\n", "b\n", "/src/main.go")
+
+ require.Contains(t, diff, "a/src/main.go")
+ require.Contains(t, diff, "b/src/main.go")
+ require.NotContains(t, diff, "a//src/main.go")
+ })
+}