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