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