Skip to main content

driven/parser/
cursor.rs

1//! Cursor rules parser (.cursorrules)
2
3use super::{RuleParser, UnifiedRule, extract_bullet_points, parse_markdown_sections};
4use crate::{DrivenError, Editor, Result};
5use std::path::Path;
6
7/// Parser for Cursor .cursorrules files
8#[derive(Debug, Default)]
9pub struct CursorParser;
10
11impl CursorParser {
12    /// Create a new Cursor parser
13    pub fn new() -> Self {
14        Self
15    }
16
17    /// Detect rule type from section heading
18    fn detect_section_type(heading: &str) -> SectionKind {
19        let lower = heading.to_lowercase();
20
21        if lower.contains("persona")
22            || lower.contains("role")
23            || lower.contains("who you are")
24            || lower.contains("identity")
25        {
26            SectionKind::Persona
27        } else if lower.contains("context")
28            || lower.contains("project")
29            || lower.contains("codebase")
30        {
31            SectionKind::Context
32        } else if lower.contains("workflow") || lower.contains("process") || lower.contains("steps")
33        {
34            SectionKind::Workflow
35        } else if lower.contains("style")
36            || lower.contains("convention")
37            || lower.contains("standard")
38            || lower.contains("rule")
39            || lower.contains("guideline")
40        {
41            SectionKind::Standards
42        } else {
43            SectionKind::Other
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum SectionKind {
50    Persona,
51    Context,
52    Workflow,
53    Standards,
54    Other,
55}
56
57impl RuleParser for CursorParser {
58    fn parse_file(&self, path: &Path) -> Result<Vec<UnifiedRule>> {
59        let content = std::fs::read_to_string(path)
60            .map_err(|e| DrivenError::Parse(format!("Failed to read {}: {}", path.display(), e)))?;
61        self.parse_content(&content)
62    }
63
64    fn parse_content(&self, content: &str) -> Result<Vec<UnifiedRule>> {
65        let mut rules = Vec::new();
66        let sections = parse_markdown_sections(content);
67
68        if sections.is_empty() {
69            // No markdown structure, treat as raw content
70            if !content.trim().is_empty() {
71                rules.push(UnifiedRule::raw(content.trim()));
72            }
73            return Ok(rules);
74        }
75
76        for (heading, body) in sections {
77            let kind = Self::detect_section_type(&heading);
78
79            match kind {
80                SectionKind::Persona => {
81                    let points = extract_bullet_points(&body);
82                    let (traits, principles): (Vec<_>, Vec<_>) = points
83                        .into_iter()
84                        .partition(|p| !p.to_lowercase().contains("principle"));
85
86                    rules.push(UnifiedRule::Persona {
87                        name: heading.clone(),
88                        role: body.lines().next().unwrap_or("").to_string(),
89                        identity: None,
90                        style: None,
91                        traits,
92                        principles,
93                    });
94                }
95                SectionKind::Context => {
96                    let points = extract_bullet_points(&body);
97                    let (includes, rest): (Vec<_>, Vec<_>) = points
98                        .into_iter()
99                        .partition(|p| p.contains("**") || p.contains("src/"));
100
101                    rules.push(UnifiedRule::Context {
102                        includes,
103                        excludes: Vec::new(),
104                        focus: rest,
105                    });
106                }
107                SectionKind::Workflow => {
108                    let points = extract_bullet_points(&body);
109                    let steps = points
110                        .into_iter()
111                        .map(|p| super::WorkflowStepData {
112                            name: p.clone(),
113                            description: p,
114                            condition: None,
115                            actions: Vec::new(),
116                        })
117                        .collect();
118
119                    rules.push(UnifiedRule::Workflow {
120                        name: heading,
121                        steps,
122                    });
123                }
124                SectionKind::Standards => {
125                    let points = extract_bullet_points(&body);
126                    for (i, point) in points.into_iter().enumerate() {
127                        rules.push(UnifiedRule::Standard {
128                            category: crate::format::RuleCategory::Style,
129                            priority: i as u8,
130                            description: point,
131                            pattern: None,
132                        });
133                    }
134                }
135                SectionKind::Other => {
136                    // Store as raw content
137                    if !body.trim().is_empty() {
138                        rules.push(UnifiedRule::raw(format!("# {}\n{}", heading, body)));
139                    }
140                }
141            }
142        }
143
144        Ok(rules)
145    }
146
147    fn editor(&self) -> Editor {
148        Editor::Cursor
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_parse_simple_cursorrules() {
158        let content = r#"# Coding Guidelines
159
160- Use snake_case for functions
161- Use PascalCase for types
162- Keep functions under 50 lines
163
164# Project Context
165
166- Focus on src/ directory
167- This is a Rust project
168"#;
169
170        let parser = CursorParser::new();
171        let rules = parser.parse_content(content).unwrap();
172
173        assert!(!rules.is_empty());
174    }
175
176    #[test]
177    fn test_parse_empty() {
178        let parser = CursorParser::new();
179        let rules = parser.parse_content("").unwrap();
180        assert!(rules.is_empty());
181    }
182
183    #[test]
184    fn test_parse_no_structure() {
185        let content = "Just some plain text without markdown structure.";
186        let parser = CursorParser::new();
187        let rules = parser.parse_content(content).unwrap();
188
189        assert_eq!(rules.len(), 1);
190        assert!(matches!(rules[0], UnifiedRule::Raw { .. }));
191    }
192
193    #[test]
194    fn test_detect_section_types() {
195        assert_eq!(
196            CursorParser::detect_section_type("AI Persona"),
197            SectionKind::Persona
198        );
199        assert_eq!(
200            CursorParser::detect_section_type("Coding Conventions"),
201            SectionKind::Standards
202        );
203        assert_eq!(
204            CursorParser::detect_section_type("Project Context"),
205            SectionKind::Context
206        );
207        assert_eq!(
208            CursorParser::detect_section_type("Development Workflow"),
209            SectionKind::Workflow
210        );
211    }
212}