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