Skip to content

Commit 6ae11dd

Browse files
authored
Merge pull request RooCodeInc#1093 from RooVetGit/jm/handle-directory
Handle errors more gracefully when reading custom instructions from files
2 parents d327c21 + 007bf18 commit 6ae11dd

File tree

3 files changed

+298
-169
lines changed

3 files changed

+298
-169
lines changed
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { loadRuleFiles, addCustomInstructions } from "../custom-instructions"
2+
import fs from "fs/promises"
3+
4+
// Mock fs/promises
5+
jest.mock("fs/promises")
6+
const mockedFs = jest.mocked(fs)
7+
8+
describe("loadRuleFiles", () => {
9+
beforeEach(() => {
10+
jest.clearAllMocks()
11+
})
12+
13+
it("should read and trim file content", async () => {
14+
mockedFs.readFile.mockResolvedValue(" content with spaces ")
15+
const result = await loadRuleFiles("/fake/path")
16+
expect(mockedFs.readFile).toHaveBeenCalled()
17+
expect(result).toBe(
18+
"\n# Rules from .clinerules:\ncontent with spaces\n" +
19+
"\n# Rules from .cursorrules:\ncontent with spaces\n" +
20+
"\n# Rules from .windsurfrules:\ncontent with spaces\n",
21+
)
22+
})
23+
24+
it("should handle ENOENT error", async () => {
25+
mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
26+
const result = await loadRuleFiles("/fake/path")
27+
expect(result).toBe("")
28+
})
29+
30+
it("should handle EISDIR error", async () => {
31+
mockedFs.readFile.mockRejectedValue({ code: "EISDIR" })
32+
const result = await loadRuleFiles("/fake/path")
33+
expect(result).toBe("")
34+
})
35+
36+
it("should throw on unexpected errors", async () => {
37+
const error = new Error("Permission denied") as NodeJS.ErrnoException
38+
error.code = "EPERM"
39+
mockedFs.readFile.mockRejectedValue(error)
40+
41+
await expect(async () => {
42+
await loadRuleFiles("/fake/path")
43+
}).rejects.toThrow()
44+
})
45+
})
46+
47+
describe("loadRuleFiles", () => {
48+
beforeEach(() => {
49+
jest.clearAllMocks()
50+
})
51+
52+
it("should combine content from multiple rule files when they exist", async () => {
53+
mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => {
54+
if (filePath.toString().endsWith(".clinerules")) {
55+
return Promise.resolve("cline rules content")
56+
}
57+
if (filePath.toString().endsWith(".cursorrules")) {
58+
return Promise.resolve("cursor rules content")
59+
}
60+
return Promise.reject({ code: "ENOENT" })
61+
}) as any)
62+
63+
const result = await loadRuleFiles("/fake/path")
64+
expect(result).toBe(
65+
"\n# Rules from .clinerules:\ncline rules content\n" +
66+
"\n# Rules from .cursorrules:\ncursor rules content\n",
67+
)
68+
})
69+
70+
it("should handle when no rule files exist", async () => {
71+
mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
72+
73+
const result = await loadRuleFiles("/fake/path")
74+
expect(result).toBe("")
75+
})
76+
77+
it("should throw on unexpected errors", async () => {
78+
const error = new Error("Permission denied") as NodeJS.ErrnoException
79+
error.code = "EPERM"
80+
mockedFs.readFile.mockRejectedValue(error)
81+
82+
await expect(async () => {
83+
await loadRuleFiles("/fake/path")
84+
}).rejects.toThrow()
85+
})
86+
87+
it("should skip directories with same name as rule files", async () => {
88+
mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => {
89+
if (filePath.toString().endsWith(".clinerules")) {
90+
return Promise.reject({ code: "EISDIR" })
91+
}
92+
if (filePath.toString().endsWith(".cursorrules")) {
93+
return Promise.resolve("cursor rules content")
94+
}
95+
return Promise.reject({ code: "ENOENT" })
96+
}) as any)
97+
98+
const result = await loadRuleFiles("/fake/path")
99+
expect(result).toBe("\n# Rules from .cursorrules:\ncursor rules content\n")
100+
})
101+
})
102+
103+
describe("addCustomInstructions", () => {
104+
beforeEach(() => {
105+
jest.clearAllMocks()
106+
})
107+
108+
it("should combine all instruction types when provided", async () => {
109+
mockedFs.readFile.mockResolvedValue("mode specific rules")
110+
111+
const result = await addCustomInstructions(
112+
"mode instructions",
113+
"global instructions",
114+
"/fake/path",
115+
"test-mode",
116+
{ preferredLanguage: "Spanish" },
117+
)
118+
119+
expect(result).toContain("Language Preference:")
120+
expect(result).toContain("Spanish")
121+
expect(result).toContain("Global Instructions:\nglobal instructions")
122+
expect(result).toContain("Mode-specific Instructions:\nmode instructions")
123+
expect(result).toContain("Rules from .clinerules-test-mode:\nmode specific rules")
124+
})
125+
126+
it("should return empty string when no instructions provided", async () => {
127+
mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
128+
129+
const result = await addCustomInstructions("", "", "/fake/path", "", {})
130+
expect(result).toBe("")
131+
})
132+
133+
it("should handle missing mode-specific rules file", async () => {
134+
mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
135+
136+
const result = await addCustomInstructions(
137+
"mode instructions",
138+
"global instructions",
139+
"/fake/path",
140+
"test-mode",
141+
)
142+
143+
expect(result).toContain("Global Instructions:")
144+
expect(result).toContain("Mode-specific Instructions:")
145+
expect(result).not.toContain("Rules from .clinerules-test-mode")
146+
})
147+
148+
it("should throw on unexpected errors", async () => {
149+
const error = new Error("Permission denied") as NodeJS.ErrnoException
150+
error.code = "EPERM"
151+
mockedFs.readFile.mockRejectedValue(error)
152+
153+
await expect(async () => {
154+
await addCustomInstructions("", "", "/fake/path", "test-mode")
155+
}).rejects.toThrow()
156+
})
157+
158+
it("should skip mode-specific rule files that are directories", async () => {
159+
mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => {
160+
if (filePath.toString().includes(".clinerules-test-mode")) {
161+
return Promise.reject({ code: "EISDIR" })
162+
}
163+
return Promise.reject({ code: "ENOENT" })
164+
}) as any)
165+
166+
const result = await addCustomInstructions(
167+
"mode instructions",
168+
"global instructions",
169+
"/fake/path",
170+
"test-mode",
171+
)
172+
173+
expect(result).toContain("Global Instructions:\nglobal instructions")
174+
expect(result).toContain("Mode-specific Instructions:\nmode instructions")
175+
expect(result).not.toContain("Rules from .clinerules-test-mode")
176+
})
177+
})

src/core/prompts/sections/custom-instructions.ts

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
import fs from "fs/promises"
22
import path from "path"
33

4+
async function safeReadFile(filePath: string): Promise<string> {
5+
try {
6+
const content = await fs.readFile(filePath, "utf-8")
7+
return content.trim()
8+
} catch (err) {
9+
const errorCode = (err as NodeJS.ErrnoException).code
10+
if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) {
11+
throw err
12+
}
13+
return ""
14+
}
15+
}
16+
417
export async function loadRuleFiles(cwd: string): Promise<string> {
518
const ruleFiles = [".clinerules", ".cursorrules", ".windsurfrules"]
619
let combinedRules = ""
720

821
for (const file of ruleFiles) {
9-
try {
10-
const content = await fs.readFile(path.join(cwd, file), "utf-8")
11-
if (content.trim()) {
12-
combinedRules += `\n# Rules from ${file}:\n${content.trim()}\n`
13-
}
14-
} catch (err) {
15-
// Silently skip if file doesn't exist
16-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
17-
throw err
18-
}
22+
const content = await safeReadFile(path.join(cwd, file))
23+
if (content) {
24+
combinedRules += `\n# Rules from ${file}:\n${content}\n`
1925
}
2026
}
2127

@@ -34,18 +40,8 @@ export async function addCustomInstructions(
3440
// Load mode-specific rules if mode is provided
3541
let modeRuleContent = ""
3642
if (mode) {
37-
try {
38-
const modeRuleFile = `.clinerules-${mode}`
39-
const content = await fs.readFile(path.join(cwd, modeRuleFile), "utf-8")
40-
if (content.trim()) {
41-
modeRuleContent = content.trim()
42-
}
43-
} catch (err) {
44-
// Silently skip if file doesn't exist
45-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
46-
throw err
47-
}
48-
}
43+
const modeRuleFile = `.clinerules-${mode}`
44+
modeRuleContent = await safeReadFile(path.join(cwd, modeRuleFile))
4945
}
5046

5147
// Add language preference if provided

0 commit comments

Comments
 (0)