diff --git a/src/core/prompts/sections/__tests__/custom-instructions.test.ts b/src/core/prompts/sections/__tests__/custom-instructions.test.ts new file mode 100644 index 00000000000..76e7d2a9717 --- /dev/null +++ b/src/core/prompts/sections/__tests__/custom-instructions.test.ts @@ -0,0 +1,177 @@ +import { loadRuleFiles, addCustomInstructions } from "../custom-instructions" +import fs from "fs/promises" + +// Mock fs/promises +jest.mock("fs/promises") +const mockedFs = jest.mocked(fs) + +describe("loadRuleFiles", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should read and trim file content", async () => { + mockedFs.readFile.mockResolvedValue(" content with spaces ") + const result = await loadRuleFiles("/fake/path") + expect(mockedFs.readFile).toHaveBeenCalled() + expect(result).toBe( + "\n# Rules from .clinerules:\ncontent with spaces\n" + + "\n# Rules from .cursorrules:\ncontent with spaces\n" + + "\n# Rules from .windsurfrules:\ncontent with spaces\n", + ) + }) + + it("should handle ENOENT error", async () => { + mockedFs.readFile.mockRejectedValue({ code: "ENOENT" }) + const result = await loadRuleFiles("/fake/path") + expect(result).toBe("") + }) + + it("should handle EISDIR error", async () => { + mockedFs.readFile.mockRejectedValue({ code: "EISDIR" }) + const result = await loadRuleFiles("/fake/path") + expect(result).toBe("") + }) + + it("should throw on unexpected errors", async () => { + const error = new Error("Permission denied") as NodeJS.ErrnoException + error.code = "EPERM" + mockedFs.readFile.mockRejectedValue(error) + + await expect(async () => { + await loadRuleFiles("/fake/path") + }).rejects.toThrow() + }) +}) + +describe("loadRuleFiles", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should combine content from multiple rule files when they exist", async () => { + mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => { + if (filePath.toString().endsWith(".clinerules")) { + return Promise.resolve("cline rules content") + } + if (filePath.toString().endsWith(".cursorrules")) { + return Promise.resolve("cursor rules content") + } + return Promise.reject({ code: "ENOENT" }) + }) as any) + + const result = await loadRuleFiles("/fake/path") + expect(result).toBe( + "\n# Rules from .clinerules:\ncline rules content\n" + + "\n# Rules from .cursorrules:\ncursor rules content\n", + ) + }) + + it("should handle when no rule files exist", async () => { + mockedFs.readFile.mockRejectedValue({ code: "ENOENT" }) + + const result = await loadRuleFiles("/fake/path") + expect(result).toBe("") + }) + + it("should throw on unexpected errors", async () => { + const error = new Error("Permission denied") as NodeJS.ErrnoException + error.code = "EPERM" + mockedFs.readFile.mockRejectedValue(error) + + await expect(async () => { + await loadRuleFiles("/fake/path") + }).rejects.toThrow() + }) + + it("should skip directories with same name as rule files", async () => { + mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => { + if (filePath.toString().endsWith(".clinerules")) { + return Promise.reject({ code: "EISDIR" }) + } + if (filePath.toString().endsWith(".cursorrules")) { + return Promise.resolve("cursor rules content") + } + return Promise.reject({ code: "ENOENT" }) + }) as any) + + const result = await loadRuleFiles("/fake/path") + expect(result).toBe("\n# Rules from .cursorrules:\ncursor rules content\n") + }) +}) + +describe("addCustomInstructions", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should combine all instruction types when provided", async () => { + mockedFs.readFile.mockResolvedValue("mode specific rules") + + const result = await addCustomInstructions( + "mode instructions", + "global instructions", + "/fake/path", + "test-mode", + { preferredLanguage: "Spanish" }, + ) + + expect(result).toContain("Language Preference:") + expect(result).toContain("Spanish") + expect(result).toContain("Global Instructions:\nglobal instructions") + expect(result).toContain("Mode-specific Instructions:\nmode instructions") + expect(result).toContain("Rules from .clinerules-test-mode:\nmode specific rules") + }) + + it("should return empty string when no instructions provided", async () => { + mockedFs.readFile.mockRejectedValue({ code: "ENOENT" }) + + const result = await addCustomInstructions("", "", "/fake/path", "", {}) + expect(result).toBe("") + }) + + it("should handle missing mode-specific rules file", async () => { + mockedFs.readFile.mockRejectedValue({ code: "ENOENT" }) + + const result = await addCustomInstructions( + "mode instructions", + "global instructions", + "/fake/path", + "test-mode", + ) + + expect(result).toContain("Global Instructions:") + expect(result).toContain("Mode-specific Instructions:") + expect(result).not.toContain("Rules from .clinerules-test-mode") + }) + + it("should throw on unexpected errors", async () => { + const error = new Error("Permission denied") as NodeJS.ErrnoException + error.code = "EPERM" + mockedFs.readFile.mockRejectedValue(error) + + await expect(async () => { + await addCustomInstructions("", "", "/fake/path", "test-mode") + }).rejects.toThrow() + }) + + it("should skip mode-specific rule files that are directories", async () => { + mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => { + if (filePath.toString().includes(".clinerules-test-mode")) { + return Promise.reject({ code: "EISDIR" }) + } + return Promise.reject({ code: "ENOENT" }) + }) as any) + + const result = await addCustomInstructions( + "mode instructions", + "global instructions", + "/fake/path", + "test-mode", + ) + + expect(result).toContain("Global Instructions:\nglobal instructions") + expect(result).toContain("Mode-specific Instructions:\nmode instructions") + expect(result).not.toContain("Rules from .clinerules-test-mode") + }) +}) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 0808c4b307b..240dfcc47e5 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -1,21 +1,27 @@ import fs from "fs/promises" import path from "path" +async function safeReadFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + return content.trim() + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException).code + if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) { + throw err + } + return "" + } +} + export async function loadRuleFiles(cwd: string): Promise { const ruleFiles = [".clinerules", ".cursorrules", ".windsurfrules"] let combinedRules = "" for (const file of ruleFiles) { - try { - const content = await fs.readFile(path.join(cwd, file), "utf-8") - if (content.trim()) { - combinedRules += `\n# Rules from ${file}:\n${content.trim()}\n` - } - } catch (err) { - // Silently skip if file doesn't exist - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err - } + const content = await safeReadFile(path.join(cwd, file)) + if (content) { + combinedRules += `\n# Rules from ${file}:\n${content}\n` } } @@ -34,18 +40,8 @@ export async function addCustomInstructions( // Load mode-specific rules if mode is provided let modeRuleContent = "" if (mode) { - try { - const modeRuleFile = `.clinerules-${mode}` - const content = await fs.readFile(path.join(cwd, modeRuleFile), "utf-8") - if (content.trim()) { - modeRuleContent = content.trim() - } - } catch (err) { - // Silently skip if file doesn't exist - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err - } - } + const modeRuleFile = `.clinerules-${mode}` + modeRuleContent = await safeReadFile(path.join(cwd, modeRuleFile)) } // Add language preference if provided diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index bd15d245780..325b8100ee8 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -8,6 +8,7 @@ "name": "webview-ui", "version": "0.1.0", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.5", @@ -16,7 +17,7 @@ "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.2.3", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.0", "@vscode/webview-ui-toolkit": "^1.4.0", @@ -3590,6 +3591,55 @@ "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", + "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", @@ -3666,24 +3716,6 @@ } } }, - "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", @@ -3710,6 +3742,23 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -3875,24 +3924,6 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -4071,6 +4102,23 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", @@ -4262,24 +4310,6 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", @@ -4383,6 +4413,23 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-progress": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", @@ -4430,24 +4477,6 @@ } } }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", @@ -4525,24 +4554,6 @@ } } }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slider": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz", @@ -4625,29 +4636,10 @@ } } }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", - "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", - "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, @@ -4824,24 +4816,6 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -5005,24 +4979,6 @@ } } }, - "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",