import { existsSync, readdirSync, realpathSync, statSync, } from "node:fs"; import { dirname, join, relative } from "node:path"; import { PROJECT_MARKERS, PROJECT_RULE_SUBDIRS, RULE_EXTENSIONS, USER_RULE_DIR, } from "./constants"; /** * Candidate rule file with metadata for filtering and sorting */ export interface RuleFileCandidate { /** Absolute path to the rule file */ path: string; /** Real path after symlink resolution (for duplicate detection) */ realPath: string; /** Whether this is a global/user-level rule */ isGlobal: boolean; /** Directory distance from current file (9999 for global rules) */ distance: number; } /** * Find project root by walking up from startPath. * Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.) * * @param startPath - Starting path to search from (file or directory) * @returns Project root path or null if not found */ export function findProjectRoot(startPath: string): string | null { let current: string; try { const stat = statSync(startPath); current = stat.isDirectory() ? startPath : dirname(startPath); } catch { current = dirname(startPath); } while (true) { for (const marker of PROJECT_MARKERS) { const markerPath = join(current, marker); if (existsSync(markerPath)) { return current; } } const parent = dirname(current); if (parent === current) { return null; } current = parent; } } /** * Recursively find all rule files (*.md, *.mdc) in a directory * * @param dir - Directory to search * @param results - Array to accumulate results */ function findRuleFilesRecursive(dir: string, results: string[]): void { if (!existsSync(dir)) return; try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { findRuleFilesRecursive(fullPath, results); } else if (entry.isFile()) { const isRuleFile = RULE_EXTENSIONS.some((ext) => entry.name.endsWith(ext), ); if (isRuleFile) { results.push(fullPath); } } } } catch { // Permission denied or other errors - silently skip } } /** * Resolve symlinks safely with fallback to original path * * @param filePath - Path to resolve * @returns Real path or original path if resolution fails */ function safeRealpathSync(filePath: string): string { try { return realpathSync(filePath); } catch { return filePath; } } /** * Calculate directory distance between a rule file and current file. * Distance is based on common ancestor within project root. * * @param rulePath - Path to the rule file * @param currentFile - Path to the current file being edited * @param projectRoot - Project root for relative path calculation * @returns Distance (0 = same directory, higher = further) */ export function calculateDistance( rulePath: string, currentFile: string, projectRoot: string | null, ): number { if (!projectRoot) { return 9999; } try { const ruleDir = dirname(rulePath); const currentDir = dirname(currentFile); const ruleRel = relative(projectRoot, ruleDir); const currentRel = relative(projectRoot, currentDir); // Handle paths outside project root if (ruleRel.startsWith("..") || currentRel.startsWith("..")) { return 9999; } const ruleParts = ruleRel ? ruleRel.split("/") : []; const currentParts = currentRel ? currentRel.split("/") : []; // Find common prefix length let common = 0; for (let i = 0; i < Math.min(ruleParts.length, currentParts.length); i++) { if (ruleParts[i] === currentParts[i]) { common++; } else { break; } } // Distance is how many directories up from current file to common ancestor return currentParts.length - common; } catch { return 9999; } } /** * Find all rule files for a given context. * Searches from currentFile upward to projectRoot for rule directories, * then user-level directory (~/.claude/rules). * * IMPORTANT: This searches EVERY directory from file to project root. * Not just the project root itself. * * @param projectRoot - Project root path (or null if outside any project) * @param homeDir - User home directory * @param currentFile - Current file being edited (for distance calculation) * @returns Array of rule file candidates sorted by distance */ export function findRuleFiles( projectRoot: string | null, homeDir: string, currentFile: string, ): RuleFileCandidate[] { const candidates: RuleFileCandidate[] = []; const seenRealPaths = new Set(); // Search from current file's directory up to project root let currentDir = dirname(currentFile); let distance = 0; while (true) { // Search rule directories in current directory for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) { const ruleDir = join(currentDir, parent, subdir); const files: string[] = []; findRuleFilesRecursive(ruleDir, files); for (const filePath of files) { const realPath = safeRealpathSync(filePath); if (seenRealPaths.has(realPath)) continue; seenRealPaths.add(realPath); candidates.push({ path: filePath, realPath, isGlobal: false, distance, }); } } // Stop at project root or filesystem root if (projectRoot && currentDir === projectRoot) break; const parentDir = dirname(currentDir); if (parentDir === currentDir) break; currentDir = parentDir; distance++; } // Search user-level rule directory (~/.claude/rules) const userRuleDir = join(homeDir, USER_RULE_DIR); const userFiles: string[] = []; findRuleFilesRecursive(userRuleDir, userFiles); for (const filePath of userFiles) { const realPath = safeRealpathSync(filePath); if (seenRealPaths.has(realPath)) continue; seenRealPaths.add(realPath); candidates.push({ path: filePath, realPath, isGlobal: true, distance: 9999, // Global rules always have max distance }); } // Sort by distance (closest first, then global rules last) candidates.sort((a, b) => { if (a.isGlobal !== b.isGlobal) { return a.isGlobal ? 1 : -1; } return a.distance - b.distance; }); return candidates; }