1use std::path::Path;
2use std::process::ExitCode;
3use std::time::Duration;
4
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use crate::explain;
9
10pub(super) fn print_json(
11 results: &AnalysisResults,
12 root: &Path,
13 elapsed: Duration,
14 explain: bool,
15) -> ExitCode {
16 match build_json(results, root, elapsed) {
17 Ok(mut output) => {
18 if explain {
19 insert_meta(&mut output, explain::check_meta());
20 }
21 match serde_json::to_string_pretty(&output) {
22 Ok(json) => {
23 println!("{json}");
24 ExitCode::SUCCESS
25 }
26 Err(e) => {
27 eprintln!("Error: failed to serialize JSON output: {e}");
28 ExitCode::from(2)
29 }
30 }
31 }
32 Err(e) => {
33 eprintln!("Error: failed to serialize results: {e}");
34 ExitCode::from(2)
35 }
36 }
37}
38
39const SCHEMA_VERSION: u32 = 3;
45
46pub fn build_json(
51 results: &AnalysisResults,
52 root: &Path,
53 elapsed: Duration,
54) -> Result<serde_json::Value, serde_json::Error> {
55 let results_value = serde_json::to_value(results)?;
56
57 let mut map = serde_json::Map::new();
58 map.insert(
59 "schema_version".to_string(),
60 serde_json::json!(SCHEMA_VERSION),
61 );
62 map.insert(
63 "version".to_string(),
64 serde_json::json!(env!("CARGO_PKG_VERSION")),
65 );
66 map.insert(
67 "elapsed_ms".to_string(),
68 serde_json::json!(elapsed.as_millis()),
69 );
70 map.insert(
71 "total_issues".to_string(),
72 serde_json::json!(results.total_issues()),
73 );
74
75 if let serde_json::Value::Object(results_map) = results_value {
76 for (key, value) in results_map {
77 map.insert(key, value);
78 }
79 }
80
81 let mut output = serde_json::Value::Object(map);
82 let root_prefix = format!("{}/", root.display());
83 strip_root_prefix(&mut output, &root_prefix);
84 Ok(output)
85}
86
87fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
92 match value {
93 serde_json::Value::String(s) => {
94 if let Some(rest) = s.strip_prefix(prefix) {
95 *s = rest.to_string();
96 }
97 }
98 serde_json::Value::Array(arr) => {
99 for item in arr {
100 strip_root_prefix(item, prefix);
101 }
102 }
103 serde_json::Value::Object(map) => {
104 for (_, v) in map.iter_mut() {
105 strip_root_prefix(v, prefix);
106 }
107 }
108 _ => {}
109 }
110}
111
112fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
114 if let serde_json::Value::Object(map) = output {
115 map.insert("_meta".to_string(), meta);
116 }
117}
118
119pub(super) fn print_health_json(
120 report: &crate::health_types::HealthReport,
121 root: &Path,
122 elapsed: Duration,
123 explain: bool,
124) -> ExitCode {
125 let report_value = match serde_json::to_value(report) {
126 Ok(v) => v,
127 Err(e) => {
128 eprintln!("Error: failed to serialize health report: {e}");
129 return ExitCode::from(2);
130 }
131 };
132
133 let mut map = serde_json::Map::new();
134 map.insert(
135 "schema_version".to_string(),
136 serde_json::json!(SCHEMA_VERSION),
137 );
138 map.insert(
139 "version".to_string(),
140 serde_json::json!(env!("CARGO_PKG_VERSION")),
141 );
142 map.insert(
143 "elapsed_ms".to_string(),
144 serde_json::json!(elapsed.as_millis()),
145 );
146 if let serde_json::Value::Object(report_map) = report_value {
147 for (key, value) in report_map {
148 map.insert(key, value);
149 }
150 }
151 let mut output = serde_json::Value::Object(map);
152 let root_prefix = format!("{}/", root.display());
153 strip_root_prefix(&mut output, &root_prefix);
154
155 if explain {
156 insert_meta(&mut output, explain::health_meta());
157 }
158
159 match serde_json::to_string_pretty(&output) {
160 Ok(json) => {
161 println!("{json}");
162 ExitCode::SUCCESS
163 }
164 Err(e) => {
165 eprintln!("Error: failed to serialize JSON output: {e}");
166 ExitCode::from(2)
167 }
168 }
169}
170
171pub(super) fn print_duplication_json(
172 report: &DuplicationReport,
173 elapsed: Duration,
174 explain: bool,
175) -> ExitCode {
176 let report_value = match serde_json::to_value(report) {
177 Ok(v) => v,
178 Err(e) => {
179 eprintln!("Error: failed to serialize duplication report: {e}");
180 return ExitCode::from(2);
181 }
182 };
183
184 let mut map = serde_json::Map::new();
186 map.insert(
187 "schema_version".to_string(),
188 serde_json::json!(SCHEMA_VERSION),
189 );
190 map.insert(
191 "version".to_string(),
192 serde_json::json!(env!("CARGO_PKG_VERSION")),
193 );
194 map.insert(
195 "elapsed_ms".to_string(),
196 serde_json::json!(elapsed.as_millis()),
197 );
198 if let serde_json::Value::Object(report_map) = report_value {
199 for (key, value) in report_map {
200 map.insert(key, value);
201 }
202 }
203 let mut output = serde_json::Value::Object(map);
204
205 if explain {
206 insert_meta(&mut output, explain::dupes_meta());
207 }
208
209 match serde_json::to_string_pretty(&output) {
210 Ok(json) => {
211 println!("{json}");
212 ExitCode::SUCCESS
213 }
214 Err(e) => {
215 eprintln!("Error: failed to serialize JSON output: {e}");
216 ExitCode::from(2)
217 }
218 }
219}
220
221pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
222 match serde_json::to_string_pretty(value) {
223 Ok(json) => println!("{json}"),
224 Err(e) => {
225 eprintln!("Error: failed to serialize trace output: {e}");
226 #[expect(clippy::exit)]
227 std::process::exit(2);
228 }
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use fallow_core::extract::MemberKind;
236 use fallow_core::results::*;
237 use std::path::{Path, PathBuf};
238 use std::time::Duration;
239
240 fn sample_results(root: &Path) -> AnalysisResults {
242 let mut r = AnalysisResults::default();
243
244 r.unused_files.push(UnusedFile {
245 path: root.join("src/dead.ts"),
246 });
247 r.unused_exports.push(UnusedExport {
248 path: root.join("src/utils.ts"),
249 export_name: "helperFn".to_string(),
250 is_type_only: false,
251 line: 10,
252 col: 4,
253 span_start: 120,
254 is_re_export: false,
255 });
256 r.unused_types.push(UnusedExport {
257 path: root.join("src/types.ts"),
258 export_name: "OldType".to_string(),
259 is_type_only: true,
260 line: 5,
261 col: 0,
262 span_start: 60,
263 is_re_export: false,
264 });
265 r.unused_dependencies.push(UnusedDependency {
266 package_name: "lodash".to_string(),
267 location: DependencyLocation::Dependencies,
268 path: root.join("package.json"),
269 line: 5,
270 });
271 r.unused_dev_dependencies.push(UnusedDependency {
272 package_name: "jest".to_string(),
273 location: DependencyLocation::DevDependencies,
274 path: root.join("package.json"),
275 line: 5,
276 });
277 r.unused_enum_members.push(UnusedMember {
278 path: root.join("src/enums.ts"),
279 parent_name: "Status".to_string(),
280 member_name: "Deprecated".to_string(),
281 kind: MemberKind::EnumMember,
282 line: 8,
283 col: 2,
284 });
285 r.unused_class_members.push(UnusedMember {
286 path: root.join("src/service.ts"),
287 parent_name: "UserService".to_string(),
288 member_name: "legacyMethod".to_string(),
289 kind: MemberKind::ClassMethod,
290 line: 42,
291 col: 4,
292 });
293 r.unresolved_imports.push(UnresolvedImport {
294 path: root.join("src/app.ts"),
295 specifier: "./missing-module".to_string(),
296 line: 3,
297 col: 0,
298 specifier_col: 0,
299 });
300 r.unlisted_dependencies.push(UnlistedDependency {
301 package_name: "chalk".to_string(),
302 imported_from: vec![ImportSite {
303 path: root.join("src/cli.ts"),
304 line: 2,
305 col: 0,
306 }],
307 });
308 r.duplicate_exports.push(DuplicateExport {
309 export_name: "Config".to_string(),
310 locations: vec![
311 DuplicateLocation {
312 path: root.join("src/config.ts"),
313 line: 15,
314 col: 0,
315 },
316 DuplicateLocation {
317 path: root.join("src/types.ts"),
318 line: 30,
319 col: 0,
320 },
321 ],
322 });
323 r.type_only_dependencies.push(TypeOnlyDependency {
324 package_name: "zod".to_string(),
325 path: root.join("package.json"),
326 line: 12,
327 });
328 r.circular_dependencies.push(CircularDependency {
329 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
330 length: 2,
331 line: 3,
332 col: 0,
333 });
334
335 r
336 }
337
338 #[test]
339 fn json_output_has_metadata_fields() {
340 let root = PathBuf::from("/project");
341 let results = AnalysisResults::default();
342 let elapsed = Duration::from_millis(123);
343 let output = build_json(&results, &root, elapsed).expect("should serialize");
344
345 assert_eq!(output["schema_version"], 3);
346 assert!(output["version"].is_string());
347 assert_eq!(output["elapsed_ms"], 123);
348 assert_eq!(output["total_issues"], 0);
349 }
350
351 #[test]
352 fn json_output_includes_issue_arrays() {
353 let root = PathBuf::from("/project");
354 let results = sample_results(&root);
355 let elapsed = Duration::from_millis(50);
356 let output = build_json(&results, &root, elapsed).expect("should serialize");
357
358 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
359 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
360 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
361 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
362 assert_eq!(
363 output["unused_dev_dependencies"].as_array().unwrap().len(),
364 1
365 );
366 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
367 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
368 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
369 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
370 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
371 assert_eq!(
372 output["type_only_dependencies"].as_array().unwrap().len(),
373 1
374 );
375 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
376 }
377
378 #[test]
379 fn json_metadata_fields_appear_first() {
380 let root = PathBuf::from("/project");
381 let results = AnalysisResults::default();
382 let elapsed = Duration::from_millis(0);
383 let output = build_json(&results, &root, elapsed).expect("should serialize");
384 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
385 assert_eq!(keys[0], "schema_version");
386 assert_eq!(keys[1], "version");
387 assert_eq!(keys[2], "elapsed_ms");
388 assert_eq!(keys[3], "total_issues");
389 }
390
391 #[test]
392 fn json_total_issues_matches_results() {
393 let root = PathBuf::from("/project");
394 let results = sample_results(&root);
395 let total = results.total_issues();
396 let elapsed = Duration::from_millis(0);
397 let output = build_json(&results, &root, elapsed).expect("should serialize");
398
399 assert_eq!(output["total_issues"], total);
400 }
401
402 #[test]
403 fn json_unused_export_contains_expected_fields() {
404 let root = PathBuf::from("/project");
405 let mut results = AnalysisResults::default();
406 results.unused_exports.push(UnusedExport {
407 path: root.join("src/utils.ts"),
408 export_name: "helperFn".to_string(),
409 is_type_only: false,
410 line: 10,
411 col: 4,
412 span_start: 120,
413 is_re_export: false,
414 });
415 let elapsed = Duration::from_millis(0);
416 let output = build_json(&results, &root, elapsed).expect("should serialize");
417
418 let export = &output["unused_exports"][0];
419 assert_eq!(export["export_name"], "helperFn");
420 assert_eq!(export["line"], 10);
421 assert_eq!(export["col"], 4);
422 assert_eq!(export["is_type_only"], false);
423 assert_eq!(export["span_start"], 120);
424 assert_eq!(export["is_re_export"], false);
425 }
426
427 #[test]
428 fn json_serializes_to_valid_json() {
429 let root = PathBuf::from("/project");
430 let results = sample_results(&root);
431 let elapsed = Duration::from_millis(42);
432 let output = build_json(&results, &root, elapsed).expect("should serialize");
433
434 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
435 let reparsed: serde_json::Value =
436 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
437 assert_eq!(reparsed, output);
438 }
439
440 #[test]
443 fn json_empty_results_produce_valid_structure() {
444 let root = PathBuf::from("/project");
445 let results = AnalysisResults::default();
446 let elapsed = Duration::from_millis(0);
447 let output = build_json(&results, &root, elapsed).expect("should serialize");
448
449 assert_eq!(output["total_issues"], 0);
450 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
451 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
452 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
453 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
454 assert_eq!(
455 output["unused_dev_dependencies"].as_array().unwrap().len(),
456 0
457 );
458 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
459 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
460 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
461 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
462 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
463 assert_eq!(
464 output["type_only_dependencies"].as_array().unwrap().len(),
465 0
466 );
467 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
468 }
469
470 #[test]
471 fn json_empty_results_round_trips_through_string() {
472 let root = PathBuf::from("/project");
473 let results = AnalysisResults::default();
474 let elapsed = Duration::from_millis(0);
475 let output = build_json(&results, &root, elapsed).expect("should serialize");
476
477 let json_str = serde_json::to_string(&output).expect("should stringify");
478 let reparsed: serde_json::Value =
479 serde_json::from_str(&json_str).expect("should parse back");
480 assert_eq!(reparsed["total_issues"], 0);
481 }
482
483 #[test]
486 fn json_paths_are_relative_to_root() {
487 let root = PathBuf::from("/project");
488 let mut results = AnalysisResults::default();
489 results.unused_files.push(UnusedFile {
490 path: root.join("src/deep/nested/file.ts"),
491 });
492 let elapsed = Duration::from_millis(0);
493 let output = build_json(&results, &root, elapsed).expect("should serialize");
494
495 let path = output["unused_files"][0]["path"].as_str().unwrap();
496 assert_eq!(path, "src/deep/nested/file.ts");
497 assert!(!path.starts_with("/project"));
498 }
499
500 #[test]
501 fn json_strips_root_from_nested_locations() {
502 let root = PathBuf::from("/project");
503 let mut results = AnalysisResults::default();
504 results.unlisted_dependencies.push(UnlistedDependency {
505 package_name: "chalk".to_string(),
506 imported_from: vec![ImportSite {
507 path: root.join("src/cli.ts"),
508 line: 2,
509 col: 0,
510 }],
511 });
512 let elapsed = Duration::from_millis(0);
513 let output = build_json(&results, &root, elapsed).expect("should serialize");
514
515 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
516 .as_str()
517 .unwrap();
518 assert_eq!(site_path, "src/cli.ts");
519 }
520
521 #[test]
522 fn json_strips_root_from_duplicate_export_locations() {
523 let root = PathBuf::from("/project");
524 let mut results = AnalysisResults::default();
525 results.duplicate_exports.push(DuplicateExport {
526 export_name: "Config".to_string(),
527 locations: vec![
528 DuplicateLocation {
529 path: root.join("src/config.ts"),
530 line: 15,
531 col: 0,
532 },
533 DuplicateLocation {
534 path: root.join("src/types.ts"),
535 line: 30,
536 col: 0,
537 },
538 ],
539 });
540 let elapsed = Duration::from_millis(0);
541 let output = build_json(&results, &root, elapsed).expect("should serialize");
542
543 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
544 .as_str()
545 .unwrap();
546 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
547 .as_str()
548 .unwrap();
549 assert_eq!(loc0, "src/config.ts");
550 assert_eq!(loc1, "src/types.ts");
551 }
552
553 #[test]
554 fn json_strips_root_from_circular_dependency_files() {
555 let root = PathBuf::from("/project");
556 let mut results = AnalysisResults::default();
557 results.circular_dependencies.push(CircularDependency {
558 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
559 length: 2,
560 line: 1,
561 col: 0,
562 });
563 let elapsed = Duration::from_millis(0);
564 let output = build_json(&results, &root, elapsed).expect("should serialize");
565
566 let files = output["circular_dependencies"][0]["files"]
567 .as_array()
568 .unwrap();
569 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
570 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
571 }
572
573 #[test]
574 fn json_path_outside_root_not_stripped() {
575 let root = PathBuf::from("/project");
576 let mut results = AnalysisResults::default();
577 results.unused_files.push(UnusedFile {
578 path: PathBuf::from("/other/project/src/file.ts"),
579 });
580 let elapsed = Duration::from_millis(0);
581 let output = build_json(&results, &root, elapsed).expect("should serialize");
582
583 let path = output["unused_files"][0]["path"].as_str().unwrap();
584 assert!(path.contains("/other/project/"));
585 }
586
587 #[test]
590 fn json_unused_file_contains_path() {
591 let root = PathBuf::from("/project");
592 let mut results = AnalysisResults::default();
593 results.unused_files.push(UnusedFile {
594 path: root.join("src/orphan.ts"),
595 });
596 let elapsed = Duration::from_millis(0);
597 let output = build_json(&results, &root, elapsed).expect("should serialize");
598
599 let file = &output["unused_files"][0];
600 assert_eq!(file["path"], "src/orphan.ts");
601 }
602
603 #[test]
604 fn json_unused_type_contains_expected_fields() {
605 let root = PathBuf::from("/project");
606 let mut results = AnalysisResults::default();
607 results.unused_types.push(UnusedExport {
608 path: root.join("src/types.ts"),
609 export_name: "OldInterface".to_string(),
610 is_type_only: true,
611 line: 20,
612 col: 0,
613 span_start: 300,
614 is_re_export: false,
615 });
616 let elapsed = Duration::from_millis(0);
617 let output = build_json(&results, &root, elapsed).expect("should serialize");
618
619 let typ = &output["unused_types"][0];
620 assert_eq!(typ["export_name"], "OldInterface");
621 assert_eq!(typ["is_type_only"], true);
622 assert_eq!(typ["line"], 20);
623 assert_eq!(typ["path"], "src/types.ts");
624 }
625
626 #[test]
627 fn json_unused_dependency_contains_expected_fields() {
628 let root = PathBuf::from("/project");
629 let mut results = AnalysisResults::default();
630 results.unused_dependencies.push(UnusedDependency {
631 package_name: "axios".to_string(),
632 location: DependencyLocation::Dependencies,
633 path: root.join("package.json"),
634 line: 10,
635 });
636 let elapsed = Duration::from_millis(0);
637 let output = build_json(&results, &root, elapsed).expect("should serialize");
638
639 let dep = &output["unused_dependencies"][0];
640 assert_eq!(dep["package_name"], "axios");
641 assert_eq!(dep["line"], 10);
642 }
643
644 #[test]
645 fn json_unused_dev_dependency_contains_expected_fields() {
646 let root = PathBuf::from("/project");
647 let mut results = AnalysisResults::default();
648 results.unused_dev_dependencies.push(UnusedDependency {
649 package_name: "vitest".to_string(),
650 location: DependencyLocation::DevDependencies,
651 path: root.join("package.json"),
652 line: 15,
653 });
654 let elapsed = Duration::from_millis(0);
655 let output = build_json(&results, &root, elapsed).expect("should serialize");
656
657 let dep = &output["unused_dev_dependencies"][0];
658 assert_eq!(dep["package_name"], "vitest");
659 }
660
661 #[test]
662 fn json_unused_optional_dependency_contains_expected_fields() {
663 let root = PathBuf::from("/project");
664 let mut results = AnalysisResults::default();
665 results.unused_optional_dependencies.push(UnusedDependency {
666 package_name: "fsevents".to_string(),
667 location: DependencyLocation::OptionalDependencies,
668 path: root.join("package.json"),
669 line: 12,
670 });
671 let elapsed = Duration::from_millis(0);
672 let output = build_json(&results, &root, elapsed).expect("should serialize");
673
674 let dep = &output["unused_optional_dependencies"][0];
675 assert_eq!(dep["package_name"], "fsevents");
676 assert_eq!(output["total_issues"], 1);
677 }
678
679 #[test]
680 fn json_unused_enum_member_contains_expected_fields() {
681 let root = PathBuf::from("/project");
682 let mut results = AnalysisResults::default();
683 results.unused_enum_members.push(UnusedMember {
684 path: root.join("src/enums.ts"),
685 parent_name: "Color".to_string(),
686 member_name: "Purple".to_string(),
687 kind: MemberKind::EnumMember,
688 line: 5,
689 col: 2,
690 });
691 let elapsed = Duration::from_millis(0);
692 let output = build_json(&results, &root, elapsed).expect("should serialize");
693
694 let member = &output["unused_enum_members"][0];
695 assert_eq!(member["parent_name"], "Color");
696 assert_eq!(member["member_name"], "Purple");
697 assert_eq!(member["line"], 5);
698 assert_eq!(member["path"], "src/enums.ts");
699 }
700
701 #[test]
702 fn json_unused_class_member_contains_expected_fields() {
703 let root = PathBuf::from("/project");
704 let mut results = AnalysisResults::default();
705 results.unused_class_members.push(UnusedMember {
706 path: root.join("src/api.ts"),
707 parent_name: "ApiClient".to_string(),
708 member_name: "deprecatedFetch".to_string(),
709 kind: MemberKind::ClassMethod,
710 line: 100,
711 col: 4,
712 });
713 let elapsed = Duration::from_millis(0);
714 let output = build_json(&results, &root, elapsed).expect("should serialize");
715
716 let member = &output["unused_class_members"][0];
717 assert_eq!(member["parent_name"], "ApiClient");
718 assert_eq!(member["member_name"], "deprecatedFetch");
719 assert_eq!(member["line"], 100);
720 }
721
722 #[test]
723 fn json_unresolved_import_contains_expected_fields() {
724 let root = PathBuf::from("/project");
725 let mut results = AnalysisResults::default();
726 results.unresolved_imports.push(UnresolvedImport {
727 path: root.join("src/app.ts"),
728 specifier: "@acme/missing-pkg".to_string(),
729 line: 7,
730 col: 0,
731 specifier_col: 0,
732 });
733 let elapsed = Duration::from_millis(0);
734 let output = build_json(&results, &root, elapsed).expect("should serialize");
735
736 let import = &output["unresolved_imports"][0];
737 assert_eq!(import["specifier"], "@acme/missing-pkg");
738 assert_eq!(import["line"], 7);
739 assert_eq!(import["path"], "src/app.ts");
740 }
741
742 #[test]
743 fn json_unlisted_dependency_contains_import_sites() {
744 let root = PathBuf::from("/project");
745 let mut results = AnalysisResults::default();
746 results.unlisted_dependencies.push(UnlistedDependency {
747 package_name: "dotenv".to_string(),
748 imported_from: vec![
749 ImportSite {
750 path: root.join("src/config.ts"),
751 line: 1,
752 col: 0,
753 },
754 ImportSite {
755 path: root.join("src/server.ts"),
756 line: 3,
757 col: 0,
758 },
759 ],
760 });
761 let elapsed = Duration::from_millis(0);
762 let output = build_json(&results, &root, elapsed).expect("should serialize");
763
764 let dep = &output["unlisted_dependencies"][0];
765 assert_eq!(dep["package_name"], "dotenv");
766 let sites = dep["imported_from"].as_array().unwrap();
767 assert_eq!(sites.len(), 2);
768 assert_eq!(sites[0]["path"], "src/config.ts");
769 assert_eq!(sites[1]["path"], "src/server.ts");
770 }
771
772 #[test]
773 fn json_duplicate_export_contains_locations() {
774 let root = PathBuf::from("/project");
775 let mut results = AnalysisResults::default();
776 results.duplicate_exports.push(DuplicateExport {
777 export_name: "Button".to_string(),
778 locations: vec![
779 DuplicateLocation {
780 path: root.join("src/ui.ts"),
781 line: 10,
782 col: 0,
783 },
784 DuplicateLocation {
785 path: root.join("src/components.ts"),
786 line: 25,
787 col: 0,
788 },
789 ],
790 });
791 let elapsed = Duration::from_millis(0);
792 let output = build_json(&results, &root, elapsed).expect("should serialize");
793
794 let dup = &output["duplicate_exports"][0];
795 assert_eq!(dup["export_name"], "Button");
796 let locs = dup["locations"].as_array().unwrap();
797 assert_eq!(locs.len(), 2);
798 assert_eq!(locs[0]["line"], 10);
799 assert_eq!(locs[1]["line"], 25);
800 }
801
802 #[test]
803 fn json_type_only_dependency_contains_expected_fields() {
804 let root = PathBuf::from("/project");
805 let mut results = AnalysisResults::default();
806 results.type_only_dependencies.push(TypeOnlyDependency {
807 package_name: "zod".to_string(),
808 path: root.join("package.json"),
809 line: 8,
810 });
811 let elapsed = Duration::from_millis(0);
812 let output = build_json(&results, &root, elapsed).expect("should serialize");
813
814 let dep = &output["type_only_dependencies"][0];
815 assert_eq!(dep["package_name"], "zod");
816 assert_eq!(dep["line"], 8);
817 }
818
819 #[test]
820 fn json_circular_dependency_contains_expected_fields() {
821 let root = PathBuf::from("/project");
822 let mut results = AnalysisResults::default();
823 results.circular_dependencies.push(CircularDependency {
824 files: vec![
825 root.join("src/a.ts"),
826 root.join("src/b.ts"),
827 root.join("src/c.ts"),
828 ],
829 length: 3,
830 line: 5,
831 col: 0,
832 });
833 let elapsed = Duration::from_millis(0);
834 let output = build_json(&results, &root, elapsed).expect("should serialize");
835
836 let cycle = &output["circular_dependencies"][0];
837 assert_eq!(cycle["length"], 3);
838 assert_eq!(cycle["line"], 5);
839 let files = cycle["files"].as_array().unwrap();
840 assert_eq!(files.len(), 3);
841 }
842
843 #[test]
846 fn json_re_export_flagged_correctly() {
847 let root = PathBuf::from("/project");
848 let mut results = AnalysisResults::default();
849 results.unused_exports.push(UnusedExport {
850 path: root.join("src/index.ts"),
851 export_name: "reExported".to_string(),
852 is_type_only: false,
853 line: 1,
854 col: 0,
855 span_start: 0,
856 is_re_export: true,
857 });
858 let elapsed = Duration::from_millis(0);
859 let output = build_json(&results, &root, elapsed).expect("should serialize");
860
861 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
862 }
863
864 #[test]
867 fn json_schema_version_is_3() {
868 let root = PathBuf::from("/project");
869 let results = AnalysisResults::default();
870 let elapsed = Duration::from_millis(0);
871 let output = build_json(&results, &root, elapsed).expect("should serialize");
872
873 assert_eq!(output["schema_version"], SCHEMA_VERSION);
874 assert_eq!(output["schema_version"], 3);
875 }
876
877 #[test]
880 fn json_version_matches_cargo_pkg_version() {
881 let root = PathBuf::from("/project");
882 let results = AnalysisResults::default();
883 let elapsed = Duration::from_millis(0);
884 let output = build_json(&results, &root, elapsed).expect("should serialize");
885
886 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
887 }
888
889 #[test]
892 fn json_elapsed_ms_zero_duration() {
893 let root = PathBuf::from("/project");
894 let results = AnalysisResults::default();
895 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
896
897 assert_eq!(output["elapsed_ms"], 0);
898 }
899
900 #[test]
901 fn json_elapsed_ms_large_duration() {
902 let root = PathBuf::from("/project");
903 let results = AnalysisResults::default();
904 let elapsed = Duration::from_secs(120);
905 let output = build_json(&results, &root, elapsed).expect("should serialize");
906
907 assert_eq!(output["elapsed_ms"], 120_000);
908 }
909
910 #[test]
911 fn json_elapsed_ms_sub_millisecond_truncated() {
912 let root = PathBuf::from("/project");
913 let results = AnalysisResults::default();
914 let elapsed = Duration::from_micros(500);
916 let output = build_json(&results, &root, elapsed).expect("should serialize");
917
918 assert_eq!(output["elapsed_ms"], 0);
919 }
920
921 #[test]
924 fn json_multiple_unused_files() {
925 let root = PathBuf::from("/project");
926 let mut results = AnalysisResults::default();
927 results.unused_files.push(UnusedFile {
928 path: root.join("src/a.ts"),
929 });
930 results.unused_files.push(UnusedFile {
931 path: root.join("src/b.ts"),
932 });
933 results.unused_files.push(UnusedFile {
934 path: root.join("src/c.ts"),
935 });
936 let elapsed = Duration::from_millis(0);
937 let output = build_json(&results, &root, elapsed).expect("should serialize");
938
939 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
940 assert_eq!(output["total_issues"], 3);
941 }
942
943 #[test]
946 fn strip_root_prefix_on_string_value() {
947 let mut value = serde_json::json!("/project/src/file.ts");
948 strip_root_prefix(&mut value, "/project/");
949 assert_eq!(value, "src/file.ts");
950 }
951
952 #[test]
953 fn strip_root_prefix_leaves_non_matching_string() {
954 let mut value = serde_json::json!("/other/src/file.ts");
955 strip_root_prefix(&mut value, "/project/");
956 assert_eq!(value, "/other/src/file.ts");
957 }
958
959 #[test]
960 fn strip_root_prefix_recurses_into_arrays() {
961 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
962 strip_root_prefix(&mut value, "/project/");
963 assert_eq!(value[0], "a.ts");
964 assert_eq!(value[1], "b.ts");
965 assert_eq!(value[2], "/other/c.ts");
966 }
967
968 #[test]
969 fn strip_root_prefix_recurses_into_nested_objects() {
970 let mut value = serde_json::json!({
971 "outer": {
972 "path": "/project/src/nested.ts"
973 }
974 });
975 strip_root_prefix(&mut value, "/project/");
976 assert_eq!(value["outer"]["path"], "src/nested.ts");
977 }
978
979 #[test]
980 fn strip_root_prefix_leaves_numbers_and_booleans() {
981 let mut value = serde_json::json!({
982 "line": 42,
983 "is_type_only": false,
984 "path": "/project/src/file.ts"
985 });
986 strip_root_prefix(&mut value, "/project/");
987 assert_eq!(value["line"], 42);
988 assert_eq!(value["is_type_only"], false);
989 assert_eq!(value["path"], "src/file.ts");
990 }
991
992 #[test]
993 fn strip_root_prefix_handles_empty_string_after_strip() {
994 let mut value = serde_json::json!("/project/");
997 strip_root_prefix(&mut value, "/project/");
998 assert_eq!(value, "");
999 }
1000
1001 #[test]
1002 fn strip_root_prefix_deeply_nested_array_of_objects() {
1003 let mut value = serde_json::json!({
1004 "groups": [{
1005 "instances": [{
1006 "file": "/project/src/a.ts"
1007 }, {
1008 "file": "/project/src/b.ts"
1009 }]
1010 }]
1011 });
1012 strip_root_prefix(&mut value, "/project/");
1013 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1014 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1015 }
1016
1017 #[test]
1020 fn json_full_sample_results_total_issues_correct() {
1021 let root = PathBuf::from("/project");
1022 let results = sample_results(&root);
1023 let elapsed = Duration::from_millis(100);
1024 let output = build_json(&results, &root, elapsed).expect("should serialize");
1025
1026 assert_eq!(output["total_issues"], results.total_issues());
1032 }
1033
1034 #[test]
1035 fn json_full_sample_no_absolute_paths_in_output() {
1036 let root = PathBuf::from("/project");
1037 let results = sample_results(&root);
1038 let elapsed = Duration::from_millis(0);
1039 let output = build_json(&results, &root, elapsed).expect("should serialize");
1040
1041 let json_str = serde_json::to_string(&output).expect("should stringify");
1042 assert!(!json_str.contains("/project/src/"));
1044 assert!(!json_str.contains("/project/package.json"));
1045 }
1046
1047 #[test]
1050 fn json_output_is_deterministic() {
1051 let root = PathBuf::from("/project");
1052 let results = sample_results(&root);
1053 let elapsed = Duration::from_millis(50);
1054
1055 let output1 = build_json(&results, &root, elapsed).expect("first build");
1056 let output2 = build_json(&results, &root, elapsed).expect("second build");
1057
1058 assert_eq!(output1, output2);
1059 }
1060
1061 #[test]
1064 fn json_results_fields_do_not_shadow_metadata() {
1065 let root = PathBuf::from("/project");
1068 let results = AnalysisResults::default();
1069 let elapsed = Duration::from_millis(99);
1070 let output = build_json(&results, &root, elapsed).expect("should serialize");
1071
1072 assert_eq!(output["schema_version"], 3);
1074 assert_eq!(output["elapsed_ms"], 99);
1075 }
1076
1077 #[test]
1080 fn json_all_issue_type_arrays_present_in_empty_results() {
1081 let root = PathBuf::from("/project");
1082 let results = AnalysisResults::default();
1083 let elapsed = Duration::from_millis(0);
1084 let output = build_json(&results, &root, elapsed).expect("should serialize");
1085
1086 let expected_arrays = [
1087 "unused_files",
1088 "unused_exports",
1089 "unused_types",
1090 "unused_dependencies",
1091 "unused_dev_dependencies",
1092 "unused_optional_dependencies",
1093 "unused_enum_members",
1094 "unused_class_members",
1095 "unresolved_imports",
1096 "unlisted_dependencies",
1097 "duplicate_exports",
1098 "type_only_dependencies",
1099 "circular_dependencies",
1100 ];
1101 for key in &expected_arrays {
1102 assert!(
1103 output[key].is_array(),
1104 "expected '{key}' to be an array in JSON output"
1105 );
1106 }
1107 }
1108}