Skip to main content

todo_tree/printer/
json.rs

1//! JSON rendering.
2
3use super::options::PrintOptions;
4use super::utils::format_path;
5use crate::core::ScanResult;
6use serde::Serialize;
7use std::collections::HashMap;
8use std::io::{self, Write};
9
10/// The top-level JSON document written by [`print_json`].
11#[derive(Debug, Serialize)]
12pub struct JsonOutput {
13    /// Files with at least one TODO item, sorted by path.
14    pub files: Vec<JsonFileEntry>,
15    /// Aggregate counts for the scan.
16    pub summary: JsonSummary,
17}
18
19/// A single file's TODO items in the JSON output.
20#[derive(Debug, Serialize)]
21pub struct JsonFileEntry {
22    /// The file's display path.
23    pub path: String,
24    /// The TODO items found in the file.
25    pub items: Vec<JsonTodoItem>,
26}
27
28/// A single TODO item in the JSON output.
29#[derive(Debug, Serialize)]
30pub struct JsonTodoItem {
31    /// The tag that matched.
32    pub tag: String,
33    /// The comment text following the tag.
34    pub message: String,
35    /// 1-based line number.
36    pub line: usize,
37    /// 1-based column.
38    pub column: usize,
39    /// The optional author/assignee.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub author: Option<String>,
42    /// The item's priority, formatted with `{:?}` (e.g. `"Critical"`).
43    pub priority: String,
44}
45
46/// Aggregate counts in the JSON output.
47#[derive(Debug, Serialize)]
48pub struct JsonSummary {
49    /// Total number of TODO items found across all files.
50    pub total_count: usize,
51    /// Number of files that contained at least one TODO item.
52    pub files_with_todos: usize,
53    /// Total number of files scanned.
54    pub files_scanned: usize,
55    /// Number of items found per tag.
56    pub tag_counts: HashMap<String, usize>,
57    /// How long the scan took, in milliseconds.
58    pub duration_ms: u128,
59}
60
61/// Renders `result` as pretty-printed JSON.
62pub fn print_json<W: Write>(
63    writer: &mut W,
64    result: &ScanResult,
65    options: &PrintOptions,
66) -> io::Result<()> {
67    let json_result = JsonOutput::from_scan_result(result, options);
68    let json_str = serde_json::to_string_pretty(&json_result).map_err(io::Error::other)?;
69    writeln!(writer, "{}", json_str)?;
70    Ok(())
71}
72
73impl JsonOutput {
74    /// Builds the JSON document from a scan result and print options
75    /// (options control whether paths are absolute, relative, or
76    /// stripped to `options.base_path`).
77    pub fn from_scan_result(result: &ScanResult, options: &PrintOptions) -> Self {
78        let mut files: Vec<JsonFileEntry> = result
79            .sorted_files()
80            .iter()
81            .map(|(path, items)| {
82                let display_path = format_path(path, options);
83
84                JsonFileEntry {
85                    path: display_path,
86                    items: items
87                        .iter()
88                        .map(|item| JsonTodoItem {
89                            tag: item.tag.clone(),
90                            message: item.message.clone(),
91                            line: item.line,
92                            column: item.column,
93                            author: item.author.clone(),
94                            priority: format!("{:?}", item.priority),
95                        })
96                        .collect(),
97                }
98            })
99            .collect();
100
101        files.sort_by(|a, b| a.path.cmp(&b.path));
102
103        let summary = JsonSummary {
104            total_count: result.summary.total_count,
105            files_with_todos: result.summary.files_with_todos,
106            files_scanned: result.summary.files_scanned,
107            tag_counts: result.summary.tag_counts.clone(),
108            duration_ms: result.summary.duration_ms,
109        };
110
111        Self { files, summary }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::core::{TodoItem, TodoPriority};
119    use std::path::PathBuf;
120
121    fn item(tag: &str, author: Option<&str>) -> TodoItem {
122        TodoItem {
123            tag: tag.to_string(),
124            message: "msg".to_string(),
125            line: 1,
126            column: 1,
127            line_content: None,
128            author: author.map(str::to_string),
129            priority: TodoPriority::from_tag(tag),
130        }
131    }
132
133    #[test]
134    fn from_scan_result_maps_items_and_summary() {
135        let mut result = ScanResult::new(PathBuf::from("."));
136        result.add_file(
137            PathBuf::from("a.rs"),
138            vec![item("TODO", Some("alice")), item("FIXME", None)],
139        );
140
141        let output = JsonOutput::from_scan_result(&result, &PrintOptions::default());
142
143        assert_eq!(output.files.len(), 1);
144        assert_eq!(output.files[0].items.len(), 2);
145        assert_eq!(output.summary.total_count, 2);
146        let with_author = output.files[0]
147            .items
148            .iter()
149            .find(|i| i.tag == "TODO")
150            .unwrap();
151        assert_eq!(with_author.author.as_deref(), Some("alice"));
152        assert_eq!(with_author.priority, "Medium");
153    }
154
155    #[test]
156    fn from_scan_result_uses_full_paths_when_requested() {
157        let mut result = ScanResult::new(PathBuf::from("."));
158        result.add_file(PathBuf::from("a.rs"), vec![item("TODO", None)]);
159
160        let opts = PrintOptions {
161            full_paths: true,
162            ..PrintOptions::default()
163        };
164        let output = JsonOutput::from_scan_result(&result, &opts);
165
166        assert_eq!(
167            output.files[0].path,
168            PathBuf::from("a.rs").display().to_string()
169        );
170    }
171
172    #[test]
173    fn from_scan_result_strips_base_path_when_set() {
174        let mut result = ScanResult::new(PathBuf::from("."));
175        result.add_file(PathBuf::from("/repo/src/a.rs"), vec![item("TODO", None)]);
176
177        let opts = PrintOptions {
178            base_path: Some(PathBuf::from("/repo")),
179            ..PrintOptions::default()
180        };
181        let output = JsonOutput::from_scan_result(&result, &opts);
182
183        assert_eq!(output.files[0].path, "src/a.rs");
184    }
185
186    #[test]
187    fn from_scan_result_falls_back_when_strip_prefix_fails() {
188        let mut result = ScanResult::new(PathBuf::from("."));
189        result.add_file(PathBuf::from("/repo/src/a.rs"), vec![item("TODO", None)]);
190
191        let opts = PrintOptions {
192            base_path: Some(PathBuf::from("/other")),
193            ..PrintOptions::default()
194        };
195        let output = JsonOutput::from_scan_result(&result, &opts);
196
197        assert_eq!(
198            output.files[0].path,
199            PathBuf::from("/repo/src/a.rs").display().to_string()
200        );
201    }
202
203    #[test]
204    fn print_json_writes_valid_pretty_json() {
205        let mut result = ScanResult::new(PathBuf::from("."));
206        result.add_file(PathBuf::from("a.rs"), vec![item("TODO", None)]);
207        let mut buf = Vec::new();
208
209        print_json(&mut buf, &result, &PrintOptions::default()).unwrap();
210        let output = String::from_utf8(buf).unwrap();
211
212        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
213        assert_eq!(parsed["summary"]["total_count"], 1);
214        assert_eq!(parsed["files"][0]["path"], "a.rs");
215    }
216}