Skip to content

Commit fb237c8

Browse files
committed
feat: add support for loading rules from global and project-local .roo directories
1 parent 64901c8 commit fb237c8

File tree

4 files changed

+602
-17
lines changed

4 files changed

+602
-17
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+
// Access the original console.log that was saved in vitest.setup.ts
5+
const originalConsoleLog = (global as any).originalConsoleLog || console.error
6+
7+
// Use vi.hoisted to ensure mocks are available during hoisting
8+
const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd } = vi.hoisted(() => ({
9+
mockHomedir: vi.fn(),
10+
mockStat: vi.fn(),
11+
mockReadFile: vi.fn(),
12+
mockReaddir: vi.fn(),
13+
mockGetRooDirectoriesForCwd: vi.fn(),
14+
}))
15+
16+
// Mock os module
17+
vi.mock("os", () => ({
18+
default: {
19+
homedir: mockHomedir,
20+
},
21+
homedir: mockHomedir,
22+
}))
23+
24+
// Mock fs/promises
25+
vi.mock("fs/promises", () => ({
26+
default: {
27+
stat: mockStat,
28+
readFile: mockReadFile,
29+
readdir: mockReaddir,
30+
},
31+
}))
32+
33+
// Mock the roo-config service
34+
vi.mock("../../../../services/roo-config", () => ({
35+
getRooDirectoriesForCwd: mockGetRooDirectoriesForCwd,
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+
})
51+
52+
afterEach(() => {
53+
vi.restoreAllMocks()
54+
})
55+
56+
describe("loadRuleFiles", () => {
57+
it("should load global rules only when project rules do not exist", async () => {
58+
// Mock directory existence checks in order:
59+
// 1. Check if global rules dir exists
60+
// 2. Check if project rules dir doesn't exist
61+
mockStat
62+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules dir exists
63+
.mockResolvedValueOnce({ isFile: () => true } as any) // for the file check inside readTextFilesFromDirectory
64+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
65+
66+
// Mock directory reading for global rules
67+
mockReaddir.mockResolvedValueOnce([
68+
{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
69+
])
70+
71+
// Mock file reading for the rules.md file
72+
mockReadFile.mockResolvedValueOnce("global rule content")
73+
74+
const result = await loadRuleFiles(mockCwd)
75+
76+
originalConsoleLog("Result:", result)
77+
originalConsoleLog("mockStat calls:", mockStat.mock.calls)
78+
originalConsoleLog("mockReaddir calls:", mockReaddir.mock.calls)
79+
originalConsoleLog("mockReadFile calls:", mockReadFile.mock.calls)
80+
81+
expect(result).toContain("# Global rules:")
82+
expect(result).toContain("global rule content")
83+
expect(result).not.toContain("# Project-specific rules:")
84+
})
85+
86+
it("should load project rules only when global rules do not exist", async () => {
87+
// Mock directory existence
88+
mockStat
89+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
90+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules dir exists
91+
92+
// Mock directory reading for project rules
93+
mockReaddir.mockResolvedValueOnce([
94+
{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
95+
])
96+
97+
// Mock file reading
98+
mockStat.mockResolvedValueOnce({ isFile: () => true } as any) // for the file check
99+
mockReadFile.mockResolvedValueOnce("project rule content")
100+
101+
const result = await loadRuleFiles(mockCwd)
102+
103+
expect(result).toContain("# Project-specific rules:")
104+
expect(result).toContain("project rule content")
105+
expect(result).not.toContain("# Global rules:")
106+
})
107+
108+
it("should merge global and project rules with project rules after global", async () => {
109+
// Mock directory existence - both exist
110+
mockStat
111+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules dir exists
112+
.mockResolvedValueOnce({ isFile: () => true } as any) // global file check
113+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules dir exists
114+
.mockResolvedValueOnce({ isFile: () => true } as any) // project file check
115+
116+
// Mock directory reading
117+
mockReaddir
118+
.mockResolvedValueOnce([{ name: "global.md", isFile: () => true, isSymbolicLink: () => false } as any])
119+
.mockResolvedValueOnce([{ name: "project.md", isFile: () => true, isSymbolicLink: () => false } as any])
120+
121+
// Mock file reading
122+
mockReadFile.mockResolvedValueOnce("global rule content").mockResolvedValueOnce("project rule content")
123+
124+
const result = await loadRuleFiles(mockCwd)
125+
126+
expect(result).toContain("# Global rules:")
127+
expect(result).toContain("global rule content")
128+
expect(result).toContain("# Project-specific rules:")
129+
expect(result).toContain("project rule content")
130+
131+
// Ensure project rules come after global rules
132+
const globalIndex = result.indexOf("# Global rules:")
133+
const projectIndex = result.indexOf("# Project-specific rules:")
134+
expect(globalIndex).toBeLessThan(projectIndex)
135+
})
136+
137+
it("should fall back to legacy .roorules file when no .roo/rules directories exist", async () => {
138+
// Mock directory existence - neither exist
139+
mockStat
140+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
141+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
142+
143+
// Mock legacy file reading
144+
mockReadFile.mockResolvedValueOnce("legacy rule content")
145+
146+
const result = await loadRuleFiles(mockCwd)
147+
148+
expect(result).toContain("# Rules from .roorules:")
149+
expect(result).toContain("legacy rule content")
150+
})
151+
152+
it("should return empty string when no rules exist anywhere", async () => {
153+
// Mock directory existence - neither exist
154+
mockStat
155+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
156+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
157+
158+
// Mock legacy file reading - both fail (using safeReadFile which catches errors)
159+
// The safeReadFile function catches ENOENT errors and returns empty string
160+
// So we don't need to mock rejections, just empty responses
161+
mockReadFile
162+
.mockResolvedValueOnce("") // .roorules returns empty (simulating ENOENT caught by safeReadFile)
163+
.mockResolvedValueOnce("") // .clinerules returns empty (simulating ENOENT caught by safeReadFile)
164+
165+
const result = await loadRuleFiles(mockCwd)
166+
167+
expect(result).toBe("")
168+
})
169+
})
170+
171+
describe("addCustomInstructions mode-specific rules", () => {
172+
it("should load global and project mode-specific rules", async () => {
173+
const mode = "code"
174+
175+
// Mock directory existence for mode-specific rules
176+
mockStat
177+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules-code dir exists
178+
.mockResolvedValueOnce({ isFile: () => true } as any) // global mode file check
179+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules-code dir exists
180+
.mockResolvedValueOnce({ isFile: () => true } as any) // project mode file check
181+
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist (for generic rules)
182+
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist (for generic rules)
183+
184+
// Mock directory reading for mode-specific rules
185+
mockReaddir
186+
.mockResolvedValueOnce([
187+
{ name: "global-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
188+
])
189+
.mockResolvedValueOnce([
190+
{ name: "project-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
191+
])
192+
193+
// Mock file reading for mode-specific rules
194+
mockReadFile
195+
.mockResolvedValueOnce("global mode rule content")
196+
.mockResolvedValueOnce("project mode rule content")
197+
.mockResolvedValueOnce("") // .roorules legacy file (empty)
198+
.mockResolvedValueOnce("") // .clinerules legacy file (empty)
199+
200+
const result = await addCustomInstructions("", "", mockCwd, mode)
201+
202+
expect(result).toContain("# Global mode-specific rules:")
203+
expect(result).toContain("global mode rule content")
204+
expect(result).toContain("# Project-specific mode-specific rules:")
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+
})

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

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import fs from "fs/promises"
22
import path from "path"
3+
import * as os from "os"
34
import { Dirent } from "fs"
45

56
import { isLanguage } from "@roo-code/types"
67

78
import { LANGUAGES } from "../../../shared/language"
9+
import { getRooDirectoriesForCwd } from "../../../services/roo-config"
810

911
/**
1012
* Safely read a file and return its trimmed content
@@ -155,19 +157,33 @@ function formatDirectoryContent(dirPath: string, files: Array<{ filename: string
155157
}
156158

157159
/**
158-
* Load rule files from the specified directory
160+
* Load rule files from global and project-local directories
161+
* Global rules are loaded first, then project-local rules which can override global ones
159162
*/
160163
export async function loadRuleFiles(cwd: string): Promise<string> {
161-
// Check for .roo/rules/ directory
162-
const rooRulesDir = path.join(cwd, ".roo", "rules")
163-
if (await directoryExists(rooRulesDir)) {
164-
const files = await readTextFilesFromDirectory(rooRulesDir)
165-
if (files.length > 0) {
166-
return formatDirectoryContent(rooRulesDir, files)
164+
const rules: string[] = []
165+
const rooDirectories = getRooDirectoriesForCwd(cwd)
166+
167+
// Check for .roo/rules/ directories in order (global first, then project-local)
168+
for (const rooDir of rooDirectories) {
169+
const rulesDir = path.join(rooDir, "rules")
170+
if (await directoryExists(rulesDir)) {
171+
const files = await readTextFilesFromDirectory(rulesDir)
172+
if (files.length > 0) {
173+
const isGlobal = rooDir.includes(path.join(os.homedir(), ".roo"))
174+
const prefix = isGlobal ? "# Global rules" : "# Project-specific rules"
175+
const content = formatDirectoryContent(rulesDir, files)
176+
rules.push(`${prefix}:\n${content}`)
177+
}
167178
}
168179
}
169180

170-
// Fall back to existing behavior
181+
// If we found rules in .roo/rules/ directories, return them
182+
if (rules.length > 0) {
183+
return "\n\n" + rules.join("\n\n")
184+
}
185+
186+
// Fall back to existing behavior for legacy .roorules/.clinerules files
171187
const ruleFiles = [".roorules", ".clinerules"]
172188

173189
for (const file of ruleFiles) {
@@ -194,18 +210,29 @@ export async function addCustomInstructions(
194210
let usedRuleFile = ""
195211

196212
if (mode) {
197-
// Check for .roo/rules-${mode}/ directory
198-
const modeRulesDir = path.join(cwd, ".roo", `rules-${mode}`)
199-
if (await directoryExists(modeRulesDir)) {
200-
const files = await readTextFilesFromDirectory(modeRulesDir)
201-
if (files.length > 0) {
202-
modeRuleContent = formatDirectoryContent(modeRulesDir, files)
203-
usedRuleFile = modeRulesDir
213+
const modeRules: string[] = []
214+
const rooDirectories = getRooDirectoriesForCwd(cwd)
215+
216+
// Check for .roo/rules-${mode}/ directories in order (global first, then project-local)
217+
for (const rooDir of rooDirectories) {
218+
const modeRulesDir = path.join(rooDir, `rules-${mode}`)
219+
if (await directoryExists(modeRulesDir)) {
220+
const files = await readTextFilesFromDirectory(modeRulesDir)
221+
if (files.length > 0) {
222+
const isGlobal = rooDir.includes(path.join(os.homedir(), ".roo"))
223+
const prefix = isGlobal ? "# Global mode-specific rules" : "# Project-specific mode-specific rules"
224+
const content = formatDirectoryContent(modeRulesDir, files)
225+
modeRules.push(`${prefix}:\n${content}`)
226+
}
204227
}
205228
}
206229

207-
// If no directory exists, fall back to existing behavior
208-
if (!modeRuleContent) {
230+
// If we found mode-specific rules in .roo/rules-${mode}/ directories, use them
231+
if (modeRules.length > 0) {
232+
modeRuleContent = "\n\n" + modeRules.join("\n\n")
233+
usedRuleFile = `rules-${mode} directories`
234+
} else {
235+
// Fall back to existing behavior for legacy files
209236
const rooModeRuleFile = `.roorules-${mode}`
210237
modeRuleContent = await safeReadFile(path.join(cwd, rooModeRuleFile))
211238
if (modeRuleContent) {

0 commit comments

Comments
 (0)