Skip to main content

driven/parser/
mod.rs

1//! Universal Rule Parser
2//!
3//! Parses AI coding rules from various editor formats into a unified AST.
4//!
5//! Supported formats:
6//! - Cursor: `.cursorrules`
7//! - Copilot: `copilot-instructions.md`
8//! - Windsurf: `.windsurfrules`
9//! - Claude: `.claude/` folder
10//! - Aider: `.aider` files
11
12mod aider;
13mod claude;
14mod copilot;
15mod cursor;
16mod unified;
17mod windsurf;
18
19pub use aider::AiderParser;
20pub use claude::ClaudeParser;
21pub use copilot::CopilotParser;
22pub use cursor::CursorParser;
23pub use unified::{ParsedRule, UnifiedRule, WorkflowStepData};
24pub use windsurf::WindsurfParser;
25
26use crate::{DrivenError, Editor, Result};
27use std::path::Path;
28
29/// Trait for parsing editor-specific rule formats
30pub trait RuleParser {
31    /// Parse rules from a file
32    fn parse_file(&self, path: &Path) -> Result<Vec<UnifiedRule>>;
33
34    /// Parse rules from content
35    fn parse_content(&self, content: &str) -> Result<Vec<UnifiedRule>>;
36
37    /// Get the editor this parser handles
38    fn editor(&self) -> Editor;
39}
40
41/// Universal parser that auto-detects format
42pub struct Parser {
43    inner: Box<dyn RuleParser + Send + Sync>,
44}
45
46impl std::fmt::Debug for Parser {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("Parser")
49            .field("editor", &self.inner.editor())
50            .finish()
51    }
52}
53
54impl Parser {
55    /// Create a parser for a specific editor
56    pub fn for_editor(editor: Editor) -> Self {
57        let inner: Box<dyn RuleParser + Send + Sync> = match editor {
58            Editor::Cursor => Box::new(CursorParser::new()),
59            Editor::Copilot => Box::new(CopilotParser::new()),
60            Editor::Windsurf => Box::new(WindsurfParser::new()),
61            Editor::Claude => Box::new(ClaudeParser::new()),
62            Editor::Aider => Box::new(AiderParser::new()),
63            Editor::Cline => Box::new(CursorParser::new()), // Similar format
64        };
65        Self { inner }
66    }
67
68    /// Auto-detect parser from file path
69    pub fn detect(path: &Path) -> Result<Self> {
70        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
71
72        let editor = if file_name == ".cursorrules" || file_name.ends_with(".cursorrules") {
73            Editor::Cursor
74        } else if file_name == "copilot-instructions.md"
75            || path.to_string_lossy().contains(".github")
76        {
77            Editor::Copilot
78        } else if file_name == ".windsurfrules" || file_name.ends_with(".windsurfrules") {
79            Editor::Windsurf
80        } else if file_name == "CLAUDE.md" || path.to_string_lossy().contains(".claude") {
81            Editor::Claude
82        } else if file_name.contains(".aider") || file_name == "aider.conf.yml" {
83            Editor::Aider
84        } else if file_name.ends_with(".md") {
85            // Default to Copilot for markdown
86            Editor::Copilot
87        } else {
88            return Err(DrivenError::UnsupportedFormat(format!(
89                "Cannot detect format for: {}",
90                path.display()
91            )));
92        };
93
94        Ok(Self::for_editor(editor))
95    }
96
97    /// Parse a file
98    pub fn parse_file(&self, path: &Path) -> Result<Vec<UnifiedRule>> {
99        self.inner.parse_file(path)
100    }
101
102    /// Parse content
103    pub fn parse_content(&self, content: &str) -> Result<Vec<UnifiedRule>> {
104        self.inner.parse_content(content)
105    }
106
107    /// Get the detected editor
108    pub fn editor(&self) -> Editor {
109        self.inner.editor()
110    }
111}
112
113/// Common markdown section parser
114pub(crate) fn parse_markdown_sections(content: &str) -> Vec<(String, String)> {
115    let mut sections = Vec::new();
116    let mut current_heading = String::new();
117    let mut current_content = String::new();
118
119    for line in content.lines() {
120        if let Some(stripped) = line.strip_prefix('#') {
121            // Save previous section if any
122            if !current_heading.is_empty() {
123                sections.push((current_heading.clone(), current_content.trim().to_string()));
124            }
125
126            // Start new section
127            current_heading = stripped.trim_start_matches('#').trim().to_string();
128            current_content.clear();
129        } else {
130            current_content.push_str(line);
131            current_content.push('\n');
132        }
133    }
134
135    // Save last section
136    if !current_heading.is_empty() {
137        sections.push((current_heading, current_content.trim().to_string()));
138    }
139
140    sections
141}
142
143/// Extract bullet points from markdown content
144pub(crate) fn extract_bullet_points(content: &str) -> Vec<String> {
145    content
146        .lines()
147        .filter_map(|line| {
148            let trimmed = line.trim();
149            if let Some(rest) = trimmed.strip_prefix("- ") {
150                Some(rest.to_string())
151            } else if let Some(rest) = trimmed.strip_prefix("* ") {
152                Some(rest.to_string())
153            } else {
154                trimmed.strip_prefix("• ").map(|rest| rest.to_string())
155            }
156        })
157        .collect()
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_detect_cursor() {
166        let parser = Parser::detect(Path::new(".cursorrules")).unwrap();
167        assert_eq!(parser.editor(), Editor::Cursor);
168    }
169
170    #[test]
171    fn test_detect_copilot() {
172        let parser = Parser::detect(Path::new(".github/copilot-instructions.md")).unwrap();
173        assert_eq!(parser.editor(), Editor::Copilot);
174    }
175
176    #[test]
177    fn test_detect_windsurf() {
178        let parser = Parser::detect(Path::new(".windsurfrules")).unwrap();
179        assert_eq!(parser.editor(), Editor::Windsurf);
180    }
181
182    #[test]
183    fn test_parse_markdown_sections() {
184        let content = r#"# Section One
185Content for section one.
186
187## Section Two
188Content for section two.
189"#;
190
191        let sections = parse_markdown_sections(content);
192        assert_eq!(sections.len(), 2);
193        assert_eq!(sections[0].0, "Section One");
194        assert_eq!(sections[1].0, "Section Two");
195    }
196
197    #[test]
198    fn test_extract_bullet_points() {
199        let content = r#"Some text
200- Point one
201- Point two
202* Point three
203• Point four
204Not a point
205"#;
206
207        let points = extract_bullet_points(content);
208        assert_eq!(points.len(), 4);
209        assert_eq!(points[0], "Point one");
210        assert_eq!(points[3], "Point four");
211    }
212}