towl 0.3.7

A fast CLI tool to scan codebases for TODO comments and output them in multiple formats
Documentation
use serde_json::json;
use std::collections::HashMap;

use crate::{
    comment::todo::{TodoComment, TodoType},
    output::formatter::{error::FormatterError, Formatter},
};

pub struct JsonFormatter;

impl Formatter for JsonFormatter {
    fn format(
        &self,
        todos_map: &HashMap<&TodoType, Vec<&TodoComment>>,
        total_count: usize,
    ) -> Result<Vec<String>, FormatterError> {
        let mut groups = Vec::with_capacity(todos_map.len());

        for (todo_type, todos_of_type) in todos_map {
            let mut group_todos = Vec::with_capacity(todos_of_type.len());

            for todo in todos_of_type {
                let mut todo_json = json!({
                    "description": todo.description.trim(),
                    "file": todo.file_path.display().to_string(), // clone: Display → owned String
                    "line": todo.line_number,
                    "column_start": todo.column_start,
                    "column_end": todo.column_end,
                    "original_text": todo.original_text.trim(),
                    "context_lines": todo.context_lines
                });

                if let Some(ref func_context) = todo.function_context {
                    todo_json["function"] = json!(func_context);
                }

                group_todos.push(todo_json);
            }

            groups.push(json!({
                "type": todo_type.to_string(), // clone: Display → owned String
                "count": todos_of_type.len(),
                "items": group_todos
            }));
        }

        let result = json!({
            "summary": {
                "total_todos": total_count,
                "total_groups": groups.len()
            },
            "groups": groups
        });

        let json_string = serde_json::to_string_pretty(&result)
            .map_err(|e| FormatterError::SerializationError(e.to_string()))?; // clone: error to string

        Ok(vec![json_string])
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::output::formatter::formatters::test_helpers::create_test_todo;
    use proptest::prelude::*;
    use rstest::rstest;

    #[rstest]
    #[case(vec![], 0)]
    #[case(vec![create_test_todo("Test", TodoType::Todo, None, true)], 1)]
    #[case(vec![create_test_todo("Fix", TodoType::Fixme, None, true), create_test_todo("Hack", TodoType::Hack, None, true)], 2)]
    #[case(vec![
        create_test_todo("Todo1", TodoType::Todo, None, true),
        create_test_todo("Todo2", TodoType::Todo, None, true),
        create_test_todo("Fix1", TodoType::Fixme, None, true)
    ], 3)]
    fn test_json_formatting_counts(#[case] todos: Vec<TodoComment>, #[case] expected_count: usize) {
        let formatter = JsonFormatter;
        let todos_map = crate::output::Output::group_todos_by_type(&todos);
        let result = formatter.format(&todos_map, expected_count).unwrap();

        assert_eq!(result.len(), 1);
        let parsed: serde_json::Value = serde_json::from_str(&result[0]).unwrap();
        assert_eq!(parsed["summary"]["total_todos"], expected_count);
    }

    #[rstest]
    #[case(true, true)]
    #[case(false, false)]
    fn test_function_context_inclusion(
        #[case] with_function: bool,
        #[case] should_have_function: bool,
    ) {
        let formatter = JsonFormatter;
        let todos = vec![create_test_todo(
            "Test",
            TodoType::Todo,
            if with_function {
                Some("test_function")
            } else {
                None
            },
            true,
        )];
        let todos_map = crate::output::Output::group_todos_by_type(&todos);

        let result = formatter.format(&todos_map, 1).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&result[0]).unwrap();

        let has_function = parsed["groups"][0]["items"][0].get("function").is_some();
        assert_eq!(has_function, should_have_function);
    }

    #[test]
    fn test_json_structure() {
        let formatter = JsonFormatter;
        let todos = vec![
            create_test_todo("First", TodoType::Todo, Some("test_function"), true),
            create_test_todo("Second", TodoType::Fixme, None, true),
        ];
        let todos_map = crate::output::Output::group_todos_by_type(&todos);

        let result = formatter.format(&todos_map, 2).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&result[0]).unwrap();

        assert!(parsed["summary"].is_object());
        assert!(parsed["groups"].is_array());
        assert_eq!(parsed["summary"]["total_todos"], 2);

        for group in parsed["groups"].as_array().unwrap() {
            assert!(group["type"].is_string());
            assert!(group["count"].is_number());
            assert!(group["items"].is_array());

            for item in group["items"].as_array().unwrap() {
                assert!(item["description"].is_string());
                assert!(item["file"].is_string());
                assert!(item["line"].is_number());
                assert!(item["column_start"].is_number());
                assert!(item["column_end"].is_number());
                assert!(item["original_text"].is_string());
                assert!(item["context_lines"].is_array());
            }
        }
    }

    proptest! {
        #[test]
        fn prop_json_output_is_valid_json(
            desc in "[a-zA-Z0-9 .,!?-]{1,50}",
            todo_type in prop::sample::select(vec![TodoType::Todo, TodoType::Fixme, TodoType::Hack, TodoType::Note, TodoType::Bug]),
        ) {
            let formatter = JsonFormatter;
            let todo = create_test_todo(&desc, todo_type, Some("test_func"), true);
            let mut todos_map = HashMap::new();
            todos_map.insert(&todo.todo_type, vec![&todo]);

            let result = formatter.format(&todos_map, 1).unwrap();
            prop_assert_eq!(result.len(), 1);

            let parsed: Result<serde_json::Value, _> = serde_json::from_str(&result[0]);
            prop_assert!(parsed.is_ok(), "Invalid JSON for description: {}", desc);

            let json = parsed.unwrap();
            prop_assert!(json["summary"].is_object());
            prop_assert!(json["groups"].is_array());
            prop_assert_eq!(json["summary"]["total_todos"].as_u64().unwrap(), 1);

            let items = &json["groups"][0]["items"];
            prop_assert!(items.is_array());
            let item = &items[0];
            prop_assert!(item["description"].is_string());
            prop_assert!(item["file"].is_string());
            prop_assert!(item["line"].is_number());
        }

    }
}