rusty_todo_md/todo_extractor_internal/
aggregator.rs

1use log::debug;
2use std::path::Path;
3use std::{marker::PhantomData, path::PathBuf};
4
5use crate::todo_extractor_internal::languages::common::CommentParser;
6use crate::todo_extractor_internal::languages::common_syntax;
7use log::{error, info};
8use pest::Parser;
9
10/// Represents a single found marked item.
11#[derive(Debug, PartialEq, Clone, Eq)]
12pub struct MarkedItem {
13    pub file_path: PathBuf,
14    pub line_number: usize,
15    pub message: String,
16    pub marker: String,
17}
18
19/// Configuration for comment markers.
20pub struct MarkerConfig {
21    pub markers: Vec<String>,
22}
23
24impl MarkerConfig {
25    /// Normalize all markers: strip trailing colons and whitespace.
26    pub fn normalized(markers: Vec<String>) -> Self {
27        let markers = markers
28            .into_iter()
29            .map(|m| m.trim().trim_end_matches(':').trim().to_string())
30            .collect();
31        MarkerConfig { markers }
32    }
33}
34
35impl Default for MarkerConfig {
36    fn default() -> Self {
37        MarkerConfig {
38            markers: vec!["TODO".to_string()],
39        }
40    }
41}
42
43/// Generic function to parse comments from source code.
44///
45/// - `parser`: A `pest::Parser` implementation (e.g., `RustParser`, `PythonParser`).
46/// - `rule`: The top-level rule for parsing the file.
47/// - `file_content`: The source code text.
48/// - Returns: A `Vec<CommentLine>` containing extracted comments.
49pub fn parse_comments<P: Parser<R>, R: pest::RuleType>(
50    _parser_type: PhantomData<P>,
51    rule: R,
52    file_content: &str,
53) -> Vec<CommentLine> {
54    let parse_result = P::parse(rule, file_content);
55    let mut comments = Vec::new();
56
57    match parse_result {
58        Ok(pairs) => {
59            debug!(
60                "Parsing successful! Found {} top-level pairs.",
61                pairs.clone().count()
62            );
63
64            for pair in pairs {
65                // Iterate over children of the rust_file or python_file.
66                for inner_pair in pair.into_inner() {
67                    //debug!(
68                    //    "Processing child pair: {:?} => '{}'",
69                    //    inner_pair.as_rule(),
70                    //    inner_pair.as_str().replace('\n', "\\n")
71                    //);
72
73                    if let Some(comment) = extract_comment_from_pair(inner_pair) {
74                        debug!("Extracted comment: {comment:?}",);
75                        comments.push(comment);
76                    } else {
77                        //debug!("Skipped non-comment pair.");
78                    }
79                }
80            }
81        }
82        Err(e) => {
83            error!("Parsing error: {e:?}");
84        }
85    }
86
87    comments
88}
89
90/// Extracts a comment from a given `pest::iterators::Pair`.
91///
92/// - `pair`: A `pest::iterators::Pair` representing a parsed token.
93/// - Returns: An `Option<CommentLine>` containing the extracted comment if successful.
94fn extract_comment_from_pair(
95    pair: pest::iterators::Pair<impl pest::RuleType>,
96) -> Option<CommentLine> {
97    let span = pair.as_span();
98    let base_line = span.start_pos().line_col().0; // Get line number
99    let text = span.as_str().trim(); // Extract the comment text
100
101    let rule_name = format!("{:?}", pair.as_rule()).to_lowercase();
102    // Skip tokens whose rule names contain "non_comment"
103    if rule_name.contains("non_comment") {
104        return None;
105    }
106    // Accept tokens if they are a comment or a docstring
107    if (rule_name.contains("comment") || rule_name.contains("docstring")) && !text.is_empty() {
108        Some(CommentLine {
109            line_number: base_line,
110            text: text.to_string(),
111        })
112    } else {
113        None
114    }
115}
116
117// Splits a multi-line comment into individual `CommentLine` entries.
118//
119// - `line`: A `CommentLine` containing multiple lines of text.
120// - Returns: A `Vec<CommentLine>` with each line split into a separate entry.
121fn split_multiline_comment_line(line: &CommentLine) -> Vec<CommentLine> {
122    let mut result = Vec::new();
123    // Split the text by newline.
124    for (i, part) in line.text.split('\n').enumerate() {
125        // Assume that the first part retains the original line number,
126        // and subsequent parts increment the line number.
127        result.push(CommentLine {
128            line_number: line.line_number + i,
129            text: part.to_string(),
130        });
131    }
132    result
133}
134
135// Flattens a list of `CommentLine` entries, splitting any multi-line comments
136// into individual `CommentLine` entries.
137//
138// - `lines`: A slice of `CommentLine` entries.
139// - Returns: A `Vec<CommentLine>` with all multi-line comments split into individual entries.
140fn flatten_comment_lines(lines: &[CommentLine]) -> Vec<CommentLine> {
141    let mut flattened = Vec::new();
142    for line in lines {
143        if line.text.contains('\n') {
144            flattened.extend(split_multiline_comment_line(line));
145        } else {
146            flattened.push(line.clone());
147        }
148    }
149    flattened
150}
151
152/// Determines the effective extension for a file, handling special cases like Dockerfile.
153///
154/// - `path`: The file path to analyze.
155/// - Returns: The effective extension as a string.
156pub fn get_effective_extension(path: &Path) -> String {
157    let extension = path
158        .extension()
159        .and_then(|s| s.to_str())
160        .unwrap_or("")
161        .to_lowercase();
162
163    // Handle special filenames like Dockerfile which have no extension
164    let file_name = path
165        .file_name()
166        .and_then(|s| s.to_str())
167        .unwrap_or("")
168        .to_lowercase();
169
170    if extension.is_empty() && file_name == "dockerfile" {
171        "dockerfile".to_string()
172    } else {
173        extension
174    }
175}
176
177/// Returns the appropriate parser function for a given file extension.
178///
179/// - `extension`: The file extension (e.g., "py", "rs").
180/// - Returns: An `Option` containing the parser function if supported.
181pub fn get_parser_for_extension(
182    extension: &str,
183    file_path: &Path,
184) -> Option<fn(&str) -> Vec<CommentLine>> {
185    let result: Option<fn(&str) -> Vec<CommentLine>> = match extension {
186        // Python-style comments (# only)
187        "py" => {
188            Some(crate::todo_extractor_internal::languages::python::PythonParser::parse_comments)
189        }
190
191        // Rust-style comments (// and /* */)
192        "rs" => Some(crate::todo_extractor_internal::languages::rust::RustParser::parse_comments),
193
194        // JavaScript and similar C-style comment languages (// and /* */)
195        "js" | "jsx" | "mjs" => {
196            Some(crate::todo_extractor_internal::languages::js::JsParser::parse_comments)
197        }
198
199        // Other C-style comment languages (using JS parser for // and /* */ comments)
200        "ts" | "tsx" | "java" | "cpp" | "hpp" | "cc" | "hh" | "cs" | "swift" | "kt" | "kts"
201        | "json" => Some(crate::todo_extractor_internal::languages::js::JsParser::parse_comments),
202
203        // Go-style comments (similar to C-style but with specific handling)
204        "go" => Some(crate::todo_extractor_internal::languages::go::GoParser::parse_comments),
205
206        // Hash-style comment languages (# only, using Python parser for line comments)
207        "sh" => Some(crate::todo_extractor_internal::languages::shell::ShellParser::parse_comments),
208        "toml" => Some(crate::todo_extractor_internal::languages::toml::TomlParser::parse_comments),
209        "dockerfile" => Some(
210            crate::todo_extractor_internal::languages::dockerfile::DockerfileParser::parse_comments,
211        ),
212
213        // YAML-style comments (# only)
214        "yml" | "yaml" => {
215            Some(crate::todo_extractor_internal::languages::yaml::YamlParser::parse_comments)
216        }
217
218        // SQL-style comments (-- for line comments)
219        "sql" => Some(crate::todo_extractor_internal::languages::sql::SqlParser::parse_comments),
220
221        // Markdown-style comments (HTML-style <!-- --> comments)
222        "md" => Some(
223            crate::todo_extractor_internal::languages::markdown::MarkdownParser::parse_comments,
224        ),
225
226        _ => None,
227    };
228
229    // Log the result
230    match &result {
231        Some(_) => {
232            info!("file {:?} have a valid parser", file_path);
233        }
234        None => {
235            debug!(
236                "No parser found for extension '{}' in file: {:?}",
237                extension, file_path
238            );
239        }
240    }
241
242    result
243}
244
245/// Extracts marked items using a provided parser function.
246pub fn extract_marked_items_with_parser(
247    path: &Path,
248    file_content: &str,
249    parser_fn: fn(&str) -> Vec<CommentLine>,
250    config: &MarkerConfig,
251) -> Vec<MarkedItem> {
252    debug!("extract_marked_items_with_parser for file {path:?}");
253
254    let comment_lines = parser_fn(file_content);
255
256    debug!(
257        "extract_marked_items_with_parser: found {} comment lines from parser: {:?}",
258        comment_lines.len(),
259        comment_lines
260    );
261
262    // Continue with the existing logic to collect and merge marked items.
263    let marked_items = collect_marked_items_from_comment_lines(&comment_lines, config, path);
264    debug!(
265        "extract_marked_items_with_parser: found {} marked items total",
266        marked_items.len()
267    );
268    marked_items
269}
270
271pub fn extract_marked_items_from_file(
272    file: &Path,
273    marker_config: &MarkerConfig,
274) -> Result<Vec<MarkedItem>, String> {
275    let effective_ext = get_effective_extension(file);
276    let parser_fn = match get_parser_for_extension(&effective_ext, file) {
277        Some(parser) => parser,
278        None => {
279            // Skip unsupported file types without reading content
280            info!("Skipping unsupported file type: {:?}", file);
281            return Ok(Vec::new());
282        }
283    };
284
285    match std::fs::read_to_string(file) {
286        Ok(content) => {
287            let todos = extract_marked_items_with_parser(file, &content, parser_fn, marker_config);
288            Ok(todos)
289        }
290        Err(e) => {
291            error!("Warning: Could not read file {file:?}, skipping. Error: {e}");
292            Err(format!("Could not read file {:?}: {}", file, e))
293        }
294    }
295}
296
297/// A single comment line with (line_number, entire_comment_text).
298#[derive(Debug, Clone)]
299pub struct CommentLine {
300    pub line_number: usize,
301    pub text: String,
302}
303
304/// Merge flattened and stripped comment lines into blocks and produce a `MarkedItem` for each block.
305/// A block is defined as a group of lines that starts with a marker (e.g. "TODO:" or "FIXME")
306/// and includes any immediately indented lines (which are treated as continuations).
307pub fn collect_marked_items_from_comment_lines(
308    lines: &[CommentLine],
309    config: &MarkerConfig,
310    path: &Path,
311) -> Vec<MarkedItem> {
312    // First, flatten multi-line comments and strip language-specific markers.
313    let stripped_lines = strip_and_flatten(lines);
314    // Group the lines into blocks based on marker lines and their indented continuations.
315    let blocks = group_lines_into_blocks_with_marker(stripped_lines, &config.markers);
316    // Convert each block into a MarkedItem.
317    blocks
318        .into_iter()
319        .map(|(line_number, marker, block)| MarkedItem {
320            file_path: path.to_path_buf(),
321            line_number,
322            message: process_block_lines(&block, &config.markers),
323            marker,
324        })
325        .collect()
326}
327
328/// Utility: Flattens multi-line comment entries and strips language-specific markers from each line.
329fn strip_and_flatten(lines: &[CommentLine]) -> Vec<CommentLine> {
330    flatten_comment_lines(lines)
331        .into_iter()
332        .map(|cl| CommentLine {
333            line_number: cl.line_number,
334            text: common_syntax::strip_markers(&cl.text),
335        })
336        .collect()
337}
338
339/// Utility: Groups stripped comment lines into blocks. Each block is a tuple containing:
340/// - The line number where the block starts (i.e. the marker line)
341/// - The marker string that matched (always the base marker, no colon)
342/// - A vector of strings representing the block’s lines (with markers already stripped)
343fn group_lines_into_blocks_with_marker(
344    lines: Vec<CommentLine>,
345    markers: &[String],
346) -> Vec<(usize, String, Vec<String>)> {
347    let mut blocks = Vec::new();
348    let mut current_block: Option<(usize, String, Vec<String>)> = None;
349
350    for cl in lines {
351        let trimmed = cl.text.trim().to_string();
352        // Try to match any marker at the start of the line.
353        // Accept if the marker is followed by nothing, a space, or a colon.
354        // Always store the base marker (no colon) in the result.
355        let matched_marker = markers.iter().find_map(|base| {
356            if let Some(rest) = trimmed.strip_prefix(base) {
357                if rest.is_empty() || rest.starts_with(' ') || rest.starts_with(':') {
358                    return Some(base.clone());
359                }
360            }
361            None
362        });
363        if let Some(marker) = matched_marker {
364            // If we were already collecting a block, push it before starting a new one.
365            if let Some(block) = current_block.take() {
366                blocks.push(block);
367            }
368            // Start a new block with the marker line.
369            current_block = Some((cl.line_number, marker, vec![trimmed]));
370        } else if let Some((_, _, ref mut block_lines)) = current_block {
371            // If the line is indented, treat it as a continuation of the current block.
372            if cl.text.starts_with(' ') || cl.text.starts_with('\t') {
373                block_lines.push(trimmed);
374            } else {
375                // If not indented, close the current block.
376                blocks.push(current_block.take().unwrap());
377            }
378        }
379        // Lines that are not marker lines and not indented within a block are ignored.
380    }
381
382    // Push any remaining block at the end.
383    if let Some(block) = current_block {
384        blocks.push(block);
385    }
386    blocks
387}
388
389/// Merges the given block lines into a single normalized message and removes the marker prefix.
390/// It also removes an optional colon (":") that immediately follows the marker.
391/// For example, if the block lines are:
392///   ["TODO Implement feature A", "more details"]
393/// or
394///   ["TODO: Implement feature A", "more details"]
395/// the resulting message will be:
396///   "Implement feature A more details"
397fn process_block_lines(lines: &[String], markers: &[String]) -> String {
398    let merged = lines.join(" ");
399    markers.iter().fold(merged, |acc, marker| {
400        if let Some(stripped) = acc.strip_prefix(marker) {
401            // If a colon immediately follows the marker, remove it.
402            let stripped = if let Some(rest) = stripped.strip_prefix(":") {
403                rest
404            } else {
405                stripped
406            };
407            stripped.trim().to_string()
408        } else {
409            acc
410        }
411    })
412}
413
414#[cfg(test)]
415mod aggregator_tests {
416    use super::*;
417    use crate::test_utils::{init_logger, test_extract_marked_items};
418
419    #[test]
420    fn test_valid_rust_extension() {
421        init_logger();
422        let src = "// TODO: Implement feature X";
423        let config = MarkerConfig {
424            markers: vec!["TODO:".to_string()],
425        };
426        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
427        assert_eq!(todos.len(), 1);
428        assert_eq!(todos[0].marker, "TODO:");
429    }
430
431    #[test]
432    fn test_valid_js_extension() {
433        init_logger();
434        let src = "// TODO: Implement feature X";
435        let config = MarkerConfig {
436            markers: vec!["TODO:".to_string()],
437        };
438        let todos = test_extract_marked_items(Path::new("file.js"), src, &config);
439        assert_eq!(todos.len(), 1);
440        assert_eq!(todos[0].marker, "TODO:");
441    }
442
443    #[test]
444    fn test_valid_jsx_extension() {
445        init_logger();
446        let src = "// TODO: Add prop validation";
447        let config = MarkerConfig {
448            markers: vec!["TODO:".to_string()],
449        };
450        let todos = test_extract_marked_items(Path::new("component.jsx"), src, &config);
451        assert_eq!(todos.len(), 1);
452        assert_eq!(todos[0].marker, "TODO:");
453    }
454
455    #[test]
456    fn test_valid_go_extension() {
457        init_logger();
458        let src = "// TODO: Implement feature X";
459        let config = MarkerConfig {
460            markers: vec!["TODO:".to_string()],
461        };
462        let todos = test_extract_marked_items(Path::new("main.go"), src, &config);
463        assert_eq!(todos.len(), 1);
464        assert_eq!(todos[0].marker, "TODO:");
465    }
466
467    #[test]
468    fn test_invalid_extension() {
469        init_logger();
470        let src = "// TODO: This should not be processed";
471        let config = MarkerConfig {
472            markers: vec!["TODO:".to_string()],
473        };
474        let todos = test_extract_marked_items(Path::new("file.unknown"), src, &config);
475        assert_eq!(todos.len(), 0);
476    }
477
478    #[test]
479    fn test_merge_multiline_todo() {
480        init_logger();
481        let src = r#"
482// TODO: Fix bug
483//     Improve error handling
484//     Add logging
485"#;
486        let config = MarkerConfig {
487            markers: vec!["TODO:".to_string()],
488        };
489        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
490        assert_eq!(todos.len(), 1);
491        assert_eq!(
492            todos[0].message,
493            "Fix bug Improve error handling Add logging"
494        );
495        assert_eq!(todos[0].marker, "TODO:");
496    }
497
498    #[test]
499    fn test_stop_merge_on_unindented_line() {
500        init_logger();
501        let src = r#"
502// TODO: Improve API
503// Refactor later
504"#;
505        let config = MarkerConfig {
506            markers: vec!["TODO:".to_string()],
507        };
508        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
509        assert_eq!(todos.len(), 1);
510        assert_eq!(todos[0].message, "Improve API"); // Does not merge second line
511    }
512
513    #[test]
514    fn test_todo_with_line_number() {
515        init_logger();
516        let src = r#"
517// Some comment
518// TODO: Implement caching
519"#;
520        let config = MarkerConfig {
521            markers: vec!["TODO:".to_string()],
522        };
523        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
524        assert_eq!(todos.len(), 1);
525        assert_eq!(todos[0].line_number, 3);
526        assert_eq!(todos[0].message, "Implement caching");
527    }
528
529    #[test]
530    fn test_empty_input_no_todos() {
531        init_logger();
532        let src = "";
533        let config = MarkerConfig {
534            markers: vec!["TODO:".to_string()],
535        };
536        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
537        assert_eq!(todos.len(), 0);
538    }
539
540    #[test]
541    fn test_display_todo_output() {
542        init_logger();
543        let src = "// TODO: Improve logging";
544        let config = MarkerConfig {
545            markers: vec!["TODO:".to_string()],
546        };
547        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
548
549        let output = format!("{} - {}", todos[0].line_number, todos[0].message);
550        assert_eq!(output, "1 - Improve logging");
551    }
552
553    #[test]
554    fn test_display_no_todos() {
555        init_logger();
556        let src = "fn main() {}";
557        let config = MarkerConfig {
558            markers: vec!["TODO:".to_string()],
559        };
560        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
561        assert!(todos.is_empty());
562    }
563
564    #[test]
565    fn test_basic_framework() {
566        init_logger();
567        assert_eq!(2 + 2, 4);
568    }
569
570    #[test]
571    fn test_false_positive_detection() {
572        init_logger();
573        let src = r#"
574let message = "TODO: This should not be detected";
575"#;
576        let config = MarkerConfig {
577            markers: vec!["TODO:".to_string()],
578        };
579        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
580        assert_eq!(todos.len(), 0);
581    }
582
583    #[test]
584    fn test_multiple_consecutive_todos() {
585        init_logger();
586        let src = r#"
587// TODO: todo1
588// TODO: todo2
589"#;
590        let config = MarkerConfig {
591            markers: vec!["TODO:".to_string()],
592        };
593        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
594
595        assert_eq!(todos.len(), 2);
596
597        // Check their line numbers and messages
598        // The first TODO should be on line 2, the second on line 3 (1-based from Pest)
599        assert_eq!(todos[0].line_number, 2);
600        assert_eq!(todos[0].message, "todo1");
601        assert_eq!(todos[1].line_number, 3);
602        assert_eq!(todos[1].message, "todo2");
603    }
604
605    #[test]
606    fn test_mixed_marker_configurations() {
607        // Test a file that mixes TODO and FIXME, with and without colons.
608        let src = r#"
609// TODO: Implement feature
610// FIXME Fix bug
611// TODO Add docs
612// FIXME: Refactor
613"#;
614        let config = MarkerConfig {
615            markers: vec!["TODO".to_string(), "FIXME".to_string()],
616        };
617        let items = test_extract_marked_items(Path::new("file.rs"), src, &config);
618        assert_eq!(items.len(), 4);
619        assert_eq!(items[0].marker, "TODO");
620        assert_eq!(items[0].message, "Implement feature");
621        assert_eq!(items[1].marker, "FIXME");
622        assert_eq!(items[1].message, "Fix bug");
623        assert_eq!(items[2].marker, "TODO");
624        assert_eq!(items[2].message, "Add docs");
625        assert_eq!(items[3].marker, "FIXME");
626        assert_eq!(items[3].message, "Refactor");
627    }
628
629    #[test]
630    fn test_ignore_todo_not_at_beginning() {
631        let src = r#"
632// This is a comment with a TODO: not at the beginning
633fn main() {}
634"#;
635        let config = MarkerConfig {
636            markers: vec!["TODO:".to_string()],
637        };
638        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
639        assert_eq!(
640            todos.len(),
641            0,
642            "A TODO not at the beginning should not be detected"
643        );
644    }
645
646    #[test]
647    fn test_fixme_with_colon() {
648        // Test a comment that uses FIXME with a colon.
649        let src = r#"
650    // FIXME: Correct the error handling
651    "#;
652        let config = MarkerConfig {
653            markers: vec!["FIXME".to_string()],
654        };
655        let items = test_extract_marked_items(Path::new("file.rs"), src, &config);
656        assert_eq!(items.len(), 1);
657        assert_eq!(items[0].message, "Correct the error handling");
658    }
659
660    #[test]
661    fn test_fixme_without_colon() {
662        // Test a comment that uses FIXME without a colon.
663        let src = r#"
664    // FIXME Correct the error handling
665    "#;
666        let config = MarkerConfig {
667            markers: vec!["FIXME".to_string()],
668        };
669        let items = test_extract_marked_items(Path::new("file.rs"), src, &config);
670        assert_eq!(items.len(), 1);
671        assert_eq!(items[0].message, "Correct the error handling");
672    }
673
674    #[test]
675    fn test_mixed_markers() {
676        // Test a file that mixes both TODO and FIXME comments,
677        // with and without the colon.
678        let src = r#"
679    // TODO: Implement feature A
680    // FIXME: Fix bug in module
681    // Some regular comment
682    // TODO Implement feature B
683    // FIXME Fix another bug
684    "#;
685        let config = MarkerConfig {
686            markers: vec!["TODO".to_string(), "FIXME".to_string()],
687        };
688        let items = test_extract_marked_items(Path::new("file.rs"), src, &config);
689
690        // We expect four items in order.
691        assert_eq!(items.len(), 4);
692        assert_eq!(items[0].message, "Implement feature A");
693        assert_eq!(items[1].message, "Fix bug in module");
694        assert_eq!(items[2].message, "Implement feature B");
695        assert_eq!(items[3].message, "Fix another bug");
696    }
697
698    #[test]
699    fn test_mixed_markers_complex() {
700        // This test mixes both TODO and FIXME comments (with and without a colon),
701        // includes multiline comment blocks, and interleaves non-comment code.
702        let src = r#"
703// TODO: Implement feature A
704
705fn some_function() {
706    // This is a normal comment
707    // FIXME: Fix bug in module
708    println!("Hello, world!");
709}
710
711/*
712   TODO: Implement feature C
713       with additional multiline details
714*/
715
716/// FIXME Fix critical bug
717///   that occurs on edge cases
718
719// TODO Implement feature B
720
721// FIXME Fix another bug
722"#;
723
724        let config = MarkerConfig {
725            markers: vec!["TODO".to_string(), "FIXME".to_string()],
726        };
727        let items = test_extract_marked_items(Path::new("file.rs"), src, &config);
728
729        // We expect six separate marked items:
730        // 1. "Implement feature A"
731        // 2. "Fix bug in module"
732        // 3. "Implement feature C with additional multiline details"
733        // 4. "Fix critical bug that occurs on edge cases"
734        // 5. "Implement feature B"
735        // 6. "Fix another bug"
736        assert_eq!(items.len(), 6);
737
738        assert_eq!(items[0].message, "Implement feature A");
739        assert_eq!(items[1].message, "Fix bug in module");
740        assert_eq!(
741            items[2].message,
742            "Implement feature C with additional multiline details"
743        );
744        assert_eq!(
745            items[3].message,
746            "Fix critical bug that occurs on edge cases"
747        );
748        assert_eq!(items[4].message, "Implement feature B");
749        assert_eq!(items[5].message, "Fix another bug");
750    }
751
752    #[test]
753    fn test_merge_multiline_todo_with_todo_in_str() {
754        init_logger();
755        let src = r#"
756// TODO add a new argument to specify what markers to look for
757//      like --markers "TODO, FIXME, HACK"
758"#;
759        let config = MarkerConfig {
760            markers: vec!["TODO".to_string()],
761        };
762        let todos = test_extract_marked_items(Path::new("file.rs"), src, &config);
763
764        assert_eq!(todos.len(), 1);
765
766        assert_eq!(todos[0].line_number, 2);
767        assert_eq!(todos[0].message, "add a new argument to specify what markers to look for like --markers \"TODO, FIXME, HACK\"");
768    }
769
770    #[test]
771    fn test_valid_sh_extension() {
772        init_logger();
773        let src = "# TODO: setup\nexit";
774        let config = MarkerConfig {
775            markers: vec!["TODO:".to_string()],
776        };
777        let todos = test_extract_marked_items(Path::new("script.sh"), src, &config);
778        assert_eq!(todos.len(), 1);
779        assert_eq!(todos[0].marker, "TODO:");
780    }
781
782    #[test]
783    fn test_valid_yaml_extension() {
784        init_logger();
785        let src = "# TODO: conf\nkey: val";
786        let config = MarkerConfig {
787            markers: vec!["TODO:".to_string()],
788        };
789        let todos = test_extract_marked_items(Path::new("config.yaml"), src, &config);
790        assert_eq!(todos.len(), 1);
791        assert_eq!(todos[0].marker, "TODO:");
792    }
793
794    #[test]
795    fn test_valid_toml_extension() {
796        init_logger();
797        let src = "# TODO: fix\nkey=1";
798        let config = MarkerConfig {
799            markers: vec!["TODO:".to_string()],
800        };
801        let todos = test_extract_marked_items(Path::new("config.toml"), src, &config);
802        assert_eq!(todos.len(), 1);
803        assert_eq!(todos[0].marker, "TODO:");
804    }
805
806    #[test]
807    fn test_valid_sql_extension() {
808        init_logger();
809        let src = "-- TODO: q\nSELECT 1;";
810        let config = MarkerConfig {
811            markers: vec!["TODO:".to_string()],
812        };
813        let todos = test_extract_marked_items(Path::new("query.sql"), src, &config);
814        assert_eq!(todos.len(), 1);
815        assert_eq!(todos[0].marker, "TODO:");
816    }
817
818    #[test]
819    fn test_valid_markdown_extension() {
820        init_logger();
821        let src = "<!-- TODO: doc -->";
822        let config = MarkerConfig {
823            markers: vec!["TODO:".to_string()],
824        };
825        let todos = test_extract_marked_items(Path::new("README.md"), src, &config);
826        assert_eq!(todos.len(), 1);
827        assert_eq!(todos[0].marker, "TODO:");
828    }
829
830    #[test]
831    fn test_dockerfile_no_extension() {
832        init_logger();
833        let src = "# TODO: step\nFROM alpine";
834        let config = MarkerConfig {
835            markers: vec!["TODO:".to_string()],
836        };
837        let todos = test_extract_marked_items(Path::new("Dockerfile"), src, &config);
838        assert_eq!(todos.len(), 1);
839        assert_eq!(todos[0].marker, "TODO:");
840    }
841
842    #[test]
843    fn test_extract_marked_items_from_file_unsupported_extension() {
844        init_logger();
845        let config = MarkerConfig {
846            markers: vec!["TODO".to_string()],
847        };
848
849        // Test with an unsupported file extension
850        let result = extract_marked_items_from_file(Path::new("file.unsupported"), &config);
851
852        // Should return Ok with empty Vec, not an error
853        assert!(result.is_ok());
854        assert_eq!(result.unwrap().len(), 0);
855    }
856
857    #[test]
858    fn test_extract_marked_items_from_file_nonexistent_file() {
859        init_logger();
860        let config = MarkerConfig {
861            markers: vec!["TODO".to_string()],
862        };
863
864        // Test with a file that doesn't exist (supported extension but unreadable)
865        let result = extract_marked_items_from_file(Path::new("nonexistent_file.rs"), &config);
866
867        // Should return an error
868        assert!(result.is_err());
869        let error_msg = result.unwrap_err();
870        assert!(error_msg.contains("Could not read file"));
871        assert!(error_msg.contains("nonexistent_file.rs"));
872    }
873
874    #[test]
875    fn test_extract_marked_items_from_file_permission_denied() {
876        init_logger();
877        let config = MarkerConfig {
878            markers: vec!["TODO".to_string()],
879        };
880
881        test_permission_denied_unix(&config);
882        test_permission_denied_cross_platform(&config);
883    }
884
885    #[cfg(unix)]
886    fn test_permission_denied_unix(config: &MarkerConfig) {
887        use std::fs;
888        use std::os::unix::fs::PermissionsExt;
889        use tempfile::Builder;
890
891        // Use tempfile with a supported extension for proper cleanup and unique paths
892        let temp_file = Builder::new()
893            .suffix(".rs")
894            .tempfile()
895            .expect("Failed to create temp file");
896        let temp_path = temp_file.path();
897
898        // Write test content
899        std::fs::write(temp_path, b"// TODO: test").expect("Failed to write test content");
900
901        // Remove read permissions
902        let metadata = fs::metadata(temp_path).expect("Failed to get metadata");
903        let mut permissions = metadata.permissions();
904        permissions.set_mode(0o000); // No permissions
905
906        if fs::set_permissions(temp_path, permissions).is_ok() {
907            let result = extract_marked_items_from_file(temp_path, config);
908
909            // Should return an error
910            assert!(result.is_err());
911            let error_msg = result.unwrap_err();
912            assert!(error_msg.contains("Could not read file"));
913
914            // Restore permissions for proper cleanup
915            let mut restore_permissions = fs::metadata(temp_path).unwrap().permissions();
916            restore_permissions.set_mode(0o644);
917            let _ = fs::set_permissions(temp_path, restore_permissions);
918        }
919        // tempfile automatically cleans up on drop
920    }
921
922    #[cfg(not(unix))]
923    fn test_permission_denied_unix(_config: &MarkerConfig) {
924        // Skip Unix-specific permission test on non-Unix platforms
925    }
926
927    fn test_permission_denied_cross_platform(config: &MarkerConfig) {
928        use std::fs;
929        use tempfile::TempDir;
930
931        // Create a temporary directory
932        let temp_dir = TempDir::new().expect("Failed to create temp directory");
933        let dir_path = temp_dir.path();
934
935        // Create a path that looks like a .rs file but is actually a directory
936        let fake_file_path = dir_path.join("test.rs");
937        fs::create_dir_all(&fake_file_path).expect("Failed to create directory");
938
939        let result = extract_marked_items_from_file(&fake_file_path, config);
940
941        // Should return an error because we're trying to read a directory as a file
942        assert!(result.is_err());
943        let error_msg = result.unwrap_err();
944        assert!(error_msg.contains("Could not read file"));
945
946        // TempDir automatically cleans up on drop
947    }
948}