1use colored::Color;
2use regex::{Regex, RegexBuilder};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct TodoItem {
9 pub tag: String,
11
12 pub message: String,
14
15 pub line: usize,
17
18 pub column: usize,
20
21 pub line_content: String,
23
24 pub author: Option<String>,
26
27 pub priority: Priority,
29}
30
31#[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 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 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#[derive(Debug, Clone)]
65pub struct TodoParser {
66 pattern: Option<Regex>,
68
69 tags: Vec<String>,
71
72 case_sensitive: bool,
74}
75
76impl TodoParser {
77 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 fn build_pattern(tags: &[String], case_sensitive: bool) -> Option<Regex> {
89 if tags.is_empty() {
90 return None;
91 }
92
93 let escaped_tags: Vec<String> = tags.iter().map(|t| regex::escape(t)).collect();
95
96 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 pub fn parse_line(&self, line: &str, line_number: usize) -> Option<TodoItem> {
118 let pattern = self.pattern.as_ref()?;
119
120 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 let column = tag_match.start() + 1;
134
135 let normalized_tag = if self.case_sensitive {
137 tag
138 } else {
139 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 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 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 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 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 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 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}