import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import * as fs from "node:fs"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { RULES_INJECTOR_STORAGE } from "./constants"; type StatSnapshot = { mtimeMs: number; size: number }; let trackedRulePath = ""; let statSnapshots: Array = []; let trackedReadFileCount = 0; let mockedHomeDir = ""; const originalReadFileSync = fs.readFileSync.bind(fs); const originalStatSync = fs.statSync.bind(fs); const originalHomedir = os.homedir.bind(os); mock.module("node:fs", () => ({ ...fs, readFileSync: (filePath: string, encoding?: string) => { if (filePath === trackedRulePath) { trackedReadFileCount += 1; } return originalReadFileSync(filePath, encoding as never); }, statSync: (filePath: string) => { if (filePath === trackedRulePath) { const next = statSnapshots.shift(); if (next instanceof Error) { throw next; } if (next) { return { mtimeMs: next.mtimeMs, size: next.size, isFile: () => true, } as ReturnType; } } return originalStatSync(filePath); }, })); mock.module("node:os", () => ({ ...os, homedir: () => mockedHomeDir || originalHomedir(), })); mock.module("./matcher", () => ({ shouldApplyRule: () => ({ applies: true, reason: "matched" }), isDuplicateByRealPath: (realPath: string, cache: Set) => cache.has(realPath), createContentHash: (content: string) => `hash:${content}`, isDuplicateByContentHash: (hash: string, cache: Set) => cache.has(hash), })); function createOutput(): { title: string; output: string; metadata: unknown } { return { title: "tool", output: "", metadata: {} }; } async function createProcessor(projectRoot: string): Promise<{ processFilePathForInjection: ( filePath: string, sessionID: string, output: { title: string; output: string; metadata: unknown } ) => Promise; }> { const { createRuleInjectionProcessor } = await import("./injector"); const sessionCaches = new Map< string, { contentHashes: Set; realPaths: Set } >(); return createRuleInjectionProcessor({ workspaceDirectory: projectRoot, truncator: { truncate: async (_sessionID: string, content: string) => ({ result: content, truncated: false, }), }, getSessionCache: (sessionID: string) => { if (!sessionCaches.has(sessionID)) { sessionCaches.set(sessionID, { contentHashes: new Set(), realPaths: new Set(), }); } const cache = sessionCaches.get(sessionID); if (!cache) { throw new Error("Session cache should exist"); } return cache; }, }); } function getInjectedRulesPath(sessionID: string): string { return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`); } describe("createRuleInjectionProcessor", () => { afterAll(() => { mock.restore(); }); let testRoot: string; let projectRoot: string; let homeRoot: string; let targetFile: string; let ruleFile: string; let ruleRealPath: string; beforeEach(() => { testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`); projectRoot = join(testRoot, "project"); homeRoot = join(testRoot, "home"); targetFile = join(projectRoot, "src", "index.ts"); ruleFile = join( projectRoot, ".github", "instructions", "typescript.instructions.md" ); mkdirSync(join(projectRoot, ".git"), { recursive: true }); mkdirSync(join(projectRoot, "src"), { recursive: true }); mkdirSync(join(projectRoot, ".github", "instructions"), { recursive: true }); mkdirSync(homeRoot, { recursive: true }); writeFileSync(targetFile, "export const value = 1;\n"); writeFileSync(ruleFile, "rule-content\n"); ruleRealPath = fs.realpathSync(ruleFile); trackedRulePath = ruleFile; statSnapshots = []; trackedReadFileCount = 0; mockedHomeDir = homeRoot; }); afterEach(() => { if (fs.existsSync(testRoot)) { rmSync(testRoot, { recursive: true, force: true }); } }); it("reads and parses same file once when stat is unchanged", async () => { // given statSnapshots = [ { mtimeMs: 1000, size: 13 }, { mtimeMs: 1000, size: 13 }, ]; const processor = await createProcessor(projectRoot); // when await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); // then expect(trackedReadFileCount).toBe(1); }); it("re-reads file when mtime changes", async () => { // given statSnapshots = [ { mtimeMs: 1000, size: 13 }, { mtimeMs: 2000, size: 13 }, ]; const processor = await createProcessor(projectRoot); // when await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); // then expect(trackedReadFileCount).toBe(2); }); it("re-reads file when size changes", async () => { // given statSnapshots = [ { mtimeMs: 1000, size: 13 }, { mtimeMs: 1000, size: 21 }, ]; const processor = await createProcessor(projectRoot); // when await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); // then expect(trackedReadFileCount).toBe(2); }); it("does not save injected rules when all candidates are already cached", async () => { // given const sessionID = `dirty-no-new-${Date.now()}`; const injectedPath = getInjectedRulesPath(sessionID); if (fs.existsSync(injectedPath)) { fs.unlinkSync(injectedPath); } const { createRuleInjectionProcessor } = await import("./injector"); const processor = createRuleInjectionProcessor({ workspaceDirectory: projectRoot, truncator: { truncate: async (_sessionID: string, content: string) => ({ result: content, truncated: false, }), }, getSessionCache: () => ({ contentHashes: new Set(), realPaths: new Set([ruleRealPath]), }), }); // when await processor.processFilePathForInjection(targetFile, sessionID, createOutput()); // then expect(fs.existsSync(injectedPath)).toBe(false); }); it("saves injected rules when a new rule is added", async () => { // given const sessionID = `dirty-new-${Date.now()}`; const injectedPath = getInjectedRulesPath(sessionID); if (fs.existsSync(injectedPath)) { fs.unlinkSync(injectedPath); } const processor = await createProcessor(projectRoot); // when await processor.processFilePathForInjection(targetFile, sessionID, createOutput()); // then expect(fs.existsSync(injectedPath)).toBe(true); if (fs.existsSync(injectedPath)) { fs.unlinkSync(injectedPath); } }); it("falls back to direct read and parse when statSync throws", async () => { // given statSnapshots = [new Error("stat failed"), new Error("stat failed")]; const processor = await createProcessor(projectRoot); // when await processor.processFilePathForInjection(targetFile, "session-1", createOutput()); await processor.processFilePathForInjection(targetFile, "session-2", createOutput()); // then expect(trackedReadFileCount).toBe(2); }); });