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(
80 results: &AnalysisResults,
81 root: &Path,
82 elapsed: Duration,
83) -> Result<serde_json::Value, serde_json::Error> {
84 let results_value = serde_json::to_value(results)?;
85
86 let mut map = serde_json::Map::new();
87 map.insert(
88 "schema_version".to_string(),
89 serde_json::json!(SCHEMA_VERSION),
90 );
91 map.insert(
92 "version".to_string(),
93 serde_json::json!(env!("CARGO_PKG_VERSION")),
94 );
95 map.insert(
96 "elapsed_ms".to_string(),
97 serde_json::json!(elapsed.as_millis()),
98 );
99 map.insert(
100 "total_issues".to_string(),
101 serde_json::json!(results.total_issues()),
102 );
103
104 if let serde_json::Value::Object(results_map) = results_value {
105 for (key, value) in results_map {
106 map.insert(key, value);
107 }
108 }
109
110 let mut output = serde_json::Value::Object(map);
111 let root_prefix = format!("{}/", root.display());
112 strip_root_prefix(&mut output, &root_prefix);
116 inject_actions(&mut output);
117 Ok(output)
118}
119
120fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
125 match value {
126 serde_json::Value::String(s) => {
127 if let Some(rest) = s.strip_prefix(prefix) {
128 *s = rest.to_string();
129 }
130 }
131 serde_json::Value::Array(arr) => {
132 for item in arr {
133 strip_root_prefix(item, prefix);
134 }
135 }
136 serde_json::Value::Object(map) => {
137 for (_, v) in map.iter_mut() {
138 strip_root_prefix(v, prefix);
139 }
140 }
141 _ => {}
142 }
143}
144
145enum SuppressKind {
149 InlineComment,
151 FileComment,
153 ConfigIgnoreDep,
155}
156
157struct ActionSpec {
159 fix_type: &'static str,
160 auto_fixable: bool,
161 description: &'static str,
162 note: Option<&'static str>,
163 suppress: SuppressKind,
164 issue_kind: &'static str,
165}
166
167fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
169 match key {
170 "unused_files" => Some(ActionSpec {
171 fix_type: "delete-file",
172 auto_fixable: false,
173 description: "Delete this file",
174 note: Some(
175 "File deletion may remove runtime functionality not visible to static analysis",
176 ),
177 suppress: SuppressKind::FileComment,
178 issue_kind: "unused-file",
179 }),
180 "unused_exports" => Some(ActionSpec {
181 fix_type: "remove-export",
182 auto_fixable: true,
183 description: "Remove the `export` keyword from the declaration",
184 note: None,
185 suppress: SuppressKind::InlineComment,
186 issue_kind: "unused-export",
187 }),
188 "unused_types" => Some(ActionSpec {
189 fix_type: "remove-export",
190 auto_fixable: true,
191 description: "Remove the `export` (or `export type`) keyword from the type declaration",
192 note: None,
193 suppress: SuppressKind::InlineComment,
194 issue_kind: "unused-type",
195 }),
196 "unused_dependencies" => Some(ActionSpec {
197 fix_type: "remove-dependency",
198 auto_fixable: true,
199 description: "Remove from dependencies in package.json",
200 note: None,
201 suppress: SuppressKind::ConfigIgnoreDep,
202 issue_kind: "unused-dependency",
203 }),
204 "unused_dev_dependencies" => Some(ActionSpec {
205 fix_type: "remove-dependency",
206 auto_fixable: true,
207 description: "Remove from devDependencies in package.json",
208 note: None,
209 suppress: SuppressKind::ConfigIgnoreDep,
210 issue_kind: "unused-dev-dependency",
211 }),
212 "unused_optional_dependencies" => Some(ActionSpec {
213 fix_type: "remove-dependency",
214 auto_fixable: true,
215 description: "Remove from optionalDependencies in package.json",
216 note: None,
217 suppress: SuppressKind::ConfigIgnoreDep,
218 issue_kind: "unused-dependency",
220 }),
221 "unused_enum_members" => Some(ActionSpec {
222 fix_type: "remove-enum-member",
223 auto_fixable: true,
224 description: "Remove this enum member",
225 note: None,
226 suppress: SuppressKind::InlineComment,
227 issue_kind: "unused-enum-member",
228 }),
229 "unused_class_members" => Some(ActionSpec {
230 fix_type: "remove-class-member",
231 auto_fixable: false,
232 description: "Remove this class member",
233 note: Some("Class member may be used via dependency injection or decorators"),
234 suppress: SuppressKind::InlineComment,
235 issue_kind: "unused-class-member",
236 }),
237 "unresolved_imports" => Some(ActionSpec {
238 fix_type: "resolve-import",
239 auto_fixable: false,
240 description: "Fix the import specifier or install the missing module",
241 note: Some("Verify the module path and check tsconfig paths configuration"),
242 suppress: SuppressKind::InlineComment,
243 issue_kind: "unresolved-import",
244 }),
245 "unlisted_dependencies" => Some(ActionSpec {
246 fix_type: "install-dependency",
247 auto_fixable: false,
248 description: "Add this package to dependencies in package.json",
249 note: Some("Verify this package should be a direct dependency before adding"),
250 suppress: SuppressKind::ConfigIgnoreDep,
251 issue_kind: "unlisted-dependency",
252 }),
253 "duplicate_exports" => Some(ActionSpec {
254 fix_type: "remove-duplicate",
255 auto_fixable: false,
256 description: "Keep one canonical export location and remove the others",
257 note: Some("Review all locations to determine which should be the canonical export"),
258 suppress: SuppressKind::InlineComment,
259 issue_kind: "duplicate-export",
260 }),
261 "type_only_dependencies" => Some(ActionSpec {
262 fix_type: "move-to-dev",
263 auto_fixable: false,
264 description: "Move to devDependencies (only type imports are used)",
265 note: Some(
266 "Type imports are erased at runtime so this dependency is not needed in production",
267 ),
268 suppress: SuppressKind::ConfigIgnoreDep,
269 issue_kind: "type-only-dependency",
270 }),
271 "test_only_dependencies" => Some(ActionSpec {
272 fix_type: "move-to-dev",
273 auto_fixable: false,
274 description: "Move to devDependencies (only test files import this)",
275 note: Some(
276 "Only test files import this package so it does not need to be a production dependency",
277 ),
278 suppress: SuppressKind::ConfigIgnoreDep,
279 issue_kind: "test-only-dependency",
280 }),
281 "circular_dependencies" => Some(ActionSpec {
282 fix_type: "refactor-cycle",
283 auto_fixable: false,
284 description: "Extract shared logic into a separate module to break the cycle",
285 note: Some(
286 "Circular imports can cause initialization issues and make code harder to reason about",
287 ),
288 suppress: SuppressKind::InlineComment,
289 issue_kind: "circular-dependency",
290 }),
291 "boundary_violations" => Some(ActionSpec {
292 fix_type: "refactor-boundary",
293 auto_fixable: false,
294 description: "Move the import through an allowed zone or restructure the dependency",
295 note: Some(
296 "This import crosses an architecture boundary that is not permitted by the configured rules",
297 ),
298 suppress: SuppressKind::InlineComment,
299 issue_kind: "boundary-violation",
300 }),
301 _ => None,
302 }
303}
304
305fn build_actions(
307 item: &serde_json::Value,
308 issue_key: &str,
309 spec: &ActionSpec,
310) -> serde_json::Value {
311 let mut actions = Vec::with_capacity(2);
312
313 let mut fix_action = serde_json::json!({
315 "type": spec.fix_type,
316 "auto_fixable": spec.auto_fixable,
317 "description": spec.description,
318 });
319 if let Some(note) = spec.note {
320 fix_action["note"] = serde_json::json!(note);
321 }
322 if (issue_key == "unused_exports" || issue_key == "unused_types")
324 && item
325 .get("is_re_export")
326 .and_then(serde_json::Value::as_bool)
327 == Some(true)
328 {
329 fix_action["note"] = serde_json::json!(
330 "This finding originates from a re-export; verify it is not part of your public API before removing"
331 );
332 }
333 actions.push(fix_action);
334
335 match spec.suppress {
337 SuppressKind::InlineComment => {
338 let mut suppress = serde_json::json!({
339 "type": "suppress-line",
340 "auto_fixable": false,
341 "description": "Suppress with an inline comment above the line",
342 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
343 });
344 if issue_key == "duplicate_exports" {
346 suppress["scope"] = serde_json::json!("per-location");
347 }
348 actions.push(suppress);
349 }
350 SuppressKind::FileComment => {
351 actions.push(serde_json::json!({
352 "type": "suppress-file",
353 "auto_fixable": false,
354 "description": "Suppress with a file-level comment at the top of the file",
355 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
356 }));
357 }
358 SuppressKind::ConfigIgnoreDep => {
359 let pkg = item
361 .get("package_name")
362 .and_then(serde_json::Value::as_str)
363 .unwrap_or("package-name");
364 actions.push(serde_json::json!({
365 "type": "add-to-config",
366 "auto_fixable": false,
367 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
368 "config_key": "ignoreDependencies",
369 "value": pkg,
370 }));
371 }
372 }
373
374 serde_json::Value::Array(actions)
375}
376
377fn inject_actions(output: &mut serde_json::Value) {
382 let Some(map) = output.as_object_mut() else {
383 return;
384 };
385
386 for (key, value) in map.iter_mut() {
387 let Some(spec) = actions_for_issue_type(key) else {
388 continue;
389 };
390 let Some(arr) = value.as_array_mut() else {
391 continue;
392 };
393 for item in arr {
394 let actions = build_actions(item, key, &spec);
395 if let serde_json::Value::Object(obj) = item {
396 obj.insert("actions".to_string(), actions);
397 }
398 }
399 }
400}
401
402fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
404 if let serde_json::Value::Object(map) = output {
405 map.insert("_meta".to_string(), meta);
406 }
407}
408
409pub(super) fn print_health_json(
410 report: &crate::health_types::HealthReport,
411 root: &Path,
412 elapsed: Duration,
413 explain: bool,
414) -> ExitCode {
415 let report_value = match serde_json::to_value(report) {
416 Ok(v) => v,
417 Err(e) => {
418 eprintln!("Error: failed to serialize health report: {e}");
419 return ExitCode::from(2);
420 }
421 };
422
423 let mut output = build_json_envelope(report_value, elapsed);
424 let root_prefix = format!("{}/", root.display());
425 strip_root_prefix(&mut output, &root_prefix);
426
427 if explain {
428 insert_meta(&mut output, explain::health_meta());
429 }
430
431 emit_json(&output, "JSON")
432}
433
434pub(super) fn print_duplication_json(
435 report: &DuplicationReport,
436 elapsed: Duration,
437 explain: bool,
438) -> ExitCode {
439 let report_value = match serde_json::to_value(report) {
440 Ok(v) => v,
441 Err(e) => {
442 eprintln!("Error: failed to serialize duplication report: {e}");
443 return ExitCode::from(2);
444 }
445 };
446
447 let mut output = build_json_envelope(report_value, elapsed);
448
449 if explain {
450 insert_meta(&mut output, explain::dupes_meta());
451 }
452
453 emit_json(&output, "JSON")
454}
455
456pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
457 match serde_json::to_string_pretty(value) {
458 Ok(json) => println!("{json}"),
459 Err(e) => {
460 eprintln!("Error: failed to serialize trace output: {e}");
461 #[expect(
462 clippy::exit,
463 reason = "fatal serialization error requires immediate exit"
464 )]
465 std::process::exit(2);
466 }
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::report::test_helpers::sample_results;
474 use fallow_core::extract::MemberKind;
475 use fallow_core::results::*;
476 use std::path::PathBuf;
477 use std::time::Duration;
478
479 #[test]
480 fn json_output_has_metadata_fields() {
481 let root = PathBuf::from("/project");
482 let results = AnalysisResults::default();
483 let elapsed = Duration::from_millis(123);
484 let output = build_json(&results, &root, elapsed).expect("should serialize");
485
486 assert_eq!(output["schema_version"], 3);
487 assert!(output["version"].is_string());
488 assert_eq!(output["elapsed_ms"], 123);
489 assert_eq!(output["total_issues"], 0);
490 }
491
492 #[test]
493 fn json_output_includes_issue_arrays() {
494 let root = PathBuf::from("/project");
495 let results = sample_results(&root);
496 let elapsed = Duration::from_millis(50);
497 let output = build_json(&results, &root, elapsed).expect("should serialize");
498
499 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
500 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
501 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
502 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
503 assert_eq!(
504 output["unused_dev_dependencies"].as_array().unwrap().len(),
505 1
506 );
507 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
508 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
509 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
510 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
511 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
512 assert_eq!(
513 output["type_only_dependencies"].as_array().unwrap().len(),
514 1
515 );
516 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
517 }
518
519 #[test]
520 fn json_metadata_fields_appear_first() {
521 let root = PathBuf::from("/project");
522 let results = AnalysisResults::default();
523 let elapsed = Duration::from_millis(0);
524 let output = build_json(&results, &root, elapsed).expect("should serialize");
525 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
526 assert_eq!(keys[0], "schema_version");
527 assert_eq!(keys[1], "version");
528 assert_eq!(keys[2], "elapsed_ms");
529 assert_eq!(keys[3], "total_issues");
530 }
531
532 #[test]
533 fn json_total_issues_matches_results() {
534 let root = PathBuf::from("/project");
535 let results = sample_results(&root);
536 let total = results.total_issues();
537 let elapsed = Duration::from_millis(0);
538 let output = build_json(&results, &root, elapsed).expect("should serialize");
539
540 assert_eq!(output["total_issues"], total);
541 }
542
543 #[test]
544 fn json_unused_export_contains_expected_fields() {
545 let root = PathBuf::from("/project");
546 let mut results = AnalysisResults::default();
547 results.unused_exports.push(UnusedExport {
548 path: root.join("src/utils.ts"),
549 export_name: "helperFn".to_string(),
550 is_type_only: false,
551 line: 10,
552 col: 4,
553 span_start: 120,
554 is_re_export: false,
555 });
556 let elapsed = Duration::from_millis(0);
557 let output = build_json(&results, &root, elapsed).expect("should serialize");
558
559 let export = &output["unused_exports"][0];
560 assert_eq!(export["export_name"], "helperFn");
561 assert_eq!(export["line"], 10);
562 assert_eq!(export["col"], 4);
563 assert_eq!(export["is_type_only"], false);
564 assert_eq!(export["span_start"], 120);
565 assert_eq!(export["is_re_export"], false);
566 }
567
568 #[test]
569 fn json_serializes_to_valid_json() {
570 let root = PathBuf::from("/project");
571 let results = sample_results(&root);
572 let elapsed = Duration::from_millis(42);
573 let output = build_json(&results, &root, elapsed).expect("should serialize");
574
575 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
576 let reparsed: serde_json::Value =
577 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
578 assert_eq!(reparsed, output);
579 }
580
581 #[test]
584 fn json_empty_results_produce_valid_structure() {
585 let root = PathBuf::from("/project");
586 let results = AnalysisResults::default();
587 let elapsed = Duration::from_millis(0);
588 let output = build_json(&results, &root, elapsed).expect("should serialize");
589
590 assert_eq!(output["total_issues"], 0);
591 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
592 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
593 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
594 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
595 assert_eq!(
596 output["unused_dev_dependencies"].as_array().unwrap().len(),
597 0
598 );
599 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
600 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
601 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
602 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
603 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
604 assert_eq!(
605 output["type_only_dependencies"].as_array().unwrap().len(),
606 0
607 );
608 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
609 }
610
611 #[test]
612 fn json_empty_results_round_trips_through_string() {
613 let root = PathBuf::from("/project");
614 let results = AnalysisResults::default();
615 let elapsed = Duration::from_millis(0);
616 let output = build_json(&results, &root, elapsed).expect("should serialize");
617
618 let json_str = serde_json::to_string(&output).expect("should stringify");
619 let reparsed: serde_json::Value =
620 serde_json::from_str(&json_str).expect("should parse back");
621 assert_eq!(reparsed["total_issues"], 0);
622 }
623
624 #[test]
627 fn json_paths_are_relative_to_root() {
628 let root = PathBuf::from("/project");
629 let mut results = AnalysisResults::default();
630 results.unused_files.push(UnusedFile {
631 path: root.join("src/deep/nested/file.ts"),
632 });
633 let elapsed = Duration::from_millis(0);
634 let output = build_json(&results, &root, elapsed).expect("should serialize");
635
636 let path = output["unused_files"][0]["path"].as_str().unwrap();
637 assert_eq!(path, "src/deep/nested/file.ts");
638 assert!(!path.starts_with("/project"));
639 }
640
641 #[test]
642 fn json_strips_root_from_nested_locations() {
643 let root = PathBuf::from("/project");
644 let mut results = AnalysisResults::default();
645 results.unlisted_dependencies.push(UnlistedDependency {
646 package_name: "chalk".to_string(),
647 imported_from: vec![ImportSite {
648 path: root.join("src/cli.ts"),
649 line: 2,
650 col: 0,
651 }],
652 });
653 let elapsed = Duration::from_millis(0);
654 let output = build_json(&results, &root, elapsed).expect("should serialize");
655
656 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
657 .as_str()
658 .unwrap();
659 assert_eq!(site_path, "src/cli.ts");
660 }
661
662 #[test]
663 fn json_strips_root_from_duplicate_export_locations() {
664 let root = PathBuf::from("/project");
665 let mut results = AnalysisResults::default();
666 results.duplicate_exports.push(DuplicateExport {
667 export_name: "Config".to_string(),
668 locations: vec![
669 DuplicateLocation {
670 path: root.join("src/config.ts"),
671 line: 15,
672 col: 0,
673 },
674 DuplicateLocation {
675 path: root.join("src/types.ts"),
676 line: 30,
677 col: 0,
678 },
679 ],
680 });
681 let elapsed = Duration::from_millis(0);
682 let output = build_json(&results, &root, elapsed).expect("should serialize");
683
684 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
685 .as_str()
686 .unwrap();
687 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
688 .as_str()
689 .unwrap();
690 assert_eq!(loc0, "src/config.ts");
691 assert_eq!(loc1, "src/types.ts");
692 }
693
694 #[test]
695 fn json_strips_root_from_circular_dependency_files() {
696 let root = PathBuf::from("/project");
697 let mut results = AnalysisResults::default();
698 results.circular_dependencies.push(CircularDependency {
699 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
700 length: 2,
701 line: 1,
702 col: 0,
703 });
704 let elapsed = Duration::from_millis(0);
705 let output = build_json(&results, &root, elapsed).expect("should serialize");
706
707 let files = output["circular_dependencies"][0]["files"]
708 .as_array()
709 .unwrap();
710 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
711 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
712 }
713
714 #[test]
715 fn json_path_outside_root_not_stripped() {
716 let root = PathBuf::from("/project");
717 let mut results = AnalysisResults::default();
718 results.unused_files.push(UnusedFile {
719 path: PathBuf::from("/other/project/src/file.ts"),
720 });
721 let elapsed = Duration::from_millis(0);
722 let output = build_json(&results, &root, elapsed).expect("should serialize");
723
724 let path = output["unused_files"][0]["path"].as_str().unwrap();
725 assert!(path.contains("/other/project/"));
726 }
727
728 #[test]
731 fn json_unused_file_contains_path() {
732 let root = PathBuf::from("/project");
733 let mut results = AnalysisResults::default();
734 results.unused_files.push(UnusedFile {
735 path: root.join("src/orphan.ts"),
736 });
737 let elapsed = Duration::from_millis(0);
738 let output = build_json(&results, &root, elapsed).expect("should serialize");
739
740 let file = &output["unused_files"][0];
741 assert_eq!(file["path"], "src/orphan.ts");
742 }
743
744 #[test]
745 fn json_unused_type_contains_expected_fields() {
746 let root = PathBuf::from("/project");
747 let mut results = AnalysisResults::default();
748 results.unused_types.push(UnusedExport {
749 path: root.join("src/types.ts"),
750 export_name: "OldInterface".to_string(),
751 is_type_only: true,
752 line: 20,
753 col: 0,
754 span_start: 300,
755 is_re_export: false,
756 });
757 let elapsed = Duration::from_millis(0);
758 let output = build_json(&results, &root, elapsed).expect("should serialize");
759
760 let typ = &output["unused_types"][0];
761 assert_eq!(typ["export_name"], "OldInterface");
762 assert_eq!(typ["is_type_only"], true);
763 assert_eq!(typ["line"], 20);
764 assert_eq!(typ["path"], "src/types.ts");
765 }
766
767 #[test]
768 fn json_unused_dependency_contains_expected_fields() {
769 let root = PathBuf::from("/project");
770 let mut results = AnalysisResults::default();
771 results.unused_dependencies.push(UnusedDependency {
772 package_name: "axios".to_string(),
773 location: DependencyLocation::Dependencies,
774 path: root.join("package.json"),
775 line: 10,
776 });
777 let elapsed = Duration::from_millis(0);
778 let output = build_json(&results, &root, elapsed).expect("should serialize");
779
780 let dep = &output["unused_dependencies"][0];
781 assert_eq!(dep["package_name"], "axios");
782 assert_eq!(dep["line"], 10);
783 }
784
785 #[test]
786 fn json_unused_dev_dependency_contains_expected_fields() {
787 let root = PathBuf::from("/project");
788 let mut results = AnalysisResults::default();
789 results.unused_dev_dependencies.push(UnusedDependency {
790 package_name: "vitest".to_string(),
791 location: DependencyLocation::DevDependencies,
792 path: root.join("package.json"),
793 line: 15,
794 });
795 let elapsed = Duration::from_millis(0);
796 let output = build_json(&results, &root, elapsed).expect("should serialize");
797
798 let dep = &output["unused_dev_dependencies"][0];
799 assert_eq!(dep["package_name"], "vitest");
800 }
801
802 #[test]
803 fn json_unused_optional_dependency_contains_expected_fields() {
804 let root = PathBuf::from("/project");
805 let mut results = AnalysisResults::default();
806 results.unused_optional_dependencies.push(UnusedDependency {
807 package_name: "fsevents".to_string(),
808 location: DependencyLocation::OptionalDependencies,
809 path: root.join("package.json"),
810 line: 12,
811 });
812 let elapsed = Duration::from_millis(0);
813 let output = build_json(&results, &root, elapsed).expect("should serialize");
814
815 let dep = &output["unused_optional_dependencies"][0];
816 assert_eq!(dep["package_name"], "fsevents");
817 assert_eq!(output["total_issues"], 1);
818 }
819
820 #[test]
821 fn json_unused_enum_member_contains_expected_fields() {
822 let root = PathBuf::from("/project");
823 let mut results = AnalysisResults::default();
824 results.unused_enum_members.push(UnusedMember {
825 path: root.join("src/enums.ts"),
826 parent_name: "Color".to_string(),
827 member_name: "Purple".to_string(),
828 kind: MemberKind::EnumMember,
829 line: 5,
830 col: 2,
831 });
832 let elapsed = Duration::from_millis(0);
833 let output = build_json(&results, &root, elapsed).expect("should serialize");
834
835 let member = &output["unused_enum_members"][0];
836 assert_eq!(member["parent_name"], "Color");
837 assert_eq!(member["member_name"], "Purple");
838 assert_eq!(member["line"], 5);
839 assert_eq!(member["path"], "src/enums.ts");
840 }
841
842 #[test]
843 fn json_unused_class_member_contains_expected_fields() {
844 let root = PathBuf::from("/project");
845 let mut results = AnalysisResults::default();
846 results.unused_class_members.push(UnusedMember {
847 path: root.join("src/api.ts"),
848 parent_name: "ApiClient".to_string(),
849 member_name: "deprecatedFetch".to_string(),
850 kind: MemberKind::ClassMethod,
851 line: 100,
852 col: 4,
853 });
854 let elapsed = Duration::from_millis(0);
855 let output = build_json(&results, &root, elapsed).expect("should serialize");
856
857 let member = &output["unused_class_members"][0];
858 assert_eq!(member["parent_name"], "ApiClient");
859 assert_eq!(member["member_name"], "deprecatedFetch");
860 assert_eq!(member["line"], 100);
861 }
862
863 #[test]
864 fn json_unresolved_import_contains_expected_fields() {
865 let root = PathBuf::from("/project");
866 let mut results = AnalysisResults::default();
867 results.unresolved_imports.push(UnresolvedImport {
868 path: root.join("src/app.ts"),
869 specifier: "@acme/missing-pkg".to_string(),
870 line: 7,
871 col: 0,
872 specifier_col: 0,
873 });
874 let elapsed = Duration::from_millis(0);
875 let output = build_json(&results, &root, elapsed).expect("should serialize");
876
877 let import = &output["unresolved_imports"][0];
878 assert_eq!(import["specifier"], "@acme/missing-pkg");
879 assert_eq!(import["line"], 7);
880 assert_eq!(import["path"], "src/app.ts");
881 }
882
883 #[test]
884 fn json_unlisted_dependency_contains_import_sites() {
885 let root = PathBuf::from("/project");
886 let mut results = AnalysisResults::default();
887 results.unlisted_dependencies.push(UnlistedDependency {
888 package_name: "dotenv".to_string(),
889 imported_from: vec![
890 ImportSite {
891 path: root.join("src/config.ts"),
892 line: 1,
893 col: 0,
894 },
895 ImportSite {
896 path: root.join("src/server.ts"),
897 line: 3,
898 col: 0,
899 },
900 ],
901 });
902 let elapsed = Duration::from_millis(0);
903 let output = build_json(&results, &root, elapsed).expect("should serialize");
904
905 let dep = &output["unlisted_dependencies"][0];
906 assert_eq!(dep["package_name"], "dotenv");
907 let sites = dep["imported_from"].as_array().unwrap();
908 assert_eq!(sites.len(), 2);
909 assert_eq!(sites[0]["path"], "src/config.ts");
910 assert_eq!(sites[1]["path"], "src/server.ts");
911 }
912
913 #[test]
914 fn json_duplicate_export_contains_locations() {
915 let root = PathBuf::from("/project");
916 let mut results = AnalysisResults::default();
917 results.duplicate_exports.push(DuplicateExport {
918 export_name: "Button".to_string(),
919 locations: vec![
920 DuplicateLocation {
921 path: root.join("src/ui.ts"),
922 line: 10,
923 col: 0,
924 },
925 DuplicateLocation {
926 path: root.join("src/components.ts"),
927 line: 25,
928 col: 0,
929 },
930 ],
931 });
932 let elapsed = Duration::from_millis(0);
933 let output = build_json(&results, &root, elapsed).expect("should serialize");
934
935 let dup = &output["duplicate_exports"][0];
936 assert_eq!(dup["export_name"], "Button");
937 let locs = dup["locations"].as_array().unwrap();
938 assert_eq!(locs.len(), 2);
939 assert_eq!(locs[0]["line"], 10);
940 assert_eq!(locs[1]["line"], 25);
941 }
942
943 #[test]
944 fn json_type_only_dependency_contains_expected_fields() {
945 let root = PathBuf::from("/project");
946 let mut results = AnalysisResults::default();
947 results.type_only_dependencies.push(TypeOnlyDependency {
948 package_name: "zod".to_string(),
949 path: root.join("package.json"),
950 line: 8,
951 });
952 let elapsed = Duration::from_millis(0);
953 let output = build_json(&results, &root, elapsed).expect("should serialize");
954
955 let dep = &output["type_only_dependencies"][0];
956 assert_eq!(dep["package_name"], "zod");
957 assert_eq!(dep["line"], 8);
958 }
959
960 #[test]
961 fn json_circular_dependency_contains_expected_fields() {
962 let root = PathBuf::from("/project");
963 let mut results = AnalysisResults::default();
964 results.circular_dependencies.push(CircularDependency {
965 files: vec![
966 root.join("src/a.ts"),
967 root.join("src/b.ts"),
968 root.join("src/c.ts"),
969 ],
970 length: 3,
971 line: 5,
972 col: 0,
973 });
974 let elapsed = Duration::from_millis(0);
975 let output = build_json(&results, &root, elapsed).expect("should serialize");
976
977 let cycle = &output["circular_dependencies"][0];
978 assert_eq!(cycle["length"], 3);
979 assert_eq!(cycle["line"], 5);
980 let files = cycle["files"].as_array().unwrap();
981 assert_eq!(files.len(), 3);
982 }
983
984 #[test]
987 fn json_re_export_flagged_correctly() {
988 let root = PathBuf::from("/project");
989 let mut results = AnalysisResults::default();
990 results.unused_exports.push(UnusedExport {
991 path: root.join("src/index.ts"),
992 export_name: "reExported".to_string(),
993 is_type_only: false,
994 line: 1,
995 col: 0,
996 span_start: 0,
997 is_re_export: true,
998 });
999 let elapsed = Duration::from_millis(0);
1000 let output = build_json(&results, &root, elapsed).expect("should serialize");
1001
1002 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1003 }
1004
1005 #[test]
1008 fn json_schema_version_is_3() {
1009 let root = PathBuf::from("/project");
1010 let results = AnalysisResults::default();
1011 let elapsed = Duration::from_millis(0);
1012 let output = build_json(&results, &root, elapsed).expect("should serialize");
1013
1014 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1015 assert_eq!(output["schema_version"], 3);
1016 }
1017
1018 #[test]
1021 fn json_version_matches_cargo_pkg_version() {
1022 let root = PathBuf::from("/project");
1023 let results = AnalysisResults::default();
1024 let elapsed = Duration::from_millis(0);
1025 let output = build_json(&results, &root, elapsed).expect("should serialize");
1026
1027 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1028 }
1029
1030 #[test]
1033 fn json_elapsed_ms_zero_duration() {
1034 let root = PathBuf::from("/project");
1035 let results = AnalysisResults::default();
1036 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1037
1038 assert_eq!(output["elapsed_ms"], 0);
1039 }
1040
1041 #[test]
1042 fn json_elapsed_ms_large_duration() {
1043 let root = PathBuf::from("/project");
1044 let results = AnalysisResults::default();
1045 let elapsed = Duration::from_secs(120);
1046 let output = build_json(&results, &root, elapsed).expect("should serialize");
1047
1048 assert_eq!(output["elapsed_ms"], 120_000);
1049 }
1050
1051 #[test]
1052 fn json_elapsed_ms_sub_millisecond_truncated() {
1053 let root = PathBuf::from("/project");
1054 let results = AnalysisResults::default();
1055 let elapsed = Duration::from_micros(500);
1057 let output = build_json(&results, &root, elapsed).expect("should serialize");
1058
1059 assert_eq!(output["elapsed_ms"], 0);
1060 }
1061
1062 #[test]
1065 fn json_multiple_unused_files() {
1066 let root = PathBuf::from("/project");
1067 let mut results = AnalysisResults::default();
1068 results.unused_files.push(UnusedFile {
1069 path: root.join("src/a.ts"),
1070 });
1071 results.unused_files.push(UnusedFile {
1072 path: root.join("src/b.ts"),
1073 });
1074 results.unused_files.push(UnusedFile {
1075 path: root.join("src/c.ts"),
1076 });
1077 let elapsed = Duration::from_millis(0);
1078 let output = build_json(&results, &root, elapsed).expect("should serialize");
1079
1080 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1081 assert_eq!(output["total_issues"], 3);
1082 }
1083
1084 #[test]
1087 fn strip_root_prefix_on_string_value() {
1088 let mut value = serde_json::json!("/project/src/file.ts");
1089 strip_root_prefix(&mut value, "/project/");
1090 assert_eq!(value, "src/file.ts");
1091 }
1092
1093 #[test]
1094 fn strip_root_prefix_leaves_non_matching_string() {
1095 let mut value = serde_json::json!("/other/src/file.ts");
1096 strip_root_prefix(&mut value, "/project/");
1097 assert_eq!(value, "/other/src/file.ts");
1098 }
1099
1100 #[test]
1101 fn strip_root_prefix_recurses_into_arrays() {
1102 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1103 strip_root_prefix(&mut value, "/project/");
1104 assert_eq!(value[0], "a.ts");
1105 assert_eq!(value[1], "b.ts");
1106 assert_eq!(value[2], "/other/c.ts");
1107 }
1108
1109 #[test]
1110 fn strip_root_prefix_recurses_into_nested_objects() {
1111 let mut value = serde_json::json!({
1112 "outer": {
1113 "path": "/project/src/nested.ts"
1114 }
1115 });
1116 strip_root_prefix(&mut value, "/project/");
1117 assert_eq!(value["outer"]["path"], "src/nested.ts");
1118 }
1119
1120 #[test]
1121 fn strip_root_prefix_leaves_numbers_and_booleans() {
1122 let mut value = serde_json::json!({
1123 "line": 42,
1124 "is_type_only": false,
1125 "path": "/project/src/file.ts"
1126 });
1127 strip_root_prefix(&mut value, "/project/");
1128 assert_eq!(value["line"], 42);
1129 assert_eq!(value["is_type_only"], false);
1130 assert_eq!(value["path"], "src/file.ts");
1131 }
1132
1133 #[test]
1134 fn strip_root_prefix_handles_empty_string_after_strip() {
1135 let mut value = serde_json::json!("/project/");
1138 strip_root_prefix(&mut value, "/project/");
1139 assert_eq!(value, "");
1140 }
1141
1142 #[test]
1143 fn strip_root_prefix_deeply_nested_array_of_objects() {
1144 let mut value = serde_json::json!({
1145 "groups": [{
1146 "instances": [{
1147 "file": "/project/src/a.ts"
1148 }, {
1149 "file": "/project/src/b.ts"
1150 }]
1151 }]
1152 });
1153 strip_root_prefix(&mut value, "/project/");
1154 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1155 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1156 }
1157
1158 #[test]
1161 fn json_full_sample_results_total_issues_correct() {
1162 let root = PathBuf::from("/project");
1163 let results = sample_results(&root);
1164 let elapsed = Duration::from_millis(100);
1165 let output = build_json(&results, &root, elapsed).expect("should serialize");
1166
1167 assert_eq!(output["total_issues"], results.total_issues());
1173 }
1174
1175 #[test]
1176 fn json_full_sample_no_absolute_paths_in_output() {
1177 let root = PathBuf::from("/project");
1178 let results = sample_results(&root);
1179 let elapsed = Duration::from_millis(0);
1180 let output = build_json(&results, &root, elapsed).expect("should serialize");
1181
1182 let json_str = serde_json::to_string(&output).expect("should stringify");
1183 assert!(!json_str.contains("/project/src/"));
1185 assert!(!json_str.contains("/project/package.json"));
1186 }
1187
1188 #[test]
1191 fn json_output_is_deterministic() {
1192 let root = PathBuf::from("/project");
1193 let results = sample_results(&root);
1194 let elapsed = Duration::from_millis(50);
1195
1196 let output1 = build_json(&results, &root, elapsed).expect("first build");
1197 let output2 = build_json(&results, &root, elapsed).expect("second build");
1198
1199 assert_eq!(output1, output2);
1200 }
1201
1202 #[test]
1205 fn json_results_fields_do_not_shadow_metadata() {
1206 let root = PathBuf::from("/project");
1209 let results = AnalysisResults::default();
1210 let elapsed = Duration::from_millis(99);
1211 let output = build_json(&results, &root, elapsed).expect("should serialize");
1212
1213 assert_eq!(output["schema_version"], 3);
1215 assert_eq!(output["elapsed_ms"], 99);
1216 }
1217
1218 #[test]
1221 fn json_all_issue_type_arrays_present_in_empty_results() {
1222 let root = PathBuf::from("/project");
1223 let results = AnalysisResults::default();
1224 let elapsed = Duration::from_millis(0);
1225 let output = build_json(&results, &root, elapsed).expect("should serialize");
1226
1227 let expected_arrays = [
1228 "unused_files",
1229 "unused_exports",
1230 "unused_types",
1231 "unused_dependencies",
1232 "unused_dev_dependencies",
1233 "unused_optional_dependencies",
1234 "unused_enum_members",
1235 "unused_class_members",
1236 "unresolved_imports",
1237 "unlisted_dependencies",
1238 "duplicate_exports",
1239 "type_only_dependencies",
1240 "test_only_dependencies",
1241 "circular_dependencies",
1242 ];
1243 for key in &expected_arrays {
1244 assert!(
1245 output[key].is_array(),
1246 "expected '{key}' to be an array in JSON output"
1247 );
1248 }
1249 }
1250
1251 #[test]
1254 fn insert_meta_adds_key_to_object() {
1255 let mut output = serde_json::json!({ "foo": 1 });
1256 let meta = serde_json::json!({ "docs": "https://example.com" });
1257 insert_meta(&mut output, meta.clone());
1258 assert_eq!(output["_meta"], meta);
1259 }
1260
1261 #[test]
1262 fn insert_meta_noop_on_non_object() {
1263 let mut output = serde_json::json!([1, 2, 3]);
1264 let meta = serde_json::json!({ "docs": "https://example.com" });
1265 insert_meta(&mut output, meta);
1266 assert!(output.is_array());
1268 }
1269
1270 #[test]
1271 fn insert_meta_overwrites_existing_meta() {
1272 let mut output = serde_json::json!({ "_meta": "old" });
1273 let meta = serde_json::json!({ "new": true });
1274 insert_meta(&mut output, meta.clone());
1275 assert_eq!(output["_meta"], meta);
1276 }
1277
1278 #[test]
1281 fn build_json_envelope_has_metadata_fields() {
1282 let report = serde_json::json!({ "findings": [] });
1283 let elapsed = Duration::from_millis(42);
1284 let output = build_json_envelope(report, elapsed);
1285
1286 assert_eq!(output["schema_version"], 3);
1287 assert!(output["version"].is_string());
1288 assert_eq!(output["elapsed_ms"], 42);
1289 assert!(output["findings"].is_array());
1290 }
1291
1292 #[test]
1293 fn build_json_envelope_metadata_appears_first() {
1294 let report = serde_json::json!({ "data": "value" });
1295 let output = build_json_envelope(report, Duration::from_millis(10));
1296
1297 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1298 assert_eq!(keys[0], "schema_version");
1299 assert_eq!(keys[1], "version");
1300 assert_eq!(keys[2], "elapsed_ms");
1301 }
1302
1303 #[test]
1304 fn build_json_envelope_non_object_report() {
1305 let report = serde_json::json!("not an object");
1307 let output = build_json_envelope(report, Duration::from_millis(0));
1308
1309 let obj = output.as_object().unwrap();
1310 assert_eq!(obj.len(), 3);
1311 assert!(obj.contains_key("schema_version"));
1312 assert!(obj.contains_key("version"));
1313 assert!(obj.contains_key("elapsed_ms"));
1314 }
1315
1316 #[test]
1319 fn strip_root_prefix_null_unchanged() {
1320 let mut value = serde_json::Value::Null;
1321 strip_root_prefix(&mut value, "/project/");
1322 assert!(value.is_null());
1323 }
1324
1325 #[test]
1328 fn strip_root_prefix_empty_string() {
1329 let mut value = serde_json::json!("");
1330 strip_root_prefix(&mut value, "/project/");
1331 assert_eq!(value, "");
1332 }
1333
1334 #[test]
1337 fn strip_root_prefix_mixed_types() {
1338 let mut value = serde_json::json!({
1339 "path": "/project/src/file.ts",
1340 "line": 42,
1341 "flag": true,
1342 "nested": {
1343 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1344 "deep": { "path": "/project/c.ts" }
1345 }
1346 });
1347 strip_root_prefix(&mut value, "/project/");
1348 assert_eq!(value["path"], "src/file.ts");
1349 assert_eq!(value["line"], 42);
1350 assert_eq!(value["flag"], true);
1351 assert_eq!(value["nested"]["items"][0], "a.ts");
1352 assert_eq!(value["nested"]["items"][1], 99);
1353 assert!(value["nested"]["items"][2].is_null());
1354 assert_eq!(value["nested"]["items"][3], "b.ts");
1355 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1356 }
1357
1358 #[test]
1361 fn json_check_meta_integrates_correctly() {
1362 let root = PathBuf::from("/project");
1363 let results = AnalysisResults::default();
1364 let elapsed = Duration::from_millis(0);
1365 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1366 insert_meta(&mut output, crate::explain::check_meta());
1367
1368 assert!(output["_meta"]["docs"].is_string());
1369 assert!(output["_meta"]["rules"].is_object());
1370 }
1371
1372 #[test]
1375 fn json_unused_member_kind_serialized() {
1376 let root = PathBuf::from("/project");
1377 let mut results = AnalysisResults::default();
1378 results.unused_enum_members.push(UnusedMember {
1379 path: root.join("src/enums.ts"),
1380 parent_name: "Color".to_string(),
1381 member_name: "Red".to_string(),
1382 kind: MemberKind::EnumMember,
1383 line: 3,
1384 col: 2,
1385 });
1386 results.unused_class_members.push(UnusedMember {
1387 path: root.join("src/class.ts"),
1388 parent_name: "Foo".to_string(),
1389 member_name: "bar".to_string(),
1390 kind: MemberKind::ClassMethod,
1391 line: 10,
1392 col: 4,
1393 });
1394
1395 let elapsed = Duration::from_millis(0);
1396 let output = build_json(&results, &root, elapsed).expect("should serialize");
1397
1398 let enum_member = &output["unused_enum_members"][0];
1399 assert!(enum_member["kind"].is_string());
1400 let class_member = &output["unused_class_members"][0];
1401 assert!(class_member["kind"].is_string());
1402 }
1403
1404 #[test]
1407 fn json_unused_export_has_actions() {
1408 let root = PathBuf::from("/project");
1409 let mut results = AnalysisResults::default();
1410 results.unused_exports.push(UnusedExport {
1411 path: root.join("src/utils.ts"),
1412 export_name: "helperFn".to_string(),
1413 is_type_only: false,
1414 line: 10,
1415 col: 4,
1416 span_start: 120,
1417 is_re_export: false,
1418 });
1419 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1420
1421 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1422 assert_eq!(actions.len(), 2);
1423
1424 assert_eq!(actions[0]["type"], "remove-export");
1426 assert_eq!(actions[0]["auto_fixable"], true);
1427 assert!(actions[0].get("note").is_none());
1428
1429 assert_eq!(actions[1]["type"], "suppress-line");
1431 assert_eq!(
1432 actions[1]["comment"],
1433 "// fallow-ignore-next-line unused-export"
1434 );
1435 }
1436
1437 #[test]
1438 fn json_unused_file_has_file_suppress_and_note() {
1439 let root = PathBuf::from("/project");
1440 let mut results = AnalysisResults::default();
1441 results.unused_files.push(UnusedFile {
1442 path: root.join("src/dead.ts"),
1443 });
1444 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1445
1446 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1447 assert_eq!(actions[0]["type"], "delete-file");
1448 assert_eq!(actions[0]["auto_fixable"], false);
1449 assert!(actions[0]["note"].is_string());
1450 assert_eq!(actions[1]["type"], "suppress-file");
1451 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1452 }
1453
1454 #[test]
1455 fn json_unused_dependency_has_config_suppress_with_package_name() {
1456 let root = PathBuf::from("/project");
1457 let mut results = AnalysisResults::default();
1458 results.unused_dependencies.push(UnusedDependency {
1459 package_name: "lodash".to_string(),
1460 location: DependencyLocation::Dependencies,
1461 path: root.join("package.json"),
1462 line: 5,
1463 });
1464 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1465
1466 let actions = output["unused_dependencies"][0]["actions"]
1467 .as_array()
1468 .unwrap();
1469 assert_eq!(actions[0]["type"], "remove-dependency");
1470 assert_eq!(actions[0]["auto_fixable"], true);
1471
1472 assert_eq!(actions[1]["type"], "add-to-config");
1474 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1475 assert_eq!(actions[1]["value"], "lodash");
1476 }
1477
1478 #[test]
1479 fn json_empty_results_have_no_actions_in_empty_arrays() {
1480 let root = PathBuf::from("/project");
1481 let results = AnalysisResults::default();
1482 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1483
1484 assert!(output["unused_exports"].as_array().unwrap().is_empty());
1486 assert!(output["unused_files"].as_array().unwrap().is_empty());
1487 }
1488
1489 #[test]
1490 fn json_all_issue_types_have_actions() {
1491 let root = PathBuf::from("/project");
1492 let results = sample_results(&root);
1493 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1494
1495 let issue_keys = [
1496 "unused_files",
1497 "unused_exports",
1498 "unused_types",
1499 "unused_dependencies",
1500 "unused_dev_dependencies",
1501 "unused_optional_dependencies",
1502 "unused_enum_members",
1503 "unused_class_members",
1504 "unresolved_imports",
1505 "unlisted_dependencies",
1506 "duplicate_exports",
1507 "type_only_dependencies",
1508 "test_only_dependencies",
1509 "circular_dependencies",
1510 ];
1511
1512 for key in &issue_keys {
1513 let arr = output[key].as_array().unwrap();
1514 if !arr.is_empty() {
1515 let actions = arr[0]["actions"].as_array();
1516 assert!(
1517 actions.is_some() && !actions.unwrap().is_empty(),
1518 "missing actions for {key}"
1519 );
1520 }
1521 }
1522 }
1523}