mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add support for loading rules from global and project-local .roo directories (#5016)
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
parent
ee751af5c6
commit
c8b92e0789
5 changed files with 894 additions and 25 deletions
|
|
@ -0,0 +1,230 @@
|
|||
import * as path from "path"
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
|
||||
// Use vi.hoisted to ensure mocks are available during hoisting
|
||||
const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd, mockGetGlobalRooDirectory } =
|
||||
vi.hoisted(() => ({
|
||||
mockHomedir: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
mockReadFile: vi.fn(),
|
||||
mockReaddir: vi.fn(),
|
||||
mockGetRooDirectoriesForCwd: vi.fn(),
|
||||
mockGetGlobalRooDirectory: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock os module
|
||||
vi.mock("os", () => ({
|
||||
default: {
|
||||
homedir: mockHomedir,
|
||||
},
|
||||
homedir: mockHomedir,
|
||||
}))
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
stat: mockStat,
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the roo-config service
|
||||
vi.mock("../../../../services/roo-config", () => ({
|
||||
getRooDirectoriesForCwd: mockGetRooDirectoriesForCwd,
|
||||
getGlobalRooDirectory: mockGetGlobalRooDirectory,
|
||||
}))
|
||||
|
||||
import { loadRuleFiles, addCustomInstructions } from "../custom-instructions"
|
||||
|
||||
describe("custom-instructions global .roo support", () => {
|
||||
const mockCwd = "/mock/project"
|
||||
const mockHomeDir = "/mock/home"
|
||||
const globalRooDir = path.join(mockHomeDir, ".roo")
|
||||
const projectRooDir = path.join(mockCwd, ".roo")
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHomedir.mockReturnValue(mockHomeDir)
|
||||
mockGetRooDirectoriesForCwd.mockReturnValue([globalRooDir, projectRooDir])
|
||||
mockGetGlobalRooDirectory.mockReturnValue(globalRooDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("loadRuleFiles", () => {
|
||||
it("should load global rules only when project rules do not exist", async () => {
|
||||
// Mock directory existence checks in order:
|
||||
// 1. Check if global rules dir exists
|
||||
// 2. Check if project rules dir doesn't exist
|
||||
mockStat
|
||||
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules dir exists
|
||||
.mockResolvedValueOnce({ isFile: () => true } as any) // for the file check inside readTextFilesFromDirectory
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
|
||||
|
||||
// Mock directory reading for global rules
|
||||
mockReaddir.mockResolvedValueOnce([
|
||||
{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
|
||||
])
|
||||
|
||||
// Mock file reading for the rules.md file
|
||||
mockReadFile.mockResolvedValueOnce("global rule content")
|
||||
|
||||
const result = await loadRuleFiles(mockCwd)
|
||||
|
||||
expect(result).toContain("# Rules from")
|
||||
expect(result).toContain("rules.md:")
|
||||
expect(result).toContain("global rule content")
|
||||
expect(result).not.toContain("project rule content")
|
||||
})
|
||||
|
||||
it("should load project rules only when global rules do not exist", async () => {
|
||||
// Mock directory existence
|
||||
mockStat
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
|
||||
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules dir exists
|
||||
|
||||
// Mock directory reading for project rules
|
||||
mockReaddir.mockResolvedValueOnce([
|
||||
{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
|
||||
])
|
||||
|
||||
// Mock file reading
|
||||
mockStat.mockResolvedValueOnce({ isFile: () => true } as any) // for the file check
|
||||
mockReadFile.mockResolvedValueOnce("project rule content")
|
||||
|
||||
const result = await loadRuleFiles(mockCwd)
|
||||
|
||||
expect(result).toContain("# Rules from")
|
||||
expect(result).toContain("rules.md:")
|
||||
expect(result).toContain("project rule content")
|
||||
expect(result).not.toContain("global rule content")
|
||||
})
|
||||
|
||||
it("should merge global and project rules with project rules after global", async () => {
|
||||
// Mock directory existence - both exist
|
||||
mockStat
|
||||
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules dir exists
|
||||
.mockResolvedValueOnce({ isFile: () => true } as any) // global file check
|
||||
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules dir exists
|
||||
.mockResolvedValueOnce({ isFile: () => true } as any) // project file check
|
||||
|
||||
// Mock directory reading
|
||||
mockReaddir
|
||||
.mockResolvedValueOnce([{ name: "global.md", isFile: () => true, isSymbolicLink: () => false } as any])
|
||||
.mockResolvedValueOnce([{ name: "project.md", isFile: () => true, isSymbolicLink: () => false } as any])
|
||||
|
||||
// Mock file reading
|
||||
mockReadFile.mockResolvedValueOnce("global rule content").mockResolvedValueOnce("project rule content")
|
||||
|
||||
const result = await loadRuleFiles(mockCwd)
|
||||
|
||||
expect(result).toContain("# Rules from")
|
||||
expect(result).toContain("global.md:")
|
||||
expect(result).toContain("global rule content")
|
||||
expect(result).toContain("project.md:")
|
||||
expect(result).toContain("project rule content")
|
||||
|
||||
// Ensure project rules come after global rules
|
||||
const globalIndex = result.indexOf("global rule content")
|
||||
const projectIndex = result.indexOf("project rule content")
|
||||
expect(globalIndex).toBeLessThan(projectIndex)
|
||||
})
|
||||
|
||||
it("should fall back to legacy .roorules file when no .roo/rules directories exist", async () => {
|
||||
// Mock directory existence - neither exist
|
||||
mockStat
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
|
||||
|
||||
// Mock legacy file reading
|
||||
mockReadFile.mockResolvedValueOnce("legacy rule content")
|
||||
|
||||
const result = await loadRuleFiles(mockCwd)
|
||||
|
||||
expect(result).toContain("# Rules from .roorules:")
|
||||
expect(result).toContain("legacy rule content")
|
||||
})
|
||||
|
||||
it("should return empty string when no rules exist anywhere", async () => {
|
||||
// Mock directory existence - neither exist
|
||||
mockStat
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
|
||||
|
||||
// Mock legacy file reading - both fail (using safeReadFile which catches errors)
|
||||
// The safeReadFile function catches ENOENT errors and returns empty string
|
||||
// So we don't need to mock rejections, just empty responses
|
||||
mockReadFile
|
||||
.mockResolvedValueOnce("") // .roorules returns empty (simulating ENOENT caught by safeReadFile)
|
||||
.mockResolvedValueOnce("") // .clinerules returns empty (simulating ENOENT caught by safeReadFile)
|
||||
|
||||
const result = await loadRuleFiles(mockCwd)
|
||||
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("addCustomInstructions mode-specific rules", () => {
|
||||
it("should load global and project mode-specific rules", async () => {
|
||||
const mode = "code"
|
||||
|
||||
// Mock directory existence for mode-specific rules
|
||||
mockStat
|
||||
.mockResolvedValueOnce({ isDirectory: () => true } as any) // global rules-code dir exists
|
||||
.mockResolvedValueOnce({ isFile: () => true } as any) // global mode file check
|
||||
.mockResolvedValueOnce({ isDirectory: () => true } as any) // project rules-code dir exists
|
||||
.mockResolvedValueOnce({ isFile: () => true } as any) // project mode file check
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist (for generic rules)
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist (for generic rules)
|
||||
|
||||
// Mock directory reading for mode-specific rules
|
||||
mockReaddir
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "global-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "project-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
|
||||
])
|
||||
|
||||
// Mock file reading for mode-specific rules
|
||||
mockReadFile
|
||||
.mockResolvedValueOnce("global mode rule content")
|
||||
.mockResolvedValueOnce("project mode rule content")
|
||||
.mockResolvedValueOnce("") // .roorules legacy file (empty)
|
||||
.mockResolvedValueOnce("") // .clinerules legacy file (empty)
|
||||
|
||||
const result = await addCustomInstructions("", "", mockCwd, mode)
|
||||
|
||||
expect(result).toContain("# Rules from")
|
||||
expect(result).toContain("global-mode.md:")
|
||||
expect(result).toContain("global mode rule content")
|
||||
expect(result).toContain("project-mode.md:")
|
||||
expect(result).toContain("project mode rule content")
|
||||
})
|
||||
|
||||
it("should fall back to legacy mode-specific files when no mode directories exist", async () => {
|
||||
const mode = "code"
|
||||
|
||||
// Mock directory existence - mode-specific dirs don't exist
|
||||
mockStat
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // global rules-code dir doesn't exist
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // project rules-code dir doesn't exist
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // global rules dir doesn't exist
|
||||
.mockRejectedValueOnce(new Error("ENOENT")) // project rules dir doesn't exist
|
||||
|
||||
// Mock legacy mode file reading
|
||||
mockReadFile
|
||||
.mockResolvedValueOnce("legacy mode rule content") // .roorules-code
|
||||
.mockResolvedValueOnce("") // generic .roorules (empty)
|
||||
.mockResolvedValueOnce("") // generic .clinerules (empty)
|
||||
|
||||
const result = await addCustomInstructions("", "", mockCwd, mode)
|
||||
|
||||
expect(result).toContain("# Rules from .roorules-code:")
|
||||
expect(result).toContain("legacy mode rule content")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
describe("custom-instructions path detection", () => {
|
||||
it("should use exact path comparison instead of string includes", () => {
|
||||
// Test the logic that our fix implements
|
||||
const fakeHomeDir = "/Users/john.roo.smith"
|
||||
const globalRooDir = path.join(fakeHomeDir, ".roo") // "/Users/john.roo.smith/.roo"
|
||||
const projectRooDir = "/projects/my-project/.roo"
|
||||
|
||||
// Old implementation (fragile):
|
||||
// const isGlobal = rooDir.includes(path.join(os.homedir(), ".roo"))
|
||||
// This could fail if the home directory path contains ".roo" elsewhere
|
||||
|
||||
// New implementation (robust):
|
||||
// const isGlobal = path.resolve(rooDir) === path.resolve(getGlobalRooDirectory())
|
||||
|
||||
// Test the new logic
|
||||
const isGlobalForGlobalDir = path.resolve(globalRooDir) === path.resolve(globalRooDir)
|
||||
const isGlobalForProjectDir = path.resolve(projectRooDir) === path.resolve(globalRooDir)
|
||||
|
||||
expect(isGlobalForGlobalDir).toBe(true)
|
||||
expect(isGlobalForProjectDir).toBe(false)
|
||||
|
||||
// Verify that the old implementation would have been problematic
|
||||
// if the home directory contained ".roo" in the path
|
||||
const oldLogicGlobal = globalRooDir.includes(path.join(fakeHomeDir, ".roo"))
|
||||
const oldLogicProject = projectRooDir.includes(path.join(fakeHomeDir, ".roo"))
|
||||
|
||||
expect(oldLogicGlobal).toBe(true) // This works
|
||||
expect(oldLogicProject).toBe(false) // This also works, but is fragile
|
||||
|
||||
// The issue was that if the home directory path itself contained ".roo",
|
||||
// the includes() check could produce false positives in edge cases
|
||||
})
|
||||
|
||||
it("should handle edge cases with path resolution", () => {
|
||||
// Test various edge cases that exact path comparison handles better
|
||||
const testCases = [
|
||||
{
|
||||
global: "/Users/test/.roo",
|
||||
project: "/Users/test/project/.roo",
|
||||
expected: { global: true, project: false },
|
||||
},
|
||||
{
|
||||
global: "/home/user/.roo",
|
||||
project: "/home/user/.roo", // Same directory
|
||||
expected: { global: true, project: true },
|
||||
},
|
||||
{
|
||||
global: "/Users/john.roo.smith/.roo",
|
||||
project: "/projects/app/.roo",
|
||||
expected: { global: true, project: false },
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ global, project, expected }) => {
|
||||
const isGlobalForGlobal = path.resolve(global) === path.resolve(global)
|
||||
const isGlobalForProject = path.resolve(project) === path.resolve(global)
|
||||
|
||||
expect(isGlobalForGlobal).toBe(expected.global)
|
||||
expect(isGlobalForProject).toBe(expected.project)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import * as os from "os"
|
||||
import { Dirent } from "fs"
|
||||
|
||||
import { isLanguage } from "@roo-code/types"
|
||||
|
||||
import { LANGUAGES } from "../../../shared/language"
|
||||
import { getRooDirectoriesForCwd, getGlobalRooDirectory } from "../../../services/roo-config"
|
||||
|
||||
/**
|
||||
* Safely read a file and return its trimmed content
|
||||
|
|
@ -144,30 +146,39 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
|
|||
function formatDirectoryContent(dirPath: string, files: Array<{ filename: string; content: string }>): string {
|
||||
if (files.length === 0) return ""
|
||||
|
||||
return (
|
||||
"\n\n" +
|
||||
files
|
||||
.map((file) => {
|
||||
return `# Rules from ${file.filename}:\n${file.content}`
|
||||
})
|
||||
.join("\n\n")
|
||||
)
|
||||
return files
|
||||
.map((file) => {
|
||||
return `# Rules from ${file.filename}:\n${file.content}`
|
||||
})
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Load rule files from the specified directory
|
||||
* Load rule files from global and project-local directories
|
||||
* Global rules are loaded first, then project-local rules which can override global ones
|
||||
*/
|
||||
export async function loadRuleFiles(cwd: string): Promise<string> {
|
||||
// Check for .roo/rules/ directory
|
||||
const rooRulesDir = path.join(cwd, ".roo", "rules")
|
||||
if (await directoryExists(rooRulesDir)) {
|
||||
const files = await readTextFilesFromDirectory(rooRulesDir)
|
||||
if (files.length > 0) {
|
||||
return formatDirectoryContent(rooRulesDir, files)
|
||||
const rules: string[] = []
|
||||
const rooDirectories = getRooDirectoriesForCwd(cwd)
|
||||
|
||||
// Check for .roo/rules/ directories in order (global first, then project-local)
|
||||
for (const rooDir of rooDirectories) {
|
||||
const rulesDir = path.join(rooDir, "rules")
|
||||
if (await directoryExists(rulesDir)) {
|
||||
const files = await readTextFilesFromDirectory(rulesDir)
|
||||
if (files.length > 0) {
|
||||
const content = formatDirectoryContent(rulesDir, files)
|
||||
rules.push(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to existing behavior
|
||||
// If we found rules in .roo/rules/ directories, return them
|
||||
if (rules.length > 0) {
|
||||
return "\n" + rules.join("\n\n")
|
||||
}
|
||||
|
||||
// Fall back to existing behavior for legacy .roorules/.clinerules files
|
||||
const ruleFiles = [".roorules", ".clinerules"]
|
||||
|
||||
for (const file of ruleFiles) {
|
||||
|
|
@ -194,18 +205,27 @@ export async function addCustomInstructions(
|
|||
let usedRuleFile = ""
|
||||
|
||||
if (mode) {
|
||||
// Check for .roo/rules-${mode}/ directory
|
||||
const modeRulesDir = path.join(cwd, ".roo", `rules-${mode}`)
|
||||
if (await directoryExists(modeRulesDir)) {
|
||||
const files = await readTextFilesFromDirectory(modeRulesDir)
|
||||
if (files.length > 0) {
|
||||
modeRuleContent = formatDirectoryContent(modeRulesDir, files)
|
||||
usedRuleFile = modeRulesDir
|
||||
const modeRules: string[] = []
|
||||
const rooDirectories = getRooDirectoriesForCwd(cwd)
|
||||
|
||||
// Check for .roo/rules-${mode}/ directories in order (global first, then project-local)
|
||||
for (const rooDir of rooDirectories) {
|
||||
const modeRulesDir = path.join(rooDir, `rules-${mode}`)
|
||||
if (await directoryExists(modeRulesDir)) {
|
||||
const files = await readTextFilesFromDirectory(modeRulesDir)
|
||||
if (files.length > 0) {
|
||||
const content = formatDirectoryContent(modeRulesDir, files)
|
||||
modeRules.push(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no directory exists, fall back to existing behavior
|
||||
if (!modeRuleContent) {
|
||||
// If we found mode-specific rules in .roo/rules-${mode}/ directories, use them
|
||||
if (modeRules.length > 0) {
|
||||
modeRuleContent = "\n" + modeRules.join("\n\n")
|
||||
usedRuleFile = `rules-${mode} directories`
|
||||
} else {
|
||||
// Fall back to existing behavior for legacy files
|
||||
const rooModeRuleFile = `.roorules-${mode}`
|
||||
modeRuleContent = await safeReadFile(path.join(cwd, rooModeRuleFile))
|
||||
if (modeRuleContent) {
|
||||
|
|
|
|||
301
src/services/roo-config/__tests__/index.spec.ts
Normal file
301
src/services/roo-config/__tests__/index.spec.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import * as path from "path"
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
|
||||
// Use vi.hoisted to ensure mocks are available during hoisting
|
||||
const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({
|
||||
mockStat: vi.fn(),
|
||||
mockReadFile: vi.fn(),
|
||||
mockHomedir: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock fs/promises module
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
stat: mockStat,
|
||||
readFile: mockReadFile,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock os module
|
||||
vi.mock("os", () => ({
|
||||
homedir: mockHomedir,
|
||||
}))
|
||||
|
||||
import {
|
||||
getGlobalRooDirectory,
|
||||
getProjectRooDirectoryForCwd,
|
||||
directoryExists,
|
||||
fileExists,
|
||||
readFileIfExists,
|
||||
getRooDirectoriesForCwd,
|
||||
loadConfiguration,
|
||||
} from "../index"
|
||||
|
||||
describe("RooConfigService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHomedir.mockReturnValue("/mock/home")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("getGlobalRooDirectory", () => {
|
||||
it("should return correct path for global .roo directory", () => {
|
||||
const result = getGlobalRooDirectory()
|
||||
expect(result).toBe(path.join("/mock/home", ".roo"))
|
||||
})
|
||||
|
||||
it("should handle different home directories", () => {
|
||||
mockHomedir.mockReturnValue("/different/home")
|
||||
const result = getGlobalRooDirectory()
|
||||
expect(result).toBe(path.join("/different/home", ".roo"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("getProjectRooDirectoryForCwd", () => {
|
||||
it("should return correct path for given cwd", () => {
|
||||
const cwd = "/custom/project/path"
|
||||
const result = getProjectRooDirectoryForCwd(cwd)
|
||||
expect(result).toBe(path.join(cwd, ".roo"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryExists", () => {
|
||||
it("should return true for existing directory", async () => {
|
||||
mockStat.mockResolvedValue({ isDirectory: () => true } as any)
|
||||
|
||||
const result = await directoryExists("/some/path")
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockStat).toHaveBeenCalledWith("/some/path")
|
||||
})
|
||||
|
||||
it("should return false for non-existing path", async () => {
|
||||
const error = new Error("ENOENT") as any
|
||||
error.code = "ENOENT"
|
||||
mockStat.mockRejectedValue(error)
|
||||
|
||||
const result = await directoryExists("/non/existing/path")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for ENOTDIR error", async () => {
|
||||
const error = new Error("ENOTDIR") as any
|
||||
error.code = "ENOTDIR"
|
||||
mockStat.mockRejectedValue(error)
|
||||
|
||||
const result = await directoryExists("/not/a/directory")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should throw unexpected errors", async () => {
|
||||
const error = new Error("Permission denied") as any
|
||||
error.code = "EACCES"
|
||||
mockStat.mockRejectedValue(error)
|
||||
|
||||
await expect(directoryExists("/permission/denied")).rejects.toThrow("Permission denied")
|
||||
})
|
||||
|
||||
it("should return false for files", async () => {
|
||||
mockStat.mockResolvedValue({ isDirectory: () => false } as any)
|
||||
|
||||
const result = await directoryExists("/some/file.txt")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fileExists", () => {
|
||||
it("should return true for existing file", async () => {
|
||||
mockStat.mockResolvedValue({ isFile: () => true } as any)
|
||||
|
||||
const result = await fileExists("/some/file.txt")
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockStat).toHaveBeenCalledWith("/some/file.txt")
|
||||
})
|
||||
|
||||
it("should return false for non-existing file", async () => {
|
||||
const error = new Error("ENOENT") as any
|
||||
error.code = "ENOENT"
|
||||
mockStat.mockRejectedValue(error)
|
||||
|
||||
const result = await fileExists("/non/existing/file.txt")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for ENOTDIR error", async () => {
|
||||
const error = new Error("ENOTDIR") as any
|
||||
error.code = "ENOTDIR"
|
||||
mockStat.mockRejectedValue(error)
|
||||
|
||||
const result = await fileExists("/not/a/directory/file.txt")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should throw unexpected errors", async () => {
|
||||
const error = new Error("Permission denied") as any
|
||||
error.code = "EACCES"
|
||||
mockStat.mockRejectedValue(error)
|
||||
|
||||
await expect(fileExists("/permission/denied/file.txt")).rejects.toThrow("Permission denied")
|
||||
})
|
||||
|
||||
it("should return false for directories", async () => {
|
||||
mockStat.mockResolvedValue({ isFile: () => false } as any)
|
||||
|
||||
const result = await fileExists("/some/directory")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("readFileIfExists", () => {
|
||||
it("should return file content for existing file", async () => {
|
||||
mockReadFile.mockResolvedValue("file content")
|
||||
|
||||
const result = await readFileIfExists("/some/file.txt")
|
||||
|
||||
expect(result).toBe("file content")
|
||||
expect(mockReadFile).toHaveBeenCalledWith("/some/file.txt", "utf-8")
|
||||
})
|
||||
|
||||
it("should return null for non-existing file", async () => {
|
||||
const error = new Error("ENOENT") as any
|
||||
error.code = "ENOENT"
|
||||
mockReadFile.mockRejectedValue(error)
|
||||
|
||||
const result = await readFileIfExists("/non/existing/file.txt")
|
||||
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("should return null for ENOTDIR error", async () => {
|
||||
const error = new Error("ENOTDIR") as any
|
||||
error.code = "ENOTDIR"
|
||||
mockReadFile.mockRejectedValue(error)
|
||||
|
||||
const result = await readFileIfExists("/not/a/directory/file.txt")
|
||||
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("should return null for EISDIR error", async () => {
|
||||
const error = new Error("EISDIR") as any
|
||||
error.code = "EISDIR"
|
||||
mockReadFile.mockRejectedValue(error)
|
||||
|
||||
const result = await readFileIfExists("/is/a/directory")
|
||||
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("should throw unexpected errors", async () => {
|
||||
const error = new Error("Permission denied") as any
|
||||
error.code = "EACCES"
|
||||
mockReadFile.mockRejectedValue(error)
|
||||
|
||||
await expect(readFileIfExists("/permission/denied/file.txt")).rejects.toThrow("Permission denied")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRooDirectoriesForCwd", () => {
|
||||
it("should return directories for given cwd", () => {
|
||||
const cwd = "/custom/project/path"
|
||||
|
||||
const result = getRooDirectoriesForCwd(cwd)
|
||||
|
||||
expect(result).toEqual([path.join("/mock/home", ".roo"), path.join(cwd, ".roo")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadConfiguration", () => {
|
||||
it("should load global configuration only when project does not exist", async () => {
|
||||
const error = new Error("ENOENT") as any
|
||||
error.code = "ENOENT"
|
||||
mockReadFile.mockResolvedValueOnce("global content").mockRejectedValueOnce(error)
|
||||
|
||||
const result = await loadConfiguration("rules/rules.md", "/project/path")
|
||||
|
||||
expect(result).toEqual({
|
||||
global: "global content",
|
||||
project: null,
|
||||
merged: "global content",
|
||||
})
|
||||
})
|
||||
|
||||
it("should load project configuration only when global does not exist", async () => {
|
||||
const error = new Error("ENOENT") as any
|
||||
error.code = "ENOENT"
|
||||
mockReadFile.mockRejectedValueOnce(error).mockResolvedValueOnce("project content")
|
||||
|
||||
const result = await loadConfiguration("rules/rules.md", "/project/path")
|
||||
|
||||
expect(result).toEqual({
|
||||
global: null,
|
||||
project: "project content",
|
||||
merged: "project content",
|
||||
})
|
||||
})
|
||||
|
||||
it("should merge global and project configurations with project overriding global", async () => {
|
||||
mockReadFile.mockResolvedValueOnce("global content").mockResolvedValueOnce("project content")
|
||||
|
||||
const result = await loadConfiguration("rules/rules.md", "/project/path")
|
||||
|
||||
expect(result).toEqual({
|
||||
global: "global content",
|
||||
project: "project content",
|
||||
merged: "global content\n\n# Project-specific rules (override global):\n\nproject content",
|
||||
})
|
||||
})
|
||||
|
||||
it("should return empty merged content when neither exists", async () => {
|
||||
const error = new Error("ENOENT") as any
|
||||
error.code = "ENOENT"
|
||||
mockReadFile.mockRejectedValueOnce(error).mockRejectedValueOnce(error)
|
||||
|
||||
const result = await loadConfiguration("rules/rules.md", "/project/path")
|
||||
|
||||
expect(result).toEqual({
|
||||
global: null,
|
||||
project: null,
|
||||
merged: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("should propagate unexpected errors from global file read", async () => {
|
||||
const error = new Error("Permission denied") as any
|
||||
error.code = "EACCES"
|
||||
mockReadFile.mockRejectedValueOnce(error)
|
||||
|
||||
await expect(loadConfiguration("rules/rules.md", "/project/path")).rejects.toThrow("Permission denied")
|
||||
})
|
||||
|
||||
it("should propagate unexpected errors from project file read", async () => {
|
||||
const globalError = new Error("ENOENT") as any
|
||||
globalError.code = "ENOENT"
|
||||
const projectError = new Error("Permission denied") as any
|
||||
projectError.code = "EACCES"
|
||||
|
||||
mockReadFile.mockRejectedValueOnce(globalError).mockRejectedValueOnce(projectError)
|
||||
|
||||
await expect(loadConfiguration("rules/rules.md", "/project/path")).rejects.toThrow("Permission denied")
|
||||
})
|
||||
|
||||
it("should use correct file paths", async () => {
|
||||
mockReadFile.mockResolvedValue("content")
|
||||
|
||||
await loadConfiguration("rules/rules.md", "/project/path")
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith(path.join("/mock/home", ".roo", "rules/rules.md"), "utf-8")
|
||||
expect(mockReadFile).toHaveBeenCalledWith(path.join("/project/path", ".roo", "rules/rules.md"), "utf-8")
|
||||
})
|
||||
})
|
||||
})
|
||||
252
src/services/roo-config/index.ts
Normal file
252
src/services/roo-config/index.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
import fs from "fs/promises"
|
||||
|
||||
/**
|
||||
* Gets the global .roo directory path based on the current platform
|
||||
*
|
||||
* @returns The absolute path to the global .roo directory
|
||||
*
|
||||
* @example Platform-specific paths:
|
||||
* ```
|
||||
* // macOS/Linux: ~/.roo/
|
||||
* // Example: /Users/john/.roo
|
||||
*
|
||||
* // Windows: %USERPROFILE%\.roo\
|
||||
* // Example: C:\Users\john\.roo
|
||||
* ```
|
||||
*
|
||||
* @example Usage:
|
||||
* ```typescript
|
||||
* const globalDir = getGlobalRooDirectory()
|
||||
* // Returns: "/Users/john/.roo" (on macOS/Linux)
|
||||
* // Returns: "C:\\Users\\john\\.roo" (on Windows)
|
||||
* ```
|
||||
*/
|
||||
export function getGlobalRooDirectory(): string {
|
||||
const homeDir = os.homedir()
|
||||
return path.join(homeDir, ".roo")
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the project-local .roo directory path for a given cwd
|
||||
*
|
||||
* @param cwd - Current working directory (project path)
|
||||
* @returns The absolute path to the project-local .roo directory
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const projectDir = getProjectRooDirectoryForCwd('/Users/john/my-project')
|
||||
* // Returns: "/Users/john/my-project/.roo"
|
||||
*
|
||||
* const windowsProjectDir = getProjectRooDirectoryForCwd('C:\\Users\\john\\my-project')
|
||||
* // Returns: "C:\\Users\\john\\my-project\\.roo"
|
||||
* ```
|
||||
*
|
||||
* @example Directory structure:
|
||||
* ```
|
||||
* /Users/john/my-project/
|
||||
* ├── .roo/ # Project-local configuration directory
|
||||
* │ ├── rules/
|
||||
* │ │ └── rules.md
|
||||
* │ ├── custom-instructions.md
|
||||
* │ └── config/
|
||||
* │ └── settings.json
|
||||
* ├── src/
|
||||
* │ └── index.ts
|
||||
* └── package.json
|
||||
* ```
|
||||
*/
|
||||
export function getProjectRooDirectoryForCwd(cwd: string): string {
|
||||
return path.join(cwd, ".roo")
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a directory exists
|
||||
*/
|
||||
export async function directoryExists(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(dirPath)
|
||||
return stat.isDirectory()
|
||||
} catch (error: any) {
|
||||
// Only catch expected "not found" errors
|
||||
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
|
||||
return false
|
||||
}
|
||||
// Re-throw unexpected errors (permission, I/O, etc.)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file exists
|
||||
*/
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
return stat.isFile()
|
||||
} catch (error: any) {
|
||||
// Only catch expected "not found" errors
|
||||
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
|
||||
return false
|
||||
}
|
||||
// Re-throw unexpected errors (permission, I/O, etc.)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file safely, returning null if it doesn't exist
|
||||
*/
|
||||
export async function readFileIfExists(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf-8")
|
||||
} catch (error: any) {
|
||||
// Only catch expected "not found" errors
|
||||
if (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "EISDIR") {
|
||||
return null
|
||||
}
|
||||
// Re-throw unexpected errors (permission, I/O, etc.)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ordered list of .roo directories to check (global first, then project-local)
|
||||
*
|
||||
* @param cwd - Current working directory (project path)
|
||||
* @returns Array of directory paths to check in order [global, project-local]
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // For a project at /Users/john/my-project
|
||||
* const directories = getRooDirectoriesForCwd('/Users/john/my-project')
|
||||
* // Returns:
|
||||
* // [
|
||||
* // '/Users/john/.roo', // Global directory
|
||||
* // '/Users/john/my-project/.roo' // Project-local directory
|
||||
* // ]
|
||||
* ```
|
||||
*
|
||||
* @example Directory structure:
|
||||
* ```
|
||||
* /Users/john/
|
||||
* ├── .roo/ # Global configuration
|
||||
* │ ├── rules/
|
||||
* │ │ └── rules.md
|
||||
* │ └── custom-instructions.md
|
||||
* └── my-project/
|
||||
* ├── .roo/ # Project-specific configuration
|
||||
* │ ├── rules/
|
||||
* │ │ └── rules.md # Overrides global rules
|
||||
* │ └── project-notes.md
|
||||
* └── src/
|
||||
* └── index.ts
|
||||
* ```
|
||||
*/
|
||||
export function getRooDirectoriesForCwd(cwd: string): string[] {
|
||||
const directories: string[] = []
|
||||
|
||||
// Add global directory first
|
||||
directories.push(getGlobalRooDirectory())
|
||||
|
||||
// Add project-local directory second
|
||||
directories.push(getProjectRooDirectoryForCwd(cwd))
|
||||
|
||||
return directories
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configuration from multiple .roo directories with project overriding global
|
||||
*
|
||||
* @param relativePath - The relative path within each .roo directory (e.g., 'rules/rules.md')
|
||||
* @param cwd - Current working directory (project path)
|
||||
* @returns Object with global and project content, plus merged content
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Load rules configuration for a project
|
||||
* const config = await loadConfiguration('rules/rules.md', '/Users/john/my-project')
|
||||
*
|
||||
* // Returns:
|
||||
* // {
|
||||
* // global: "Global rules content...", // From ~/.roo/rules/rules.md
|
||||
* // project: "Project rules content...", // From /Users/john/my-project/.roo/rules/rules.md
|
||||
* // merged: "Global rules content...\n\n# Project-specific rules (override global):\n\nProject rules content..."
|
||||
* // }
|
||||
* ```
|
||||
*
|
||||
* @example File paths resolved:
|
||||
* ```
|
||||
* relativePath: 'rules/rules.md'
|
||||
* cwd: '/Users/john/my-project'
|
||||
*
|
||||
* Reads from:
|
||||
* - Global: /Users/john/.roo/rules/rules.md
|
||||
* - Project: /Users/john/my-project/.roo/rules/rules.md
|
||||
*
|
||||
* Other common relativePath examples:
|
||||
* - 'custom-instructions.md'
|
||||
* - 'config/settings.json'
|
||||
* - 'templates/component.tsx'
|
||||
* ```
|
||||
*
|
||||
* @example Merging behavior:
|
||||
* ```
|
||||
* // If only global exists:
|
||||
* { global: "content", project: null, merged: "content" }
|
||||
*
|
||||
* // If only project exists:
|
||||
* { global: null, project: "content", merged: "content" }
|
||||
*
|
||||
* // If both exist:
|
||||
* {
|
||||
* global: "global content",
|
||||
* project: "project content",
|
||||
* merged: "global content\n\n# Project-specific rules (override global):\n\nproject content"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function loadConfiguration(
|
||||
relativePath: string,
|
||||
cwd: string,
|
||||
): Promise<{
|
||||
global: string | null
|
||||
project: string | null
|
||||
merged: string
|
||||
}> {
|
||||
const globalDir = getGlobalRooDirectory()
|
||||
const projectDir = getProjectRooDirectoryForCwd(cwd)
|
||||
|
||||
const globalFilePath = path.join(globalDir, relativePath)
|
||||
const projectFilePath = path.join(projectDir, relativePath)
|
||||
|
||||
// Read global configuration
|
||||
const globalContent = await readFileIfExists(globalFilePath)
|
||||
|
||||
// Read project-local configuration
|
||||
const projectContent = await readFileIfExists(projectFilePath)
|
||||
|
||||
// Merge configurations - project overrides global
|
||||
let merged = ""
|
||||
|
||||
if (globalContent) {
|
||||
merged += globalContent
|
||||
}
|
||||
|
||||
if (projectContent) {
|
||||
if (merged) {
|
||||
merged += "\n\n# Project-specific rules (override global):\n\n"
|
||||
}
|
||||
merged += projectContent
|
||||
}
|
||||
|
||||
return {
|
||||
global: globalContent,
|
||||
project: projectContent,
|
||||
merged: merged || "",
|
||||
}
|
||||
}
|
||||
|
||||
// Export with backward compatibility alias
|
||||
export const loadRooConfiguration: typeof loadConfiguration = loadConfiguration
|
||||
Loading…
Add table
Reference in a new issue