todo_tree/
parser.rs

1use colored::Color;
2use regex::{Regex, RegexBuilder};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6/// Represents a found TODO item in the source code
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct TodoItem {
9    /// The tag that was matched (e.g., "TODO", "FIXME")
10    pub tag: String,
11
12    /// The message following the tag
13    pub message: String,
14
15    /// Line number where the tag was found (1-indexed)
16    pub line: usize,
17
18    /// Column number where the tag starts (1-indexed)
19    pub column: usize,
20
21    /// The full line content
22    pub line_content: String,
23
24    /// Optional author/assignee if specified (e.g., TODO(john): ...)
25    pub author: Option<String>,
26
27    /// Priority level inferred from tag type
28    pub priority: Priority,
29}
30
31/// Priority levels for different tag types
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
33pub enum Priority {
34    Low,
35    Medium,
36    High,
37    Critical,
38}
39
40impl Priority {
41    /// Infer priority from tag name
42    pub fn from_tag(tag: &str) -> Self {
43        match tag.to_uppercase().as_str() {
44            "BUG" | "FIXME" | "XXX" => Priority::Critical,
45            "HACK" | "WARN" | "WARNING" => Priority::High,
46            "TODO" | "PERF" => Priority::Medium,
47            "NOTE" | "INFO" | "IDEA" => Priority::Low,
48            _ => Priority::Medium,
49        }
50    }
51
52    /// Get the color associated with this priority level
53    pub fn to_color(self) -> Color {
54        match self {
55            Priority::Critical => Color::Red,
56            Priority::High => Color::Yellow,
57            Priority::Medium => Color::Cyan,
58            Priority::Low => Color::Green,
59        }
60    }
61}
62
63/// Parser for detecting TODO-style tags in source code
64#[derive(Debug, Clone)]
65pub struct TodoParser {
66    /// Compiled regex pattern for matching tags (None if no tags to search for)
67    pattern: Option<Regex>,
68
69    /// Tags being searched for
70    tags: Vec<String>,
71
72    /// Whether matching is case-sensitive
73    case_sensitive: bool,
74}
75
76impl TodoParser {
77    /// Create a new parser with the given tags
78    pub fn new(tags: &[String], case_sensitive: bool) -> Self {
79        let pattern = Self::build_pattern(tags, case_sensitive);
80        Self {
81            pattern,
82            tags: tags.to_vec(),
83            case_sensitive,
84        }
85    }
86
87    /// Build the regex pattern for matching tags
88    fn build_pattern(tags: &[String], case_sensitive: bool) -> Option<Regex> {
89        if tags.is_empty() {
90            return None;
91        }
92
93        // Escape special regex characters in tags
94        let escaped_tags: Vec<String> = tags.iter().map(|t| regex::escape(t)).collect();
95
96        // Build pattern that matches:
97        // - Optional comment prefix (// # /* <!-- -- ; etc.)
98        // - Tag
99        // - Optional author in parentheses
100        // - Optional colon
101        // - Message
102        let pattern = format!(
103            r"(?:^|[^a-zA-Z0-9_])({tags})(?:\(([^)]+)\))?[:\s]+(.*)$",
104            tags = escaped_tags.join("|")
105        );
106
107        Some(
108            RegexBuilder::new(&pattern)
109                .case_insensitive(!case_sensitive)
110                .multi_line(true)
111                .build()
112                .expect("Failed to build regex pattern"),
113        )
114    }
115
116    /// Parse a single line for TODO items
117    pub fn parse_line(&self, line: &str, line_number: usize) -> Option<TodoItem> {
118        let pattern = self.pattern.as_ref()?;
119
120        // Try to match the pattern
121        if let Some(captures) = pattern.captures(line) {
122            let tag_match = captures.get(1)?;
123            let tag = tag_match.as_str().to_string();
124
125            let author = captures.get(2).map(|m| m.as_str().to_string());
126
127            let message = captures
128                .get(3)
129                .map(|m| m.as_str().trim().to_string())
130                .unwrap_or_default();
131
132            // Calculate column (1-indexed)
133            let column = tag_match.start() + 1;
134
135            // Normalize the tag case for consistency
136            let normalized_tag = if self.case_sensitive {
137                tag
138            } else {
139                // Find the matching tag from our list (preserving original case)
140                self.tags
141                    .iter()
142                    .find(|t| t.eq_ignore_ascii_case(&tag))
143                    .cloned()
144                    .unwrap_or(tag)
145            };
146
147            let priority = Priority::from_tag(&normalized_tag);
148
149            return Some(TodoItem {
150                tag: normalized_tag,
151                message,
152                line: line_number,
153                column,
154                line_content: line.to_string(),
155                author,
156                priority,
157            });
158        }
159
160        None
161    }
162
163    /// Parse content (multiple lines) for TODO items
164    pub fn parse_content(&self, content: &str) -> Vec<TodoItem> {
165        content
166            .lines()
167            .enumerate()
168            .filter_map(|(idx, line)| self.parse_line(line, idx + 1))
169            .collect()
170    }
171
172    /// Parse a file for TODO items
173    pub fn parse_file(&self, path: &Path) -> std::io::Result<Vec<TodoItem>> {
174        let content = std::fs::read_to_string(path)?;
175        Ok(self.parse_content(&content))
176    }
177
178    /// Get the tags being searched for
179    pub fn tags(&self) -> &[String] {
180        &self.tags
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn default_tags() -> Vec<String> {
189        vec![
190            "TODO".to_string(),
191            "FIXME".to_string(),
192            "BUG".to_string(),
193            "NOTE".to_string(),
194            "HACK".to_string(),
195        ]
196    }
197
198    #[test]
199    fn test_parse_simple_todo() {
200        let parser = TodoParser::new(&default_tags(), false);
201        let result = parser.parse_line("// TODO: Fix this later", 1);
202
203        assert!(result.is_some());
204        let item = result.unwrap();
205        assert_eq!(item.tag, "TODO");
206        assert_eq!(item.message, "Fix this later");
207        assert_eq!(item.line, 1);
208    }
209
210    #[test]
211    fn test_parse_todo_with_author() {
212        let parser = TodoParser::new(&default_tags(), false);
213        let result = parser.parse_line("// TODO(john): Implement this", 5);
214
215        assert!(result.is_some());
216        let item = result.unwrap();
217        assert_eq!(item.tag, "TODO");
218        assert_eq!(item.author, Some("john".to_string()));
219        assert_eq!(item.message, "Implement this");
220    }
221
222    #[test]
223    fn test_parse_hash_comment() {
224        let parser = TodoParser::new(&default_tags(), false);
225        let result = parser.parse_line("# FIXME: This is broken", 1);
226
227        assert!(result.is_some());
228        let item = result.unwrap();
229        assert_eq!(item.tag, "FIXME");
230        assert_eq!(item.message, "This is broken");
231    }
232
233    #[test]
234    fn test_parse_case_insensitive() {
235        let parser = TodoParser::new(&default_tags(), false);
236
237        let result1 = parser.parse_line("// todo: lowercase", 1);
238        assert!(result1.is_some());
239        assert_eq!(result1.unwrap().tag, "TODO");
240
241        let result2 = parser.parse_line("// Todo: mixed case", 1);
242        assert!(result2.is_some());
243        assert_eq!(result2.unwrap().tag, "TODO");
244    }
245
246    #[test]
247    fn test_parse_case_sensitive() {
248        let parser = TodoParser::new(&default_tags(), true);
249
250        let result1 = parser.parse_line("// TODO: uppercase", 1);
251        assert!(result1.is_some());
252
253        let result2 = parser.parse_line("// todo: lowercase", 1);
254        assert!(result2.is_none());
255    }
256
257    #[test]
258    fn test_parse_multiple_lines() {
259        let parser = TodoParser::new(&default_tags(), false);
260        let content = r#"
261// Regular comment
262// TODO: First item
263fn main() {}
264// FIXME: Second item
265// NOTE: Third item
266"#;
267        let items = parser.parse_content(content);
268
269        assert_eq!(items.len(), 3);
270        assert_eq!(items[0].tag, "TODO");
271        assert_eq!(items[1].tag, "FIXME");
272        assert_eq!(items[2].tag, "NOTE");
273    }
274
275    #[test]
276    fn test_priority_from_tag() {
277        assert_eq!(Priority::from_tag("BUG"), Priority::Critical);
278        assert_eq!(Priority::from_tag("FIXME"), Priority::Critical);
279        assert_eq!(Priority::from_tag("HACK"), Priority::High);
280        assert_eq!(Priority::from_tag("TODO"), Priority::Medium);
281        assert_eq!(Priority::from_tag("NOTE"), Priority::Low);
282    }
283
284    #[test]
285    fn test_todo_without_colon() {
286        let parser = TodoParser::new(&default_tags(), false);
287        let result = parser.parse_line("// TODO fix this", 1);
288
289        assert!(result.is_some());
290        let item = result.unwrap();
291        assert_eq!(item.tag, "TODO");
292        assert_eq!(item.message, "fix this");
293    }
294
295    #[test]
296    fn test_empty_tags() {
297        let parser = TodoParser::new(&[], false);
298        let result = parser.parse_line("// TODO: something", 1);
299        assert!(result.is_none());
300    }
301
302    #[test]
303    fn test_special_characters_in_message() {
304        let parser = TodoParser::new(&default_tags(), false);
305        let result = parser.parse_line("// TODO: Handle special chars: @#$%^&*()", 1);
306
307        assert!(result.is_some());
308        let item = result.unwrap();
309        assert!(item.message.contains("@#$%^&*()"));
310    }
311
312    #[test]
313    fn test_priority_to_color() {
314        // Test all priority levels have a color
315        assert_eq!(Priority::Critical.to_color(), Color::Red);
316        assert_eq!(Priority::High.to_color(), Color::Yellow);
317        assert_eq!(Priority::Medium.to_color(), Color::Cyan);
318        assert_eq!(Priority::Low.to_color(), Color::Green);
319    }
320
321    #[test]
322    fn test_priority_from_unknown_tag() {
323        // Unknown tags should default to Medium priority
324        assert_eq!(Priority::from_tag("UNKNOWN"), Priority::Medium);
325        assert_eq!(Priority::from_tag("CUSTOM"), Priority::Medium);
326        assert_eq!(Priority::from_tag("RANDOM"), Priority::Medium);
327    }
328
329    #[test]
330    fn test_priority_from_tag_case_variations() {
331        // Test case variations
332        assert_eq!(Priority::from_tag("bug"), Priority::Critical);
333        assert_eq!(Priority::from_tag("Bug"), Priority::Critical);
334        assert_eq!(Priority::from_tag("hack"), Priority::High);
335        assert_eq!(Priority::from_tag("Hack"), Priority::High);
336        assert_eq!(Priority::from_tag("warn"), Priority::High);
337        assert_eq!(Priority::from_tag("WARNING"), Priority::High);
338        assert_eq!(Priority::from_tag("perf"), Priority::Medium);
339        assert_eq!(Priority::from_tag("info"), Priority::Low);
340        assert_eq!(Priority::from_tag("IDEA"), Priority::Low);
341    }
342
343    #[test]
344    fn test_parse_file() {
345        use tempfile::TempDir;
346
347        let temp_dir = TempDir::new().unwrap();
348        let file_path = temp_dir.path().join("test.rs");
349
350        std::fs::write(
351            &file_path,
352            r#"
353// TODO: First item
354fn main() {
355    // FIXME: Second item
356}
357"#,
358        )
359        .unwrap();
360
361        let parser = TodoParser::new(&default_tags(), false);
362        let items = parser.parse_file(&file_path).unwrap();
363
364        assert_eq!(items.len(), 2);
365        assert_eq!(items[0].tag, "TODO");
366        assert_eq!(items[1].tag, "FIXME");
367    }
368
369    #[test]
370    fn test_parse_file_nonexistent() {
371        let parser = TodoParser::new(&default_tags(), false);
372        let result = parser.parse_file(std::path::Path::new("/nonexistent/file.rs"));
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn test_parser_tags_method() {
378        let tags = default_tags();
379        let parser = TodoParser::new(&tags, false);
380        assert_eq!(parser.tags(), &tags);
381    }
382
383    #[test]
384    fn test_parse_xxx_tag() {
385        let tags = vec!["XXX".to_string()];
386        let parser = TodoParser::new(&tags, false);
387        let result = parser.parse_line("// XXX: Critical issue", 1);
388
389        assert!(result.is_some());
390        let item = result.unwrap();
391        assert_eq!(item.tag, "XXX");
392        assert_eq!(item.priority, Priority::Critical);
393    }
394
395    #[test]
396    fn test_todo_item_equality() {
397        let item1 = TodoItem {
398            tag: "TODO".to_string(),
399            message: "Test".to_string(),
400            line: 1,
401            column: 1,
402            line_content: "// TODO: Test".to_string(),
403            author: None,
404            priority: Priority::Medium,
405        };
406
407        let item2 = TodoItem {
408            tag: "TODO".to_string(),
409            message: "Test".to_string(),
410            line: 1,
411            column: 1,
412            line_content: "// TODO: Test".to_string(),
413            author: None,
414            priority: Priority::Medium,
415        };
416
417        assert_eq!(item1, item2);
418    }
419
420    #[test]
421    fn test_priority_ordering() {
422        assert!(Priority::Critical > Priority::High);
423        assert!(Priority::High > Priority::Medium);
424        assert!(Priority::Medium > Priority::Low);
425    }
426}