Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/core/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ function validateFrontmatter(data: unknown, filepath: string): void {
`Missing required field "description" in ${filepath}: must be a descriptive string`,
);
}
if (!obj.description || typeof obj.description !== "string") {
if (typeof obj.description !== "string") {
throw new Error(
`Invalid "description" field in ${filepath}: must be a non-empty string, got ${typeof obj.description}`,
`Invalid "description" field in ${filepath}: must be a string, got ${typeof obj.description}`,
);
}

Expand Down
221 changes: 168 additions & 53 deletions src/generators/rules/cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,83 +13,198 @@ describe("generateCursorConfig", () => {
});

const mockConfig: Config = {
projectName: "test-project",
rulesDir: ".rulesync",
aiRulesDir: ".rulesync",
outputPaths: {
copilot: ".github/instructions",
cursor: ".cursor/rules",
cline: ".clinerules",
claudecode: "",
claude: "",
roo: ".roo/rules",
geminicli: "",
},
watchEnabled: false,
defaultTargets: ["cursor"],
};

const mockRule: ParsedRule = {
frontmatter: {
root: true,
targets: ["cursor"],
description: "Test rule",
globs: ["**/*.ts"],
},
content: "Test rule content",
filename: "test-rule",
filepath: ".rulesync/test-rule.md",
};

it("should generate cursor config files", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] });

const outputs = await generateCursorConfig([mockRule], mockConfig);

expect(outputs).toHaveLength(1);
expect(outputs[0]).toEqual({
tool: "cursor",
filepath: ".cursor/rules/test-rule.mdc",
content: expect.stringContaining("description: Test rule"),
describe("rule type generation based on 4 type of .mdc", () => {
it("should generate 'always' type for globs: ['**/*']", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] });

const alwaysRule: ParsedRule = {
frontmatter: {
root: false,
targets: ["*"],
description: "API development rule",
globs: ["**/*"],
},
content: "# Always Applied Rule\n\nThis rule applies to all files.",
filename: "always-rule",
filepath: ".rulesync/always-rule.md",
};

const outputs = await generateCursorConfig([alwaysRule], mockConfig);

expect(outputs).toHaveLength(1);
expect(outputs[0].content).toContain("description:");
expect(outputs[0].content).toContain("globs:");
expect(outputs[0].content).toContain("alwaysApply: true");
expect(outputs[0].content).toContain("# Always Applied Rule");
});
});

it("should generate .cursorignore when .rulesyncignore exists", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({
patterns: ["*.test.md", "temp/**/*"],
it("should generate 'manual' type for empty description and empty globs", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] });

const manualRule: ParsedRule = {
frontmatter: {
root: false,
targets: ["*"],
description: "",
globs: [],
},
content: "# Manual Rule\n\nThis rule requires manual application.",
filename: "manual-rule",
filepath: ".rulesync/manual-rule.md",
};

const outputs = await generateCursorConfig([manualRule], mockConfig);

expect(outputs).toHaveLength(1);
expect(outputs[0].content).toContain("description:");
expect(outputs[0].content).toContain("globs:");
expect(outputs[0].content).toContain("alwaysApply: false");
expect(outputs[0].content).toContain("# Manual Rule");
});

const outputs = await generateCursorConfig([mockRule], mockConfig);

expect(outputs).toHaveLength(2);
it("should generate 'autoattached' type for empty description and non-empty globs", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] });

const autoAttachedRule: ParsedRule = {
frontmatter: {
root: false,
targets: ["*"],
description: "",
globs: ["**/*.json", "**/*.ts", "**/*.js"],
},
content: "# Auto Attached Rule\n\nThis rule auto-attaches to specific files.",
filename: "auto-attached-rule",
filepath: ".rulesync/auto-attached-rule.md",
};

const outputs = await generateCursorConfig([autoAttachedRule], mockConfig);

expect(outputs).toHaveLength(1);
expect(outputs[0].content).toContain("description:");
expect(outputs[0].content).toContain("globs: **/*.json,**/*.ts,**/*.js");
expect(outputs[0].content).toContain("alwaysApply: false");
expect(outputs[0].content).toContain("# Auto Attached Rule");
});

// Check rule file
expect(outputs[0].filepath).toBe(".cursor/rules/test-rule.mdc");
it("should generate 'agentrequested' type for non-empty description and empty globs", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] });

const agentRequestedRule: ParsedRule = {
frontmatter: {
root: false,
targets: ["*"],
description: "API development rule",
globs: [],
},
content: "# Agent Requested Rule\n\nThis rule is applied when requested by agent.",
filename: "agent-requested-rule",
filepath: ".rulesync/agent-requested-rule.md",
};

const outputs = await generateCursorConfig([agentRequestedRule], mockConfig);

expect(outputs).toHaveLength(1);
expect(outputs[0].content).toContain("description: API development rule");
expect(outputs[0].content).toContain("globs:");
expect(outputs[0].content).toContain("alwaysApply: false");
expect(outputs[0].content).toContain("# Agent Requested Rule");
});

// Check .cursorignore file
expect(outputs[1]).toEqual({
tool: "cursor",
filepath: ".cursorignore",
content: expect.stringContaining("# Generated by rulesync from .rulesyncignore"),
it("should handle edge case: non-empty description and non-empty globs (should be agentrequested)", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] });

const edgeCaseRule: ParsedRule = {
frontmatter: {
root: false,
targets: ["*"],
description: "API development rule",
globs: ["**/*.ts"],
},
content: "# Edge Case Rule\n\nThis has both description and globs.",
filename: "edge-case-rule",
filepath: ".rulesync/edge-case-rule.md",
};

const outputs = await generateCursorConfig([edgeCaseRule], mockConfig);

expect(outputs).toHaveLength(1);
// According to the specification order, this should be 'agentrequested'
// because it doesn't match 'always' (globs != ["**/*"]) or 'manual' (description not empty)
// or 'autoattached' (description not empty), so falls to 'agentrequested'
expect(outputs[0].content).toContain("description: API development rule");
expect(outputs[0].content).toContain("globs:");
expect(outputs[0].content).toContain("alwaysApply: false");
});
expect(outputs[1].content).toContain("*.test.md");
expect(outputs[1].content).toContain("temp/**/*");
});

it("should not generate .cursorignore when no ignore patterns exist", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] });
describe("ignore file generation", () => {
const testRule: ParsedRule = {
frontmatter: {
root: false,
targets: ["*"],
description: "Test rule",
globs: ["**/*.ts"],
},
content: "Test rule content",
filename: "test-rule",
filepath: ".rulesync/test-rule.md",
};

it("should generate .cursorignore when .rulesyncignore exists", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({
patterns: ["*.test.md", "temp/**/*"],
});

const outputs = await generateCursorConfig([testRule], mockConfig);

expect(outputs).toHaveLength(2);

// Check rule file
expect(outputs[0].filepath).toBe(".cursor/rules/test-rule.mdc");

// Check .cursorignore file
expect(outputs[1]).toEqual({
tool: "cursor",
filepath: ".cursorignore",
content: expect.stringContaining("# Generated by rulesync from .rulesyncignore"),
});
expect(outputs[1].content).toContain("*.test.md");
expect(outputs[1].content).toContain("temp/**/*");
});

const outputs = await generateCursorConfig([mockRule], mockConfig);
it("should not generate .cursorignore when no ignore patterns exist", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({ patterns: [] });

expect(outputs).toHaveLength(1);
expect(outputs.every((o) => o.filepath !== ".cursorignore")).toBe(true);
});
const outputs = await generateCursorConfig([testRule], mockConfig);

it("should respect baseDir parameter", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({
patterns: ["*.test.md"],
expect(outputs).toHaveLength(1);
expect(outputs.every((o) => o.filepath !== ".cursorignore")).toBe(true);
});

const outputs = await generateCursorConfig([mockRule], mockConfig, "/custom/base");
it("should respect baseDir parameter", async () => {
vi.mocked(loadIgnorePatterns).mockResolvedValue({
patterns: ["*.test.md"],
});

const outputs = await generateCursorConfig([testRule], mockConfig, "/custom/base");

expect(outputs).toHaveLength(2);
expect(outputs[0].filepath).toBe("/custom/base/.cursor/rules/test-rule.mdc");
expect(outputs[1].filepath).toBe("/custom/base/.cursorignore");
expect(outputs).toHaveLength(2);
expect(outputs[0].filepath).toBe("/custom/base/.cursor/rules/test-rule.mdc");
expect(outputs[1].filepath).toBe("/custom/base/.cursorignore");
});
});
});
83 changes: 69 additions & 14 deletions src/generators/rules/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,31 +44,86 @@ export async function generateCursorConfig(
function generateCursorMarkdown(rule: ParsedRule): string {
const lines: string[] = [];

// Determine rule type based on four kinds of .mdc files
const ruleType = determineCursorRuleType(rule.frontmatter);

// Add MDC header for Cursor
lines.push("---");
lines.push(`description: ${rule.frontmatter.description}`);
if (rule.frontmatter.globs.length > 0) {
lines.push(`globs: ${rule.frontmatter.globs.join(",")}`);
}

// Determine ruletype based on root and globs
let ruletype: string;
if (rule.frontmatter.root === true) {
ruletype = "always";
} else if (rule.frontmatter.root === false && rule.frontmatter.globs.length === 0) {
ruletype = "agentrequested";
} else {
ruletype = "autoattached";
switch (ruleType) {
case "always":
// 1. always: description and globs are empty, alwaysApply: true
lines.push("description:");
lines.push("globs:");
lines.push("alwaysApply: true");
break;

case "manual":
// 2. manual: keep original empty values, alwaysApply: false
lines.push("description:");
lines.push("globs:");
lines.push("alwaysApply: false");
break;

case "autoattached":
// 3. auto attached: empty description, globs from original (comma-separated), alwaysApply: false
lines.push("description:");
lines.push(`globs: ${rule.frontmatter.globs.join(",")}`);
lines.push("alwaysApply: false");
break;

case "agentrequested":
// 4. agent_request: description from original, empty globs, alwaysApply: false
lines.push(`description: ${rule.frontmatter.description}`);
lines.push("globs:");
lines.push("alwaysApply: false");
break;
}

lines.push(`ruletype: ${ruletype}`);
lines.push("---");

lines.push("");
lines.push(rule.content);

return lines.join("\n");
}

/**
* Determine Cursor rule type based on four kinds of .mdc specification
* Order of checking: 1. always → 2. manual → 3. auto attached → 4. agent_request
*/
function determineCursorRuleType(
frontmatter: import("../../types/index.js").RuleFrontmatter,
): string {
const isDescriptionEmpty = !frontmatter.description || frontmatter.description.trim() === "";
const isGlobsEmpty = frontmatter.globs.length === 0;
const isGlobsExactlyAllFiles = frontmatter.globs.length === 1 && frontmatter.globs[0] === "**/*";

// 1. always: globs is exactly ["**/*"]
if (isGlobsExactlyAllFiles) {
return "always";
}

// 2. manual: description is empty/undefined AND globs is empty/undefined
if (isDescriptionEmpty && isGlobsEmpty) {
return "manual";
}

// 3. auto attached: description is empty/undefined AND globs is non-empty (but not ["**/*"])
if (isDescriptionEmpty && !isGlobsEmpty) {
return "autoattached";
}

// 4. agent request: description is non-empty AND globs is empty/undefined
if (!isDescriptionEmpty && isGlobsEmpty) {
return "agentrequested";
}

// Edge case: description is non-empty AND globs is non-empty (but not ["**/*"])
// According to specification order, this should be treated as "agentrequested"
// because it doesn't match 1, 2, or 3, so it falls to 4
return "agentrequested";
}

function generateCursorIgnore(patterns: string[]): string {
const lines: string[] = [
"# Generated by rulesync from .rulesyncignore",
Expand Down
Loading