1use 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#[derive(Debug, Serialize)]
12pub struct JsonOutput {
13 pub files: Vec<JsonFileEntry>,
15 pub summary: JsonSummary,
17}
18
19#[derive(Debug, Serialize)]
21pub struct JsonFileEntry {
22 pub path: String,
24 pub items: Vec<JsonTodoItem>,
26}
27
28#[derive(Debug, Serialize)]
30pub struct JsonTodoItem {
31 pub tag: String,
33 pub message: String,
35 pub line: usize,
37 pub column: usize,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub author: Option<String>,
42 pub priority: String,
44}
45
46#[derive(Debug, Serialize)]
48pub struct JsonSummary {
49 pub total_count: usize,
51 pub files_with_todos: usize,
53 pub files_scanned: usize,
55 pub tag_counts: HashMap<String, usize>,
57 pub duration_ms: u128,
59}
60
61pub 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 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}