Skip to content

Commit 41feb8c

Browse files
samhvw8daniel-lxs
authored andcommitted
feat: add support for loading rules from global and project-local .roo directories (#5016)
Co-authored-by: Daniel Riccio <[email protected]>
1 parent b6a01e2 commit 41feb8c

File tree

5 files changed

+894
-25
lines changed

5 files changed

+894
-25
lines changed
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import * as path from "path"
2+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
3+
4+
// Use vi.hoisted to ensure mocks are available during hoisting
5+
const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd, mockGetGlobalRooDirectory } =
6+
vi.hoisted(() => ({
7+
mockHomedir: vi.fn(),
8+
mockStat: vi.fn(),
9+
mockReadFile: vi.fn(),
10+
mockReaddir: vi.fn(),
11+
mockGetRooDirectoriesForCwd: vi.fn(),
12+
mockGetGlobalRooDirectory: vi.fn(),
13+
}))
14+
15+
// Mock os module
16+
vi.mock("os", () => ({
17+
default: {
18+
homedir: mockHomedir,
19+
},
20+
homedir: mockHomedir,
21+
}))
22+
23+
// Mock fs/promises
24+
vi.mock("fs/promises", () => ({
25+
default: {
26+
stat: mockStat,
27+
readFile: mockReadFile,
28+
readdir: mockReaddir,
29+
},
30+
}))
31+
32+
// Mock the roo-config service
33+
vi.mock("../../../../services/roo-config", () => ({
34+
getRooDirectoriesForCwd: mockGetRooDirectoriesForCwd,
35+
getGlobalRooDirectory: mockGetGlobalRooDirectory,
36+
}))
37+
38+
import { loadRuleFiles, addCustomInstructions } from "../custom-instructions"
39+
40+
describe("custom-instructions global .roo support", () => {
41+
const mockCwd = "/mock/project"
42+
const mockHomeDir = "/mock/home"
43+
const globalRooDir = path.join(mockHomeDir, ".roo")
44+
const projectRooDir = path.join(mockCwd, ".roo")
45+
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
mockHomedir.mockReturnValue(mockHomeDir)
49+
mockGetRooDirectoriesForCwd.mockReturnValue([globalRooDir, projectRooDir])
50+
mockGetGlobalRooDirectory.mockReturnValue(globalRooDir)
51+
})
52+
53+
afterEach(() => {
54+
vi.restoreAllMocks()
55+
})
56+
57+
describe("loadRuleFiles", () => {
58+
it("should load global rules only when project rules do not exist", async () => {
59+
// Mock directory existence checks in order:
60+
// 1. Check if global rules dir exists
61+
// 2. Check if project rules dir doesn't exist
62+
mockStat
63+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules dir exists
64+
.mockResolvedValueOnce({ isFile: () => true } as any) // for the file check inside readTextFilesFromDirectory
65+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
66+
67+
// Mock directory reading for global rules
68+
mockReaddir.mockResolvedValueOnce([
69+
{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
70+
])
71+
72+
// Mock file reading for the rules.md file
73+
mockReadFile.mockResolvedValueOnce("global rule content")
74+
75+
const result = await loadRuleFiles(mockCwd)
76+
77+
expect(result).toContain("# Rules from")
78+
expect(result).toContain("rules.md:")
79+
expect(result).toContain("global rule content")
80+
expect(result).not.toContain("project rule content")
81+
})
82+
83+
it("should load project rules only when global rules do not exist", async () => {
84+
// Mock directory existence
85+
mockStat
86+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
87+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules dir exists
88+
89+
// Mock directory reading for project rules
90+
mockReaddir.mockResolvedValueOnce([
91+
{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
92+
])
93+
94+
// Mock file reading
95+
mockStat.mockResolvedValueOnce({ isFile: () => true } as any) // for the file check
96+
mockReadFile.mockResolvedValueOnce("project rule content")
97+
98+
const result = await loadRuleFiles(mockCwd)
99+
100+
expect(result).toContain("# Rules from")
101+
expect(result).toContain("rules.md:")
102+
expect(result).toContain("project rule content")
103+
expect(result).not.toContain("global rule content")
104+
})
105+
106+
it("should merge global and project rules with project rules after global", async () => {
107+
// Mock directory existence - both exist
108+
mockStat
109+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules dir exists
110+
.mockResolvedValueOnce({ isFile: () => true } as any) // global file check
111+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules dir exists
112+
.mockResolvedValueOnce({ isFile: () => true } as any) // project file check
113+
114+
// Mock directory reading
115+
mockReaddir
116+
.mockResolvedValueOnce([{ name: "global.md", isFile: () => true, isSymbolicLink: () => false } as any])
117+
.mockResolvedValueOnce([{ name: "project.md", isFile: () => true, isSymbolicLink: () => false } as any])
118+
119+
// Mock file reading
120+
mockReadFile.mockResolvedValueOnce("global rule content").mockResolvedValueOnce("project rule content")
121+
122+
const result = await loadRuleFiles(mockCwd)
123+
124+
expect(result).toContain("# Rules from")
125+
expect(result).toContain("global.md:")
126+
expect(result).toContain("global rule content")
127+
expect(result).toContain("project.md:")
128+
expect(result).toContain("project rule content")
129+
130+
// Ensure project rules come after global rules
131+
const globalIndex = result.indexOf("global rule content")
132+
const projectIndex = result.indexOf("project rule content")
133+
expect(globalIndex).toBeLessThan(projectIndex)
134+
})
135+
136+
it("should fall back to legacy .roorules file when no .roo/rules directories exist", async () => {
137+
// Mock directory existence - neither exist
138+
mockStat
139+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
140+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
141+
142+
// Mock legacy file reading
143+
mockReadFile.mockResolvedValueOnce("legacy rule content")
144+
145+
const result = await loadRuleFiles(mockCwd)
146+
147+
expect(result).toContain("# Rules from .roorules:")
148+
expect(result).toContain("legacy rule content")
149+
})
150+
151+
it("should return empty string when no rules exist anywhere", async () => {
152+
// Mock directory existence - neither exist
153+
mockStat
154+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
155+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
156+
157+
// Mock legacy file reading - both fail (using safeReadFile which catches errors)
158+
// The safeReadFile function catches ENOENT errors and returns empty string
159+
// So we don't need to mock rejections, just empty responses
160+
mockReadFile
161+
.mockResolvedValueOnce("") // .roorules returns empty (simulating ENOENT caught by safeReadFile)
162+
.mockResolvedValueOnce("") // .clinerules returns empty (simulating ENOENT caught by safeReadFile)
163+
164+
const result = await loadRuleFiles(mockCwd)
165+
166+
expect(result).toBe("")
167+
})
168+
})
169+
170+
describe("addCustomInstructions mode-specific rules", () => {
171+
it("should load global and project mode-specific rules", async () => {
172+
const mode = "code"
173+
174+
// Mock directory existence for mode-specific rules
175+
mockStat
176+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules-code dir exists
177+
.mockResolvedValueOnce({ isFile: () => true } as any) // global mode file check
178+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules-code dir exists
179+
.mockResolvedValueOnce({ isFile: () => true } as any) // project mode file check
180+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist (for generic rules)
181+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist (for generic rules)
182+
183+
// Mock directory reading for mode-specific rules
184+
mockReaddir
185+
.mockResolvedValueOnce([
186+
{ name: "global-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
187+
])
188+
.mockResolvedValueOnce([
189+
{ name: "project-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
190+
])
191+
192+
// Mock file reading for mode-specific rules
193+
mockReadFile
194+
.mockResolvedValueOnce("global mode rule content")
195+
.mockResolvedValueOnce("project mode rule content")
196+
.mockResolvedValueOnce("") // .roorules legacy file (empty)
197+
.mockResolvedValueOnce("") // .clinerules legacy file (empty)
198+
199+
const result = await addCustomInstructions("", "", mockCwd, mode)
200+
201+
expect(result).toContain("# Rules from")
202+
expect(result).toContain("global-mode.md:")
203+
expect(result).toContain("global mode rule content")
204+
expect(result).toContain("project-mode.md:")
205+
expect(result).toContain("project mode rule content")
206+
})
207+
208+
it("should fall back to legacy mode-specific files when no mode directories exist", async () => {
209+
const mode = "code"
210+
211+
// Mock directory existence - mode-specific dirs don't exist
212+
mockStat
213+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules-code dir doesn't exist
214+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules-code dir doesn't exist
215+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
216+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
217+
218+
// Mock legacy mode file reading
219+
mockReadFile
220+
.mockResolvedValueOnce("legacy mode rule content") // .roorules-code
221+
.mockResolvedValueOnce("") // generic .roorules (empty)
222+
.mockResolvedValueOnce("") // generic .clinerules (empty)
223+
224+
const result = await addCustomInstructions("", "", mockCwd, mode)
225+
226+
expect(result).toContain("# Rules from .roorules-code:")
227+
expect(result).toContain("legacy mode rule content")
228+
})
229+
})
230+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi } from "vitest"
2+
import * as os from "os"
3+
import * as path from "path"
4+
5+
describe("custom-instructions path detection", () => {
6+
it("should use exact path comparison instead of string includes", () => {
7+
// Test the logic that our fix implements
8+
const fakeHomeDir = "/Users/john.roo.smith"
9+
const globalRooDir = path.join(fakeHomeDir, ".roo") // "/Users/john.roo.smith/.roo"
10+
const projectRooDir = "/projects/my-project/.roo"
11+
12+
// Old implementation (fragile):
13+
// const isGlobal = rooDir.includes(path.join(os.homedir(), ".roo"))
14+
// This could fail if the home directory path contains ".roo" elsewhere
15+
16+
// New implementation (robust):
17+
// const isGlobal = path.resolve(rooDir) === path.resolve(getGlobalRooDirectory())
18+
19+
// Test the new logic
20+
const isGlobalForGlobalDir = path.resolve(globalRooDir) === path.resolve(globalRooDir)
21+
const isGlobalForProjectDir = path.resolve(projectRooDir) === path.resolve(globalRooDir)
22+
23+
expect(isGlobalForGlobalDir).toBe(true)
24+
expect(isGlobalForProjectDir).toBe(false)
25+
26+
// Verify that the old implementation would have been problematic
27+
// if the home directory contained ".roo" in the path
28+
const oldLogicGlobal = globalRooDir.includes(path.join(fakeHomeDir, ".roo"))
29+
const oldLogicProject = projectRooDir.includes(path.join(fakeHomeDir, ".roo"))
30+
31+
expect(oldLogicGlobal).toBe(true) // This works
32+
expect(oldLogicProject).toBe(false) // This also works, but is fragile
33+
34+
// The issue was that if the home directory path itself contained ".roo",
35+
// the includes() check could produce false positives in edge cases
36+
})
37+
38+
it("should handle edge cases with path resolution", () => {
39+
// Test various edge cases that exact path comparison handles better
40+
const testCases = [
41+
{
42+
global: "/Users/test/.roo",
43+
project: "/Users/test/project/.roo",
44+
expected: { global: true, project: false },
45+
},
46+
{
47+
global: "/home/user/.roo",
48+
project: "/home/user/.roo", // Same directory
49+
expected: { global: true, project: true },
50+
},
51+
{
52+
global: "/Users/john.roo.smith/.roo",
53+
project: "/projects/app/.roo",
54+
expected: { global: true, project: false },
55+
},
56+
]
57+
58+
testCases.forEach(({ global, project, expected }) => {
59+
const isGlobalForGlobal = path.resolve(global) === path.resolve(global)
60+
const isGlobalForProject = path.resolve(project) === path.resolve(global)
61+
62+
expect(isGlobalForGlobal).toBe(expected.global)
63+
expect(isGlobalForProject).toBe(expected.project)
64+
})
65+
})
66+
})

0 commit comments

Comments
 (0)