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
215pub fn 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![
679 serde_json::json!({
680 "type": "add-tests",
681 "auto_fixable": false,
682 "description": format!("Add test coverage for `{path}`"),
683 "note": "No test dependency path reaches this runtime file",
684 }),
685 serde_json::json!({
686 "type": "suppress-file",
687 "auto_fixable": false,
688 "description": format!("Suppress coverage gap reporting for `{path}`"),
689 "comment": "// fallow-ignore-file coverage-gaps",
690 }),
691 ])
692}
693
694fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
696 let path = item
697 .get("path")
698 .and_then(serde_json::Value::as_str)
699 .unwrap_or("file");
700 let export_name = item
701 .get("export_name")
702 .and_then(serde_json::Value::as_str)
703 .unwrap_or("export");
704
705 serde_json::Value::Array(vec![
706 serde_json::json!({
707 "type": "add-test-import",
708 "auto_fixable": false,
709 "description": format!("Import and test `{export_name}` from `{path}`"),
710 "note": "This export is runtime-reachable but no test-reachable module references it",
711 }),
712 serde_json::json!({
713 "type": "suppress-file",
714 "auto_fixable": false,
715 "description": format!("Suppress coverage gap reporting for `{path}`"),
716 "comment": "// fallow-ignore-file coverage-gaps",
717 }),
718 ])
719}
720
721#[allow(
728 clippy::redundant_pub_crate,
729 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
730)]
731pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
732 let Some(map) = output.as_object_mut() else {
733 return;
734 };
735
736 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
738 for item in families {
739 let actions = build_clone_family_actions(item);
740 if let serde_json::Value::Object(obj) = item {
741 obj.insert("actions".to_string(), actions);
742 }
743 }
744 }
745
746 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
748 for item in groups {
749 let actions = build_clone_group_actions(item);
750 if let serde_json::Value::Object(obj) = item {
751 obj.insert("actions".to_string(), actions);
752 }
753 }
754 }
755}
756
757fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
759 let group_count = item
760 .get("groups")
761 .and_then(|v| v.as_array())
762 .map_or(0, Vec::len);
763
764 let total_lines = item
765 .get("total_duplicated_lines")
766 .and_then(serde_json::Value::as_u64)
767 .unwrap_or(0);
768
769 let mut actions = vec![serde_json::json!({
770 "type": "extract-shared",
771 "auto_fixable": false,
772 "description": format!(
773 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
774 if group_count == 1 { "" } else { "s" }
775 ),
776 "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
777 })];
778
779 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
781 for suggestion in suggestions {
782 if let Some(desc) = suggestion
783 .get("description")
784 .and_then(serde_json::Value::as_str)
785 {
786 actions.push(serde_json::json!({
787 "type": "apply-suggestion",
788 "auto_fixable": false,
789 "description": desc,
790 }));
791 }
792 }
793 }
794
795 actions.push(serde_json::json!({
796 "type": "suppress-line",
797 "auto_fixable": false,
798 "description": "Suppress with an inline comment above the duplicated code",
799 "comment": "// fallow-ignore-next-line code-duplication",
800 }));
801
802 serde_json::Value::Array(actions)
803}
804
805fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
807 let instance_count = item
808 .get("instances")
809 .and_then(|v| v.as_array())
810 .map_or(0, Vec::len);
811
812 let line_count = item
813 .get("line_count")
814 .and_then(serde_json::Value::as_u64)
815 .unwrap_or(0);
816
817 let actions = vec![
818 serde_json::json!({
819 "type": "extract-shared",
820 "auto_fixable": false,
821 "description": format!(
822 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
823 if instance_count == 1 { "" } else { "s" }
824 ),
825 }),
826 serde_json::json!({
827 "type": "suppress-line",
828 "auto_fixable": false,
829 "description": "Suppress with an inline comment above the duplicated code",
830 "comment": "// fallow-ignore-next-line code-duplication",
831 }),
832 ];
833
834 serde_json::Value::Array(actions)
835}
836
837fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
839 if let serde_json::Value::Object(map) = output {
840 map.insert("_meta".to_string(), meta);
841 }
842}
843
844pub(super) fn print_health_json(
845 report: &crate::health_types::HealthReport,
846 root: &Path,
847 elapsed: Duration,
848 explain: bool,
849) -> ExitCode {
850 let report_value = match serde_json::to_value(report) {
851 Ok(v) => v,
852 Err(e) => {
853 eprintln!("Error: failed to serialize health report: {e}");
854 return ExitCode::from(2);
855 }
856 };
857
858 let mut output = build_json_envelope(report_value, elapsed);
859 let root_prefix = format!("{}/", root.display());
860 strip_root_prefix(&mut output, &root_prefix);
861 inject_health_actions(&mut output);
862
863 if explain {
864 insert_meta(&mut output, explain::health_meta());
865 }
866
867 emit_json(&output, "JSON")
868}
869
870pub(super) fn print_duplication_json(
871 report: &DuplicationReport,
872 root: &Path,
873 elapsed: Duration,
874 explain: bool,
875) -> ExitCode {
876 let report_value = match serde_json::to_value(report) {
877 Ok(v) => v,
878 Err(e) => {
879 eprintln!("Error: failed to serialize duplication report: {e}");
880 return ExitCode::from(2);
881 }
882 };
883
884 let mut output = build_json_envelope(report_value, elapsed);
885 let root_prefix = format!("{}/", root.display());
886 strip_root_prefix(&mut output, &root_prefix);
887 inject_dupes_actions(&mut output);
888
889 if explain {
890 insert_meta(&mut output, explain::dupes_meta());
891 }
892
893 emit_json(&output, "JSON")
894}
895
896pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
897 match serde_json::to_string_pretty(value) {
898 Ok(json) => println!("{json}"),
899 Err(e) => {
900 eprintln!("Error: failed to serialize trace output: {e}");
901 #[expect(
902 clippy::exit,
903 reason = "fatal serialization error requires immediate exit"
904 )]
905 std::process::exit(2);
906 }
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913 use crate::report::test_helpers::sample_results;
914 use fallow_core::extract::MemberKind;
915 use fallow_core::results::*;
916 use std::path::PathBuf;
917 use std::time::Duration;
918
919 #[test]
920 fn json_output_has_metadata_fields() {
921 let root = PathBuf::from("/project");
922 let results = AnalysisResults::default();
923 let elapsed = Duration::from_millis(123);
924 let output = build_json(&results, &root, elapsed).expect("should serialize");
925
926 assert_eq!(output["schema_version"], 3);
927 assert!(output["version"].is_string());
928 assert_eq!(output["elapsed_ms"], 123);
929 assert_eq!(output["total_issues"], 0);
930 }
931
932 #[test]
933 fn json_output_includes_issue_arrays() {
934 let root = PathBuf::from("/project");
935 let results = sample_results(&root);
936 let elapsed = Duration::from_millis(50);
937 let output = build_json(&results, &root, elapsed).expect("should serialize");
938
939 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
940 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
941 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
942 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
943 assert_eq!(
944 output["unused_dev_dependencies"].as_array().unwrap().len(),
945 1
946 );
947 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
948 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
949 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
950 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
951 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
952 assert_eq!(
953 output["type_only_dependencies"].as_array().unwrap().len(),
954 1
955 );
956 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
957 }
958
959 #[test]
960 fn json_metadata_fields_appear_first() {
961 let root = PathBuf::from("/project");
962 let results = AnalysisResults::default();
963 let elapsed = Duration::from_millis(0);
964 let output = build_json(&results, &root, elapsed).expect("should serialize");
965 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
966 assert_eq!(keys[0], "schema_version");
967 assert_eq!(keys[1], "version");
968 assert_eq!(keys[2], "elapsed_ms");
969 assert_eq!(keys[3], "total_issues");
970 }
971
972 #[test]
973 fn json_total_issues_matches_results() {
974 let root = PathBuf::from("/project");
975 let results = sample_results(&root);
976 let total = results.total_issues();
977 let elapsed = Duration::from_millis(0);
978 let output = build_json(&results, &root, elapsed).expect("should serialize");
979
980 assert_eq!(output["total_issues"], total);
981 }
982
983 #[test]
984 fn json_unused_export_contains_expected_fields() {
985 let root = PathBuf::from("/project");
986 let mut results = AnalysisResults::default();
987 results.unused_exports.push(UnusedExport {
988 path: root.join("src/utils.ts"),
989 export_name: "helperFn".to_string(),
990 is_type_only: false,
991 line: 10,
992 col: 4,
993 span_start: 120,
994 is_re_export: false,
995 });
996 let elapsed = Duration::from_millis(0);
997 let output = build_json(&results, &root, elapsed).expect("should serialize");
998
999 let export = &output["unused_exports"][0];
1000 assert_eq!(export["export_name"], "helperFn");
1001 assert_eq!(export["line"], 10);
1002 assert_eq!(export["col"], 4);
1003 assert_eq!(export["is_type_only"], false);
1004 assert_eq!(export["span_start"], 120);
1005 assert_eq!(export["is_re_export"], false);
1006 }
1007
1008 #[test]
1009 fn json_serializes_to_valid_json() {
1010 let root = PathBuf::from("/project");
1011 let results = sample_results(&root);
1012 let elapsed = Duration::from_millis(42);
1013 let output = build_json(&results, &root, elapsed).expect("should serialize");
1014
1015 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1016 let reparsed: serde_json::Value =
1017 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1018 assert_eq!(reparsed, output);
1019 }
1020
1021 #[test]
1024 fn json_empty_results_produce_valid_structure() {
1025 let root = PathBuf::from("/project");
1026 let results = AnalysisResults::default();
1027 let elapsed = Duration::from_millis(0);
1028 let output = build_json(&results, &root, elapsed).expect("should serialize");
1029
1030 assert_eq!(output["total_issues"], 0);
1031 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1032 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1033 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1034 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1035 assert_eq!(
1036 output["unused_dev_dependencies"].as_array().unwrap().len(),
1037 0
1038 );
1039 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1040 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1041 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1042 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1043 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1044 assert_eq!(
1045 output["type_only_dependencies"].as_array().unwrap().len(),
1046 0
1047 );
1048 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1049 }
1050
1051 #[test]
1052 fn json_empty_results_round_trips_through_string() {
1053 let root = PathBuf::from("/project");
1054 let results = AnalysisResults::default();
1055 let elapsed = Duration::from_millis(0);
1056 let output = build_json(&results, &root, elapsed).expect("should serialize");
1057
1058 let json_str = serde_json::to_string(&output).expect("should stringify");
1059 let reparsed: serde_json::Value =
1060 serde_json::from_str(&json_str).expect("should parse back");
1061 assert_eq!(reparsed["total_issues"], 0);
1062 }
1063
1064 #[test]
1067 fn json_paths_are_relative_to_root() {
1068 let root = PathBuf::from("/project");
1069 let mut results = AnalysisResults::default();
1070 results.unused_files.push(UnusedFile {
1071 path: root.join("src/deep/nested/file.ts"),
1072 });
1073 let elapsed = Duration::from_millis(0);
1074 let output = build_json(&results, &root, elapsed).expect("should serialize");
1075
1076 let path = output["unused_files"][0]["path"].as_str().unwrap();
1077 assert_eq!(path, "src/deep/nested/file.ts");
1078 assert!(!path.starts_with("/project"));
1079 }
1080
1081 #[test]
1082 fn json_strips_root_from_nested_locations() {
1083 let root = PathBuf::from("/project");
1084 let mut results = AnalysisResults::default();
1085 results.unlisted_dependencies.push(UnlistedDependency {
1086 package_name: "chalk".to_string(),
1087 imported_from: vec![ImportSite {
1088 path: root.join("src/cli.ts"),
1089 line: 2,
1090 col: 0,
1091 }],
1092 });
1093 let elapsed = Duration::from_millis(0);
1094 let output = build_json(&results, &root, elapsed).expect("should serialize");
1095
1096 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1097 .as_str()
1098 .unwrap();
1099 assert_eq!(site_path, "src/cli.ts");
1100 }
1101
1102 #[test]
1103 fn json_strips_root_from_duplicate_export_locations() {
1104 let root = PathBuf::from("/project");
1105 let mut results = AnalysisResults::default();
1106 results.duplicate_exports.push(DuplicateExport {
1107 export_name: "Config".to_string(),
1108 locations: vec![
1109 DuplicateLocation {
1110 path: root.join("src/config.ts"),
1111 line: 15,
1112 col: 0,
1113 },
1114 DuplicateLocation {
1115 path: root.join("src/types.ts"),
1116 line: 30,
1117 col: 0,
1118 },
1119 ],
1120 });
1121 let elapsed = Duration::from_millis(0);
1122 let output = build_json(&results, &root, elapsed).expect("should serialize");
1123
1124 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1125 .as_str()
1126 .unwrap();
1127 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1128 .as_str()
1129 .unwrap();
1130 assert_eq!(loc0, "src/config.ts");
1131 assert_eq!(loc1, "src/types.ts");
1132 }
1133
1134 #[test]
1135 fn json_strips_root_from_circular_dependency_files() {
1136 let root = PathBuf::from("/project");
1137 let mut results = AnalysisResults::default();
1138 results.circular_dependencies.push(CircularDependency {
1139 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1140 length: 2,
1141 line: 1,
1142 col: 0,
1143 is_cross_package: false,
1144 });
1145 let elapsed = Duration::from_millis(0);
1146 let output = build_json(&results, &root, elapsed).expect("should serialize");
1147
1148 let files = output["circular_dependencies"][0]["files"]
1149 .as_array()
1150 .unwrap();
1151 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1152 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1153 }
1154
1155 #[test]
1156 fn json_path_outside_root_not_stripped() {
1157 let root = PathBuf::from("/project");
1158 let mut results = AnalysisResults::default();
1159 results.unused_files.push(UnusedFile {
1160 path: PathBuf::from("/other/project/src/file.ts"),
1161 });
1162 let elapsed = Duration::from_millis(0);
1163 let output = build_json(&results, &root, elapsed).expect("should serialize");
1164
1165 let path = output["unused_files"][0]["path"].as_str().unwrap();
1166 assert!(path.contains("/other/project/"));
1167 }
1168
1169 #[test]
1172 fn json_unused_file_contains_path() {
1173 let root = PathBuf::from("/project");
1174 let mut results = AnalysisResults::default();
1175 results.unused_files.push(UnusedFile {
1176 path: root.join("src/orphan.ts"),
1177 });
1178 let elapsed = Duration::from_millis(0);
1179 let output = build_json(&results, &root, elapsed).expect("should serialize");
1180
1181 let file = &output["unused_files"][0];
1182 assert_eq!(file["path"], "src/orphan.ts");
1183 }
1184
1185 #[test]
1186 fn json_unused_type_contains_expected_fields() {
1187 let root = PathBuf::from("/project");
1188 let mut results = AnalysisResults::default();
1189 results.unused_types.push(UnusedExport {
1190 path: root.join("src/types.ts"),
1191 export_name: "OldInterface".to_string(),
1192 is_type_only: true,
1193 line: 20,
1194 col: 0,
1195 span_start: 300,
1196 is_re_export: false,
1197 });
1198 let elapsed = Duration::from_millis(0);
1199 let output = build_json(&results, &root, elapsed).expect("should serialize");
1200
1201 let typ = &output["unused_types"][0];
1202 assert_eq!(typ["export_name"], "OldInterface");
1203 assert_eq!(typ["is_type_only"], true);
1204 assert_eq!(typ["line"], 20);
1205 assert_eq!(typ["path"], "src/types.ts");
1206 }
1207
1208 #[test]
1209 fn json_unused_dependency_contains_expected_fields() {
1210 let root = PathBuf::from("/project");
1211 let mut results = AnalysisResults::default();
1212 results.unused_dependencies.push(UnusedDependency {
1213 package_name: "axios".to_string(),
1214 location: DependencyLocation::Dependencies,
1215 path: root.join("package.json"),
1216 line: 10,
1217 });
1218 let elapsed = Duration::from_millis(0);
1219 let output = build_json(&results, &root, elapsed).expect("should serialize");
1220
1221 let dep = &output["unused_dependencies"][0];
1222 assert_eq!(dep["package_name"], "axios");
1223 assert_eq!(dep["line"], 10);
1224 }
1225
1226 #[test]
1227 fn json_unused_dev_dependency_contains_expected_fields() {
1228 let root = PathBuf::from("/project");
1229 let mut results = AnalysisResults::default();
1230 results.unused_dev_dependencies.push(UnusedDependency {
1231 package_name: "vitest".to_string(),
1232 location: DependencyLocation::DevDependencies,
1233 path: root.join("package.json"),
1234 line: 15,
1235 });
1236 let elapsed = Duration::from_millis(0);
1237 let output = build_json(&results, &root, elapsed).expect("should serialize");
1238
1239 let dep = &output["unused_dev_dependencies"][0];
1240 assert_eq!(dep["package_name"], "vitest");
1241 }
1242
1243 #[test]
1244 fn json_unused_optional_dependency_contains_expected_fields() {
1245 let root = PathBuf::from("/project");
1246 let mut results = AnalysisResults::default();
1247 results.unused_optional_dependencies.push(UnusedDependency {
1248 package_name: "fsevents".to_string(),
1249 location: DependencyLocation::OptionalDependencies,
1250 path: root.join("package.json"),
1251 line: 12,
1252 });
1253 let elapsed = Duration::from_millis(0);
1254 let output = build_json(&results, &root, elapsed).expect("should serialize");
1255
1256 let dep = &output["unused_optional_dependencies"][0];
1257 assert_eq!(dep["package_name"], "fsevents");
1258 assert_eq!(output["total_issues"], 1);
1259 }
1260
1261 #[test]
1262 fn json_unused_enum_member_contains_expected_fields() {
1263 let root = PathBuf::from("/project");
1264 let mut results = AnalysisResults::default();
1265 results.unused_enum_members.push(UnusedMember {
1266 path: root.join("src/enums.ts"),
1267 parent_name: "Color".to_string(),
1268 member_name: "Purple".to_string(),
1269 kind: MemberKind::EnumMember,
1270 line: 5,
1271 col: 2,
1272 });
1273 let elapsed = Duration::from_millis(0);
1274 let output = build_json(&results, &root, elapsed).expect("should serialize");
1275
1276 let member = &output["unused_enum_members"][0];
1277 assert_eq!(member["parent_name"], "Color");
1278 assert_eq!(member["member_name"], "Purple");
1279 assert_eq!(member["line"], 5);
1280 assert_eq!(member["path"], "src/enums.ts");
1281 }
1282
1283 #[test]
1284 fn json_unused_class_member_contains_expected_fields() {
1285 let root = PathBuf::from("/project");
1286 let mut results = AnalysisResults::default();
1287 results.unused_class_members.push(UnusedMember {
1288 path: root.join("src/api.ts"),
1289 parent_name: "ApiClient".to_string(),
1290 member_name: "deprecatedFetch".to_string(),
1291 kind: MemberKind::ClassMethod,
1292 line: 100,
1293 col: 4,
1294 });
1295 let elapsed = Duration::from_millis(0);
1296 let output = build_json(&results, &root, elapsed).expect("should serialize");
1297
1298 let member = &output["unused_class_members"][0];
1299 assert_eq!(member["parent_name"], "ApiClient");
1300 assert_eq!(member["member_name"], "deprecatedFetch");
1301 assert_eq!(member["line"], 100);
1302 }
1303
1304 #[test]
1305 fn json_unresolved_import_contains_expected_fields() {
1306 let root = PathBuf::from("/project");
1307 let mut results = AnalysisResults::default();
1308 results.unresolved_imports.push(UnresolvedImport {
1309 path: root.join("src/app.ts"),
1310 specifier: "@acme/missing-pkg".to_string(),
1311 line: 7,
1312 col: 0,
1313 specifier_col: 0,
1314 });
1315 let elapsed = Duration::from_millis(0);
1316 let output = build_json(&results, &root, elapsed).expect("should serialize");
1317
1318 let import = &output["unresolved_imports"][0];
1319 assert_eq!(import["specifier"], "@acme/missing-pkg");
1320 assert_eq!(import["line"], 7);
1321 assert_eq!(import["path"], "src/app.ts");
1322 }
1323
1324 #[test]
1325 fn json_unlisted_dependency_contains_import_sites() {
1326 let root = PathBuf::from("/project");
1327 let mut results = AnalysisResults::default();
1328 results.unlisted_dependencies.push(UnlistedDependency {
1329 package_name: "dotenv".to_string(),
1330 imported_from: vec![
1331 ImportSite {
1332 path: root.join("src/config.ts"),
1333 line: 1,
1334 col: 0,
1335 },
1336 ImportSite {
1337 path: root.join("src/server.ts"),
1338 line: 3,
1339 col: 0,
1340 },
1341 ],
1342 });
1343 let elapsed = Duration::from_millis(0);
1344 let output = build_json(&results, &root, elapsed).expect("should serialize");
1345
1346 let dep = &output["unlisted_dependencies"][0];
1347 assert_eq!(dep["package_name"], "dotenv");
1348 let sites = dep["imported_from"].as_array().unwrap();
1349 assert_eq!(sites.len(), 2);
1350 assert_eq!(sites[0]["path"], "src/config.ts");
1351 assert_eq!(sites[1]["path"], "src/server.ts");
1352 }
1353
1354 #[test]
1355 fn json_duplicate_export_contains_locations() {
1356 let root = PathBuf::from("/project");
1357 let mut results = AnalysisResults::default();
1358 results.duplicate_exports.push(DuplicateExport {
1359 export_name: "Button".to_string(),
1360 locations: vec![
1361 DuplicateLocation {
1362 path: root.join("src/ui.ts"),
1363 line: 10,
1364 col: 0,
1365 },
1366 DuplicateLocation {
1367 path: root.join("src/components.ts"),
1368 line: 25,
1369 col: 0,
1370 },
1371 ],
1372 });
1373 let elapsed = Duration::from_millis(0);
1374 let output = build_json(&results, &root, elapsed).expect("should serialize");
1375
1376 let dup = &output["duplicate_exports"][0];
1377 assert_eq!(dup["export_name"], "Button");
1378 let locs = dup["locations"].as_array().unwrap();
1379 assert_eq!(locs.len(), 2);
1380 assert_eq!(locs[0]["line"], 10);
1381 assert_eq!(locs[1]["line"], 25);
1382 }
1383
1384 #[test]
1385 fn json_type_only_dependency_contains_expected_fields() {
1386 let root = PathBuf::from("/project");
1387 let mut results = AnalysisResults::default();
1388 results.type_only_dependencies.push(TypeOnlyDependency {
1389 package_name: "zod".to_string(),
1390 path: root.join("package.json"),
1391 line: 8,
1392 });
1393 let elapsed = Duration::from_millis(0);
1394 let output = build_json(&results, &root, elapsed).expect("should serialize");
1395
1396 let dep = &output["type_only_dependencies"][0];
1397 assert_eq!(dep["package_name"], "zod");
1398 assert_eq!(dep["line"], 8);
1399 }
1400
1401 #[test]
1402 fn json_circular_dependency_contains_expected_fields() {
1403 let root = PathBuf::from("/project");
1404 let mut results = AnalysisResults::default();
1405 results.circular_dependencies.push(CircularDependency {
1406 files: vec![
1407 root.join("src/a.ts"),
1408 root.join("src/b.ts"),
1409 root.join("src/c.ts"),
1410 ],
1411 length: 3,
1412 line: 5,
1413 col: 0,
1414 is_cross_package: false,
1415 });
1416 let elapsed = Duration::from_millis(0);
1417 let output = build_json(&results, &root, elapsed).expect("should serialize");
1418
1419 let cycle = &output["circular_dependencies"][0];
1420 assert_eq!(cycle["length"], 3);
1421 assert_eq!(cycle["line"], 5);
1422 let files = cycle["files"].as_array().unwrap();
1423 assert_eq!(files.len(), 3);
1424 }
1425
1426 #[test]
1429 fn json_re_export_flagged_correctly() {
1430 let root = PathBuf::from("/project");
1431 let mut results = AnalysisResults::default();
1432 results.unused_exports.push(UnusedExport {
1433 path: root.join("src/index.ts"),
1434 export_name: "reExported".to_string(),
1435 is_type_only: false,
1436 line: 1,
1437 col: 0,
1438 span_start: 0,
1439 is_re_export: true,
1440 });
1441 let elapsed = Duration::from_millis(0);
1442 let output = build_json(&results, &root, elapsed).expect("should serialize");
1443
1444 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1445 }
1446
1447 #[test]
1450 fn json_schema_version_is_3() {
1451 let root = PathBuf::from("/project");
1452 let results = AnalysisResults::default();
1453 let elapsed = Duration::from_millis(0);
1454 let output = build_json(&results, &root, elapsed).expect("should serialize");
1455
1456 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1457 assert_eq!(output["schema_version"], 3);
1458 }
1459
1460 #[test]
1463 fn json_version_matches_cargo_pkg_version() {
1464 let root = PathBuf::from("/project");
1465 let results = AnalysisResults::default();
1466 let elapsed = Duration::from_millis(0);
1467 let output = build_json(&results, &root, elapsed).expect("should serialize");
1468
1469 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1470 }
1471
1472 #[test]
1475 fn json_elapsed_ms_zero_duration() {
1476 let root = PathBuf::from("/project");
1477 let results = AnalysisResults::default();
1478 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1479
1480 assert_eq!(output["elapsed_ms"], 0);
1481 }
1482
1483 #[test]
1484 fn json_elapsed_ms_large_duration() {
1485 let root = PathBuf::from("/project");
1486 let results = AnalysisResults::default();
1487 let elapsed = Duration::from_secs(120);
1488 let output = build_json(&results, &root, elapsed).expect("should serialize");
1489
1490 assert_eq!(output["elapsed_ms"], 120_000);
1491 }
1492
1493 #[test]
1494 fn json_elapsed_ms_sub_millisecond_truncated() {
1495 let root = PathBuf::from("/project");
1496 let results = AnalysisResults::default();
1497 let elapsed = Duration::from_micros(500);
1499 let output = build_json(&results, &root, elapsed).expect("should serialize");
1500
1501 assert_eq!(output["elapsed_ms"], 0);
1502 }
1503
1504 #[test]
1507 fn json_multiple_unused_files() {
1508 let root = PathBuf::from("/project");
1509 let mut results = AnalysisResults::default();
1510 results.unused_files.push(UnusedFile {
1511 path: root.join("src/a.ts"),
1512 });
1513 results.unused_files.push(UnusedFile {
1514 path: root.join("src/b.ts"),
1515 });
1516 results.unused_files.push(UnusedFile {
1517 path: root.join("src/c.ts"),
1518 });
1519 let elapsed = Duration::from_millis(0);
1520 let output = build_json(&results, &root, elapsed).expect("should serialize");
1521
1522 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1523 assert_eq!(output["total_issues"], 3);
1524 }
1525
1526 #[test]
1529 fn strip_root_prefix_on_string_value() {
1530 let mut value = serde_json::json!("/project/src/file.ts");
1531 strip_root_prefix(&mut value, "/project/");
1532 assert_eq!(value, "src/file.ts");
1533 }
1534
1535 #[test]
1536 fn strip_root_prefix_leaves_non_matching_string() {
1537 let mut value = serde_json::json!("/other/src/file.ts");
1538 strip_root_prefix(&mut value, "/project/");
1539 assert_eq!(value, "/other/src/file.ts");
1540 }
1541
1542 #[test]
1543 fn strip_root_prefix_recurses_into_arrays() {
1544 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1545 strip_root_prefix(&mut value, "/project/");
1546 assert_eq!(value[0], "a.ts");
1547 assert_eq!(value[1], "b.ts");
1548 assert_eq!(value[2], "/other/c.ts");
1549 }
1550
1551 #[test]
1552 fn strip_root_prefix_recurses_into_nested_objects() {
1553 let mut value = serde_json::json!({
1554 "outer": {
1555 "path": "/project/src/nested.ts"
1556 }
1557 });
1558 strip_root_prefix(&mut value, "/project/");
1559 assert_eq!(value["outer"]["path"], "src/nested.ts");
1560 }
1561
1562 #[test]
1563 fn strip_root_prefix_leaves_numbers_and_booleans() {
1564 let mut value = serde_json::json!({
1565 "line": 42,
1566 "is_type_only": false,
1567 "path": "/project/src/file.ts"
1568 });
1569 strip_root_prefix(&mut value, "/project/");
1570 assert_eq!(value["line"], 42);
1571 assert_eq!(value["is_type_only"], false);
1572 assert_eq!(value["path"], "src/file.ts");
1573 }
1574
1575 #[test]
1576 fn strip_root_prefix_handles_empty_string_after_strip() {
1577 let mut value = serde_json::json!("/project/");
1580 strip_root_prefix(&mut value, "/project/");
1581 assert_eq!(value, "");
1582 }
1583
1584 #[test]
1585 fn strip_root_prefix_deeply_nested_array_of_objects() {
1586 let mut value = serde_json::json!({
1587 "groups": [{
1588 "instances": [{
1589 "file": "/project/src/a.ts"
1590 }, {
1591 "file": "/project/src/b.ts"
1592 }]
1593 }]
1594 });
1595 strip_root_prefix(&mut value, "/project/");
1596 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1597 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1598 }
1599
1600 #[test]
1603 fn json_full_sample_results_total_issues_correct() {
1604 let root = PathBuf::from("/project");
1605 let results = sample_results(&root);
1606 let elapsed = Duration::from_millis(100);
1607 let output = build_json(&results, &root, elapsed).expect("should serialize");
1608
1609 assert_eq!(output["total_issues"], results.total_issues());
1615 }
1616
1617 #[test]
1618 fn json_full_sample_no_absolute_paths_in_output() {
1619 let root = PathBuf::from("/project");
1620 let results = sample_results(&root);
1621 let elapsed = Duration::from_millis(0);
1622 let output = build_json(&results, &root, elapsed).expect("should serialize");
1623
1624 let json_str = serde_json::to_string(&output).expect("should stringify");
1625 assert!(!json_str.contains("/project/src/"));
1627 assert!(!json_str.contains("/project/package.json"));
1628 }
1629
1630 #[test]
1633 fn json_output_is_deterministic() {
1634 let root = PathBuf::from("/project");
1635 let results = sample_results(&root);
1636 let elapsed = Duration::from_millis(50);
1637
1638 let output1 = build_json(&results, &root, elapsed).expect("first build");
1639 let output2 = build_json(&results, &root, elapsed).expect("second build");
1640
1641 assert_eq!(output1, output2);
1642 }
1643
1644 #[test]
1647 fn json_results_fields_do_not_shadow_metadata() {
1648 let root = PathBuf::from("/project");
1651 let results = AnalysisResults::default();
1652 let elapsed = Duration::from_millis(99);
1653 let output = build_json(&results, &root, elapsed).expect("should serialize");
1654
1655 assert_eq!(output["schema_version"], 3);
1657 assert_eq!(output["elapsed_ms"], 99);
1658 }
1659
1660 #[test]
1663 fn json_all_issue_type_arrays_present_in_empty_results() {
1664 let root = PathBuf::from("/project");
1665 let results = AnalysisResults::default();
1666 let elapsed = Duration::from_millis(0);
1667 let output = build_json(&results, &root, elapsed).expect("should serialize");
1668
1669 let expected_arrays = [
1670 "unused_files",
1671 "unused_exports",
1672 "unused_types",
1673 "unused_dependencies",
1674 "unused_dev_dependencies",
1675 "unused_optional_dependencies",
1676 "unused_enum_members",
1677 "unused_class_members",
1678 "unresolved_imports",
1679 "unlisted_dependencies",
1680 "duplicate_exports",
1681 "type_only_dependencies",
1682 "test_only_dependencies",
1683 "circular_dependencies",
1684 ];
1685 for key in &expected_arrays {
1686 assert!(
1687 output[key].is_array(),
1688 "expected '{key}' to be an array in JSON output"
1689 );
1690 }
1691 }
1692
1693 #[test]
1696 fn insert_meta_adds_key_to_object() {
1697 let mut output = serde_json::json!({ "foo": 1 });
1698 let meta = serde_json::json!({ "docs": "https://example.com" });
1699 insert_meta(&mut output, meta.clone());
1700 assert_eq!(output["_meta"], meta);
1701 }
1702
1703 #[test]
1704 fn insert_meta_noop_on_non_object() {
1705 let mut output = serde_json::json!([1, 2, 3]);
1706 let meta = serde_json::json!({ "docs": "https://example.com" });
1707 insert_meta(&mut output, meta);
1708 assert!(output.is_array());
1710 }
1711
1712 #[test]
1713 fn insert_meta_overwrites_existing_meta() {
1714 let mut output = serde_json::json!({ "_meta": "old" });
1715 let meta = serde_json::json!({ "new": true });
1716 insert_meta(&mut output, meta.clone());
1717 assert_eq!(output["_meta"], meta);
1718 }
1719
1720 #[test]
1723 fn build_json_envelope_has_metadata_fields() {
1724 let report = serde_json::json!({ "findings": [] });
1725 let elapsed = Duration::from_millis(42);
1726 let output = build_json_envelope(report, elapsed);
1727
1728 assert_eq!(output["schema_version"], 3);
1729 assert!(output["version"].is_string());
1730 assert_eq!(output["elapsed_ms"], 42);
1731 assert!(output["findings"].is_array());
1732 }
1733
1734 #[test]
1735 fn build_json_envelope_metadata_appears_first() {
1736 let report = serde_json::json!({ "data": "value" });
1737 let output = build_json_envelope(report, Duration::from_millis(10));
1738
1739 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1740 assert_eq!(keys[0], "schema_version");
1741 assert_eq!(keys[1], "version");
1742 assert_eq!(keys[2], "elapsed_ms");
1743 }
1744
1745 #[test]
1746 fn build_json_envelope_non_object_report() {
1747 let report = serde_json::json!("not an object");
1749 let output = build_json_envelope(report, Duration::from_millis(0));
1750
1751 let obj = output.as_object().unwrap();
1752 assert_eq!(obj.len(), 3);
1753 assert!(obj.contains_key("schema_version"));
1754 assert!(obj.contains_key("version"));
1755 assert!(obj.contains_key("elapsed_ms"));
1756 }
1757
1758 #[test]
1761 fn strip_root_prefix_null_unchanged() {
1762 let mut value = serde_json::Value::Null;
1763 strip_root_prefix(&mut value, "/project/");
1764 assert!(value.is_null());
1765 }
1766
1767 #[test]
1770 fn strip_root_prefix_empty_string() {
1771 let mut value = serde_json::json!("");
1772 strip_root_prefix(&mut value, "/project/");
1773 assert_eq!(value, "");
1774 }
1775
1776 #[test]
1779 fn strip_root_prefix_mixed_types() {
1780 let mut value = serde_json::json!({
1781 "path": "/project/src/file.ts",
1782 "line": 42,
1783 "flag": true,
1784 "nested": {
1785 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1786 "deep": { "path": "/project/c.ts" }
1787 }
1788 });
1789 strip_root_prefix(&mut value, "/project/");
1790 assert_eq!(value["path"], "src/file.ts");
1791 assert_eq!(value["line"], 42);
1792 assert_eq!(value["flag"], true);
1793 assert_eq!(value["nested"]["items"][0], "a.ts");
1794 assert_eq!(value["nested"]["items"][1], 99);
1795 assert!(value["nested"]["items"][2].is_null());
1796 assert_eq!(value["nested"]["items"][3], "b.ts");
1797 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1798 }
1799
1800 #[test]
1803 fn json_check_meta_integrates_correctly() {
1804 let root = PathBuf::from("/project");
1805 let results = AnalysisResults::default();
1806 let elapsed = Duration::from_millis(0);
1807 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1808 insert_meta(&mut output, crate::explain::check_meta());
1809
1810 assert!(output["_meta"]["docs"].is_string());
1811 assert!(output["_meta"]["rules"].is_object());
1812 }
1813
1814 #[test]
1817 fn json_unused_member_kind_serialized() {
1818 let root = PathBuf::from("/project");
1819 let mut results = AnalysisResults::default();
1820 results.unused_enum_members.push(UnusedMember {
1821 path: root.join("src/enums.ts"),
1822 parent_name: "Color".to_string(),
1823 member_name: "Red".to_string(),
1824 kind: MemberKind::EnumMember,
1825 line: 3,
1826 col: 2,
1827 });
1828 results.unused_class_members.push(UnusedMember {
1829 path: root.join("src/class.ts"),
1830 parent_name: "Foo".to_string(),
1831 member_name: "bar".to_string(),
1832 kind: MemberKind::ClassMethod,
1833 line: 10,
1834 col: 4,
1835 });
1836
1837 let elapsed = Duration::from_millis(0);
1838 let output = build_json(&results, &root, elapsed).expect("should serialize");
1839
1840 let enum_member = &output["unused_enum_members"][0];
1841 assert!(enum_member["kind"].is_string());
1842 let class_member = &output["unused_class_members"][0];
1843 assert!(class_member["kind"].is_string());
1844 }
1845
1846 #[test]
1849 fn json_unused_export_has_actions() {
1850 let root = PathBuf::from("/project");
1851 let mut results = AnalysisResults::default();
1852 results.unused_exports.push(UnusedExport {
1853 path: root.join("src/utils.ts"),
1854 export_name: "helperFn".to_string(),
1855 is_type_only: false,
1856 line: 10,
1857 col: 4,
1858 span_start: 120,
1859 is_re_export: false,
1860 });
1861 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1862
1863 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1864 assert_eq!(actions.len(), 2);
1865
1866 assert_eq!(actions[0]["type"], "remove-export");
1868 assert_eq!(actions[0]["auto_fixable"], true);
1869 assert!(actions[0].get("note").is_none());
1870
1871 assert_eq!(actions[1]["type"], "suppress-line");
1873 assert_eq!(
1874 actions[1]["comment"],
1875 "// fallow-ignore-next-line unused-export"
1876 );
1877 }
1878
1879 #[test]
1880 fn json_unused_file_has_file_suppress_and_note() {
1881 let root = PathBuf::from("/project");
1882 let mut results = AnalysisResults::default();
1883 results.unused_files.push(UnusedFile {
1884 path: root.join("src/dead.ts"),
1885 });
1886 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1887
1888 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1889 assert_eq!(actions[0]["type"], "delete-file");
1890 assert_eq!(actions[0]["auto_fixable"], false);
1891 assert!(actions[0]["note"].is_string());
1892 assert_eq!(actions[1]["type"], "suppress-file");
1893 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1894 }
1895
1896 #[test]
1897 fn json_unused_dependency_has_config_suppress_with_package_name() {
1898 let root = PathBuf::from("/project");
1899 let mut results = AnalysisResults::default();
1900 results.unused_dependencies.push(UnusedDependency {
1901 package_name: "lodash".to_string(),
1902 location: DependencyLocation::Dependencies,
1903 path: root.join("package.json"),
1904 line: 5,
1905 });
1906 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1907
1908 let actions = output["unused_dependencies"][0]["actions"]
1909 .as_array()
1910 .unwrap();
1911 assert_eq!(actions[0]["type"], "remove-dependency");
1912 assert_eq!(actions[0]["auto_fixable"], true);
1913
1914 assert_eq!(actions[1]["type"], "add-to-config");
1916 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1917 assert_eq!(actions[1]["value"], "lodash");
1918 }
1919
1920 #[test]
1921 fn json_empty_results_have_no_actions_in_empty_arrays() {
1922 let root = PathBuf::from("/project");
1923 let results = AnalysisResults::default();
1924 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1925
1926 assert!(output["unused_exports"].as_array().unwrap().is_empty());
1928 assert!(output["unused_files"].as_array().unwrap().is_empty());
1929 }
1930
1931 #[test]
1932 fn json_all_issue_types_have_actions() {
1933 let root = PathBuf::from("/project");
1934 let results = sample_results(&root);
1935 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1936
1937 let issue_keys = [
1938 "unused_files",
1939 "unused_exports",
1940 "unused_types",
1941 "unused_dependencies",
1942 "unused_dev_dependencies",
1943 "unused_optional_dependencies",
1944 "unused_enum_members",
1945 "unused_class_members",
1946 "unresolved_imports",
1947 "unlisted_dependencies",
1948 "duplicate_exports",
1949 "type_only_dependencies",
1950 "test_only_dependencies",
1951 "circular_dependencies",
1952 ];
1953
1954 for key in &issue_keys {
1955 let arr = output[key].as_array().unwrap();
1956 if !arr.is_empty() {
1957 let actions = arr[0]["actions"].as_array();
1958 assert!(
1959 actions.is_some() && !actions.unwrap().is_empty(),
1960 "missing actions for {key}"
1961 );
1962 }
1963 }
1964 }
1965
1966 #[test]
1969 fn health_finding_has_actions() {
1970 let mut output = serde_json::json!({
1971 "findings": [{
1972 "path": "src/utils.ts",
1973 "name": "processData",
1974 "line": 10,
1975 "col": 0,
1976 "cyclomatic": 25,
1977 "cognitive": 30,
1978 "line_count": 150,
1979 "exceeded": "both"
1980 }]
1981 });
1982
1983 inject_health_actions(&mut output);
1984
1985 let actions = output["findings"][0]["actions"].as_array().unwrap();
1986 assert_eq!(actions.len(), 2);
1987 assert_eq!(actions[0]["type"], "refactor-function");
1988 assert_eq!(actions[0]["auto_fixable"], false);
1989 assert!(
1990 actions[0]["description"]
1991 .as_str()
1992 .unwrap()
1993 .contains("processData")
1994 );
1995 assert_eq!(actions[1]["type"], "suppress-line");
1996 assert_eq!(
1997 actions[1]["comment"],
1998 "// fallow-ignore-next-line complexity"
1999 );
2000 }
2001
2002 #[test]
2003 fn refactoring_target_has_actions() {
2004 let mut output = serde_json::json!({
2005 "targets": [{
2006 "path": "src/big-module.ts",
2007 "priority": 85.0,
2008 "efficiency": 42.5,
2009 "recommendation": "Split module: 12 exports, 4 unused",
2010 "category": "split_high_impact",
2011 "effort": "medium",
2012 "confidence": "high",
2013 "evidence": { "unused_exports": 4 }
2014 }]
2015 });
2016
2017 inject_health_actions(&mut output);
2018
2019 let actions = output["targets"][0]["actions"].as_array().unwrap();
2020 assert_eq!(actions.len(), 2);
2021 assert_eq!(actions[0]["type"], "apply-refactoring");
2022 assert_eq!(
2023 actions[0]["description"],
2024 "Split module: 12 exports, 4 unused"
2025 );
2026 assert_eq!(actions[0]["category"], "split_high_impact");
2027 assert_eq!(actions[1]["type"], "suppress-line");
2029 }
2030
2031 #[test]
2032 fn refactoring_target_without_evidence_has_no_suppress() {
2033 let mut output = serde_json::json!({
2034 "targets": [{
2035 "path": "src/simple.ts",
2036 "priority": 30.0,
2037 "efficiency": 15.0,
2038 "recommendation": "Consider extracting helper functions",
2039 "category": "extract_complex_functions",
2040 "effort": "small",
2041 "confidence": "medium"
2042 }]
2043 });
2044
2045 inject_health_actions(&mut output);
2046
2047 let actions = output["targets"][0]["actions"].as_array().unwrap();
2048 assert_eq!(actions.len(), 1);
2049 assert_eq!(actions[0]["type"], "apply-refactoring");
2050 }
2051
2052 #[test]
2053 fn health_empty_findings_no_actions() {
2054 let mut output = serde_json::json!({
2055 "findings": [],
2056 "targets": []
2057 });
2058
2059 inject_health_actions(&mut output);
2060
2061 assert!(output["findings"].as_array().unwrap().is_empty());
2062 assert!(output["targets"].as_array().unwrap().is_empty());
2063 }
2064
2065 #[test]
2066 fn hotspot_has_actions() {
2067 let mut output = serde_json::json!({
2068 "hotspots": [{
2069 "path": "src/utils.ts",
2070 "complexity_score": 45.0,
2071 "churn_score": 12,
2072 "hotspot_score": 540.0
2073 }]
2074 });
2075
2076 inject_health_actions(&mut output);
2077
2078 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2079 assert_eq!(actions.len(), 2);
2080 assert_eq!(actions[0]["type"], "refactor-file");
2081 assert!(
2082 actions[0]["description"]
2083 .as_str()
2084 .unwrap()
2085 .contains("src/utils.ts")
2086 );
2087 assert_eq!(actions[1]["type"], "add-tests");
2088 }
2089
2090 #[test]
2091 fn health_finding_suppress_has_placement() {
2092 let mut output = serde_json::json!({
2093 "findings": [{
2094 "path": "src/utils.ts",
2095 "name": "processData",
2096 "line": 10,
2097 "col": 0,
2098 "cyclomatic": 25,
2099 "cognitive": 30,
2100 "line_count": 150,
2101 "exceeded": "both"
2102 }]
2103 });
2104
2105 inject_health_actions(&mut output);
2106
2107 let suppress = &output["findings"][0]["actions"][1];
2108 assert_eq!(suppress["placement"], "above-function-declaration");
2109 }
2110
2111 #[test]
2114 fn clone_family_has_actions() {
2115 let mut output = serde_json::json!({
2116 "clone_families": [{
2117 "files": ["src/a.ts", "src/b.ts"],
2118 "groups": [
2119 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
2120 ],
2121 "total_duplicated_lines": 20,
2122 "total_duplicated_tokens": 100,
2123 "suggestions": [
2124 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
2125 ]
2126 }]
2127 });
2128
2129 inject_dupes_actions(&mut output);
2130
2131 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
2132 assert_eq!(actions.len(), 3);
2133 assert_eq!(actions[0]["type"], "extract-shared");
2134 assert_eq!(actions[0]["auto_fixable"], false);
2135 assert!(
2136 actions[0]["description"]
2137 .as_str()
2138 .unwrap()
2139 .contains("20 lines")
2140 );
2141 assert_eq!(actions[1]["type"], "apply-suggestion");
2143 assert!(
2144 actions[1]["description"]
2145 .as_str()
2146 .unwrap()
2147 .contains("validation logic")
2148 );
2149 assert_eq!(actions[2]["type"], "suppress-line");
2151 assert_eq!(
2152 actions[2]["comment"],
2153 "// fallow-ignore-next-line code-duplication"
2154 );
2155 }
2156
2157 #[test]
2158 fn clone_group_has_actions() {
2159 let mut output = serde_json::json!({
2160 "clone_groups": [{
2161 "instances": [
2162 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
2163 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
2164 ],
2165 "token_count": 50,
2166 "line_count": 10
2167 }]
2168 });
2169
2170 inject_dupes_actions(&mut output);
2171
2172 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
2173 assert_eq!(actions.len(), 2);
2174 assert_eq!(actions[0]["type"], "extract-shared");
2175 assert!(
2176 actions[0]["description"]
2177 .as_str()
2178 .unwrap()
2179 .contains("10 lines")
2180 );
2181 assert!(
2182 actions[0]["description"]
2183 .as_str()
2184 .unwrap()
2185 .contains("2 instances")
2186 );
2187 assert_eq!(actions[1]["type"], "suppress-line");
2188 }
2189
2190 #[test]
2191 fn dupes_empty_results_no_actions() {
2192 let mut output = serde_json::json!({
2193 "clone_families": [],
2194 "clone_groups": []
2195 });
2196
2197 inject_dupes_actions(&mut output);
2198
2199 assert!(output["clone_families"].as_array().unwrap().is_empty());
2200 assert!(output["clone_groups"].as_array().unwrap().is_empty());
2201 }
2202}