import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
BuildSystemPromptOptions,
ExtensionAPI,
} from "@earendil-works/pi-coding-agent";
import { AGENT_SUITE_DIR_ENV } from "../../shared/agent-suite-storage";
import projectRules from "./index";
const previousAgentSuiteDir = process.env[AGENT_SUITE_DIR_ENV];
const tempDirs: string[] = [];
interface RegisteredHandler {
readonly eventName: string;
readonly handler: unknown;
}
interface Notification {
readonly message: string;
readonly type: string | undefined;
}
interface ExtensionApiFake extends ExtensionAPI {
readonly handlers: RegisteredHandler[];
}
interface ContextFake {
readonly notifications: Notification[];
readonly ctx: {
readonly cwd: string;
readonly hasUI?: boolean;
readonly ui: {
notify(message: string, type: string | undefined): void;
};
};
}
interface BeforeAgentStartEventFake {
readonly type: "before_agent_start";
readonly prompt: string;
readonly images: readonly unknown[];
readonly systemPrompt: string;
readonly systemPromptOptions: BuildSystemPromptOptions;
}
afterEach(async () => {
if (previousAgentSuiteDir === undefined) {
delete process.env[AGENT_SUITE_DIR_ENV];
} else {
process.env[AGENT_SUITE_DIR_ENV] = previousAgentSuiteDir;
}
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
describe("project-rules", () => {
test("appends recursive non-empty Markdown rules in deterministic visible-path order", async () => {
// Purpose: project rule files must become a separate prompt section after the existing system prompt.
// Input and expected output: direct and nested Markdown files are sorted by their visible path and appended.
// Edge case: empty, whitespace-only, and non-Markdown files do not create project_rule blocks.
// Dependencies: this test uses temporary project files and in-memory lifecycle fakes.
await withIsolatedSuiteDir(async () => {
const projectDir = await createTempDir("project-rules-project-");
const rulesDir = join(projectDir, ".pi");
await mkdir(join(rulesDir, "nested"), { recursive: true });
await writeFile(join(rulesDir, "z.md"), "Z rule");
await writeFile(join(rulesDir, "nested", "a.md"), "A rule");
await writeFile(join(rulesDir, "empty.md"), "");
await writeFile(join(rulesDir, "space.md"), " \n\t");
await writeFile(join(rulesDir, "note.txt"), "Ignored");
const pi = createExtensionApiFake();
const context = createContextFake(projectDir);
projectRules(pi);
const prompt = await runBeforeAgentStartHandlers(
pi,
createBeforeAgentStartEvent(projectDir),
context.ctx,
);
expect(prompt).toBe(
[
"Original pi prompt",
"",
"",
' ',
"A rule",
" ",
' ',
"Z rule",
" ",
"",
].join("\n"),
);
});
});
test("follows symlinked rules directory, files, and subdirectories while avoiding directory cycles", async () => {
// Purpose: approved symlink behavior must allow shared rule files outside the cwd.
// Input and expected output: rulesDir symlink, file symlink, and directory symlink are followed once.
// Edge case: a symlink cycle must not repeat files or loop forever.
// Dependencies: this test uses filesystem symlinks in temporary directories.
await withIsolatedSuiteDir(async () => {
const projectDir = await createTempDir("project-rules-project-");
const realRulesDir = await createTempDir("project-rules-real-");
const externalDir = await createTempDir("project-rules-external-");
await writeFile(join(realRulesDir, "root.md"), "Root rule");
await writeFile(
join(externalDir, "shared-source.txt"),
"Linked file rule",
);
await mkdir(join(externalDir, "shared-dir"));
await writeFile(
join(externalDir, "shared-dir", "nested.md"),
"Linked dir rule",
);
await symlink(realRulesDir, join(projectDir, ".pi"), "dir");
await symlink(
join(externalDir, "shared-source.txt"),
join(realRulesDir, "shared.md"),
);
await symlink(
join(externalDir, "shared-dir"),
join(realRulesDir, "shared-dir"),
"dir",
);
await symlink(realRulesDir, join(realRulesDir, "cycle"), "dir");
const pi = createExtensionApiFake();
const context = createContextFake(projectDir);
projectRules(pi);
const prompt = await runBeforeAgentStartHandlers(
pi,
createBeforeAgentStartEvent(projectDir),
context.ctx,
);
expect(prompt).toContain('path=".pi/root.md"');
expect(prompt).toContain("Root rule");
expect(prompt).toContain('path=".pi/shared-dir/nested.md"');
expect(prompt).toContain("Linked dir rule");
expect(prompt).toContain('path=".pi/shared.md"');
expect(prompt).toContain("Linked file rule");
expect(prompt.match(/Root rule/g)).toHaveLength(1);
});
});
test("uses the first visible symlinked directory path when targets repeat", async () => {
// Purpose: duplicate symlinked directories must produce deterministic visible paths.
// Input and expected output: two sorted links to one real directory include only the first visible path.
// Edge case: directory cycle protection must not let filesystem timing choose the visible path.
// Dependencies: this test uses filesystem symlinks in temporary directories.
await withIsolatedSuiteDir(async () => {
const projectDir = await createTempDir("project-rules-repeat-project-");
const sharedDir = await createTempDir("project-rules-repeat-shared-");
await mkdir(join(projectDir, ".pi"));
await writeFile(join(sharedDir, "rule.md"), "Shared rule");
await symlink(sharedDir, join(projectDir, ".pi", "a"), "dir");
await symlink(sharedDir, join(projectDir, ".pi", "b"), "dir");
const prompt = await renderPrompt(projectDir);
expect(prompt).toContain('path=".pi/a/rule.md"');
expect(prompt).not.toContain('path=".pi/b/rule.md"');
expect(prompt.match(/Shared rule/g)).toHaveLength(1);
});
});
test("does not append project_rules when disabled, missing, empty, or only empty files", async () => {
// Purpose: the extension must avoid empty prompt wrappers when no rule content is available.
// Input and expected output: disabled config, missing rulesDir, and whitespace-only rules keep the original prompt.
// Edge case: an existing rulesDir with no non-empty Markdown files behaves like no rules.
// Dependencies: this test uses temporary suite and project directories.
await withIsolatedSuiteDir(async (suiteDir) => {
const disabledProjectDir = await createTempDir("project-rules-disabled-");
await writeProjectRulesConfig(suiteDir, { enabled: false });
expect(await renderPrompt(disabledProjectDir)).toBe("Original pi prompt");
const missingProjectDir = await createTempDir("project-rules-missing-");
await writeProjectRulesConfig(suiteDir, { enabled: true });
expect(await renderPrompt(missingProjectDir)).toBe("Original pi prompt");
const emptyProjectDir = await createTempDir("project-rules-empty-");
await mkdir(join(emptyProjectDir, ".pi"));
await writeFile(join(emptyProjectDir, ".pi", "empty.md"), "\n\t ");
expect(await renderPrompt(emptyProjectDir)).toBe("Original pi prompt");
});
});
test("fails the whole section and warns when rulesDir itself is a broken symlink", async () => {
// Purpose: a broken rulesDir symlink must not silently disable project rules.
// Input and expected output: .pi points to a missing directory, so the prompt is unchanged and one warning is emitted.
// Edge case: a genuinely missing .pi directory remains a no-op in a separate test.
// Dependencies: this test uses a filesystem symlink in a temporary project directory.
await withIsolatedSuiteDir(async () => {
const projectDir = await createTempDir("project-rules-broken-root-");
await symlink(join(projectDir, "missing-rules"), join(projectDir, ".pi"));
const context = createContextFake(projectDir);
expect(await renderPrompt(projectDir, context)).toBe(
"Original pi prompt",
);
expect(context.notifications).toHaveLength(1);
});
});
test("fails the whole section and warns when config or rule loading fails", async () => {
// Purpose: partial project rules must not be appended when config or file loading is unreliable.
// Input and expected output: invalid config and broken symlink keep the original prompt and warn.
// Edge case: one broken rule prevents all project_rules output.
// Dependencies: this test uses temporary suite storage, symlinks, and in-memory notifications.
await withIsolatedSuiteDir(async (suiteDir) => {
const invalidConfigProjectDir = await createTempDir(
"project-rules-invalid-config-",
);
await writeProjectRulesConfig(suiteDir, { enabled: "true" });
const invalidConfigContext = createContextFake(invalidConfigProjectDir);
expect(
await renderPrompt(invalidConfigProjectDir, invalidConfigContext),
).toBe("Original pi prompt");
expect(invalidConfigContext.notifications).toHaveLength(1);
await writeProjectRulesConfig(suiteDir, { enabled: true });
const brokenSymlinkProjectDir = await createTempDir(
"project-rules-broken-symlink-",
);
await mkdir(join(brokenSymlinkProjectDir, ".pi"));
await writeFile(
join(brokenSymlinkProjectDir, ".pi", "valid.md"),
"Valid",
);
await symlink(
join(brokenSymlinkProjectDir, "missing.md"),
join(brokenSymlinkProjectDir, ".pi", "broken.md"),
);
const brokenSymlinkContext = createContextFake(brokenSymlinkProjectDir);
expect(
await renderPrompt(brokenSymlinkProjectDir, brokenSymlinkContext),
).toBe("Original pi prompt");
expect(brokenSymlinkContext.notifications).toHaveLength(1);
});
});
});
function createExtensionApiFake(): ExtensionApiFake {
const handlers: RegisteredHandler[] = [];
return {
handlers,
on(eventName: string, handler: unknown): void {
handlers.push({ eventName, handler });
},
} as ExtensionApiFake;
}
function createContextFake(cwd: string, hasUI?: boolean): ContextFake {
const notifications: Notification[] = [];
return {
notifications,
ctx: {
cwd,
...(hasUI !== undefined ? { hasUI } : {}),
ui: {
notify(message: string, type: string | undefined): void {
notifications.push({ message, type });
},
},
},
};
}
async function runBeforeAgentStartHandlers(
pi: ExtensionApiFake,
event: BeforeAgentStartEventFake,
ctx: ContextFake["ctx"],
): Promise {
let currentEvent = event;
for (const item of pi.handlers.filter(
(handler) => handler.eventName === "before_agent_start",
)) {
if (typeof item.handler !== "function") {
continue;
}
const result = await item.handler(currentEvent, ctx);
if (isPromptResult(result)) {
currentEvent = { ...currentEvent, systemPrompt: result.systemPrompt };
}
}
return currentEvent.systemPrompt;
}
function isPromptResult(
value: unknown,
): value is { readonly systemPrompt: string } {
return (
typeof value === "object" &&
value !== null &&
"systemPrompt" in value &&
typeof value.systemPrompt === "string"
);
}
function createBeforeAgentStartEvent(cwd: string): BeforeAgentStartEventFake {
return {
type: "before_agent_start",
prompt: "work",
images: [],
systemPrompt: "Original pi prompt",
systemPromptOptions: { cwd },
};
}
async function renderPrompt(
projectDir: string,
context = createContextFake(projectDir),
): Promise {
const pi = createExtensionApiFake();
projectRules(pi);
return runBeforeAgentStartHandlers(
pi,
createBeforeAgentStartEvent(projectDir),
context.ctx,
);
}
async function withIsolatedSuiteDir(
testBody: (suiteDir: string) => Promise,
): Promise {
const suiteDir = await createTempDir("project-rules-suite-");
process.env[AGENT_SUITE_DIR_ENV] = suiteDir;
await testBody(suiteDir);
}
async function createTempDir(prefix: string): Promise {
const dir = await mkdtemp(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
async function writeProjectRulesConfig(
suiteDir: string,
config: unknown,
): Promise {
const configDir = join(suiteDir, "project-rules");
await mkdir(configDir, { recursive: true });
await writeFile(join(configDir, "config.json"), JSON.stringify(config));
}