1mod 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
29pub trait RuleParser {
31 fn parse_file(&self, path: &Path) -> Result<Vec<UnifiedRule>>;
33
34 fn parse_content(&self, content: &str) -> Result<Vec<UnifiedRule>>;
36
37 fn editor(&self) -> Editor;
39}
40
41pub 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 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()), };
65 Self { inner }
66 }
67
68 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 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 pub fn parse_file(&self, path: &Path) -> Result<Vec<UnifiedRule>> {
99 self.inner.parse_file(path)
100 }
101
102 pub fn parse_content(&self, content: &str) -> Result<Vec<UnifiedRule>> {
104 self.inner.parse_content(content)
105 }
106
107 pub fn editor(&self) -> Editor {
109 self.inner.editor()
110 }
111}
112
113pub(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 if !current_heading.is_empty() {
123 sections.push((current_heading.clone(), current_content.trim().to_string()));
124 }
125
126 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 if !current_heading.is_empty() {
137 sections.push((current_heading, current_content.trim().to_string()));
138 }
139
140 sections
141}
142
143pub(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}