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 });
299 r.unlisted_dependencies.push(UnlistedDependency {
300 package_name: "chalk".to_string(),
301 imported_from: vec![ImportSite {
302 path: root.join("src/cli.ts"),
303 line: 2,
304 col: 0,
305 }],
306 });
307 r.duplicate_exports.push(DuplicateExport {
308 export_name: "Config".to_string(),
309 locations: vec![
310 DuplicateLocation {
311 path: root.join("src/config.ts"),
312 line: 15,
313 col: 0,
314 },
315 DuplicateLocation {
316 path: root.join("src/types.ts"),
317 line: 30,
318 col: 0,
319 },
320 ],
321 });
322 r.type_only_dependencies.push(TypeOnlyDependency {
323 package_name: "zod".to_string(),
324 path: root.join("package.json"),
325 line: 12,
326 });
327 r.circular_dependencies.push(CircularDependency {
328 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
329 length: 2,
330 line: 3,
331 col: 0,
332 });
333
334 r
335 }
336
337 #[test]
338 fn json_output_has_metadata_fields() {
339 let root = PathBuf::from("/project");
340 let results = AnalysisResults::default();
341 let elapsed = Duration::from_millis(123);
342 let output = build_json(&results, &root, elapsed).expect("should serialize");
343
344 assert_eq!(output["schema_version"], 3);
345 assert!(output["version"].is_string());
346 assert_eq!(output["elapsed_ms"], 123);
347 assert_eq!(output["total_issues"], 0);
348 }
349
350 #[test]
351 fn json_output_includes_issue_arrays() {
352 let root = PathBuf::from("/project");
353 let results = sample_results(&root);
354 let elapsed = Duration::from_millis(50);
355 let output = build_json(&results, &root, elapsed).expect("should serialize");
356
357 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
358 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
359 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
360 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
361 assert_eq!(
362 output["unused_dev_dependencies"].as_array().unwrap().len(),
363 1
364 );
365 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
366 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
367 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
368 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
369 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
370 assert_eq!(
371 output["type_only_dependencies"].as_array().unwrap().len(),
372 1
373 );
374 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
375 }
376
377 #[test]
378 fn json_metadata_fields_appear_first() {
379 let root = PathBuf::from("/project");
380 let results = AnalysisResults::default();
381 let elapsed = Duration::from_millis(0);
382 let output = build_json(&results, &root, elapsed).expect("should serialize");
383 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
384 assert_eq!(keys[0], "schema_version");
385 assert_eq!(keys[1], "version");
386 assert_eq!(keys[2], "elapsed_ms");
387 assert_eq!(keys[3], "total_issues");
388 }
389
390 #[test]
391 fn json_total_issues_matches_results() {
392 let root = PathBuf::from("/project");
393 let results = sample_results(&root);
394 let total = results.total_issues();
395 let elapsed = Duration::from_millis(0);
396 let output = build_json(&results, &root, elapsed).expect("should serialize");
397
398 assert_eq!(output["total_issues"], total);
399 }
400
401 #[test]
402 fn json_unused_export_contains_expected_fields() {
403 let root = PathBuf::from("/project");
404 let mut results = AnalysisResults::default();
405 results.unused_exports.push(UnusedExport {
406 path: root.join("src/utils.ts"),
407 export_name: "helperFn".to_string(),
408 is_type_only: false,
409 line: 10,
410 col: 4,
411 span_start: 120,
412 is_re_export: false,
413 });
414 let elapsed = Duration::from_millis(0);
415 let output = build_json(&results, &root, elapsed).expect("should serialize");
416
417 let export = &output["unused_exports"][0];
418 assert_eq!(export["export_name"], "helperFn");
419 assert_eq!(export["line"], 10);
420 assert_eq!(export["col"], 4);
421 assert_eq!(export["is_type_only"], false);
422 assert_eq!(export["span_start"], 120);
423 assert_eq!(export["is_re_export"], false);
424 }
425
426 #[test]
427 fn json_serializes_to_valid_json() {
428 let root = PathBuf::from("/project");
429 let results = sample_results(&root);
430 let elapsed = Duration::from_millis(42);
431 let output = build_json(&results, &root, elapsed).expect("should serialize");
432
433 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
434 let reparsed: serde_json::Value =
435 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
436 assert_eq!(reparsed, output);
437 }
438
439 #[test]
442 fn json_empty_results_produce_valid_structure() {
443 let root = PathBuf::from("/project");
444 let results = AnalysisResults::default();
445 let elapsed = Duration::from_millis(0);
446 let output = build_json(&results, &root, elapsed).expect("should serialize");
447
448 assert_eq!(output["total_issues"], 0);
449 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
450 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
451 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
452 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
453 assert_eq!(
454 output["unused_dev_dependencies"].as_array().unwrap().len(),
455 0
456 );
457 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
458 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
459 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
460 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
461 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
462 assert_eq!(
463 output["type_only_dependencies"].as_array().unwrap().len(),
464 0
465 );
466 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
467 }
468
469 #[test]
470 fn json_empty_results_round_trips_through_string() {
471 let root = PathBuf::from("/project");
472 let results = AnalysisResults::default();
473 let elapsed = Duration::from_millis(0);
474 let output = build_json(&results, &root, elapsed).expect("should serialize");
475
476 let json_str = serde_json::to_string(&output).expect("should stringify");
477 let reparsed: serde_json::Value =
478 serde_json::from_str(&json_str).expect("should parse back");
479 assert_eq!(reparsed["total_issues"], 0);
480 }
481
482 #[test]
485 fn json_paths_are_relative_to_root() {
486 let root = PathBuf::from("/project");
487 let mut results = AnalysisResults::default();
488 results.unused_files.push(UnusedFile {
489 path: root.join("src/deep/nested/file.ts"),
490 });
491 let elapsed = Duration::from_millis(0);
492 let output = build_json(&results, &root, elapsed).expect("should serialize");
493
494 let path = output["unused_files"][0]["path"].as_str().unwrap();
495 assert_eq!(path, "src/deep/nested/file.ts");
496 assert!(!path.starts_with("/project"));
497 }
498
499 #[test]
500 fn json_strips_root_from_nested_locations() {
501 let root = PathBuf::from("/project");
502 let mut results = AnalysisResults::default();
503 results.unlisted_dependencies.push(UnlistedDependency {
504 package_name: "chalk".to_string(),
505 imported_from: vec![ImportSite {
506 path: root.join("src/cli.ts"),
507 line: 2,
508 col: 0,
509 }],
510 });
511 let elapsed = Duration::from_millis(0);
512 let output = build_json(&results, &root, elapsed).expect("should serialize");
513
514 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
515 .as_str()
516 .unwrap();
517 assert_eq!(site_path, "src/cli.ts");
518 }
519
520 #[test]
521 fn json_strips_root_from_duplicate_export_locations() {
522 let root = PathBuf::from("/project");
523 let mut results = AnalysisResults::default();
524 results.duplicate_exports.push(DuplicateExport {
525 export_name: "Config".to_string(),
526 locations: vec![
527 DuplicateLocation {
528 path: root.join("src/config.ts"),
529 line: 15,
530 col: 0,
531 },
532 DuplicateLocation {
533 path: root.join("src/types.ts"),
534 line: 30,
535 col: 0,
536 },
537 ],
538 });
539 let elapsed = Duration::from_millis(0);
540 let output = build_json(&results, &root, elapsed).expect("should serialize");
541
542 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
543 .as_str()
544 .unwrap();
545 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
546 .as_str()
547 .unwrap();
548 assert_eq!(loc0, "src/config.ts");
549 assert_eq!(loc1, "src/types.ts");
550 }
551
552 #[test]
553 fn json_strips_root_from_circular_dependency_files() {
554 let root = PathBuf::from("/project");
555 let mut results = AnalysisResults::default();
556 results.circular_dependencies.push(CircularDependency {
557 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
558 length: 2,
559 line: 1,
560 col: 0,
561 });
562 let elapsed = Duration::from_millis(0);
563 let output = build_json(&results, &root, elapsed).expect("should serialize");
564
565 let files = output["circular_dependencies"][0]["files"]
566 .as_array()
567 .unwrap();
568 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
569 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
570 }
571
572 #[test]
573 fn json_path_outside_root_not_stripped() {
574 let root = PathBuf::from("/project");
575 let mut results = AnalysisResults::default();
576 results.unused_files.push(UnusedFile {
577 path: PathBuf::from("/other/project/src/file.ts"),
578 });
579 let elapsed = Duration::from_millis(0);
580 let output = build_json(&results, &root, elapsed).expect("should serialize");
581
582 let path = output["unused_files"][0]["path"].as_str().unwrap();
583 assert!(path.contains("/other/project/"));
584 }
585
586 #[test]
589 fn json_unused_file_contains_path() {
590 let root = PathBuf::from("/project");
591 let mut results = AnalysisResults::default();
592 results.unused_files.push(UnusedFile {
593 path: root.join("src/orphan.ts"),
594 });
595 let elapsed = Duration::from_millis(0);
596 let output = build_json(&results, &root, elapsed).expect("should serialize");
597
598 let file = &output["unused_files"][0];
599 assert_eq!(file["path"], "src/orphan.ts");
600 }
601
602 #[test]
603 fn json_unused_type_contains_expected_fields() {
604 let root = PathBuf::from("/project");
605 let mut results = AnalysisResults::default();
606 results.unused_types.push(UnusedExport {
607 path: root.join("src/types.ts"),
608 export_name: "OldInterface".to_string(),
609 is_type_only: true,
610 line: 20,
611 col: 0,
612 span_start: 300,
613 is_re_export: false,
614 });
615 let elapsed = Duration::from_millis(0);
616 let output = build_json(&results, &root, elapsed).expect("should serialize");
617
618 let typ = &output["unused_types"][0];
619 assert_eq!(typ["export_name"], "OldInterface");
620 assert_eq!(typ["is_type_only"], true);
621 assert_eq!(typ["line"], 20);
622 assert_eq!(typ["path"], "src/types.ts");
623 }
624
625 #[test]
626 fn json_unused_dependency_contains_expected_fields() {
627 let root = PathBuf::from("/project");
628 let mut results = AnalysisResults::default();
629 results.unused_dependencies.push(UnusedDependency {
630 package_name: "axios".to_string(),
631 location: DependencyLocation::Dependencies,
632 path: root.join("package.json"),
633 line: 10,
634 });
635 let elapsed = Duration::from_millis(0);
636 let output = build_json(&results, &root, elapsed).expect("should serialize");
637
638 let dep = &output["unused_dependencies"][0];
639 assert_eq!(dep["package_name"], "axios");
640 assert_eq!(dep["line"], 10);
641 }
642
643 #[test]
644 fn json_unused_dev_dependency_contains_expected_fields() {
645 let root = PathBuf::from("/project");
646 let mut results = AnalysisResults::default();
647 results.unused_dev_dependencies.push(UnusedDependency {
648 package_name: "vitest".to_string(),
649 location: DependencyLocation::DevDependencies,
650 path: root.join("package.json"),
651 line: 15,
652 });
653 let elapsed = Duration::from_millis(0);
654 let output = build_json(&results, &root, elapsed).expect("should serialize");
655
656 let dep = &output["unused_dev_dependencies"][0];
657 assert_eq!(dep["package_name"], "vitest");
658 }
659
660 #[test]
661 fn json_unused_optional_dependency_contains_expected_fields() {
662 let root = PathBuf::from("/project");
663 let mut results = AnalysisResults::default();
664 results.unused_optional_dependencies.push(UnusedDependency {
665 package_name: "fsevents".to_string(),
666 location: DependencyLocation::OptionalDependencies,
667 path: root.join("package.json"),
668 line: 12,
669 });
670 let elapsed = Duration::from_millis(0);
671 let output = build_json(&results, &root, elapsed).expect("should serialize");
672
673 let dep = &output["unused_optional_dependencies"][0];
674 assert_eq!(dep["package_name"], "fsevents");
675 assert_eq!(output["total_issues"], 1);
676 }
677
678 #[test]
679 fn json_unused_enum_member_contains_expected_fields() {
680 let root = PathBuf::from("/project");
681 let mut results = AnalysisResults::default();
682 results.unused_enum_members.push(UnusedMember {
683 path: root.join("src/enums.ts"),
684 parent_name: "Color".to_string(),
685 member_name: "Purple".to_string(),
686 kind: MemberKind::EnumMember,
687 line: 5,
688 col: 2,
689 });
690 let elapsed = Duration::from_millis(0);
691 let output = build_json(&results, &root, elapsed).expect("should serialize");
692
693 let member = &output["unused_enum_members"][0];
694 assert_eq!(member["parent_name"], "Color");
695 assert_eq!(member["member_name"], "Purple");
696 assert_eq!(member["line"], 5);
697 assert_eq!(member["path"], "src/enums.ts");
698 }
699
700 #[test]
701 fn json_unused_class_member_contains_expected_fields() {
702 let root = PathBuf::from("/project");
703 let mut results = AnalysisResults::default();
704 results.unused_class_members.push(UnusedMember {
705 path: root.join("src/api.ts"),
706 parent_name: "ApiClient".to_string(),
707 member_name: "deprecatedFetch".to_string(),
708 kind: MemberKind::ClassMethod,
709 line: 100,
710 col: 4,
711 });
712 let elapsed = Duration::from_millis(0);
713 let output = build_json(&results, &root, elapsed).expect("should serialize");
714
715 let member = &output["unused_class_members"][0];
716 assert_eq!(member["parent_name"], "ApiClient");
717 assert_eq!(member["member_name"], "deprecatedFetch");
718 assert_eq!(member["line"], 100);
719 }
720
721 #[test]
722 fn json_unresolved_import_contains_expected_fields() {
723 let root = PathBuf::from("/project");
724 let mut results = AnalysisResults::default();
725 results.unresolved_imports.push(UnresolvedImport {
726 path: root.join("src/app.ts"),
727 specifier: "@acme/missing-pkg".to_string(),
728 line: 7,
729 col: 0,
730 });
731 let elapsed = Duration::from_millis(0);
732 let output = build_json(&results, &root, elapsed).expect("should serialize");
733
734 let import = &output["unresolved_imports"][0];
735 assert_eq!(import["specifier"], "@acme/missing-pkg");
736 assert_eq!(import["line"], 7);
737 assert_eq!(import["path"], "src/app.ts");
738 }
739
740 #[test]
741 fn json_unlisted_dependency_contains_import_sites() {
742 let root = PathBuf::from("/project");
743 let mut results = AnalysisResults::default();
744 results.unlisted_dependencies.push(UnlistedDependency {
745 package_name: "dotenv".to_string(),
746 imported_from: vec![
747 ImportSite {
748 path: root.join("src/config.ts"),
749 line: 1,
750 col: 0,
751 },
752 ImportSite {
753 path: root.join("src/server.ts"),
754 line: 3,
755 col: 0,
756 },
757 ],
758 });
759 let elapsed = Duration::from_millis(0);
760 let output = build_json(&results, &root, elapsed).expect("should serialize");
761
762 let dep = &output["unlisted_dependencies"][0];
763 assert_eq!(dep["package_name"], "dotenv");
764 let sites = dep["imported_from"].as_array().unwrap();
765 assert_eq!(sites.len(), 2);
766 assert_eq!(sites[0]["path"], "src/config.ts");
767 assert_eq!(sites[1]["path"], "src/server.ts");
768 }
769
770 #[test]
771 fn json_duplicate_export_contains_locations() {
772 let root = PathBuf::from("/project");
773 let mut results = AnalysisResults::default();
774 results.duplicate_exports.push(DuplicateExport {
775 export_name: "Button".to_string(),
776 locations: vec![
777 DuplicateLocation {
778 path: root.join("src/ui.ts"),
779 line: 10,
780 col: 0,
781 },
782 DuplicateLocation {
783 path: root.join("src/components.ts"),
784 line: 25,
785 col: 0,
786 },
787 ],
788 });
789 let elapsed = Duration::from_millis(0);
790 let output = build_json(&results, &root, elapsed).expect("should serialize");
791
792 let dup = &output["duplicate_exports"][0];
793 assert_eq!(dup["export_name"], "Button");
794 let locs = dup["locations"].as_array().unwrap();
795 assert_eq!(locs.len(), 2);
796 assert_eq!(locs[0]["line"], 10);
797 assert_eq!(locs[1]["line"], 25);
798 }
799
800 #[test]
801 fn json_type_only_dependency_contains_expected_fields() {
802 let root = PathBuf::from("/project");
803 let mut results = AnalysisResults::default();
804 results.type_only_dependencies.push(TypeOnlyDependency {
805 package_name: "zod".to_string(),
806 path: root.join("package.json"),
807 line: 8,
808 });
809 let elapsed = Duration::from_millis(0);
810 let output = build_json(&results, &root, elapsed).expect("should serialize");
811
812 let dep = &output["type_only_dependencies"][0];
813 assert_eq!(dep["package_name"], "zod");
814 assert_eq!(dep["line"], 8);
815 }
816
817 #[test]
818 fn json_circular_dependency_contains_expected_fields() {
819 let root = PathBuf::from("/project");
820 let mut results = AnalysisResults::default();
821 results.circular_dependencies.push(CircularDependency {
822 files: vec![
823 root.join("src/a.ts"),
824 root.join("src/b.ts"),
825 root.join("src/c.ts"),
826 ],
827 length: 3,
828 line: 5,
829 col: 0,
830 });
831 let elapsed = Duration::from_millis(0);
832 let output = build_json(&results, &root, elapsed).expect("should serialize");
833
834 let cycle = &output["circular_dependencies"][0];
835 assert_eq!(cycle["length"], 3);
836 assert_eq!(cycle["line"], 5);
837 let files = cycle["files"].as_array().unwrap();
838 assert_eq!(files.len(), 3);
839 }
840
841 #[test]
844 fn json_re_export_flagged_correctly() {
845 let root = PathBuf::from("/project");
846 let mut results = AnalysisResults::default();
847 results.unused_exports.push(UnusedExport {
848 path: root.join("src/index.ts"),
849 export_name: "reExported".to_string(),
850 is_type_only: false,
851 line: 1,
852 col: 0,
853 span_start: 0,
854 is_re_export: true,
855 });
856 let elapsed = Duration::from_millis(0);
857 let output = build_json(&results, &root, elapsed).expect("should serialize");
858
859 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
860 }
861
862 #[test]
865 fn json_schema_version_is_3() {
866 let root = PathBuf::from("/project");
867 let results = AnalysisResults::default();
868 let elapsed = Duration::from_millis(0);
869 let output = build_json(&results, &root, elapsed).expect("should serialize");
870
871 assert_eq!(output["schema_version"], SCHEMA_VERSION);
872 assert_eq!(output["schema_version"], 3);
873 }
874
875 #[test]
878 fn json_version_matches_cargo_pkg_version() {
879 let root = PathBuf::from("/project");
880 let results = AnalysisResults::default();
881 let elapsed = Duration::from_millis(0);
882 let output = build_json(&results, &root, elapsed).expect("should serialize");
883
884 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
885 }
886
887 #[test]
890 fn json_elapsed_ms_zero_duration() {
891 let root = PathBuf::from("/project");
892 let results = AnalysisResults::default();
893 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
894
895 assert_eq!(output["elapsed_ms"], 0);
896 }
897
898 #[test]
899 fn json_elapsed_ms_large_duration() {
900 let root = PathBuf::from("/project");
901 let results = AnalysisResults::default();
902 let elapsed = Duration::from_secs(120);
903 let output = build_json(&results, &root, elapsed).expect("should serialize");
904
905 assert_eq!(output["elapsed_ms"], 120_000);
906 }
907
908 #[test]
909 fn json_elapsed_ms_sub_millisecond_truncated() {
910 let root = PathBuf::from("/project");
911 let results = AnalysisResults::default();
912 let elapsed = Duration::from_micros(500);
914 let output = build_json(&results, &root, elapsed).expect("should serialize");
915
916 assert_eq!(output["elapsed_ms"], 0);
917 }
918
919 #[test]
922 fn json_multiple_unused_files() {
923 let root = PathBuf::from("/project");
924 let mut results = AnalysisResults::default();
925 results.unused_files.push(UnusedFile {
926 path: root.join("src/a.ts"),
927 });
928 results.unused_files.push(UnusedFile {
929 path: root.join("src/b.ts"),
930 });
931 results.unused_files.push(UnusedFile {
932 path: root.join("src/c.ts"),
933 });
934 let elapsed = Duration::from_millis(0);
935 let output = build_json(&results, &root, elapsed).expect("should serialize");
936
937 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
938 assert_eq!(output["total_issues"], 3);
939 }
940
941 #[test]
944 fn strip_root_prefix_on_string_value() {
945 let mut value = serde_json::json!("/project/src/file.ts");
946 strip_root_prefix(&mut value, "/project/");
947 assert_eq!(value, "src/file.ts");
948 }
949
950 #[test]
951 fn strip_root_prefix_leaves_non_matching_string() {
952 let mut value = serde_json::json!("/other/src/file.ts");
953 strip_root_prefix(&mut value, "/project/");
954 assert_eq!(value, "/other/src/file.ts");
955 }
956
957 #[test]
958 fn strip_root_prefix_recurses_into_arrays() {
959 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
960 strip_root_prefix(&mut value, "/project/");
961 assert_eq!(value[0], "a.ts");
962 assert_eq!(value[1], "b.ts");
963 assert_eq!(value[2], "/other/c.ts");
964 }
965
966 #[test]
967 fn strip_root_prefix_recurses_into_nested_objects() {
968 let mut value = serde_json::json!({
969 "outer": {
970 "path": "/project/src/nested.ts"
971 }
972 });
973 strip_root_prefix(&mut value, "/project/");
974 assert_eq!(value["outer"]["path"], "src/nested.ts");
975 }
976
977 #[test]
978 fn strip_root_prefix_leaves_numbers_and_booleans() {
979 let mut value = serde_json::json!({
980 "line": 42,
981 "is_type_only": false,
982 "path": "/project/src/file.ts"
983 });
984 strip_root_prefix(&mut value, "/project/");
985 assert_eq!(value["line"], 42);
986 assert_eq!(value["is_type_only"], false);
987 assert_eq!(value["path"], "src/file.ts");
988 }
989
990 #[test]
991 fn strip_root_prefix_handles_empty_string_after_strip() {
992 let mut value = serde_json::json!("/project/");
995 strip_root_prefix(&mut value, "/project/");
996 assert_eq!(value, "");
997 }
998
999 #[test]
1000 fn strip_root_prefix_deeply_nested_array_of_objects() {
1001 let mut value = serde_json::json!({
1002 "groups": [{
1003 "instances": [{
1004 "file": "/project/src/a.ts"
1005 }, {
1006 "file": "/project/src/b.ts"
1007 }]
1008 }]
1009 });
1010 strip_root_prefix(&mut value, "/project/");
1011 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1012 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1013 }
1014
1015 #[test]
1018 fn json_full_sample_results_total_issues_correct() {
1019 let root = PathBuf::from("/project");
1020 let results = sample_results(&root);
1021 let elapsed = Duration::from_millis(100);
1022 let output = build_json(&results, &root, elapsed).expect("should serialize");
1023
1024 assert_eq!(output["total_issues"], results.total_issues());
1030 }
1031
1032 #[test]
1033 fn json_full_sample_no_absolute_paths_in_output() {
1034 let root = PathBuf::from("/project");
1035 let results = sample_results(&root);
1036 let elapsed = Duration::from_millis(0);
1037 let output = build_json(&results, &root, elapsed).expect("should serialize");
1038
1039 let json_str = serde_json::to_string(&output).expect("should stringify");
1040 assert!(!json_str.contains("/project/src/"));
1042 assert!(!json_str.contains("/project/package.json"));
1043 }
1044
1045 #[test]
1048 fn json_output_is_deterministic() {
1049 let root = PathBuf::from("/project");
1050 let results = sample_results(&root);
1051 let elapsed = Duration::from_millis(50);
1052
1053 let output1 = build_json(&results, &root, elapsed).expect("first build");
1054 let output2 = build_json(&results, &root, elapsed).expect("second build");
1055
1056 assert_eq!(output1, output2);
1057 }
1058
1059 #[test]
1062 fn json_results_fields_do_not_shadow_metadata() {
1063 let root = PathBuf::from("/project");
1066 let results = AnalysisResults::default();
1067 let elapsed = Duration::from_millis(99);
1068 let output = build_json(&results, &root, elapsed).expect("should serialize");
1069
1070 assert_eq!(output["schema_version"], 3);
1072 assert_eq!(output["elapsed_ms"], 99);
1073 }
1074
1075 #[test]
1078 fn json_all_issue_type_arrays_present_in_empty_results() {
1079 let root = PathBuf::from("/project");
1080 let results = AnalysisResults::default();
1081 let elapsed = Duration::from_millis(0);
1082 let output = build_json(&results, &root, elapsed).expect("should serialize");
1083
1084 let expected_arrays = [
1085 "unused_files",
1086 "unused_exports",
1087 "unused_types",
1088 "unused_dependencies",
1089 "unused_dev_dependencies",
1090 "unused_optional_dependencies",
1091 "unused_enum_members",
1092 "unused_class_members",
1093 "unresolved_imports",
1094 "unlisted_dependencies",
1095 "duplicate_exports",
1096 "type_only_dependencies",
1097 "circular_dependencies",
1098 ];
1099 for key in &expected_arrays {
1100 assert!(
1101 output[key].is_array(),
1102 "expected '{key}' to be an array in JSON output"
1103 );
1104 }
1105 }
1106}