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