Skip to content
This repository was archived by the owner on Feb 5, 2026. It is now read-only.
Merged
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
5 changes: 5 additions & 0 deletions .changeset/brown-cycles-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@multiverse-io/cari": minor
---

Handle rules file in root directory or in nested directories
5 changes: 5 additions & 0 deletions .changeset/purple-places-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@multiverse-io/cari": patch
---

Gracefully handle nested rule folders and root level rules
27 changes: 15 additions & 12 deletions src/prompting/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,21 @@ export const directoryChoice = (
repo: string,
directory: string,
ruleRelativeFilePaths: RuleFilePath[]
): PromptChoice<DirectoryChoice> => ({
name: `---> Whole directory: ${directory}`,
value: {
type: "directory",
org,
repo,
directory: directory,
ruleRelativeFilePaths,
},
short: `Dir: ${directory}`,
disabled: false,
});
): PromptChoice<DirectoryChoice> => {
const directoryForPrompt = directory === "" ? "root" : directory;
return {
name: `---> Files in directory: ${directoryForPrompt}`,
value: {
type: "directory",
org,
repo,
directory: directory,
ruleRelativeFilePaths,
},
short: `Dir: ${directory}`,
disabled: false,
};
};

export const fileChoice = (
org: string,
Expand Down
106 changes: 106 additions & 0 deletions src/rules/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,66 @@ describe("writeRulesToProject", () => {
)
).toBe("some more rule content");
});

it("should handle rules that are directly in the rules directory", async () => {
mockFs({
[projectDir]: {},
// File directly in the rules directory
"/home/user/.cari/org/repo/rules/root-rule.mdc": "root rule content",
});
const rules: RepoRules[] = [
{
org: "org",
repo: "repo",
relativeFilePaths: [
{
fileName: "root-rule.mdc",
categoryFolderName: "", // Empty for root-level rules
},
],
},
];
await writeRulesToProject(rules);

expect(
fs.readFileSync(
path.join(projectDir, ".cursor/rules/org/repo/root-rule.mdc"),
"utf8"
)
).toBe("root rule content");
});

it("should handle rules with nested category folders", async () => {
mockFs({
[projectDir]: {},
// File in nested category directories
"/home/user/.cari/org/repo/rules/typescript/events/nested-rule.mdc":
"nested rule content",
});
const rules: RepoRules[] = [
{
org: "org",
repo: "repo",
relativeFilePaths: [
{
fileName: "nested-rule.mdc",
categoryFolderName: "typescript/events", // Nested path
},
],
},
];
await writeRulesToProject(rules);

expect(
fs.readFileSync(
path.join(
projectDir,
".cursor/rules/org/repo/typescript/events/nested-rule.mdc"
),
"utf8"
)
).toBe("nested rule content");
});
});

describe("getCentralRules", () => {
Expand Down Expand Up @@ -154,6 +214,52 @@ describe("getCentralRules", () => {
]);
});

it("should extract categoryFolderName as path relative to rules directory", async () => {
const mockFolderStructure = {
"/home/user/.cari/my-org/rules-repo": {
rules: {
// File directly in rules directory
"root-rule.mdc": "root rule content",
// File in category directory
category: {
"category-rule.mdc": "category rule content",
},
// File in nested category directories
typescript: {
events: {
"events-rule.mdc": "events rule content",
},
},
},
},
};

mockFs(mockFolderStructure);
const centralRules = await getCentralRules([
{
orgName: "my-org",
repoName: "rules-repo",
repoDir: "/home/user/.cari/my-org/rules-repo",
},
]);

// Find each rule by filename and check its categoryFolderName
const rootRule = centralRules[0].relativeFilePaths.find(
(rule) => rule.fileName === "root-rule.mdc"
);
const categoryRule = centralRules[0].relativeFilePaths.find(
(rule) => rule.fileName === "category-rule.mdc"
);
const eventsRule = centralRules[0].relativeFilePaths.find(
(rule) => rule.fileName === "events-rule.mdc"
);

// Assert that categoryFolderName is correctly set
expect(rootRule?.categoryFolderName).toBe("");
expect(categoryRule?.categoryFolderName).toBe("category");
expect(eventsRule?.categoryFolderName).toBe("typescript/events");
});

it("should log a warning when a repo has no rules", async () => {
const mockFolderStructure = {
"/home/user/.cari/my-org/empty-repo": {
Expand Down
12 changes: 7 additions & 5 deletions src/rules/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export const getCentralRules = async (

const fileNames = relativeFilePaths.map((filePath) => {
const fileName = path.basename(filePath);
const categoryFolderName = path.basename(path.dirname(filePath));
const rulesDir = path.join(repoDir, "rules");
const fileDir = path.dirname(filePath);
const categoryFolderName =
fileDir === rulesDir ? "" : path.relative(rulesDir, fileDir);
return {
fileName,
categoryFolderName,
Expand Down Expand Up @@ -91,11 +94,10 @@ const getCentralRepoRulePath = (
repo: string,
relativeFilePath: RuleFilePath
) => {
const basePath = path.join(getAriHomeDir(), org, repo, "rules");

return path.join(
getAriHomeDir(),
org,
repo,
"rules",
basePath,
relativeFilePath.categoryFolderName,
relativeFilePath.fileName
);
Expand Down