1use std::path::Path;
2use std::process::ExitCode;
3use std::time::Duration;
4
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8pub(super) fn print_json(results: &AnalysisResults, root: &Path, elapsed: Duration) -> ExitCode {
9 match build_json(results, root, elapsed) {
10 Ok(output) => match serde_json::to_string_pretty(&output) {
11 Ok(json) => {
12 println!("{json}");
13 ExitCode::SUCCESS
14 }
15 Err(e) => {
16 eprintln!("Error: failed to serialize JSON output: {e}");
17 ExitCode::from(2)
18 }
19 },
20 Err(e) => {
21 eprintln!("Error: failed to serialize results: {e}");
22 ExitCode::from(2)
23 }
24 }
25}
26
27const SCHEMA_VERSION: u32 = 3;
33
34pub fn build_json(
39 results: &AnalysisResults,
40 root: &Path,
41 elapsed: Duration,
42) -> Result<serde_json::Value, serde_json::Error> {
43 let results_value = serde_json::to_value(results)?;
44
45 let mut map = serde_json::Map::new();
46 map.insert(
47 "schema_version".to_string(),
48 serde_json::json!(SCHEMA_VERSION),
49 );
50 map.insert(
51 "version".to_string(),
52 serde_json::json!(env!("CARGO_PKG_VERSION")),
53 );
54 map.insert(
55 "elapsed_ms".to_string(),
56 serde_json::json!(elapsed.as_millis()),
57 );
58 map.insert(
59 "total_issues".to_string(),
60 serde_json::json!(results.total_issues()),
61 );
62
63 if let serde_json::Value::Object(results_map) = results_value {
64 for (key, value) in results_map {
65 map.insert(key, value);
66 }
67 }
68
69 let mut output = serde_json::Value::Object(map);
70 let root_prefix = format!("{}/", root.display());
71 strip_root_prefix(&mut output, &root_prefix);
72 Ok(output)
73}
74
75fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
80 match value {
81 serde_json::Value::String(s) => {
82 if let Some(rest) = s.strip_prefix(prefix) {
83 *s = rest.to_string();
84 }
85 }
86 serde_json::Value::Array(arr) => {
87 for item in arr {
88 strip_root_prefix(item, prefix);
89 }
90 }
91 serde_json::Value::Object(map) => {
92 for (_, v) in map.iter_mut() {
93 strip_root_prefix(v, prefix);
94 }
95 }
96 _ => {}
97 }
98}
99
100pub(super) fn print_health_json(
101 report: &crate::health_types::HealthReport,
102 root: &Path,
103 elapsed: Duration,
104) -> ExitCode {
105 let report_value = match serde_json::to_value(report) {
106 Ok(v) => v,
107 Err(e) => {
108 eprintln!("Error: failed to serialize health report: {e}");
109 return ExitCode::from(2);
110 }
111 };
112
113 let mut map = serde_json::Map::new();
114 map.insert(
115 "schema_version".to_string(),
116 serde_json::json!(SCHEMA_VERSION),
117 );
118 map.insert(
119 "version".to_string(),
120 serde_json::json!(env!("CARGO_PKG_VERSION")),
121 );
122 map.insert(
123 "elapsed_ms".to_string(),
124 serde_json::json!(elapsed.as_millis()),
125 );
126 if let serde_json::Value::Object(report_map) = report_value {
127 for (key, value) in report_map {
128 map.insert(key, value);
129 }
130 }
131 let mut output = serde_json::Value::Object(map);
132 let root_prefix = format!("{}/", root.display());
133 strip_root_prefix(&mut output, &root_prefix);
134
135 match serde_json::to_string_pretty(&output) {
136 Ok(json) => {
137 println!("{json}");
138 ExitCode::SUCCESS
139 }
140 Err(e) => {
141 eprintln!("Error: failed to serialize JSON output: {e}");
142 ExitCode::from(2)
143 }
144 }
145}
146
147pub(super) fn print_duplication_json(report: &DuplicationReport, elapsed: Duration) -> ExitCode {
148 let report_value = match serde_json::to_value(report) {
149 Ok(v) => v,
150 Err(e) => {
151 eprintln!("Error: failed to serialize duplication report: {e}");
152 return ExitCode::from(2);
153 }
154 };
155
156 let mut map = serde_json::Map::new();
158 map.insert(
159 "schema_version".to_string(),
160 serde_json::json!(SCHEMA_VERSION),
161 );
162 map.insert(
163 "version".to_string(),
164 serde_json::json!(env!("CARGO_PKG_VERSION")),
165 );
166 map.insert(
167 "elapsed_ms".to_string(),
168 serde_json::json!(elapsed.as_millis()),
169 );
170 if let serde_json::Value::Object(report_map) = report_value {
171 for (key, value) in report_map {
172 map.insert(key, value);
173 }
174 }
175 let output = serde_json::Value::Object(map);
176
177 match serde_json::to_string_pretty(&output) {
178 Ok(json) => {
179 println!("{json}");
180 ExitCode::SUCCESS
181 }
182 Err(e) => {
183 eprintln!("Error: failed to serialize JSON output: {e}");
184 ExitCode::from(2)
185 }
186 }
187}
188
189pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
190 match serde_json::to_string_pretty(value) {
191 Ok(json) => println!("{json}"),
192 Err(e) => {
193 eprintln!("Error: failed to serialize trace output: {e}");
194 #[expect(clippy::exit)]
195 std::process::exit(2);
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use fallow_core::extract::MemberKind;
204 use fallow_core::results::*;
205 use std::path::{Path, PathBuf};
206 use std::time::Duration;
207
208 fn sample_results(root: &Path) -> AnalysisResults {
210 let mut r = AnalysisResults::default();
211
212 r.unused_files.push(UnusedFile {
213 path: root.join("src/dead.ts"),
214 });
215 r.unused_exports.push(UnusedExport {
216 path: root.join("src/utils.ts"),
217 export_name: "helperFn".to_string(),
218 is_type_only: false,
219 line: 10,
220 col: 4,
221 span_start: 120,
222 is_re_export: false,
223 });
224 r.unused_types.push(UnusedExport {
225 path: root.join("src/types.ts"),
226 export_name: "OldType".to_string(),
227 is_type_only: true,
228 line: 5,
229 col: 0,
230 span_start: 60,
231 is_re_export: false,
232 });
233 r.unused_dependencies.push(UnusedDependency {
234 package_name: "lodash".to_string(),
235 location: DependencyLocation::Dependencies,
236 path: root.join("package.json"),
237 line: 5,
238 });
239 r.unused_dev_dependencies.push(UnusedDependency {
240 package_name: "jest".to_string(),
241 location: DependencyLocation::DevDependencies,
242 path: root.join("package.json"),
243 line: 5,
244 });
245 r.unused_enum_members.push(UnusedMember {
246 path: root.join("src/enums.ts"),
247 parent_name: "Status".to_string(),
248 member_name: "Deprecated".to_string(),
249 kind: MemberKind::EnumMember,
250 line: 8,
251 col: 2,
252 });
253 r.unused_class_members.push(UnusedMember {
254 path: root.join("src/service.ts"),
255 parent_name: "UserService".to_string(),
256 member_name: "legacyMethod".to_string(),
257 kind: MemberKind::ClassMethod,
258 line: 42,
259 col: 4,
260 });
261 r.unresolved_imports.push(UnresolvedImport {
262 path: root.join("src/app.ts"),
263 specifier: "./missing-module".to_string(),
264 line: 3,
265 col: 0,
266 });
267 r.unlisted_dependencies.push(UnlistedDependency {
268 package_name: "chalk".to_string(),
269 imported_from: vec![ImportSite {
270 path: root.join("src/cli.ts"),
271 line: 2,
272 col: 0,
273 }],
274 });
275 r.duplicate_exports.push(DuplicateExport {
276 export_name: "Config".to_string(),
277 locations: vec![
278 DuplicateLocation {
279 path: root.join("src/config.ts"),
280 line: 15,
281 col: 0,
282 },
283 DuplicateLocation {
284 path: root.join("src/types.ts"),
285 line: 30,
286 col: 0,
287 },
288 ],
289 });
290 r.circular_dependencies.push(CircularDependency {
291 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
292 length: 2,
293 line: 3,
294 col: 0,
295 });
296
297 r
298 }
299
300 #[test]
301 fn json_output_has_metadata_fields() {
302 let root = PathBuf::from("/project");
303 let results = AnalysisResults::default();
304 let elapsed = Duration::from_millis(123);
305 let output = build_json(&results, &root, elapsed).expect("should serialize");
306
307 assert_eq!(output["schema_version"], 3);
308 assert!(output["version"].is_string());
309 assert_eq!(output["elapsed_ms"], 123);
310 assert_eq!(output["total_issues"], 0);
311 }
312
313 #[test]
314 fn json_output_includes_issue_arrays() {
315 let root = PathBuf::from("/project");
316 let results = sample_results(&root);
317 let elapsed = Duration::from_millis(50);
318 let output = build_json(&results, &root, elapsed).expect("should serialize");
319
320 assert!(output["unused_files"].is_array());
321 assert!(output["unused_exports"].is_array());
322 assert!(output["unused_types"].is_array());
323 assert!(output["unused_dependencies"].is_array());
324 assert!(output["unused_dev_dependencies"].is_array());
325 assert!(output["unused_enum_members"].is_array());
326 assert!(output["unused_class_members"].is_array());
327 assert!(output["unresolved_imports"].is_array());
328 assert!(output["unlisted_dependencies"].is_array());
329 assert!(output["duplicate_exports"].is_array());
330 assert!(output["type_only_dependencies"].is_array());
331 }
332
333 #[test]
334 fn json_metadata_fields_appear_first() {
335 let root = PathBuf::from("/project");
336 let results = AnalysisResults::default();
337 let elapsed = Duration::from_millis(0);
338 let output = build_json(&results, &root, elapsed).expect("should serialize");
339 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
340 assert_eq!(keys[0], "schema_version");
341 assert_eq!(keys[1], "version");
342 assert_eq!(keys[2], "elapsed_ms");
343 assert_eq!(keys[3], "total_issues");
344 }
345
346 #[test]
347 fn json_total_issues_matches_results() {
348 let root = PathBuf::from("/project");
349 let results = sample_results(&root);
350 let total = results.total_issues();
351 let elapsed = Duration::from_millis(0);
352 let output = build_json(&results, &root, elapsed).expect("should serialize");
353
354 assert_eq!(output["total_issues"], total);
355 }
356
357 #[test]
358 fn json_unused_export_contains_expected_fields() {
359 let root = PathBuf::from("/project");
360 let mut results = AnalysisResults::default();
361 results.unused_exports.push(UnusedExport {
362 path: root.join("src/utils.ts"),
363 export_name: "helperFn".to_string(),
364 is_type_only: false,
365 line: 10,
366 col: 4,
367 span_start: 120,
368 is_re_export: false,
369 });
370 let elapsed = Duration::from_millis(0);
371 let output = build_json(&results, &root, elapsed).expect("should serialize");
372
373 let export = &output["unused_exports"][0];
374 assert_eq!(export["export_name"], "helperFn");
375 assert_eq!(export["line"], 10);
376 assert_eq!(export["col"], 4);
377 assert_eq!(export["is_type_only"], false);
378 assert_eq!(export["span_start"], 120);
379 assert_eq!(export["is_re_export"], false);
380 }
381
382 #[test]
383 fn json_serializes_to_valid_json() {
384 let root = PathBuf::from("/project");
385 let results = sample_results(&root);
386 let elapsed = Duration::from_millis(42);
387 let output = build_json(&results, &root, elapsed).expect("should serialize");
388
389 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
390 let reparsed: serde_json::Value =
391 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
392 assert_eq!(reparsed, output);
393 }
394}