Skip to main content

ccboard_core/parsers/
rules.rs

1//! Parser for CLAUDE.md rules files
2//!
3//! Parses global (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) rules files.
4
5use anyhow::Result;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Combined rules from global and project CLAUDE.md files
10#[derive(Debug, Clone, Default)]
11pub struct Rules {
12    /// Global rules from ~/.claude/CLAUDE.md
13    pub global: Option<RulesFile>,
14
15    /// Project rules from .claude/CLAUDE.md
16    pub project: Option<RulesFile>,
17}
18
19/// Individual rules file
20#[derive(Debug, Clone)]
21pub struct RulesFile {
22    /// Path to the file
23    pub path: PathBuf,
24
25    /// Full content
26    pub content: String,
27
28    /// File size in bytes
29    pub size: u64,
30}
31
32impl Rules {
33    /// Load rules from global and optional project paths
34    pub fn load(claude_home: &Path, project: Option<&Path>) -> Result<Self> {
35        let global = Self::load_file(&claude_home.join("CLAUDE.md"));
36
37        let project = project.and_then(|p| Self::load_file(&p.join(".claude/CLAUDE.md")));
38
39        Ok(Rules { global, project })
40    }
41
42    /// Load a single rules file if it exists
43    fn load_file(path: &Path) -> Option<RulesFile> {
44        if !path.exists() {
45            return None;
46        }
47
48        let content = fs::read_to_string(path).ok()?;
49        let size = fs::metadata(path).ok()?.len();
50
51        Some(RulesFile {
52            path: path.to_path_buf(),
53            content,
54            size,
55        })
56    }
57
58    /// Get preview lines (first N lines) from a rules file
59    pub fn preview(file: &RulesFile, max_lines: usize) -> Vec<String> {
60        file.content
61            .lines()
62            .take(max_lines)
63            .map(|s| s.to_string())
64            .collect()
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use std::fs;
72    use tempfile::TempDir;
73
74    #[test]
75    fn test_load_global_only() {
76        let temp = TempDir::new().unwrap();
77        let claude_home = temp.path();
78
79        fs::write(
80            claude_home.join("CLAUDE.md"),
81            "# Global Rules\n\nBe helpful.",
82        )
83        .unwrap();
84
85        let rules = Rules::load(claude_home, None).unwrap();
86        assert!(rules.global.is_some());
87        assert!(rules.project.is_none());
88
89        let global = rules.global.unwrap();
90        assert_eq!(global.size, 27);
91        assert!(global.content.contains("Be helpful"));
92    }
93
94    #[test]
95    fn test_load_with_project() {
96        let temp = TempDir::new().unwrap();
97        let claude_home = temp.path();
98        let project_dir = temp.path().join("myproject");
99
100        fs::create_dir_all(project_dir.join(".claude")).unwrap();
101        fs::write(claude_home.join("CLAUDE.md"), "# Global\n").unwrap();
102        fs::write(
103            project_dir.join(".claude/CLAUDE.md"),
104            "# Project Rules\n\nUse TypeScript.",
105        )
106        .unwrap();
107
108        let rules = Rules::load(claude_home, Some(&project_dir)).unwrap();
109        assert!(rules.global.is_some());
110        assert!(rules.project.is_some());
111
112        let project = rules.project.unwrap();
113        assert!(project.content.contains("TypeScript"));
114    }
115
116    #[test]
117    fn test_load_missing_files() {
118        let temp = TempDir::new().unwrap();
119        let rules = Rules::load(temp.path(), None).unwrap();
120        assert!(rules.global.is_none());
121        assert!(rules.project.is_none());
122    }
123
124    #[test]
125    fn test_preview() {
126        let file = RulesFile {
127            path: PathBuf::from("/fake/path"),
128            content: "Line 1\nLine 2\nLine 3\nLine 4".to_string(),
129            size: 28,
130        };
131
132        let preview = Rules::preview(&file, 2);
133        assert_eq!(preview.len(), 2);
134        assert_eq!(preview[0], "Line 1");
135        assert_eq!(preview[1], "Line 2");
136    }
137}