Skip to content

Commit 6908d3e

Browse files
committed
(feat) implement .clinerules-5m type rules which are sent along on a schedule
1 parent ff54c63 commit 6908d3e

File tree

5 files changed

+556
-3
lines changed

5 files changed

+556
-3
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/***********************************************
2+
* FILE: custom-instructions-schedulable.test.ts
3+
* CREATED: 2025-03-11 03:19:21
4+
*
5+
* CHANGELOG:
6+
* - 2025-03-11 03:19:21: (Schedulable Rules - Implementation) Initial implementation of tests for schedulable rules integration with custom instructions
7+
*
8+
* PURPOSE:
9+
* This file contains tests for schedulable rules integration with the custom instructions system.
10+
*
11+
* METHODS:
12+
* - None (test file)
13+
***********************************************/
14+
15+
import { addCustomInstructions } from "../custom-instructions"
16+
import { SchedulableRulesManager, SchedulableRule } from "../schedulable-rules"
17+
import * as fs from "fs/promises"
18+
19+
// Mock dependencies
20+
jest.mock("fs/promises")
21+
jest.mock("../schedulable-rules")
22+
23+
describe("addCustomInstructions with schedulable rules", () => {
24+
let mockSchedulableRulesManager: jest.Mocked<SchedulableRulesManager>
25+
const mockRules: SchedulableRule[] = [
26+
{
27+
filePath: "/path/to/.clinerules-5m",
28+
fileName: ".clinerules-5m",
29+
interval: 5 * 60 * 1000,
30+
timeUnit: "m",
31+
displayInterval: "5 minutes",
32+
content: "Some rule content for 5 minutes",
33+
lastExecuted: 0,
34+
},
35+
{
36+
filePath: "/path/to/.clinerules-10s",
37+
fileName: ".clinerules-10s",
38+
interval: 10 * 1000,
39+
timeUnit: "s",
40+
displayInterval: "10 seconds",
41+
content: "Some rule content for 10 seconds",
42+
lastExecuted: 0,
43+
},
44+
]
45+
46+
beforeEach(() => {
47+
jest.resetAllMocks()
48+
49+
// Mock SchedulableRulesManager
50+
mockSchedulableRulesManager = {
51+
resetAllRules: jest.fn(),
52+
loadSchedulableRules: jest.fn(),
53+
shouldExecuteRule: jest.fn(),
54+
markRuleAsExecuted: jest.fn(),
55+
getExecutableRules: jest.fn().mockResolvedValue(mockRules),
56+
getAllRules: jest.fn(),
57+
} as unknown as jest.Mocked<SchedulableRulesManager>
58+
59+
// Mock fs
60+
;(fs.readFile as jest.Mock).mockImplementation((filePath: string) => {
61+
if (filePath.endsWith(".clinerules")) {
62+
return Promise.resolve("Generic rules content")
63+
}
64+
if (filePath.endsWith(".clinerules-code")) {
65+
return Promise.resolve("Mode specific rules content")
66+
}
67+
return Promise.resolve("")
68+
})
69+
})
70+
71+
test("should include schedulable rules in custom instructions", async () => {
72+
const result = await addCustomInstructions(
73+
"Mode custom instructions",
74+
"Global custom instructions",
75+
"/fake/cwd",
76+
"code",
77+
{},
78+
mockSchedulableRulesManager,
79+
)
80+
81+
// Check that getExecutableRules was called
82+
expect(mockSchedulableRulesManager.getExecutableRules).toHaveBeenCalledWith("/fake/cwd")
83+
84+
// Check that markRuleAsExecuted was called for each rule
85+
expect(mockSchedulableRulesManager.markRuleAsExecuted).toHaveBeenCalledTimes(2)
86+
expect(mockSchedulableRulesManager.markRuleAsExecuted).toHaveBeenCalledWith(mockRules[0])
87+
expect(mockSchedulableRulesManager.markRuleAsExecuted).toHaveBeenCalledWith(mockRules[1])
88+
89+
// Check that the result includes the rule content
90+
expect(result).toContain("Rules from .clinerules-5m (every 5 minutes)")
91+
expect(result).toContain("Some rule content for 5 minutes")
92+
expect(result).toContain("Rules from .clinerules-10s (every 10 seconds)")
93+
expect(result).toContain("Some rule content for 10 seconds")
94+
})
95+
96+
test("should work without a schedulable rules manager", async () => {
97+
const result = await addCustomInstructions(
98+
"Mode custom instructions",
99+
"Global custom instructions",
100+
"/fake/cwd",
101+
"code",
102+
{},
103+
)
104+
105+
// Check that the result includes normal content but not schedulable rules
106+
expect(result).not.toContain("Rules from .clinerules-5m")
107+
expect(result).not.toContain("Rules from .clinerules-10s")
108+
expect(result).toContain("Mode custom instructions")
109+
expect(result).toContain("Global custom instructions")
110+
})
111+
})
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/***********************************************
2+
* FILE: schedulable-rules.test.ts
3+
* CREATED: 2025-03-11 03:18:01
4+
*
5+
* CHANGELOG:
6+
* - 2025-03-11 03:18:01: (Schedulable Rules - Implementation) Initial implementation of unit tests for parseTimeInterval and SchedulableRulesManager
7+
* - 2025-03-11 03:53:05: (Schedulable Rules - Bug Fix) Fixed import path for utils/logging module in test mocks
8+
* - 2025-03-11 03:53:37: (Schedulable Rules - Bug Fix) Added explicit mock implementations for fs/promises readdir and readFile functions
9+
*
10+
* PURPOSE:
11+
* This file contains tests for the schedulable rules implementation.
12+
*
13+
* METHODS:
14+
* - None (test file)
15+
***********************************************/
16+
17+
import { parseTimeInterval, SchedulableRule, SchedulableRulesManager } from "../schedulable-rules"
18+
import * as fs from "fs/promises"
19+
import * as path from "path"
20+
21+
// Mock dependencies
22+
jest.mock("fs/promises", () => ({
23+
readdir: jest.fn(),
24+
readFile: jest.fn(),
25+
}))
26+
jest.mock("../../../../utils/logging", () => ({
27+
logger: {
28+
info: jest.fn(),
29+
debug: jest.fn(),
30+
error: jest.fn(),
31+
},
32+
}))
33+
34+
describe("parseTimeInterval", () => {
35+
test("should correctly parse seconds", () => {
36+
const result = parseTimeInterval("5s")
37+
expect(result.interval).toBe(5 * 1000)
38+
expect(result.unit).toBe("s")
39+
expect(result.display).toBe("5 seconds")
40+
})
41+
42+
test("should correctly parse minutes", () => {
43+
const result = parseTimeInterval("10m")
44+
expect(result.interval).toBe(10 * 60 * 1000)
45+
expect(result.unit).toBe("m")
46+
expect(result.display).toBe("10 minutes")
47+
})
48+
49+
test("should correctly parse hours", () => {
50+
const result = parseTimeInterval("2h")
51+
expect(result.interval).toBe(2 * 60 * 60 * 1000)
52+
expect(result.unit).toBe("h")
53+
expect(result.display).toBe("2 hours")
54+
})
55+
56+
test("should correctly parse days", () => {
57+
const result = parseTimeInterval("1d")
58+
expect(result.interval).toBe(24 * 60 * 60 * 1000)
59+
expect(result.unit).toBe("d")
60+
expect(result.display).toBe("1 day")
61+
})
62+
63+
test("should handle singular units correctly", () => {
64+
const result = parseTimeInterval("1s")
65+
expect(result.display).toBe("1 second")
66+
})
67+
68+
test("should throw an error for invalid formats", () => {
69+
expect(() => parseTimeInterval("5x")).toThrow()
70+
expect(() => parseTimeInterval("abc")).toThrow()
71+
expect(() => parseTimeInterval("5")).toThrow()
72+
})
73+
})
74+
75+
describe("SchedulableRulesManager", () => {
76+
let manager: SchedulableRulesManager
77+
const mockRules: SchedulableRule[] = [
78+
{
79+
filePath: "/path/to/.clinerules-5m",
80+
fileName: ".clinerules-5m",
81+
interval: 5 * 60 * 1000,
82+
timeUnit: "m",
83+
displayInterval: "5 minutes",
84+
content: "Some rule content",
85+
lastExecuted: 0,
86+
},
87+
{
88+
filePath: "/path/to/.clinerules-10s",
89+
fileName: ".clinerules-10s",
90+
interval: 10 * 1000,
91+
timeUnit: "s",
92+
displayInterval: "10 seconds",
93+
content: "Another rule content",
94+
lastExecuted: 0,
95+
},
96+
]
97+
98+
beforeEach(() => {
99+
jest.resetAllMocks()
100+
manager = new SchedulableRulesManager()
101+
102+
// Mock readdir to return rule files
103+
const mockFiles = [".clinerules-5m", ".clinerules-10s", "other-file.txt"]
104+
;(fs.readdir as jest.Mock).mockResolvedValue(mockFiles)
105+
106+
// Mock readFile to return content
107+
;(fs.readFile as jest.Mock).mockImplementation((filePath) => {
108+
if (filePath.includes(".clinerules-5m")) {
109+
return Promise.resolve("Some rule content")
110+
} else if (filePath.includes(".clinerules-10s")) {
111+
return Promise.resolve("Another rule content")
112+
}
113+
return Promise.resolve("")
114+
})
115+
})
116+
117+
test("should load schedulable rules from directory", async () => {
118+
const rules = await manager.loadSchedulableRules("/fake/cwd")
119+
120+
expect(fs.readdir).toHaveBeenCalledWith("/fake/cwd")
121+
expect(rules).toHaveLength(2)
122+
expect(rules[0].fileName).toBe(".clinerules-5m")
123+
expect(rules[1].fileName).toBe(".clinerules-10s")
124+
})
125+
126+
test("should check if a rule should be executed", () => {
127+
// Rule should execute if it has never been executed
128+
expect(manager.shouldExecuteRule(mockRules[0])).toBe(true)
129+
130+
// Mark the rule as executed
131+
manager.markRuleAsExecuted(mockRules[0])
132+
133+
// Rule should not execute immediately after being marked
134+
expect(manager.shouldExecuteRule(mockRules[0])).toBe(false)
135+
136+
// Simulate time passing (manually setting lastExecutionTimes)
137+
const sixMinutesAgo = Date.now() - 6 * 60 * 1000
138+
Object.defineProperty(manager, "lastExecutionTimes", {
139+
value: new Map([[mockRules[0].fileName, sixMinutesAgo]]),
140+
})
141+
142+
// Now the rule should execute again (5 minutes have passed)
143+
expect(manager.shouldExecuteRule(mockRules[0])).toBe(true)
144+
})
145+
146+
test("should return executable rules", async () => {
147+
// Mock implementation to return our test rules
148+
jest.spyOn(manager, "loadSchedulableRules").mockResolvedValue(mockRules)
149+
150+
// Initially, all rules should be executable
151+
let executableRules = await manager.getExecutableRules("/fake/cwd")
152+
expect(executableRules).toHaveLength(2)
153+
154+
// Mark one rule as executed
155+
manager.markRuleAsExecuted(mockRules[0])
156+
157+
// Mock shouldExecuteRule to return false for the first rule
158+
jest.spyOn(manager, "shouldExecuteRule").mockImplementation((rule) => {
159+
return rule.fileName !== mockRules[0].fileName
160+
})
161+
162+
// Now only one rule should be executable
163+
executableRules = await manager.getExecutableRules("/fake/cwd")
164+
expect(executableRules).toHaveLength(1)
165+
expect(executableRules[0].fileName).toBe(mockRules[1].fileName)
166+
})
167+
168+
test("should reset all rules", () => {
169+
// Mark rules as executed
170+
manager.markRuleAsExecuted(mockRules[0])
171+
manager.markRuleAsExecuted(mockRules[1])
172+
173+
// Verify the lastExecutionTimes map has entries
174+
expect((manager as any).lastExecutionTimes.size).toBe(2)
175+
176+
// Reset all rules
177+
manager.resetAllRules()
178+
179+
// Verify the lastExecutionTimes map is cleared
180+
expect((manager as any).lastExecutionTimes.size).toBe(0)
181+
})
182+
183+
test("should handle errors when loading rules", async () => {
184+
// Mock readdir to throw an error
185+
;(fs.readdir as jest.Mock).mockRejectedValue(new Error("Directory not found"))
186+
187+
const rules = await manager.loadSchedulableRules("/fake/cwd")
188+
189+
// Should return empty array on error
190+
expect(rules).toEqual([])
191+
})
192+
193+
test("should get all rules with next execution time", async () => {
194+
// Mock implementation to return our test rules
195+
jest.spyOn(manager, "loadSchedulableRules").mockResolvedValue(mockRules)
196+
197+
// Mark one rule as executed 3 minutes ago
198+
const threeMinutesAgo = Date.now() - 3 * 60 * 1000
199+
Object.defineProperty(manager, "lastExecutionTimes", {
200+
value: new Map([[mockRules[0].fileName, threeMinutesAgo]]),
201+
})
202+
203+
const rulesWithStatus = await manager.getAllRules("/fake/cwd")
204+
205+
expect(rulesWithStatus).toHaveLength(2)
206+
207+
// First rule should have nextExecution time of approximately 2 minutes (5 - 3)
208+
const twoMinutesMs = 2 * 60 * 1000
209+
expect(rulesWithStatus[0].nextExecution).toBeGreaterThan(twoMinutesMs - 100)
210+
expect(rulesWithStatus[0].nextExecution).toBeLessThan(twoMinutesMs + 100)
211+
212+
// Second rule should have nextExecution time of 0 (never executed)
213+
expect(rulesWithStatus[1].nextExecution).toBe(0)
214+
})
215+
})

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "fs/promises"
22
import path from "path"
3+
import { SchedulableRulesManager } from "./schedulable-rules"
34

45
async function safeReadFile(filePath: string): Promise<string> {
56
try {
@@ -34,6 +35,7 @@ export async function addCustomInstructions(
3435
cwd: string,
3536
mode: string,
3637
options: { preferredLanguage?: string; rooIgnoreInstructions?: string } = {},
38+
schedulableRulesManager?: SchedulableRulesManager,
3739
): Promise<string> {
3840
const sections = []
3941

@@ -64,6 +66,7 @@ export async function addCustomInstructions(
6466
// Add rules - include both mode-specific and generic rules if they exist
6567
const rules = []
6668

69+
// Add mode-specific rules first if they exist
6770
// Add mode-specific rules first if they exist
6871
if (modeRuleContent && modeRuleContent.trim()) {
6972
const modeRuleFile = `.clinerules-${mode}`
@@ -74,12 +77,20 @@ export async function addCustomInstructions(
7477
rules.push(options.rooIgnoreInstructions)
7578
}
7679

80+
// Add schedulable rules if manager is provided
81+
if (schedulableRulesManager) {
82+
const executableRules = await schedulableRulesManager.getExecutableRules(cwd)
83+
for (const rule of executableRules) {
84+
rules.push(`# Rules from ${rule.fileName} (every ${rule.displayInterval}):\n${rule.content}`)
85+
schedulableRulesManager.markRuleAsExecuted(rule)
86+
}
87+
}
88+
7789
// Add generic rules
7890
const genericRuleContent = await loadRuleFiles(cwd)
7991
if (genericRuleContent && genericRuleContent.trim()) {
8092
rules.push(genericRuleContent.trim())
8193
}
82-
8394
if (rules.length > 0) {
8495
sections.push(`Rules:\n\n${rules.join("\n\n")}`)
8596
}

0 commit comments

Comments
 (0)