Skip to content

Commit d8b6267

Browse files
committed
Fix #3653: insertContentTool to handle appending content when original file already has newline at end
1 parent 3f3825a commit d8b6267

File tree

2 files changed

+323
-1
lines changed

2 files changed

+323
-1
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import * as path from "path"
2+
import * as fs from "fs/promises"
3+
4+
import { fileExistsAtPath } from "../../../utils/fs"
5+
import { ToolUse, ToolResponse } from "../../../shared/tools"
6+
import { insertContentTool } from "../insertContentTool"
7+
8+
// Mock external dependencies
9+
jest.mock("path", () => {
10+
const originalPath = jest.requireActual("path")
11+
return {
12+
...originalPath,
13+
resolve: jest.fn().mockImplementation((...args) => args.join("/")),
14+
}
15+
})
16+
17+
jest.mock("fs/promises", () => ({
18+
readFile: jest.fn(),
19+
writeFile: jest.fn(),
20+
}))
21+
22+
jest.mock("delay", () => jest.fn())
23+
24+
jest.mock("../../../utils/fs", () => ({
25+
fileExistsAtPath: jest.fn().mockResolvedValue(false),
26+
}))
27+
28+
jest.mock("../../prompts/responses", () => ({
29+
formatResponse: {
30+
toolError: jest.fn((msg) => `Error: ${msg}`),
31+
rooIgnoreError: jest.fn((path) => `Access denied: ${path}`),
32+
createPrettyPatch: jest.fn((_path, original, updated) => `Diff: ${original} -> ${updated}`),
33+
},
34+
}))
35+
36+
jest.mock("../../../utils/path", () => ({
37+
getReadablePath: jest.fn().mockReturnValue("test/path.txt"),
38+
}))
39+
40+
jest.mock("../../ignore/RooIgnoreController", () => ({
41+
RooIgnoreController: class {
42+
initialize() {
43+
return Promise.resolve()
44+
}
45+
validateAccess() {
46+
return true
47+
}
48+
},
49+
}))
50+
51+
// Mock insertGroups from diff/insert-groups
52+
jest.mock("../../diff/insert-groups", () => ({
53+
insertGroups: jest.fn().mockImplementation((lines, groups) => {
54+
let newLines = [...lines]
55+
for (const group of groups) {
56+
const { index, elements } = group
57+
if (index === -1 || index >= newLines.length) {
58+
// Append to end
59+
newLines.push(...elements)
60+
} else if (index < 0) {
61+
// Insert at beginning (index -1 for line 0, but insertGroups expects 0 for beginning)
62+
// This mock simplifies, assuming index -1 is always append.
63+
// For line 1, index is 0.
64+
newLines.splice(0, 0, ...elements)
65+
} else {
66+
newLines.splice(index, 0, ...elements)
67+
}
68+
}
69+
return newLines
70+
}),
71+
}))
72+
73+
describe("insertContentTool", () => {
74+
const testFilePath = "test/file.txt"
75+
const absoluteFilePath = "/test/file.txt"
76+
77+
const mockedFileExistsAtPath = fileExistsAtPath as jest.MockedFunction<typeof fileExistsAtPath>
78+
const mockedFsReadFile = fs.readFile as jest.MockedFunction<typeof fs.readFile>
79+
const mockedPathResolve = path.resolve as jest.MockedFunction<typeof path.resolve>
80+
const mockedInsertGroups = require("../../diff/insert-groups").insertGroups as jest.MockedFunction<any>
81+
82+
let mockCline: any
83+
let mockAskApproval: jest.Mock
84+
let mockHandleError: jest.Mock
85+
let mockPushToolResult: jest.Mock
86+
let mockRemoveClosingTag: jest.Mock
87+
let toolResult: ToolResponse | undefined
88+
89+
beforeEach(() => {
90+
jest.clearAllMocks()
91+
92+
mockedPathResolve.mockReturnValue(absoluteFilePath)
93+
mockedFileExistsAtPath.mockResolvedValue(true) // Assume file exists by default for insert
94+
mockedFsReadFile.mockResolvedValue("") // Default empty file content
95+
96+
mockCline = {
97+
cwd: "/",
98+
consecutiveMistakeCount: 0,
99+
didEditFile: false,
100+
rooIgnoreController: {
101+
validateAccess: jest.fn().mockReturnValue(true),
102+
},
103+
diffViewProvider: {
104+
editType: undefined,
105+
isEditing: false,
106+
originalContent: "",
107+
open: jest.fn().mockResolvedValue(undefined),
108+
update: jest.fn().mockResolvedValue(undefined),
109+
reset: jest.fn().mockResolvedValue(undefined),
110+
revertChanges: jest.fn().mockResolvedValue(undefined),
111+
saveChanges: jest.fn().mockResolvedValue({
112+
newProblemsMessage: "",
113+
userEdits: null,
114+
finalContent: "final content",
115+
}),
116+
scrollToFirstDiff: jest.fn(),
117+
pushToolWriteResult: jest.fn().mockImplementation(async function (
118+
this: any,
119+
task: any,
120+
cwd: string,
121+
isNewFile: boolean,
122+
) {
123+
return "Tool result message"
124+
}),
125+
},
126+
fileContextTracker: {
127+
trackFileContext: jest.fn().mockResolvedValue(undefined),
128+
},
129+
say: jest.fn().mockResolvedValue(undefined),
130+
ask: jest.fn().mockResolvedValue({ response: "yesButtonClicked" }), // Default to approval
131+
recordToolError: jest.fn(),
132+
sayAndCreateMissingParamError: jest.fn().mockResolvedValue("Missing param error"),
133+
}
134+
135+
mockAskApproval = jest.fn().mockResolvedValue(true)
136+
mockHandleError = jest.fn().mockResolvedValue(undefined)
137+
mockRemoveClosingTag = jest.fn((tag, content) => content)
138+
139+
toolResult = undefined
140+
})
141+
142+
async function executeInsertContentTool(
143+
params: Partial<ToolUse["params"]> = {},
144+
options: {
145+
fileExists?: boolean
146+
isPartial?: boolean
147+
accessAllowed?: boolean
148+
fileContent?: string
149+
askApprovalResponse?: "yesButtonClicked" | "noButtonClicked" | string
150+
} = {},
151+
): Promise<ToolResponse | undefined> {
152+
const fileExists = options.fileExists ?? true
153+
const isPartial = options.isPartial ?? false
154+
const accessAllowed = options.accessAllowed ?? true
155+
const fileContent = options.fileContent ?? ""
156+
157+
mockedFileExistsAtPath.mockResolvedValue(fileExists)
158+
mockedFsReadFile.mockResolvedValue(fileContent)
159+
mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed)
160+
mockCline.ask.mockResolvedValue({ response: options.askApprovalResponse ?? "yesButtonClicked" })
161+
162+
const toolUse: ToolUse = {
163+
type: "tool_use",
164+
name: "insert_content",
165+
params: {
166+
path: testFilePath,
167+
line: "1",
168+
content: "New content",
169+
...params,
170+
},
171+
partial: isPartial,
172+
}
173+
174+
await insertContentTool(
175+
mockCline,
176+
toolUse,
177+
mockAskApproval,
178+
mockHandleError,
179+
(result: ToolResponse) => {
180+
toolResult = result
181+
},
182+
mockRemoveClosingTag,
183+
)
184+
185+
return toolResult
186+
}
187+
188+
describe("parameter validation", () => {
189+
it("returns error if path is missing", async () => {
190+
const result = await executeInsertContentTool({ path: undefined })
191+
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("insert_content", "path")
192+
expect(result).toBe("Missing param error")
193+
})
194+
195+
it("returns error if line is missing", async () => {
196+
const result = await executeInsertContentTool({ line: undefined })
197+
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("insert_content", "line")
198+
expect(result).toBe("Missing param error")
199+
})
200+
201+
it("returns error if content is missing", async () => {
202+
const result = await executeInsertContentTool({ content: undefined })
203+
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("insert_content", "content")
204+
expect(result).toBe("Missing param error")
205+
})
206+
207+
it("returns error if line number is invalid (NaN)", async () => {
208+
const result = await executeInsertContentTool({ line: "abc" })
209+
expect(result).toBe("Error: Invalid line number. Must be a non-negative integer.")
210+
})
211+
212+
it("returns error if line number is invalid (negative)", async () => {
213+
const result = await executeInsertContentTool({ line: "-5" })
214+
expect(result).toBe("Error: Invalid line number. Must be a non-negative integer.")
215+
})
216+
})
217+
218+
describe("file existence and access", () => {
219+
it("returns error if file does not exist", async () => {
220+
const result = await executeInsertContentTool({}, { fileExists: false })
221+
expect(mockCline.say).toHaveBeenCalledWith(
222+
"error",
223+
expect.stringContaining(`File does not exist at path: ${absoluteFilePath}`),
224+
)
225+
expect(result).toBe(
226+
`File does not exist at path: ${absoluteFilePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`,
227+
)
228+
})
229+
230+
it("returns error if access is denied by rooIgnoreController", async () => {
231+
const result = await executeInsertContentTool({}, { accessAllowed: false })
232+
expect(mockCline.say).toHaveBeenCalledWith("rooignore_error", testFilePath)
233+
})
234+
})
235+
236+
describe("insertion logic", () => {
237+
it("inserts content at the beginning of an empty file (line 1)", async () => {
238+
const contentToInsert = "Line 1\nLine 2"
239+
await executeInsertContentTool({ line: "1", content: contentToInsert }, { fileContent: "" })
240+
241+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(contentToInsert + "\n", true)
242+
})
243+
244+
it("inserts content at the beginning of a file with content (line 1)", async () => {
245+
const originalContent = "Existing Line 1\nExisting Line 2"
246+
const contentToInsert = "New Line A\nNew Line B"
247+
await executeInsertContentTool({ line: "1", content: contentToInsert }, { fileContent: originalContent })
248+
249+
const expectedContent = "New Line A\nNew Line B\nExisting Line 1\nExisting Line 2"
250+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expectedContent, true)
251+
})
252+
253+
it("appends content to an empty file (line 0)", async () => {
254+
const contentToInsert = "Appended Line 1\nAppended Line 2"
255+
await executeInsertContentTool({ line: "0", content: contentToInsert }, { fileContent: "" })
256+
257+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(contentToInsert, true)
258+
})
259+
260+
it("appends content to a file that does NOT end with a newline (line 0)", async () => {
261+
const originalContent = "Existing Line 1\nExisting Line 2"
262+
const contentToInsert = "Appended Line 1\nAppended Line 2"
263+
await executeInsertContentTool({ line: "0", content: contentToInsert }, { fileContent: originalContent })
264+
265+
const expectedContent = "Existing Line 1\nExisting Line 2\nAppended Line 1\nAppended Line 2"
266+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expectedContent, true)
267+
})
268+
269+
it("appends content to a file that DOES end with a newline (line 0)", async () => {
270+
const originalContent = "Existing Line 1\nExisting Line 2\n" // Ends with newline
271+
const contentToInsert = "Appended Line 1\nAppended Line 2"
272+
await executeInsertContentTool({ line: "0", content: contentToInsert }, { fileContent: originalContent })
273+
274+
// Expected: no extra blank line
275+
const expectedContent = "Existing Line 1\nExisting Line 2\nAppended Line 1\nAppended Line 2"
276+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expectedContent, true)
277+
})
278+
279+
it("handles content with multiple leading carriage returns", async () => {
280+
const originalContent = "Existing Line"
281+
const contentToInsert = "\n\nNew Line"
282+
await executeInsertContentTool({ line: "0", content: contentToInsert }, { fileContent: originalContent })
283+
284+
const expectedContent = "Existing Line\n\n\nNew Line" // Original + 2 newlines + content
285+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expectedContent, true)
286+
})
287+
288+
it("handles content with multiple trailing carriage returns", async () => {
289+
const originalContent = "Existing Line"
290+
const contentToInsert = "New Line\n\n"
291+
await executeInsertContentTool({ line: "0", content: contentToInsert }, { fileContent: originalContent })
292+
293+
const expectedContent = "Existing Line\nNew Line\n\n"
294+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expectedContent, true)
295+
})
296+
297+
it("handles content with both leading and trailing carriage returns", async () => {
298+
const originalContent = "Existing Line"
299+
const contentToInsert = "\n\nNew Line\n\n"
300+
await executeInsertContentTool({ line: "0", content: contentToInsert }, { fileContent: originalContent })
301+
302+
const expectedContent = "Existing Line\n\n\nNew Line\n\n"
303+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expectedContent, true)
304+
})
305+
306+
it("inserts content in the middle of a file", async () => {
307+
const originalContent = "Line 1\nLine 2\nLine 3"
308+
const contentToInsert = "Inserted A\nInserted B"
309+
await executeInsertContentTool({ line: "2", content: contentToInsert }, { fileContent: originalContent })
310+
311+
const expectedContent = "Line 1\nInserted A\nInserted B\nLine 2\nLine 3"
312+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expectedContent, true)
313+
})
314+
})
315+
})

src/core/tools/insertContentTool.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,14 @@ export async function insertContentTool(
9292
const fileContent = await fs.readFile(absolutePath, "utf8")
9393
cline.diffViewProvider.editType = "modify"
9494
cline.diffViewProvider.originalContent = fileContent
95-
const lines = fileContent.split("\n")
95+
let lines = fileContent.split("\n")
96+
97+
// Special handling for appending to the end of the file (line 0)
98+
// If the original file content ends with a newline, split("\n") will result in a trailing empty string.
99+
// When appending, we want to avoid adding an extra blank line.
100+
if (lineNumber === 0 && lines.length > 0 && lines[lines.length - 1] === "") {
101+
lines.pop() // Remove the trailing empty string
102+
}
96103

97104
const updatedContent = insertGroups(lines, [
98105
{

0 commit comments

Comments
 (0)