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;
10use crate::report::grouping::{OwnershipResolver, ResultGroup};
11
12pub(super) fn print_json(
13 results: &AnalysisResults,
14 root: &Path,
15 elapsed: Duration,
16 explain: bool,
17 regression: Option<&crate::regression::RegressionOutcome>,
18 baseline_matched: Option<(usize, usize)>,
19) -> ExitCode {
20 match build_json(results, root, elapsed) {
21 Ok(mut output) => {
22 if let Some(outcome) = regression
23 && let serde_json::Value::Object(ref mut map) = output
24 {
25 map.insert("regression".to_string(), outcome.to_json());
26 }
27 if let Some((entries, matched)) = baseline_matched
28 && let serde_json::Value::Object(ref mut map) = output
29 {
30 map.insert(
31 "baseline".to_string(),
32 serde_json::json!({
33 "entries": entries,
34 "matched": matched,
35 }),
36 );
37 }
38 if explain {
39 insert_meta(&mut output, explain::check_meta());
40 }
41 emit_json(&output, "JSON")
42 }
43 Err(e) => {
44 eprintln!("Error: failed to serialize results: {e}");
45 ExitCode::from(2)
46 }
47 }
48}
49
50#[must_use]
56pub(super) fn print_grouped_json(
57 groups: &[ResultGroup],
58 original: &AnalysisResults,
59 root: &Path,
60 elapsed: Duration,
61 explain: bool,
62 resolver: &OwnershipResolver,
63) -> ExitCode {
64 let root_prefix = format!("{}/", root.display());
65
66 let group_values: Vec<serde_json::Value> = groups
67 .iter()
68 .filter_map(|group| {
69 let mut value = serde_json::to_value(&group.results).ok()?;
70 strip_root_prefix(&mut value, &root_prefix);
71 inject_actions(&mut value);
72
73 if let serde_json::Value::Object(ref mut map) = value {
74 let mut ordered = serde_json::Map::new();
76 ordered.insert("key".to_string(), serde_json::json!(group.key));
77 ordered.insert(
78 "total_issues".to_string(),
79 serde_json::json!(group.results.total_issues()),
80 );
81 for (k, v) in map.iter() {
82 ordered.insert(k.clone(), v.clone());
83 }
84 Some(serde_json::Value::Object(ordered))
85 } else {
86 Some(value)
87 }
88 })
89 .collect();
90
91 let mut output = serde_json::json!({
92 "schema_version": SCHEMA_VERSION,
93 "version": env!("CARGO_PKG_VERSION"),
94 "elapsed_ms": elapsed.as_millis() as u64,
95 "grouped_by": resolver.mode_label(),
96 "total_issues": original.total_issues(),
97 "groups": group_values,
98 });
99
100 if explain {
101 insert_meta(&mut output, explain::check_meta());
102 }
103
104 emit_json(&output, "JSON")
105}
106
107const SCHEMA_VERSION: u32 = 3;
113
114fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
120 let mut map = serde_json::Map::new();
121 map.insert(
122 "schema_version".to_string(),
123 serde_json::json!(SCHEMA_VERSION),
124 );
125 map.insert(
126 "version".to_string(),
127 serde_json::json!(env!("CARGO_PKG_VERSION")),
128 );
129 map.insert(
130 "elapsed_ms".to_string(),
131 serde_json::json!(elapsed.as_millis()),
132 );
133 if let serde_json::Value::Object(report_map) = report_value {
134 for (key, value) in report_map {
135 map.insert(key, value);
136 }
137 }
138 serde_json::Value::Object(map)
139}
140
141pub fn build_json(
150 results: &AnalysisResults,
151 root: &Path,
152 elapsed: Duration,
153) -> Result<serde_json::Value, serde_json::Error> {
154 let results_value = serde_json::to_value(results)?;
155
156 let mut map = serde_json::Map::new();
157 map.insert(
158 "schema_version".to_string(),
159 serde_json::json!(SCHEMA_VERSION),
160 );
161 map.insert(
162 "version".to_string(),
163 serde_json::json!(env!("CARGO_PKG_VERSION")),
164 );
165 map.insert(
166 "elapsed_ms".to_string(),
167 serde_json::json!(elapsed.as_millis()),
168 );
169 map.insert(
170 "total_issues".to_string(),
171 serde_json::json!(results.total_issues()),
172 );
173
174 if let Some(ref ep) = results.entry_point_summary {
176 let sources: serde_json::Map<String, serde_json::Value> = ep
177 .by_source
178 .iter()
179 .map(|(k, v)| (k.replace(' ', "_"), serde_json::json!(v)))
180 .collect();
181 map.insert(
182 "entry_points".to_string(),
183 serde_json::json!({
184 "total": ep.total,
185 "sources": sources,
186 }),
187 );
188 }
189
190 let summary = serde_json::json!({
192 "total_issues": results.total_issues(),
193 "unused_files": results.unused_files.len(),
194 "unused_exports": results.unused_exports.len(),
195 "unused_types": results.unused_types.len(),
196 "unused_dependencies": results.unused_dependencies.len()
197 + results.unused_dev_dependencies.len()
198 + results.unused_optional_dependencies.len(),
199 "unused_enum_members": results.unused_enum_members.len(),
200 "unused_class_members": results.unused_class_members.len(),
201 "unresolved_imports": results.unresolved_imports.len(),
202 "unlisted_dependencies": results.unlisted_dependencies.len(),
203 "duplicate_exports": results.duplicate_exports.len(),
204 "type_only_dependencies": results.type_only_dependencies.len(),
205 "test_only_dependencies": results.test_only_dependencies.len(),
206 "circular_dependencies": results.circular_dependencies.len(),
207 "boundary_violations": results.boundary_violations.len(),
208 });
209 map.insert("summary".to_string(), summary);
210
211 if let serde_json::Value::Object(results_map) = results_value {
212 for (key, value) in results_map {
213 map.insert(key, value);
214 }
215 }
216
217 let mut output = serde_json::Value::Object(map);
218 let root_prefix = format!("{}/", root.display());
219 strip_root_prefix(&mut output, &root_prefix);
223 inject_actions(&mut output);
224 Ok(output)
225}
226
227pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
232 match value {
233 serde_json::Value::String(s) => {
234 if let Some(rest) = s.strip_prefix(prefix) {
235 *s = rest.to_string();
236 }
237 }
238 serde_json::Value::Array(arr) => {
239 for item in arr {
240 strip_root_prefix(item, prefix);
241 }
242 }
243 serde_json::Value::Object(map) => {
244 for (_, v) in map.iter_mut() {
245 strip_root_prefix(v, prefix);
246 }
247 }
248 _ => {}
249 }
250}
251
252enum SuppressKind {
256 InlineComment,
258 FileComment,
260 ConfigIgnoreDep,
262}
263
264struct ActionSpec {
266 fix_type: &'static str,
267 auto_fixable: bool,
268 description: &'static str,
269 note: Option<&'static str>,
270 suppress: SuppressKind,
271 issue_kind: &'static str,
272}
273
274fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
276 match key {
277 "unused_files" => Some(ActionSpec {
278 fix_type: "delete-file",
279 auto_fixable: false,
280 description: "Delete this file",
281 note: Some(
282 "File deletion may remove runtime functionality not visible to static analysis",
283 ),
284 suppress: SuppressKind::FileComment,
285 issue_kind: "unused-file",
286 }),
287 "unused_exports" => Some(ActionSpec {
288 fix_type: "remove-export",
289 auto_fixable: true,
290 description: "Remove the `export` keyword from the declaration",
291 note: None,
292 suppress: SuppressKind::InlineComment,
293 issue_kind: "unused-export",
294 }),
295 "unused_types" => Some(ActionSpec {
296 fix_type: "remove-export",
297 auto_fixable: true,
298 description: "Remove the `export` (or `export type`) keyword from the type declaration",
299 note: None,
300 suppress: SuppressKind::InlineComment,
301 issue_kind: "unused-type",
302 }),
303 "unused_dependencies" => Some(ActionSpec {
304 fix_type: "remove-dependency",
305 auto_fixable: true,
306 description: "Remove from dependencies in package.json",
307 note: None,
308 suppress: SuppressKind::ConfigIgnoreDep,
309 issue_kind: "unused-dependency",
310 }),
311 "unused_dev_dependencies" => Some(ActionSpec {
312 fix_type: "remove-dependency",
313 auto_fixable: true,
314 description: "Remove from devDependencies in package.json",
315 note: None,
316 suppress: SuppressKind::ConfigIgnoreDep,
317 issue_kind: "unused-dev-dependency",
318 }),
319 "unused_optional_dependencies" => Some(ActionSpec {
320 fix_type: "remove-dependency",
321 auto_fixable: true,
322 description: "Remove from optionalDependencies in package.json",
323 note: None,
324 suppress: SuppressKind::ConfigIgnoreDep,
325 issue_kind: "unused-dependency",
327 }),
328 "unused_enum_members" => Some(ActionSpec {
329 fix_type: "remove-enum-member",
330 auto_fixable: true,
331 description: "Remove this enum member",
332 note: None,
333 suppress: SuppressKind::InlineComment,
334 issue_kind: "unused-enum-member",
335 }),
336 "unused_class_members" => Some(ActionSpec {
337 fix_type: "remove-class-member",
338 auto_fixable: false,
339 description: "Remove this class member",
340 note: Some("Class member may be used via dependency injection or decorators"),
341 suppress: SuppressKind::InlineComment,
342 issue_kind: "unused-class-member",
343 }),
344 "unresolved_imports" => Some(ActionSpec {
345 fix_type: "resolve-import",
346 auto_fixable: false,
347 description: "Fix the import specifier or install the missing module",
348 note: Some("Verify the module path and check tsconfig paths configuration"),
349 suppress: SuppressKind::InlineComment,
350 issue_kind: "unresolved-import",
351 }),
352 "unlisted_dependencies" => Some(ActionSpec {
353 fix_type: "install-dependency",
354 auto_fixable: false,
355 description: "Add this package to dependencies in package.json",
356 note: Some("Verify this package should be a direct dependency before adding"),
357 suppress: SuppressKind::ConfigIgnoreDep,
358 issue_kind: "unlisted-dependency",
359 }),
360 "duplicate_exports" => Some(ActionSpec {
361 fix_type: "remove-duplicate",
362 auto_fixable: false,
363 description: "Keep one canonical export location and remove the others",
364 note: Some("Review all locations to determine which should be the canonical export"),
365 suppress: SuppressKind::InlineComment,
366 issue_kind: "duplicate-export",
367 }),
368 "type_only_dependencies" => Some(ActionSpec {
369 fix_type: "move-to-dev",
370 auto_fixable: false,
371 description: "Move to devDependencies (only type imports are used)",
372 note: Some(
373 "Type imports are erased at runtime so this dependency is not needed in production",
374 ),
375 suppress: SuppressKind::ConfigIgnoreDep,
376 issue_kind: "type-only-dependency",
377 }),
378 "test_only_dependencies" => Some(ActionSpec {
379 fix_type: "move-to-dev",
380 auto_fixable: false,
381 description: "Move to devDependencies (only test files import this)",
382 note: Some(
383 "Only test files import this package so it does not need to be a production dependency",
384 ),
385 suppress: SuppressKind::ConfigIgnoreDep,
386 issue_kind: "test-only-dependency",
387 }),
388 "circular_dependencies" => Some(ActionSpec {
389 fix_type: "refactor-cycle",
390 auto_fixable: false,
391 description: "Extract shared logic into a separate module to break the cycle",
392 note: Some(
393 "Circular imports can cause initialization issues and make code harder to reason about",
394 ),
395 suppress: SuppressKind::InlineComment,
396 issue_kind: "circular-dependency",
397 }),
398 "boundary_violations" => Some(ActionSpec {
399 fix_type: "refactor-boundary",
400 auto_fixable: false,
401 description: "Move the import through an allowed zone or restructure the dependency",
402 note: Some(
403 "This import crosses an architecture boundary that is not permitted by the configured rules",
404 ),
405 suppress: SuppressKind::InlineComment,
406 issue_kind: "boundary-violation",
407 }),
408 _ => None,
409 }
410}
411
412fn build_actions(
414 item: &serde_json::Value,
415 issue_key: &str,
416 spec: &ActionSpec,
417) -> serde_json::Value {
418 let mut actions = Vec::with_capacity(2);
419
420 let mut fix_action = serde_json::json!({
422 "type": spec.fix_type,
423 "auto_fixable": spec.auto_fixable,
424 "description": spec.description,
425 });
426 if let Some(note) = spec.note {
427 fix_action["note"] = serde_json::json!(note);
428 }
429 if (issue_key == "unused_exports" || issue_key == "unused_types")
431 && item
432 .get("is_re_export")
433 .and_then(serde_json::Value::as_bool)
434 == Some(true)
435 {
436 fix_action["note"] = serde_json::json!(
437 "This finding originates from a re-export; verify it is not part of your public API before removing"
438 );
439 }
440 actions.push(fix_action);
441
442 match spec.suppress {
444 SuppressKind::InlineComment => {
445 let mut suppress = serde_json::json!({
446 "type": "suppress-line",
447 "auto_fixable": false,
448 "description": "Suppress with an inline comment above the line",
449 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
450 });
451 if issue_key == "duplicate_exports" {
453 suppress["scope"] = serde_json::json!("per-location");
454 }
455 actions.push(suppress);
456 }
457 SuppressKind::FileComment => {
458 actions.push(serde_json::json!({
459 "type": "suppress-file",
460 "auto_fixable": false,
461 "description": "Suppress with a file-level comment at the top of the file",
462 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
463 }));
464 }
465 SuppressKind::ConfigIgnoreDep => {
466 let pkg = item
468 .get("package_name")
469 .and_then(serde_json::Value::as_str)
470 .unwrap_or("package-name");
471 actions.push(serde_json::json!({
472 "type": "add-to-config",
473 "auto_fixable": false,
474 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
475 "config_key": "ignoreDependencies",
476 "value": pkg,
477 }));
478 }
479 }
480
481 serde_json::Value::Array(actions)
482}
483
484fn inject_actions(output: &mut serde_json::Value) {
489 let Some(map) = output.as_object_mut() else {
490 return;
491 };
492
493 for (key, value) in map.iter_mut() {
494 let Some(spec) = actions_for_issue_type(key) else {
495 continue;
496 };
497 let Some(arr) = value.as_array_mut() else {
498 continue;
499 };
500 for item in arr {
501 let actions = build_actions(item, key, &spec);
502 if let serde_json::Value::Object(obj) = item {
503 obj.insert("actions".to_string(), actions);
504 }
505 }
506 }
507}
508
509pub fn build_baseline_deltas_json<'a>(
517 total_delta: i64,
518 per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
519) -> serde_json::Value {
520 let mut per_cat = serde_json::Map::new();
521 for (cat, current, baseline, delta) in per_category {
522 per_cat.insert(
523 cat.to_string(),
524 serde_json::json!({
525 "current": current,
526 "baseline": baseline,
527 "delta": delta,
528 }),
529 );
530 }
531 serde_json::json!({
532 "total_delta": total_delta,
533 "per_category": per_cat
534 })
535}
536
537#[allow(
542 clippy::redundant_pub_crate,
543 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
544)]
545pub(crate) fn inject_health_actions(output: &mut serde_json::Value) {
546 let Some(map) = output.as_object_mut() else {
547 return;
548 };
549
550 if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
552 for item in findings {
553 let actions = build_health_finding_actions(item);
554 if let serde_json::Value::Object(obj) = item {
555 obj.insert("actions".to_string(), actions);
556 }
557 }
558 }
559
560 if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
562 for item in targets {
563 let actions = build_refactoring_target_actions(item);
564 if let serde_json::Value::Object(obj) = item {
565 obj.insert("actions".to_string(), actions);
566 }
567 }
568 }
569
570 if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
572 for item in hotspots {
573 let actions = build_hotspot_actions(item);
574 if let serde_json::Value::Object(obj) = item {
575 obj.insert("actions".to_string(), actions);
576 }
577 }
578 }
579
580 if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
582 if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
583 for item in files {
584 let actions = build_untested_file_actions(item);
585 if let serde_json::Value::Object(obj) = item {
586 obj.insert("actions".to_string(), actions);
587 }
588 }
589 }
590 if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
591 for item in exports {
592 let actions = build_untested_export_actions(item);
593 if let serde_json::Value::Object(obj) = item {
594 obj.insert("actions".to_string(), actions);
595 }
596 }
597 }
598 }
599}
600
601fn build_health_finding_actions(item: &serde_json::Value) -> serde_json::Value {
603 let name = item
604 .get("name")
605 .and_then(serde_json::Value::as_str)
606 .unwrap_or("function");
607
608 let mut actions = vec![serde_json::json!({
609 "type": "refactor-function",
610 "auto_fixable": false,
611 "description": format!("Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"),
612 "note": "Consider splitting into smaller functions with single responsibilities",
613 })];
614
615 actions.push(serde_json::json!({
616 "type": "suppress-line",
617 "auto_fixable": false,
618 "description": "Suppress with an inline comment above the function declaration",
619 "comment": "// fallow-ignore-next-line complexity",
620 "placement": "above-function-declaration",
621 }));
622
623 serde_json::Value::Array(actions)
624}
625
626fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
628 let path = item
629 .get("path")
630 .and_then(serde_json::Value::as_str)
631 .unwrap_or("file");
632
633 let actions = vec![
634 serde_json::json!({
635 "type": "refactor-file",
636 "auto_fixable": false,
637 "description": format!("Refactor `{path}` — high complexity combined with frequent changes makes this a maintenance risk"),
638 "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
639 }),
640 serde_json::json!({
641 "type": "add-tests",
642 "auto_fixable": false,
643 "description": format!("Add test coverage for `{path}` to reduce change risk"),
644 "note": "Frequently changed complex files benefit most from comprehensive test coverage",
645 }),
646 ];
647
648 serde_json::Value::Array(actions)
649}
650
651fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
653 let recommendation = item
654 .get("recommendation")
655 .and_then(serde_json::Value::as_str)
656 .unwrap_or("Apply the recommended refactoring");
657
658 let category = item
659 .get("category")
660 .and_then(serde_json::Value::as_str)
661 .unwrap_or("refactoring");
662
663 let mut actions = vec![serde_json::json!({
664 "type": "apply-refactoring",
665 "auto_fixable": false,
666 "description": recommendation,
667 "category": category,
668 })];
669
670 if item.get("evidence").is_some() {
672 actions.push(serde_json::json!({
673 "type": "suppress-line",
674 "auto_fixable": false,
675 "description": "Suppress the underlying complexity finding",
676 "comment": "// fallow-ignore-next-line complexity",
677 }));
678 }
679
680 serde_json::Value::Array(actions)
681}
682
683fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
685 let path = item
686 .get("path")
687 .and_then(serde_json::Value::as_str)
688 .unwrap_or("file");
689
690 serde_json::Value::Array(vec![
691 serde_json::json!({
692 "type": "add-tests",
693 "auto_fixable": false,
694 "description": format!("Add test coverage for `{path}`"),
695 "note": "No test dependency path reaches this runtime file",
696 }),
697 serde_json::json!({
698 "type": "suppress-file",
699 "auto_fixable": false,
700 "description": format!("Suppress coverage gap reporting for `{path}`"),
701 "comment": "// fallow-ignore-file coverage-gaps",
702 }),
703 ])
704}
705
706fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
708 let path = item
709 .get("path")
710 .and_then(serde_json::Value::as_str)
711 .unwrap_or("file");
712 let export_name = item
713 .get("export_name")
714 .and_then(serde_json::Value::as_str)
715 .unwrap_or("export");
716
717 serde_json::Value::Array(vec![
718 serde_json::json!({
719 "type": "add-test-import",
720 "auto_fixable": false,
721 "description": format!("Import and test `{export_name}` from `{path}`"),
722 "note": "This export is runtime-reachable but no test-reachable module references it",
723 }),
724 serde_json::json!({
725 "type": "suppress-file",
726 "auto_fixable": false,
727 "description": format!("Suppress coverage gap reporting for `{path}`"),
728 "comment": "// fallow-ignore-file coverage-gaps",
729 }),
730 ])
731}
732
733#[allow(
740 clippy::redundant_pub_crate,
741 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
742)]
743pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
744 let Some(map) = output.as_object_mut() else {
745 return;
746 };
747
748 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
750 for item in families {
751 let actions = build_clone_family_actions(item);
752 if let serde_json::Value::Object(obj) = item {
753 obj.insert("actions".to_string(), actions);
754 }
755 }
756 }
757
758 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
760 for item in groups {
761 let actions = build_clone_group_actions(item);
762 if let serde_json::Value::Object(obj) = item {
763 obj.insert("actions".to_string(), actions);
764 }
765 }
766 }
767}
768
769fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
771 let group_count = item
772 .get("groups")
773 .and_then(|v| v.as_array())
774 .map_or(0, Vec::len);
775
776 let total_lines = item
777 .get("total_duplicated_lines")
778 .and_then(serde_json::Value::as_u64)
779 .unwrap_or(0);
780
781 let mut actions = vec![serde_json::json!({
782 "type": "extract-shared",
783 "auto_fixable": false,
784 "description": format!(
785 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
786 if group_count == 1 { "" } else { "s" }
787 ),
788 "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
789 })];
790
791 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
793 for suggestion in suggestions {
794 if let Some(desc) = suggestion
795 .get("description")
796 .and_then(serde_json::Value::as_str)
797 {
798 actions.push(serde_json::json!({
799 "type": "apply-suggestion",
800 "auto_fixable": false,
801 "description": desc,
802 }));
803 }
804 }
805 }
806
807 actions.push(serde_json::json!({
808 "type": "suppress-line",
809 "auto_fixable": false,
810 "description": "Suppress with an inline comment above the duplicated code",
811 "comment": "// fallow-ignore-next-line code-duplication",
812 }));
813
814 serde_json::Value::Array(actions)
815}
816
817fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
819 let instance_count = item
820 .get("instances")
821 .and_then(|v| v.as_array())
822 .map_or(0, Vec::len);
823
824 let line_count = item
825 .get("line_count")
826 .and_then(serde_json::Value::as_u64)
827 .unwrap_or(0);
828
829 let actions = vec![
830 serde_json::json!({
831 "type": "extract-shared",
832 "auto_fixable": false,
833 "description": format!(
834 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
835 if instance_count == 1 { "" } else { "s" }
836 ),
837 }),
838 serde_json::json!({
839 "type": "suppress-line",
840 "auto_fixable": false,
841 "description": "Suppress with an inline comment above the duplicated code",
842 "comment": "// fallow-ignore-next-line code-duplication",
843 }),
844 ];
845
846 serde_json::Value::Array(actions)
847}
848
849fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
851 if let serde_json::Value::Object(map) = output {
852 map.insert("_meta".to_string(), meta);
853 }
854}
855
856pub(super) fn print_health_json(
857 report: &crate::health_types::HealthReport,
858 root: &Path,
859 elapsed: Duration,
860 explain: bool,
861) -> ExitCode {
862 let report_value = match serde_json::to_value(report) {
863 Ok(v) => v,
864 Err(e) => {
865 eprintln!("Error: failed to serialize health report: {e}");
866 return ExitCode::from(2);
867 }
868 };
869
870 let mut output = build_json_envelope(report_value, elapsed);
871 let root_prefix = format!("{}/", root.display());
872 strip_root_prefix(&mut output, &root_prefix);
873 inject_health_actions(&mut output);
874
875 if explain {
876 insert_meta(&mut output, explain::health_meta());
877 }
878
879 emit_json(&output, "JSON")
880}
881
882pub(super) fn print_duplication_json(
883 report: &DuplicationReport,
884 root: &Path,
885 elapsed: Duration,
886 explain: bool,
887) -> ExitCode {
888 let report_value = match serde_json::to_value(report) {
889 Ok(v) => v,
890 Err(e) => {
891 eprintln!("Error: failed to serialize duplication report: {e}");
892 return ExitCode::from(2);
893 }
894 };
895
896 let mut output = build_json_envelope(report_value, elapsed);
897 let root_prefix = format!("{}/", root.display());
898 strip_root_prefix(&mut output, &root_prefix);
899 inject_dupes_actions(&mut output);
900
901 if explain {
902 insert_meta(&mut output, explain::dupes_meta());
903 }
904
905 emit_json(&output, "JSON")
906}
907
908pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
909 match serde_json::to_string_pretty(value) {
910 Ok(json) => println!("{json}"),
911 Err(e) => {
912 eprintln!("Error: failed to serialize trace output: {e}");
913 #[expect(
914 clippy::exit,
915 reason = "fatal serialization error requires immediate exit"
916 )]
917 std::process::exit(2);
918 }
919 }
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use crate::report::test_helpers::sample_results;
926 use fallow_core::extract::MemberKind;
927 use fallow_core::results::*;
928 use std::path::PathBuf;
929 use std::time::Duration;
930
931 #[test]
932 fn json_output_has_metadata_fields() {
933 let root = PathBuf::from("/project");
934 let results = AnalysisResults::default();
935 let elapsed = Duration::from_millis(123);
936 let output = build_json(&results, &root, elapsed).expect("should serialize");
937
938 assert_eq!(output["schema_version"], 3);
939 assert!(output["version"].is_string());
940 assert_eq!(output["elapsed_ms"], 123);
941 assert_eq!(output["total_issues"], 0);
942 }
943
944 #[test]
945 fn json_output_includes_issue_arrays() {
946 let root = PathBuf::from("/project");
947 let results = sample_results(&root);
948 let elapsed = Duration::from_millis(50);
949 let output = build_json(&results, &root, elapsed).expect("should serialize");
950
951 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
952 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
953 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
954 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
955 assert_eq!(
956 output["unused_dev_dependencies"].as_array().unwrap().len(),
957 1
958 );
959 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
960 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
961 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
962 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
963 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
964 assert_eq!(
965 output["type_only_dependencies"].as_array().unwrap().len(),
966 1
967 );
968 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
969 }
970
971 #[test]
972 fn json_metadata_fields_appear_first() {
973 let root = PathBuf::from("/project");
974 let results = AnalysisResults::default();
975 let elapsed = Duration::from_millis(0);
976 let output = build_json(&results, &root, elapsed).expect("should serialize");
977 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
978 assert_eq!(keys[0], "schema_version");
979 assert_eq!(keys[1], "version");
980 assert_eq!(keys[2], "elapsed_ms");
981 assert_eq!(keys[3], "total_issues");
982 }
983
984 #[test]
985 fn json_total_issues_matches_results() {
986 let root = PathBuf::from("/project");
987 let results = sample_results(&root);
988 let total = results.total_issues();
989 let elapsed = Duration::from_millis(0);
990 let output = build_json(&results, &root, elapsed).expect("should serialize");
991
992 assert_eq!(output["total_issues"], total);
993 }
994
995 #[test]
996 fn json_unused_export_contains_expected_fields() {
997 let root = PathBuf::from("/project");
998 let mut results = AnalysisResults::default();
999 results.unused_exports.push(UnusedExport {
1000 path: root.join("src/utils.ts"),
1001 export_name: "helperFn".to_string(),
1002 is_type_only: false,
1003 line: 10,
1004 col: 4,
1005 span_start: 120,
1006 is_re_export: false,
1007 });
1008 let elapsed = Duration::from_millis(0);
1009 let output = build_json(&results, &root, elapsed).expect("should serialize");
1010
1011 let export = &output["unused_exports"][0];
1012 assert_eq!(export["export_name"], "helperFn");
1013 assert_eq!(export["line"], 10);
1014 assert_eq!(export["col"], 4);
1015 assert_eq!(export["is_type_only"], false);
1016 assert_eq!(export["span_start"], 120);
1017 assert_eq!(export["is_re_export"], false);
1018 }
1019
1020 #[test]
1021 fn json_serializes_to_valid_json() {
1022 let root = PathBuf::from("/project");
1023 let results = sample_results(&root);
1024 let elapsed = Duration::from_millis(42);
1025 let output = build_json(&results, &root, elapsed).expect("should serialize");
1026
1027 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1028 let reparsed: serde_json::Value =
1029 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1030 assert_eq!(reparsed, output);
1031 }
1032
1033 #[test]
1036 fn json_empty_results_produce_valid_structure() {
1037 let root = PathBuf::from("/project");
1038 let results = AnalysisResults::default();
1039 let elapsed = Duration::from_millis(0);
1040 let output = build_json(&results, &root, elapsed).expect("should serialize");
1041
1042 assert_eq!(output["total_issues"], 0);
1043 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1044 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1045 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1046 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1047 assert_eq!(
1048 output["unused_dev_dependencies"].as_array().unwrap().len(),
1049 0
1050 );
1051 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1052 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1053 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1054 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1055 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1056 assert_eq!(
1057 output["type_only_dependencies"].as_array().unwrap().len(),
1058 0
1059 );
1060 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1061 }
1062
1063 #[test]
1064 fn json_empty_results_round_trips_through_string() {
1065 let root = PathBuf::from("/project");
1066 let results = AnalysisResults::default();
1067 let elapsed = Duration::from_millis(0);
1068 let output = build_json(&results, &root, elapsed).expect("should serialize");
1069
1070 let json_str = serde_json::to_string(&output).expect("should stringify");
1071 let reparsed: serde_json::Value =
1072 serde_json::from_str(&json_str).expect("should parse back");
1073 assert_eq!(reparsed["total_issues"], 0);
1074 }
1075
1076 #[test]
1079 fn json_paths_are_relative_to_root() {
1080 let root = PathBuf::from("/project");
1081 let mut results = AnalysisResults::default();
1082 results.unused_files.push(UnusedFile {
1083 path: root.join("src/deep/nested/file.ts"),
1084 });
1085 let elapsed = Duration::from_millis(0);
1086 let output = build_json(&results, &root, elapsed).expect("should serialize");
1087
1088 let path = output["unused_files"][0]["path"].as_str().unwrap();
1089 assert_eq!(path, "src/deep/nested/file.ts");
1090 assert!(!path.starts_with("/project"));
1091 }
1092
1093 #[test]
1094 fn json_strips_root_from_nested_locations() {
1095 let root = PathBuf::from("/project");
1096 let mut results = AnalysisResults::default();
1097 results.unlisted_dependencies.push(UnlistedDependency {
1098 package_name: "chalk".to_string(),
1099 imported_from: vec![ImportSite {
1100 path: root.join("src/cli.ts"),
1101 line: 2,
1102 col: 0,
1103 }],
1104 });
1105 let elapsed = Duration::from_millis(0);
1106 let output = build_json(&results, &root, elapsed).expect("should serialize");
1107
1108 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1109 .as_str()
1110 .unwrap();
1111 assert_eq!(site_path, "src/cli.ts");
1112 }
1113
1114 #[test]
1115 fn json_strips_root_from_duplicate_export_locations() {
1116 let root = PathBuf::from("/project");
1117 let mut results = AnalysisResults::default();
1118 results.duplicate_exports.push(DuplicateExport {
1119 export_name: "Config".to_string(),
1120 locations: vec![
1121 DuplicateLocation {
1122 path: root.join("src/config.ts"),
1123 line: 15,
1124 col: 0,
1125 },
1126 DuplicateLocation {
1127 path: root.join("src/types.ts"),
1128 line: 30,
1129 col: 0,
1130 },
1131 ],
1132 });
1133 let elapsed = Duration::from_millis(0);
1134 let output = build_json(&results, &root, elapsed).expect("should serialize");
1135
1136 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1137 .as_str()
1138 .unwrap();
1139 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1140 .as_str()
1141 .unwrap();
1142 assert_eq!(loc0, "src/config.ts");
1143 assert_eq!(loc1, "src/types.ts");
1144 }
1145
1146 #[test]
1147 fn json_strips_root_from_circular_dependency_files() {
1148 let root = PathBuf::from("/project");
1149 let mut results = AnalysisResults::default();
1150 results.circular_dependencies.push(CircularDependency {
1151 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1152 length: 2,
1153 line: 1,
1154 col: 0,
1155 is_cross_package: false,
1156 });
1157 let elapsed = Duration::from_millis(0);
1158 let output = build_json(&results, &root, elapsed).expect("should serialize");
1159
1160 let files = output["circular_dependencies"][0]["files"]
1161 .as_array()
1162 .unwrap();
1163 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1164 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1165 }
1166
1167 #[test]
1168 fn json_path_outside_root_not_stripped() {
1169 let root = PathBuf::from("/project");
1170 let mut results = AnalysisResults::default();
1171 results.unused_files.push(UnusedFile {
1172 path: PathBuf::from("/other/project/src/file.ts"),
1173 });
1174 let elapsed = Duration::from_millis(0);
1175 let output = build_json(&results, &root, elapsed).expect("should serialize");
1176
1177 let path = output["unused_files"][0]["path"].as_str().unwrap();
1178 assert!(path.contains("/other/project/"));
1179 }
1180
1181 #[test]
1184 fn json_unused_file_contains_path() {
1185 let root = PathBuf::from("/project");
1186 let mut results = AnalysisResults::default();
1187 results.unused_files.push(UnusedFile {
1188 path: root.join("src/orphan.ts"),
1189 });
1190 let elapsed = Duration::from_millis(0);
1191 let output = build_json(&results, &root, elapsed).expect("should serialize");
1192
1193 let file = &output["unused_files"][0];
1194 assert_eq!(file["path"], "src/orphan.ts");
1195 }
1196
1197 #[test]
1198 fn json_unused_type_contains_expected_fields() {
1199 let root = PathBuf::from("/project");
1200 let mut results = AnalysisResults::default();
1201 results.unused_types.push(UnusedExport {
1202 path: root.join("src/types.ts"),
1203 export_name: "OldInterface".to_string(),
1204 is_type_only: true,
1205 line: 20,
1206 col: 0,
1207 span_start: 300,
1208 is_re_export: false,
1209 });
1210 let elapsed = Duration::from_millis(0);
1211 let output = build_json(&results, &root, elapsed).expect("should serialize");
1212
1213 let typ = &output["unused_types"][0];
1214 assert_eq!(typ["export_name"], "OldInterface");
1215 assert_eq!(typ["is_type_only"], true);
1216 assert_eq!(typ["line"], 20);
1217 assert_eq!(typ["path"], "src/types.ts");
1218 }
1219
1220 #[test]
1221 fn json_unused_dependency_contains_expected_fields() {
1222 let root = PathBuf::from("/project");
1223 let mut results = AnalysisResults::default();
1224 results.unused_dependencies.push(UnusedDependency {
1225 package_name: "axios".to_string(),
1226 location: DependencyLocation::Dependencies,
1227 path: root.join("package.json"),
1228 line: 10,
1229 });
1230 let elapsed = Duration::from_millis(0);
1231 let output = build_json(&results, &root, elapsed).expect("should serialize");
1232
1233 let dep = &output["unused_dependencies"][0];
1234 assert_eq!(dep["package_name"], "axios");
1235 assert_eq!(dep["line"], 10);
1236 }
1237
1238 #[test]
1239 fn json_unused_dev_dependency_contains_expected_fields() {
1240 let root = PathBuf::from("/project");
1241 let mut results = AnalysisResults::default();
1242 results.unused_dev_dependencies.push(UnusedDependency {
1243 package_name: "vitest".to_string(),
1244 location: DependencyLocation::DevDependencies,
1245 path: root.join("package.json"),
1246 line: 15,
1247 });
1248 let elapsed = Duration::from_millis(0);
1249 let output = build_json(&results, &root, elapsed).expect("should serialize");
1250
1251 let dep = &output["unused_dev_dependencies"][0];
1252 assert_eq!(dep["package_name"], "vitest");
1253 }
1254
1255 #[test]
1256 fn json_unused_optional_dependency_contains_expected_fields() {
1257 let root = PathBuf::from("/project");
1258 let mut results = AnalysisResults::default();
1259 results.unused_optional_dependencies.push(UnusedDependency {
1260 package_name: "fsevents".to_string(),
1261 location: DependencyLocation::OptionalDependencies,
1262 path: root.join("package.json"),
1263 line: 12,
1264 });
1265 let elapsed = Duration::from_millis(0);
1266 let output = build_json(&results, &root, elapsed).expect("should serialize");
1267
1268 let dep = &output["unused_optional_dependencies"][0];
1269 assert_eq!(dep["package_name"], "fsevents");
1270 assert_eq!(output["total_issues"], 1);
1271 }
1272
1273 #[test]
1274 fn json_unused_enum_member_contains_expected_fields() {
1275 let root = PathBuf::from("/project");
1276 let mut results = AnalysisResults::default();
1277 results.unused_enum_members.push(UnusedMember {
1278 path: root.join("src/enums.ts"),
1279 parent_name: "Color".to_string(),
1280 member_name: "Purple".to_string(),
1281 kind: MemberKind::EnumMember,
1282 line: 5,
1283 col: 2,
1284 });
1285 let elapsed = Duration::from_millis(0);
1286 let output = build_json(&results, &root, elapsed).expect("should serialize");
1287
1288 let member = &output["unused_enum_members"][0];
1289 assert_eq!(member["parent_name"], "Color");
1290 assert_eq!(member["member_name"], "Purple");
1291 assert_eq!(member["line"], 5);
1292 assert_eq!(member["path"], "src/enums.ts");
1293 }
1294
1295 #[test]
1296 fn json_unused_class_member_contains_expected_fields() {
1297 let root = PathBuf::from("/project");
1298 let mut results = AnalysisResults::default();
1299 results.unused_class_members.push(UnusedMember {
1300 path: root.join("src/api.ts"),
1301 parent_name: "ApiClient".to_string(),
1302 member_name: "deprecatedFetch".to_string(),
1303 kind: MemberKind::ClassMethod,
1304 line: 100,
1305 col: 4,
1306 });
1307 let elapsed = Duration::from_millis(0);
1308 let output = build_json(&results, &root, elapsed).expect("should serialize");
1309
1310 let member = &output["unused_class_members"][0];
1311 assert_eq!(member["parent_name"], "ApiClient");
1312 assert_eq!(member["member_name"], "deprecatedFetch");
1313 assert_eq!(member["line"], 100);
1314 }
1315
1316 #[test]
1317 fn json_unresolved_import_contains_expected_fields() {
1318 let root = PathBuf::from("/project");
1319 let mut results = AnalysisResults::default();
1320 results.unresolved_imports.push(UnresolvedImport {
1321 path: root.join("src/app.ts"),
1322 specifier: "@acme/missing-pkg".to_string(),
1323 line: 7,
1324 col: 0,
1325 specifier_col: 0,
1326 });
1327 let elapsed = Duration::from_millis(0);
1328 let output = build_json(&results, &root, elapsed).expect("should serialize");
1329
1330 let import = &output["unresolved_imports"][0];
1331 assert_eq!(import["specifier"], "@acme/missing-pkg");
1332 assert_eq!(import["line"], 7);
1333 assert_eq!(import["path"], "src/app.ts");
1334 }
1335
1336 #[test]
1337 fn json_unlisted_dependency_contains_import_sites() {
1338 let root = PathBuf::from("/project");
1339 let mut results = AnalysisResults::default();
1340 results.unlisted_dependencies.push(UnlistedDependency {
1341 package_name: "dotenv".to_string(),
1342 imported_from: vec![
1343 ImportSite {
1344 path: root.join("src/config.ts"),
1345 line: 1,
1346 col: 0,
1347 },
1348 ImportSite {
1349 path: root.join("src/server.ts"),
1350 line: 3,
1351 col: 0,
1352 },
1353 ],
1354 });
1355 let elapsed = Duration::from_millis(0);
1356 let output = build_json(&results, &root, elapsed).expect("should serialize");
1357
1358 let dep = &output["unlisted_dependencies"][0];
1359 assert_eq!(dep["package_name"], "dotenv");
1360 let sites = dep["imported_from"].as_array().unwrap();
1361 assert_eq!(sites.len(), 2);
1362 assert_eq!(sites[0]["path"], "src/config.ts");
1363 assert_eq!(sites[1]["path"], "src/server.ts");
1364 }
1365
1366 #[test]
1367 fn json_duplicate_export_contains_locations() {
1368 let root = PathBuf::from("/project");
1369 let mut results = AnalysisResults::default();
1370 results.duplicate_exports.push(DuplicateExport {
1371 export_name: "Button".to_string(),
1372 locations: vec![
1373 DuplicateLocation {
1374 path: root.join("src/ui.ts"),
1375 line: 10,
1376 col: 0,
1377 },
1378 DuplicateLocation {
1379 path: root.join("src/components.ts"),
1380 line: 25,
1381 col: 0,
1382 },
1383 ],
1384 });
1385 let elapsed = Duration::from_millis(0);
1386 let output = build_json(&results, &root, elapsed).expect("should serialize");
1387
1388 let dup = &output["duplicate_exports"][0];
1389 assert_eq!(dup["export_name"], "Button");
1390 let locs = dup["locations"].as_array().unwrap();
1391 assert_eq!(locs.len(), 2);
1392 assert_eq!(locs[0]["line"], 10);
1393 assert_eq!(locs[1]["line"], 25);
1394 }
1395
1396 #[test]
1397 fn json_type_only_dependency_contains_expected_fields() {
1398 let root = PathBuf::from("/project");
1399 let mut results = AnalysisResults::default();
1400 results.type_only_dependencies.push(TypeOnlyDependency {
1401 package_name: "zod".to_string(),
1402 path: root.join("package.json"),
1403 line: 8,
1404 });
1405 let elapsed = Duration::from_millis(0);
1406 let output = build_json(&results, &root, elapsed).expect("should serialize");
1407
1408 let dep = &output["type_only_dependencies"][0];
1409 assert_eq!(dep["package_name"], "zod");
1410 assert_eq!(dep["line"], 8);
1411 }
1412
1413 #[test]
1414 fn json_circular_dependency_contains_expected_fields() {
1415 let root = PathBuf::from("/project");
1416 let mut results = AnalysisResults::default();
1417 results.circular_dependencies.push(CircularDependency {
1418 files: vec![
1419 root.join("src/a.ts"),
1420 root.join("src/b.ts"),
1421 root.join("src/c.ts"),
1422 ],
1423 length: 3,
1424 line: 5,
1425 col: 0,
1426 is_cross_package: false,
1427 });
1428 let elapsed = Duration::from_millis(0);
1429 let output = build_json(&results, &root, elapsed).expect("should serialize");
1430
1431 let cycle = &output["circular_dependencies"][0];
1432 assert_eq!(cycle["length"], 3);
1433 assert_eq!(cycle["line"], 5);
1434 let files = cycle["files"].as_array().unwrap();
1435 assert_eq!(files.len(), 3);
1436 }
1437
1438 #[test]
1441 fn json_re_export_flagged_correctly() {
1442 let root = PathBuf::from("/project");
1443 let mut results = AnalysisResults::default();
1444 results.unused_exports.push(UnusedExport {
1445 path: root.join("src/index.ts"),
1446 export_name: "reExported".to_string(),
1447 is_type_only: false,
1448 line: 1,
1449 col: 0,
1450 span_start: 0,
1451 is_re_export: true,
1452 });
1453 let elapsed = Duration::from_millis(0);
1454 let output = build_json(&results, &root, elapsed).expect("should serialize");
1455
1456 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1457 }
1458
1459 #[test]
1462 fn json_schema_version_is_3() {
1463 let root = PathBuf::from("/project");
1464 let results = AnalysisResults::default();
1465 let elapsed = Duration::from_millis(0);
1466 let output = build_json(&results, &root, elapsed).expect("should serialize");
1467
1468 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1469 assert_eq!(output["schema_version"], 3);
1470 }
1471
1472 #[test]
1475 fn json_version_matches_cargo_pkg_version() {
1476 let root = PathBuf::from("/project");
1477 let results = AnalysisResults::default();
1478 let elapsed = Duration::from_millis(0);
1479 let output = build_json(&results, &root, elapsed).expect("should serialize");
1480
1481 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1482 }
1483
1484 #[test]
1487 fn json_elapsed_ms_zero_duration() {
1488 let root = PathBuf::from("/project");
1489 let results = AnalysisResults::default();
1490 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1491
1492 assert_eq!(output["elapsed_ms"], 0);
1493 }
1494
1495 #[test]
1496 fn json_elapsed_ms_large_duration() {
1497 let root = PathBuf::from("/project");
1498 let results = AnalysisResults::default();
1499 let elapsed = Duration::from_secs(120);
1500 let output = build_json(&results, &root, elapsed).expect("should serialize");
1501
1502 assert_eq!(output["elapsed_ms"], 120_000);
1503 }
1504
1505 #[test]
1506 fn json_elapsed_ms_sub_millisecond_truncated() {
1507 let root = PathBuf::from("/project");
1508 let results = AnalysisResults::default();
1509 let elapsed = Duration::from_micros(500);
1511 let output = build_json(&results, &root, elapsed).expect("should serialize");
1512
1513 assert_eq!(output["elapsed_ms"], 0);
1514 }
1515
1516 #[test]
1519 fn json_multiple_unused_files() {
1520 let root = PathBuf::from("/project");
1521 let mut results = AnalysisResults::default();
1522 results.unused_files.push(UnusedFile {
1523 path: root.join("src/a.ts"),
1524 });
1525 results.unused_files.push(UnusedFile {
1526 path: root.join("src/b.ts"),
1527 });
1528 results.unused_files.push(UnusedFile {
1529 path: root.join("src/c.ts"),
1530 });
1531 let elapsed = Duration::from_millis(0);
1532 let output = build_json(&results, &root, elapsed).expect("should serialize");
1533
1534 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1535 assert_eq!(output["total_issues"], 3);
1536 }
1537
1538 #[test]
1541 fn strip_root_prefix_on_string_value() {
1542 let mut value = serde_json::json!("/project/src/file.ts");
1543 strip_root_prefix(&mut value, "/project/");
1544 assert_eq!(value, "src/file.ts");
1545 }
1546
1547 #[test]
1548 fn strip_root_prefix_leaves_non_matching_string() {
1549 let mut value = serde_json::json!("/other/src/file.ts");
1550 strip_root_prefix(&mut value, "/project/");
1551 assert_eq!(value, "/other/src/file.ts");
1552 }
1553
1554 #[test]
1555 fn strip_root_prefix_recurses_into_arrays() {
1556 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1557 strip_root_prefix(&mut value, "/project/");
1558 assert_eq!(value[0], "a.ts");
1559 assert_eq!(value[1], "b.ts");
1560 assert_eq!(value[2], "/other/c.ts");
1561 }
1562
1563 #[test]
1564 fn strip_root_prefix_recurses_into_nested_objects() {
1565 let mut value = serde_json::json!({
1566 "outer": {
1567 "path": "/project/src/nested.ts"
1568 }
1569 });
1570 strip_root_prefix(&mut value, "/project/");
1571 assert_eq!(value["outer"]["path"], "src/nested.ts");
1572 }
1573
1574 #[test]
1575 fn strip_root_prefix_leaves_numbers_and_booleans() {
1576 let mut value = serde_json::json!({
1577 "line": 42,
1578 "is_type_only": false,
1579 "path": "/project/src/file.ts"
1580 });
1581 strip_root_prefix(&mut value, "/project/");
1582 assert_eq!(value["line"], 42);
1583 assert_eq!(value["is_type_only"], false);
1584 assert_eq!(value["path"], "src/file.ts");
1585 }
1586
1587 #[test]
1588 fn strip_root_prefix_handles_empty_string_after_strip() {
1589 let mut value = serde_json::json!("/project/");
1592 strip_root_prefix(&mut value, "/project/");
1593 assert_eq!(value, "");
1594 }
1595
1596 #[test]
1597 fn strip_root_prefix_deeply_nested_array_of_objects() {
1598 let mut value = serde_json::json!({
1599 "groups": [{
1600 "instances": [{
1601 "file": "/project/src/a.ts"
1602 }, {
1603 "file": "/project/src/b.ts"
1604 }]
1605 }]
1606 });
1607 strip_root_prefix(&mut value, "/project/");
1608 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1609 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1610 }
1611
1612 #[test]
1615 fn json_full_sample_results_total_issues_correct() {
1616 let root = PathBuf::from("/project");
1617 let results = sample_results(&root);
1618 let elapsed = Duration::from_millis(100);
1619 let output = build_json(&results, &root, elapsed).expect("should serialize");
1620
1621 assert_eq!(output["total_issues"], results.total_issues());
1627 }
1628
1629 #[test]
1630 fn json_full_sample_no_absolute_paths_in_output() {
1631 let root = PathBuf::from("/project");
1632 let results = sample_results(&root);
1633 let elapsed = Duration::from_millis(0);
1634 let output = build_json(&results, &root, elapsed).expect("should serialize");
1635
1636 let json_str = serde_json::to_string(&output).expect("should stringify");
1637 assert!(!json_str.contains("/project/src/"));
1639 assert!(!json_str.contains("/project/package.json"));
1640 }
1641
1642 #[test]
1645 fn json_output_is_deterministic() {
1646 let root = PathBuf::from("/project");
1647 let results = sample_results(&root);
1648 let elapsed = Duration::from_millis(50);
1649
1650 let output1 = build_json(&results, &root, elapsed).expect("first build");
1651 let output2 = build_json(&results, &root, elapsed).expect("second build");
1652
1653 assert_eq!(output1, output2);
1654 }
1655
1656 #[test]
1659 fn json_results_fields_do_not_shadow_metadata() {
1660 let root = PathBuf::from("/project");
1663 let results = AnalysisResults::default();
1664 let elapsed = Duration::from_millis(99);
1665 let output = build_json(&results, &root, elapsed).expect("should serialize");
1666
1667 assert_eq!(output["schema_version"], 3);
1669 assert_eq!(output["elapsed_ms"], 99);
1670 }
1671
1672 #[test]
1675 fn json_all_issue_type_arrays_present_in_empty_results() {
1676 let root = PathBuf::from("/project");
1677 let results = AnalysisResults::default();
1678 let elapsed = Duration::from_millis(0);
1679 let output = build_json(&results, &root, elapsed).expect("should serialize");
1680
1681 let expected_arrays = [
1682 "unused_files",
1683 "unused_exports",
1684 "unused_types",
1685 "unused_dependencies",
1686 "unused_dev_dependencies",
1687 "unused_optional_dependencies",
1688 "unused_enum_members",
1689 "unused_class_members",
1690 "unresolved_imports",
1691 "unlisted_dependencies",
1692 "duplicate_exports",
1693 "type_only_dependencies",
1694 "test_only_dependencies",
1695 "circular_dependencies",
1696 ];
1697 for key in &expected_arrays {
1698 assert!(
1699 output[key].is_array(),
1700 "expected '{key}' to be an array in JSON output"
1701 );
1702 }
1703 }
1704
1705 #[test]
1708 fn insert_meta_adds_key_to_object() {
1709 let mut output = serde_json::json!({ "foo": 1 });
1710 let meta = serde_json::json!({ "docs": "https://example.com" });
1711 insert_meta(&mut output, meta.clone());
1712 assert_eq!(output["_meta"], meta);
1713 }
1714
1715 #[test]
1716 fn insert_meta_noop_on_non_object() {
1717 let mut output = serde_json::json!([1, 2, 3]);
1718 let meta = serde_json::json!({ "docs": "https://example.com" });
1719 insert_meta(&mut output, meta);
1720 assert!(output.is_array());
1722 }
1723
1724 #[test]
1725 fn insert_meta_overwrites_existing_meta() {
1726 let mut output = serde_json::json!({ "_meta": "old" });
1727 let meta = serde_json::json!({ "new": true });
1728 insert_meta(&mut output, meta.clone());
1729 assert_eq!(output["_meta"], meta);
1730 }
1731
1732 #[test]
1735 fn build_json_envelope_has_metadata_fields() {
1736 let report = serde_json::json!({ "findings": [] });
1737 let elapsed = Duration::from_millis(42);
1738 let output = build_json_envelope(report, elapsed);
1739
1740 assert_eq!(output["schema_version"], 3);
1741 assert!(output["version"].is_string());
1742 assert_eq!(output["elapsed_ms"], 42);
1743 assert!(output["findings"].is_array());
1744 }
1745
1746 #[test]
1747 fn build_json_envelope_metadata_appears_first() {
1748 let report = serde_json::json!({ "data": "value" });
1749 let output = build_json_envelope(report, Duration::from_millis(10));
1750
1751 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1752 assert_eq!(keys[0], "schema_version");
1753 assert_eq!(keys[1], "version");
1754 assert_eq!(keys[2], "elapsed_ms");
1755 }
1756
1757 #[test]
1758 fn build_json_envelope_non_object_report() {
1759 let report = serde_json::json!("not an object");
1761 let output = build_json_envelope(report, Duration::from_millis(0));
1762
1763 let obj = output.as_object().unwrap();
1764 assert_eq!(obj.len(), 3);
1765 assert!(obj.contains_key("schema_version"));
1766 assert!(obj.contains_key("version"));
1767 assert!(obj.contains_key("elapsed_ms"));
1768 }
1769
1770 #[test]
1773 fn strip_root_prefix_null_unchanged() {
1774 let mut value = serde_json::Value::Null;
1775 strip_root_prefix(&mut value, "/project/");
1776 assert!(value.is_null());
1777 }
1778
1779 #[test]
1782 fn strip_root_prefix_empty_string() {
1783 let mut value = serde_json::json!("");
1784 strip_root_prefix(&mut value, "/project/");
1785 assert_eq!(value, "");
1786 }
1787
1788 #[test]
1791 fn strip_root_prefix_mixed_types() {
1792 let mut value = serde_json::json!({
1793 "path": "/project/src/file.ts",
1794 "line": 42,
1795 "flag": true,
1796 "nested": {
1797 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1798 "deep": { "path": "/project/c.ts" }
1799 }
1800 });
1801 strip_root_prefix(&mut value, "/project/");
1802 assert_eq!(value["path"], "src/file.ts");
1803 assert_eq!(value["line"], 42);
1804 assert_eq!(value["flag"], true);
1805 assert_eq!(value["nested"]["items"][0], "a.ts");
1806 assert_eq!(value["nested"]["items"][1], 99);
1807 assert!(value["nested"]["items"][2].is_null());
1808 assert_eq!(value["nested"]["items"][3], "b.ts");
1809 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1810 }
1811
1812 #[test]
1815 fn json_check_meta_integrates_correctly() {
1816 let root = PathBuf::from("/project");
1817 let results = AnalysisResults::default();
1818 let elapsed = Duration::from_millis(0);
1819 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1820 insert_meta(&mut output, crate::explain::check_meta());
1821
1822 assert!(output["_meta"]["docs"].is_string());
1823 assert!(output["_meta"]["rules"].is_object());
1824 }
1825
1826 #[test]
1829 fn json_unused_member_kind_serialized() {
1830 let root = PathBuf::from("/project");
1831 let mut results = AnalysisResults::default();
1832 results.unused_enum_members.push(UnusedMember {
1833 path: root.join("src/enums.ts"),
1834 parent_name: "Color".to_string(),
1835 member_name: "Red".to_string(),
1836 kind: MemberKind::EnumMember,
1837 line: 3,
1838 col: 2,
1839 });
1840 results.unused_class_members.push(UnusedMember {
1841 path: root.join("src/class.ts"),
1842 parent_name: "Foo".to_string(),
1843 member_name: "bar".to_string(),
1844 kind: MemberKind::ClassMethod,
1845 line: 10,
1846 col: 4,
1847 });
1848
1849 let elapsed = Duration::from_millis(0);
1850 let output = build_json(&results, &root, elapsed).expect("should serialize");
1851
1852 let enum_member = &output["unused_enum_members"][0];
1853 assert!(enum_member["kind"].is_string());
1854 let class_member = &output["unused_class_members"][0];
1855 assert!(class_member["kind"].is_string());
1856 }
1857
1858 #[test]
1861 fn json_unused_export_has_actions() {
1862 let root = PathBuf::from("/project");
1863 let mut results = AnalysisResults::default();
1864 results.unused_exports.push(UnusedExport {
1865 path: root.join("src/utils.ts"),
1866 export_name: "helperFn".to_string(),
1867 is_type_only: false,
1868 line: 10,
1869 col: 4,
1870 span_start: 120,
1871 is_re_export: false,
1872 });
1873 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1874
1875 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1876 assert_eq!(actions.len(), 2);
1877
1878 assert_eq!(actions[0]["type"], "remove-export");
1880 assert_eq!(actions[0]["auto_fixable"], true);
1881 assert!(actions[0].get("note").is_none());
1882
1883 assert_eq!(actions[1]["type"], "suppress-line");
1885 assert_eq!(
1886 actions[1]["comment"],
1887 "// fallow-ignore-next-line unused-export"
1888 );
1889 }
1890
1891 #[test]
1892 fn json_unused_file_has_file_suppress_and_note() {
1893 let root = PathBuf::from("/project");
1894 let mut results = AnalysisResults::default();
1895 results.unused_files.push(UnusedFile {
1896 path: root.join("src/dead.ts"),
1897 });
1898 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1899
1900 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1901 assert_eq!(actions[0]["type"], "delete-file");
1902 assert_eq!(actions[0]["auto_fixable"], false);
1903 assert!(actions[0]["note"].is_string());
1904 assert_eq!(actions[1]["type"], "suppress-file");
1905 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1906 }
1907
1908 #[test]
1909 fn json_unused_dependency_has_config_suppress_with_package_name() {
1910 let root = PathBuf::from("/project");
1911 let mut results = AnalysisResults::default();
1912 results.unused_dependencies.push(UnusedDependency {
1913 package_name: "lodash".to_string(),
1914 location: DependencyLocation::Dependencies,
1915 path: root.join("package.json"),
1916 line: 5,
1917 });
1918 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1919
1920 let actions = output["unused_dependencies"][0]["actions"]
1921 .as_array()
1922 .unwrap();
1923 assert_eq!(actions[0]["type"], "remove-dependency");
1924 assert_eq!(actions[0]["auto_fixable"], true);
1925
1926 assert_eq!(actions[1]["type"], "add-to-config");
1928 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1929 assert_eq!(actions[1]["value"], "lodash");
1930 }
1931
1932 #[test]
1933 fn json_empty_results_have_no_actions_in_empty_arrays() {
1934 let root = PathBuf::from("/project");
1935 let results = AnalysisResults::default();
1936 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1937
1938 assert!(output["unused_exports"].as_array().unwrap().is_empty());
1940 assert!(output["unused_files"].as_array().unwrap().is_empty());
1941 }
1942
1943 #[test]
1944 fn json_all_issue_types_have_actions() {
1945 let root = PathBuf::from("/project");
1946 let results = sample_results(&root);
1947 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1948
1949 let issue_keys = [
1950 "unused_files",
1951 "unused_exports",
1952 "unused_types",
1953 "unused_dependencies",
1954 "unused_dev_dependencies",
1955 "unused_optional_dependencies",
1956 "unused_enum_members",
1957 "unused_class_members",
1958 "unresolved_imports",
1959 "unlisted_dependencies",
1960 "duplicate_exports",
1961 "type_only_dependencies",
1962 "test_only_dependencies",
1963 "circular_dependencies",
1964 ];
1965
1966 for key in &issue_keys {
1967 let arr = output[key].as_array().unwrap();
1968 if !arr.is_empty() {
1969 let actions = arr[0]["actions"].as_array();
1970 assert!(
1971 actions.is_some() && !actions.unwrap().is_empty(),
1972 "missing actions for {key}"
1973 );
1974 }
1975 }
1976 }
1977
1978 #[test]
1981 fn health_finding_has_actions() {
1982 let mut output = serde_json::json!({
1983 "findings": [{
1984 "path": "src/utils.ts",
1985 "name": "processData",
1986 "line": 10,
1987 "col": 0,
1988 "cyclomatic": 25,
1989 "cognitive": 30,
1990 "line_count": 150,
1991 "exceeded": "both"
1992 }]
1993 });
1994
1995 inject_health_actions(&mut output);
1996
1997 let actions = output["findings"][0]["actions"].as_array().unwrap();
1998 assert_eq!(actions.len(), 2);
1999 assert_eq!(actions[0]["type"], "refactor-function");
2000 assert_eq!(actions[0]["auto_fixable"], false);
2001 assert!(
2002 actions[0]["description"]
2003 .as_str()
2004 .unwrap()
2005 .contains("processData")
2006 );
2007 assert_eq!(actions[1]["type"], "suppress-line");
2008 assert_eq!(
2009 actions[1]["comment"],
2010 "// fallow-ignore-next-line complexity"
2011 );
2012 }
2013
2014 #[test]
2015 fn refactoring_target_has_actions() {
2016 let mut output = serde_json::json!({
2017 "targets": [{
2018 "path": "src/big-module.ts",
2019 "priority": 85.0,
2020 "efficiency": 42.5,
2021 "recommendation": "Split module: 12 exports, 4 unused",
2022 "category": "split_high_impact",
2023 "effort": "medium",
2024 "confidence": "high",
2025 "evidence": { "unused_exports": 4 }
2026 }]
2027 });
2028
2029 inject_health_actions(&mut output);
2030
2031 let actions = output["targets"][0]["actions"].as_array().unwrap();
2032 assert_eq!(actions.len(), 2);
2033 assert_eq!(actions[0]["type"], "apply-refactoring");
2034 assert_eq!(
2035 actions[0]["description"],
2036 "Split module: 12 exports, 4 unused"
2037 );
2038 assert_eq!(actions[0]["category"], "split_high_impact");
2039 assert_eq!(actions[1]["type"], "suppress-line");
2041 }
2042
2043 #[test]
2044 fn refactoring_target_without_evidence_has_no_suppress() {
2045 let mut output = serde_json::json!({
2046 "targets": [{
2047 "path": "src/simple.ts",
2048 "priority": 30.0,
2049 "efficiency": 15.0,
2050 "recommendation": "Consider extracting helper functions",
2051 "category": "extract_complex_functions",
2052 "effort": "small",
2053 "confidence": "medium"
2054 }]
2055 });
2056
2057 inject_health_actions(&mut output);
2058
2059 let actions = output["targets"][0]["actions"].as_array().unwrap();
2060 assert_eq!(actions.len(), 1);
2061 assert_eq!(actions[0]["type"], "apply-refactoring");
2062 }
2063
2064 #[test]
2065 fn health_empty_findings_no_actions() {
2066 let mut output = serde_json::json!({
2067 "findings": [],
2068 "targets": []
2069 });
2070
2071 inject_health_actions(&mut output);
2072
2073 assert!(output["findings"].as_array().unwrap().is_empty());
2074 assert!(output["targets"].as_array().unwrap().is_empty());
2075 }
2076
2077 #[test]
2078 fn hotspot_has_actions() {
2079 let mut output = serde_json::json!({
2080 "hotspots": [{
2081 "path": "src/utils.ts",
2082 "complexity_score": 45.0,
2083 "churn_score": 12,
2084 "hotspot_score": 540.0
2085 }]
2086 });
2087
2088 inject_health_actions(&mut output);
2089
2090 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2091 assert_eq!(actions.len(), 2);
2092 assert_eq!(actions[0]["type"], "refactor-file");
2093 assert!(
2094 actions[0]["description"]
2095 .as_str()
2096 .unwrap()
2097 .contains("src/utils.ts")
2098 );
2099 assert_eq!(actions[1]["type"], "add-tests");
2100 }
2101
2102 #[test]
2103 fn health_finding_suppress_has_placement() {
2104 let mut output = serde_json::json!({
2105 "findings": [{
2106 "path": "src/utils.ts",
2107 "name": "processData",
2108 "line": 10,
2109 "col": 0,
2110 "cyclomatic": 25,
2111 "cognitive": 30,
2112 "line_count": 150,
2113 "exceeded": "both"
2114 }]
2115 });
2116
2117 inject_health_actions(&mut output);
2118
2119 let suppress = &output["findings"][0]["actions"][1];
2120 assert_eq!(suppress["placement"], "above-function-declaration");
2121 }
2122
2123 #[test]
2126 fn clone_family_has_actions() {
2127 let mut output = serde_json::json!({
2128 "clone_families": [{
2129 "files": ["src/a.ts", "src/b.ts"],
2130 "groups": [
2131 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
2132 ],
2133 "total_duplicated_lines": 20,
2134 "total_duplicated_tokens": 100,
2135 "suggestions": [
2136 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
2137 ]
2138 }]
2139 });
2140
2141 inject_dupes_actions(&mut output);
2142
2143 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
2144 assert_eq!(actions.len(), 3);
2145 assert_eq!(actions[0]["type"], "extract-shared");
2146 assert_eq!(actions[0]["auto_fixable"], false);
2147 assert!(
2148 actions[0]["description"]
2149 .as_str()
2150 .unwrap()
2151 .contains("20 lines")
2152 );
2153 assert_eq!(actions[1]["type"], "apply-suggestion");
2155 assert!(
2156 actions[1]["description"]
2157 .as_str()
2158 .unwrap()
2159 .contains("validation logic")
2160 );
2161 assert_eq!(actions[2]["type"], "suppress-line");
2163 assert_eq!(
2164 actions[2]["comment"],
2165 "// fallow-ignore-next-line code-duplication"
2166 );
2167 }
2168
2169 #[test]
2170 fn clone_group_has_actions() {
2171 let mut output = serde_json::json!({
2172 "clone_groups": [{
2173 "instances": [
2174 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
2175 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
2176 ],
2177 "token_count": 50,
2178 "line_count": 10
2179 }]
2180 });
2181
2182 inject_dupes_actions(&mut output);
2183
2184 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
2185 assert_eq!(actions.len(), 2);
2186 assert_eq!(actions[0]["type"], "extract-shared");
2187 assert!(
2188 actions[0]["description"]
2189 .as_str()
2190 .unwrap()
2191 .contains("10 lines")
2192 );
2193 assert!(
2194 actions[0]["description"]
2195 .as_str()
2196 .unwrap()
2197 .contains("2 instances")
2198 );
2199 assert_eq!(actions[1]["type"], "suppress-line");
2200 }
2201
2202 #[test]
2203 fn dupes_empty_results_no_actions() {
2204 let mut output = serde_json::json!({
2205 "clone_families": [],
2206 "clone_groups": []
2207 });
2208
2209 inject_dupes_actions(&mut output);
2210
2211 assert!(output["clone_families"].as_array().unwrap().is_empty());
2212 assert!(output["clone_groups"].as_array().unwrap().is_empty());
2213 }
2214}