Skip to content

Commit eeabb0b

Browse files
ivanarifinCodeKing
authored andcommitted
feat(settings): add rules management UI (Zoo-Code-Org#657)
* feat(settings): add rules management UI * fix: address rules settings review feedback * test: increase rules settings coverage * test: improve rules settings patch coverage * fix: address rules settings review feedback
1 parent 7b0ab2f commit eeabb0b

34 files changed

Lines changed: 3217 additions & 18 deletions

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export * from "./provider-settings.js"
2222
export * from "./task.js"
2323
export * from "./todo.js"
2424
export * from "./skills.js"
25+
export * from "./rules.js"
2526
export * from "./marketplace.js"
2627
export * from "./telemetry.js"
2728
export * from "./terminal.js"

packages/types/src/rules.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export type RuleScope = "global" | "project"
2+
3+
export type RuleKind = "generic" | "mode"
4+
5+
export interface RuleMetadata {
6+
id: string
7+
name: string
8+
scope: RuleScope
9+
kind: RuleKind
10+
modeSlug?: string
11+
modeName?: string
12+
filePath: string
13+
relativePath: string
14+
directoryPath: string
15+
description?: string
16+
isSymlink?: boolean
17+
}
18+
19+
export interface CreateRuleInput {
20+
scope: RuleScope
21+
kind: RuleKind
22+
modeSlug?: string
23+
fileName: string
24+
}
25+
26+
export interface DeleteRuleInput {
27+
id?: string
28+
scope: RuleScope
29+
kind: RuleKind
30+
modeSlug?: string
31+
relativePath: string
32+
}
33+
34+
export type RuleLookupInput = DeleteRuleInput

packages/types/src/vscode-extension-host.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { McpServer } from "./mcp.js"
1515
import type { ModelInfo, ModelRecord, RouterModels } from "./model.js"
1616
import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
1717
import type { SkillMetadata } from "./skills.js"
18+
import type { RuleMetadata } from "./rules.js"
1819
import type { TelemetrySetting } from "./telemetry.js"
1920
import type { WorktreeIncludeStatus } from "./worktree.js"
2021

@@ -99,6 +100,7 @@ export interface ExtensionMessage {
99100
| "branchWorktreeIncludeResult"
100101
| "folderSelected"
101102
| "skills"
103+
| "rules"
102104
| "fileContent"
103105
| "rooHistoryImportProgress"
104106
text?: string
@@ -178,6 +180,7 @@ export interface ExtensionMessage {
178180
list?: string[] // For dismissedUpsells
179181
tools?: SerializedCustomToolDefinition[] // For customToolsResult
180182
skills?: SkillMetadata[] // For skills response
183+
rules?: RuleMetadata[] // For rules response
181184
modes?: { slug: string; name: string }[] // For modes response
182185
rooHistoryImportProgress?: {
183186
status: "starting" | "copying" | "finished" | "failed"
@@ -613,6 +616,12 @@ export interface WebviewMessage {
613616
| "moveSkill"
614617
| "updateSkillModes"
615618
| "openSkillFile"
619+
// Rules messages
620+
| "requestRules"
621+
| "createRule"
622+
| "deleteRule"
623+
| "openRuleFile"
624+
| "openRulesDirectory"
616625
text?: string
617626
taskId?: string
618627
editedMessageContent?: string
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import type { RuleMetadata, WebviewMessage } from "@roo-code/types"
2+
import type { ClineProvider } from "../ClineProvider"
3+
4+
vi.mock("vscode", () => ({
5+
window: {
6+
showErrorMessage: vi.fn(),
7+
showWarningMessage: vi.fn(),
8+
},
9+
}))
10+
11+
vi.mock("../../../integrations/misc/open-file", () => ({
12+
openFile: vi.fn(),
13+
}))
14+
15+
vi.mock("../../../services/rules/rules", () => ({
16+
getRules: vi.fn(),
17+
createRule: vi.fn(),
18+
deleteRule: vi.fn(),
19+
resolveRuleFile: vi.fn(),
20+
getRulesDirectoryPath: vi.fn(),
21+
}))
22+
23+
import * as vscode from "vscode"
24+
import { openFile } from "../../../integrations/misc/open-file"
25+
import { createRule, deleteRule, getRules, getRulesDirectoryPath, resolveRuleFile } from "../../../services/rules/rules"
26+
import {
27+
handleCreateRule,
28+
handleDeleteRule,
29+
handleOpenRuleFile,
30+
handleOpenRulesDirectory,
31+
handleRequestRules,
32+
} from "../rulesMessageHandler"
33+
34+
const mockRules: RuleMetadata[] = [
35+
{
36+
id: "global:generic:generic:rule.md",
37+
name: "rule.md",
38+
scope: "global",
39+
kind: "generic",
40+
filePath: "/home/.roo/rules/rule.md",
41+
relativePath: "rule.md",
42+
directoryPath: "/home/.roo/rules",
43+
},
44+
]
45+
46+
describe("rulesMessageHandler", () => {
47+
const mockLog = vi.fn()
48+
const mockPostMessageToWebview = vi.fn()
49+
const mockGetModes = vi.fn()
50+
51+
const createMockProvider = (): ClineProvider =>
52+
({
53+
log: mockLog,
54+
postMessageToWebview: mockPostMessageToWebview,
55+
getModes: mockGetModes,
56+
}) as unknown as ClineProvider
57+
58+
beforeEach(() => {
59+
vi.clearAllMocks()
60+
mockGetModes.mockResolvedValue([{ slug: "code", name: "Code" }])
61+
vi.mocked(getRules).mockResolvedValue(mockRules)
62+
})
63+
64+
it("handleRequestRules posts rules", async () => {
65+
const provider = createMockProvider()
66+
67+
const result = await handleRequestRules(provider, "/workspace")
68+
69+
expect(result).toEqual(mockRules)
70+
expect(getRules).toHaveBeenCalledWith("/workspace", { modes: [{ slug: "code", name: "Code" }] })
71+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "rules", rules: mockRules })
72+
})
73+
74+
it("handleRequestRules posts an empty list on failure", async () => {
75+
const provider = createMockProvider()
76+
vi.mocked(getRules).mockRejectedValue(new Error("scan failed"))
77+
78+
const result = await handleRequestRules(provider, "/workspace")
79+
80+
expect(result).toEqual([])
81+
expect(mockLog).toHaveBeenCalled()
82+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "rules", rules: [] })
83+
})
84+
85+
it("handleCreateRule creates, opens, and posts refreshed rules", async () => {
86+
const provider = createMockProvider()
87+
vi.mocked(createRule).mockResolvedValue("/workspace/.roo/rules/new.md")
88+
89+
const result = await handleCreateRule(provider, "/workspace", {
90+
type: "createRule",
91+
values: { scope: "project", kind: "generic", fileName: "new.md" },
92+
} as WebviewMessage)
93+
94+
expect(result).toEqual(mockRules)
95+
expect(createRule).toHaveBeenCalledWith("/workspace", { scope: "project", kind: "generic", fileName: "new.md" })
96+
expect(openFile).toHaveBeenCalledWith("/workspace/.roo/rules/new.md")
97+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "rules", rules: mockRules })
98+
})
99+
100+
it("handleDeleteRule deletes and posts refreshed rules", async () => {
101+
const provider = createMockProvider()
102+
103+
const result = await handleDeleteRule(provider, "/workspace", {
104+
type: "deleteRule",
105+
values: { scope: "global", kind: "generic", relativePath: "rule.md" },
106+
} as WebviewMessage)
107+
108+
expect(result).toEqual(mockRules)
109+
expect(deleteRule).toHaveBeenCalledWith("/workspace", {
110+
scope: "global",
111+
kind: "generic",
112+
relativePath: "rule.md",
113+
})
114+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "rules", rules: mockRules })
115+
})
116+
117+
it("handleOpenRuleFile safely resolves and opens the rule file", async () => {
118+
const provider = createMockProvider()
119+
vi.mocked(resolveRuleFile).mockResolvedValue("/workspace/.roo/rules/rule.md")
120+
121+
await handleOpenRuleFile(provider, "/workspace", {
122+
type: "openRuleFile",
123+
values: { scope: "project", kind: "generic", relativePath: "rule.md" },
124+
} as WebviewMessage)
125+
126+
expect(resolveRuleFile).toHaveBeenCalledWith("/workspace", {
127+
scope: "project",
128+
kind: "generic",
129+
relativePath: "rule.md",
130+
})
131+
expect(openFile).toHaveBeenCalledWith("/workspace/.roo/rules/rule.md")
132+
})
133+
134+
it("handleDeleteRule shows an error when required delete values are missing", async () => {
135+
const provider = createMockProvider()
136+
137+
const result = await handleDeleteRule(provider, "/workspace", {
138+
type: "deleteRule",
139+
values: { scope: "global", kind: "generic" },
140+
} as WebviewMessage)
141+
142+
expect(result).toBeUndefined()
143+
expect(deleteRule).not.toHaveBeenCalled()
144+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
145+
"Failed to delete rule: Missing required fields: scope, kind, or relativePath",
146+
)
147+
})
148+
149+
it("handleOpenRuleFile shows an error when the resolved rule file is missing", async () => {
150+
const provider = createMockProvider()
151+
vi.mocked(resolveRuleFile).mockResolvedValue(undefined)
152+
153+
await handleOpenRuleFile(provider, "/workspace", {
154+
type: "openRuleFile",
155+
values: { scope: "project", kind: "generic", relativePath: "missing.md" },
156+
} as WebviewMessage)
157+
158+
expect(openFile).not.toHaveBeenCalled()
159+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to open rule file: Rule file not found")
160+
})
161+
162+
it("handleOpenRulesDirectory shows an error when directory input is invalid", async () => {
163+
const provider = createMockProvider()
164+
vi.mocked(getRulesDirectoryPath).mockImplementation(() => {
165+
throw new Error("Invalid rule scope")
166+
})
167+
168+
await handleOpenRulesDirectory(provider, "/workspace", {
169+
type: "openRulesDirectory",
170+
values: { scope: "team", kind: "generic" },
171+
} as WebviewMessage)
172+
173+
expect(openFile).not.toHaveBeenCalled()
174+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
175+
"Failed to open rules directory: Invalid rule scope",
176+
)
177+
})
178+
179+
it("handleCreateRule shows an error for missing required values before creating", async () => {
180+
const provider = createMockProvider()
181+
182+
const result = await handleCreateRule(provider, "/workspace", {
183+
type: "createRule",
184+
values: { scope: "project", kind: "generic" },
185+
} as WebviewMessage)
186+
187+
expect(result).toBeUndefined()
188+
expect(createRule).not.toHaveBeenCalled()
189+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
190+
"Failed to create rule: Missing required fields: scope, kind, or fileName",
191+
)
192+
})
193+
194+
it("handleCreateRule validates scope and kind before creating", async () => {
195+
const provider = createMockProvider()
196+
197+
const result = await handleCreateRule(provider, "/workspace", {
198+
type: "createRule",
199+
values: { scope: "team", kind: "workspace", fileName: "new.md" },
200+
} as WebviewMessage)
201+
202+
expect(result).toBeUndefined()
203+
expect(createRule).not.toHaveBeenCalled()
204+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
205+
"Failed to create rule: Missing required fields: scope, kind, or fileName",
206+
)
207+
})
208+
209+
it("handleCreateRule shows an error for missing workspace project rules and does not refresh", async () => {
210+
const provider = createMockProvider()
211+
vi.mocked(createRule).mockRejectedValue(new Error("Workspace rules require an open workspace"))
212+
213+
const result = await handleCreateRule(provider, "", {
214+
type: "createRule",
215+
values: { scope: "project", kind: "generic", fileName: "new.md" },
216+
} as WebviewMessage)
217+
218+
expect(result).toBeUndefined()
219+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
220+
"Failed to create rule: Workspace rules require an open workspace",
221+
)
222+
expect(mockPostMessageToWebview).not.toHaveBeenCalled()
223+
})
224+
it("handleRequestRules safely handles nullish errors", async () => {
225+
const provider = createMockProvider()
226+
vi.mocked(getRules).mockRejectedValue(null)
227+
228+
const result = await handleRequestRules(provider, "/workspace")
229+
230+
expect(result).toEqual([])
231+
expect(mockLog).toHaveBeenCalledWith("Error fetching rules: null")
232+
expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "rules", rules: [] })
233+
})
234+
235+
it("handleCreateRule warns when refresh fails after creation succeeds", async () => {
236+
const provider = createMockProvider()
237+
vi.mocked(createRule).mockResolvedValue("/workspace/.roo/rules/new.md")
238+
vi.mocked(getRules).mockRejectedValue(new Error("refresh failed"))
239+
240+
const result = await handleCreateRule(provider, "/workspace", {
241+
type: "createRule",
242+
values: { scope: "project", kind: "generic", fileName: "new.md" },
243+
} as WebviewMessage)
244+
245+
expect(result).toBeUndefined()
246+
expect(createRule).toHaveBeenCalledWith("/workspace", { scope: "project", kind: "generic", fileName: "new.md" })
247+
expect(openFile).toHaveBeenCalledWith("/workspace/.roo/rules/new.md")
248+
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
249+
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
250+
"Rule created, but refreshing the rules list failed.",
251+
)
252+
})
253+
254+
it("handleDeleteRule warns when refresh fails after deletion succeeds", async () => {
255+
const provider = createMockProvider()
256+
vi.mocked(getRules).mockRejectedValue(new Error("refresh failed"))
257+
258+
const result = await handleDeleteRule(provider, "/workspace", {
259+
type: "deleteRule",
260+
values: { scope: "global", kind: "generic", relativePath: "rule.md" },
261+
} as WebviewMessage)
262+
263+
expect(result).toBeUndefined()
264+
expect(deleteRule).toHaveBeenCalledWith("/workspace", {
265+
scope: "global",
266+
kind: "generic",
267+
relativePath: "rule.md",
268+
})
269+
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
270+
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
271+
"Rule deleted, but refreshing the rules list failed.",
272+
)
273+
})
274+
275+
it("handleOpenRulesDirectory resolves and opens the requested rules directory", async () => {
276+
const provider = createMockProvider()
277+
vi.mocked(getRulesDirectoryPath).mockReturnValue("/workspace/.roo/rules-code")
278+
279+
await handleOpenRulesDirectory(provider, "/workspace", {
280+
type: "openRulesDirectory",
281+
values: { scope: "project", kind: "mode", modeSlug: "code" },
282+
} as WebviewMessage)
283+
284+
expect(getRulesDirectoryPath).toHaveBeenCalledWith("/workspace", {
285+
scope: "project",
286+
kind: "mode",
287+
modeSlug: "code",
288+
})
289+
expect(openFile).toHaveBeenCalledWith("/workspace/.roo/rules-code")
290+
})
291+
})

0 commit comments

Comments
 (0)