use std::collections::HashMap;
use crate::{
comment::todo::{TodoComment, TodoType},
output::formatter::{error::FormatterError, formatters::pluralize, Formatter},
};
const INDENTED_CODE_FENCE: &str = " ```";
use crate::escape_markdown;
pub struct MarkdownFormatter;
impl Formatter for MarkdownFormatter {
fn format(
&self,
todos_map: &HashMap<&TodoType, Vec<&TodoComment>>,
total_count: usize,
) -> Result<Vec<String>, FormatterError> {
let capacity = 2 + todos_map.len() + total_count.saturating_mul(2);
let mut output: Vec<String> = Vec::with_capacity(capacity);
output.push("# TODO Comments\n\n".to_string()); output.push(format!(
"Found {total_count} TODO comment{}:\n\n",
pluralize(total_count)
));
for (todo_type, todos_of_type) in todos_map {
output.push(format!(
"## {todo_type} ({} item{})\n\n",
todos_of_type.len(),
pluralize(todos_of_type.len())
));
for todo in todos_of_type {
let location = format!("{}:{}", todo.file_path.display(), todo.line_number);
let escaped_desc = escape_markdown(todo.description.trim());
if let Some(ref func_context) = todo.function_context {
output.push(format!(
"- **{escaped_desc}** @ `{location}` (in `{func_context}`)"
));
} else {
output.push(format!("- **{escaped_desc}** @ `{location}`"));
}
if !todo.context_lines.is_empty() {
output.push(INDENTED_CODE_FENCE.to_string()); for context_line in &todo.context_lines {
output.push(format!(" {context_line}"));
}
output.push(INDENTED_CODE_FENCE.to_string()); }
output.push(String::new());
}
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::formatter::formatters::test_helpers::create_test_todo;
use proptest::prelude::*;
use rstest::rstest;
#[rstest]
#[case(0, "Found 0 TODO comments")]
#[case(1, "Found 1 TODO comment")]
#[case(10, "Found 10 TODO comments")]
fn test_markdown_summary_count(#[case] count: usize, #[case] expected: &str) {
let formatter = MarkdownFormatter;
let todos_map = HashMap::new();
let result = formatter.format(&todos_map, count).unwrap();
assert!(result[1].contains(expected));
}
#[rstest]
#[case(Some("main"), true)]
#[case(None, false)]
fn test_markdown_function_context(
#[case] function: Option<&str>,
#[case] should_contain: bool,
) {
let formatter = MarkdownFormatter;
let todo = create_test_todo("Test", TodoType::Todo, function, false);
let mut todos_map = HashMap::new();
todos_map.insert(&todo.todo_type, vec![&todo]);
let result = formatter.format(&todos_map, 1).unwrap();
let output = result.join("\n");
assert_eq!(output.contains("(in `"), should_contain);
assert!(output.contains("test.rs:42"));
}
#[test]
fn test_markdown_with_context_lines() {
let formatter = MarkdownFormatter;
let todo = create_test_todo("Test", TodoType::Hack, None, true);
let mut todos_map = HashMap::new();
todos_map.insert(&todo.todo_type, vec![&todo]);
let result = formatter.format(&todos_map, 1).unwrap();
let output = result.join("\n");
assert!(output.contains("```"));
assert!(output.contains("context line 1"));
assert!(output.contains("context line 2"));
}
#[rstest]
#[case(TodoType::Todo, "## TODO")]
#[case(TodoType::Fixme, "## FIXME")]
#[case(TodoType::Hack, "## HACK")]
#[case(TodoType::Note, "## NOTE")]
#[case(TodoType::Bug, "## BUG")]
fn test_markdown_section_headers(#[case] todo_type: TodoType, #[case] expected_header: &str) {
let formatter = MarkdownFormatter;
let todo = create_test_todo("Test", todo_type, None, false);
let mut todos_map = HashMap::new();
todos_map.insert(&todo.todo_type, vec![&todo]);
let result = formatter.format(&todos_map, 1).unwrap();
let output = result.join("\n");
assert!(output.contains(expected_header));
assert!(output.contains("(1 item)"));
}
#[test]
fn test_markdown_multiple_todos() {
let formatter = MarkdownFormatter;
let todo1 = create_test_todo("First", TodoType::Todo, Some("func1"), true);
let todo2 = create_test_todo("Second", TodoType::Todo, None, false);
let todo3 = create_test_todo("Third", TodoType::Bug, Some("func3"), false);
let mut todos_map = HashMap::new();
todos_map.insert(&TodoType::Todo, vec![&todo1, &todo2]);
todos_map.insert(&TodoType::Bug, vec![&todo3]);
let result = formatter.format(&todos_map, 3).unwrap();
let output = result.join("\n");
assert!(output.contains("## TODO (2 items)"));
assert!(output.contains("## BUG (1 item)"));
assert!(output.contains("**First**"));
assert!(output.contains("**Second**"));
assert!(output.contains("**Third**"));
assert!(output.contains("(in `func1`)"));
assert!(output.contains("(in `func3`)"));
}
proptest! {
#[test]
fn prop_markdown_structure_valid(
desc in "[a-zA-Z0-9 ]{1,50}",
count in 1usize..5,
) {
let formatter = MarkdownFormatter;
let todos: Vec<_> = (0..count)
.map(|_| create_test_todo(&desc, TodoType::Todo, None, false))
.collect();
let refs: Vec<&TodoComment> = todos.iter().collect();
let mut todos_map = HashMap::new();
todos_map.insert(&TodoType::Todo, refs);
let result = formatter.format(&todos_map, count).unwrap();
let output = result.join("\n");
let expected_count = format!("Found {count} TODO comment");
let expected_items = format!("({count} item");
prop_assert!(output.contains("# TODO Comments"));
prop_assert!(output.contains(&expected_count));
prop_assert!(output.contains("## TODO"));
prop_assert!(output.contains(&expected_items));
for line in &result {
if line.starts_with("- **") {
prop_assert!(
line.contains("test.rs:42"),
"Each item should have file:line location"
);
}
}
}
}
}