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, normalize_uri};
9use crate::explain;
10use crate::report::grouping::{OwnershipResolver, ResultGroup};
11
12pub(super) fn print_json(
13 results: &AnalysisResults,
14 root: &Path,
15 elapsed: Duration,
16 explain: bool,
17 regression: Option<&crate::regression::RegressionOutcome>,
18 baseline_matched: Option<(usize, usize)>,
19) -> ExitCode {
20 match build_json(results, root, elapsed) {
21 Ok(mut output) => {
22 if let Some(outcome) = regression
23 && let serde_json::Value::Object(ref mut map) = output
24 {
25 map.insert("regression".to_string(), outcome.to_json());
26 }
27 if let Some((entries, matched)) = baseline_matched
28 && let serde_json::Value::Object(ref mut map) = output
29 {
30 map.insert(
31 "baseline".to_string(),
32 serde_json::json!({
33 "entries": entries,
34 "matched": matched,
35 }),
36 );
37 }
38 if explain {
39 insert_meta(&mut output, explain::check_meta());
40 }
41 emit_json(&output, "JSON")
42 }
43 Err(e) => {
44 eprintln!("Error: failed to serialize results: {e}");
45 ExitCode::from(2)
46 }
47 }
48}
49
50#[must_use]
56pub(super) fn print_grouped_json(
57 groups: &[ResultGroup],
58 original: &AnalysisResults,
59 root: &Path,
60 elapsed: Duration,
61 explain: bool,
62 resolver: &OwnershipResolver,
63) -> ExitCode {
64 let root_prefix = format!("{}/", root.display());
65
66 let group_values: Vec<serde_json::Value> = groups
67 .iter()
68 .filter_map(|group| {
69 let mut value = serde_json::to_value(&group.results).ok()?;
70 strip_root_prefix(&mut value, &root_prefix);
71 inject_actions(&mut value);
72
73 if let serde_json::Value::Object(ref mut map) = value {
74 let mut ordered = serde_json::Map::new();
77 ordered.insert("key".to_string(), serde_json::json!(group.key));
78 if let Some(ref owners) = group.owners {
79 ordered.insert("owners".to_string(), serde_json::json!(owners));
80 }
81 ordered.insert(
82 "total_issues".to_string(),
83 serde_json::json!(group.results.total_issues()),
84 );
85 for (k, v) in map.iter() {
86 ordered.insert(k.clone(), v.clone());
87 }
88 Some(serde_json::Value::Object(ordered))
89 } else {
90 Some(value)
91 }
92 })
93 .collect();
94
95 let mut output = serde_json::json!({
96 "schema_version": SCHEMA_VERSION,
97 "version": env!("CARGO_PKG_VERSION"),
98 "elapsed_ms": elapsed.as_millis() as u64,
99 "grouped_by": resolver.mode_label(),
100 "total_issues": original.total_issues(),
101 "groups": group_values,
102 });
103
104 if explain {
105 insert_meta(&mut output, explain::check_meta());
106 }
107
108 emit_json(&output, "JSON")
109}
110
111const SCHEMA_VERSION: u32 = 4;
117
118fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
124 let mut map = serde_json::Map::new();
125 map.insert(
126 "schema_version".to_string(),
127 serde_json::json!(SCHEMA_VERSION),
128 );
129 map.insert(
130 "version".to_string(),
131 serde_json::json!(env!("CARGO_PKG_VERSION")),
132 );
133 map.insert(
134 "elapsed_ms".to_string(),
135 serde_json::json!(elapsed.as_millis()),
136 );
137 if let serde_json::Value::Object(report_map) = report_value {
138 for (key, value) in report_map {
139 map.insert(key, value);
140 }
141 }
142 serde_json::Value::Object(map)
143}
144
145pub fn build_json(
154 results: &AnalysisResults,
155 root: &Path,
156 elapsed: Duration,
157) -> Result<serde_json::Value, serde_json::Error> {
158 let results_value = serde_json::to_value(results)?;
159
160 let mut map = serde_json::Map::new();
161 map.insert(
162 "schema_version".to_string(),
163 serde_json::json!(SCHEMA_VERSION),
164 );
165 map.insert(
166 "version".to_string(),
167 serde_json::json!(env!("CARGO_PKG_VERSION")),
168 );
169 map.insert(
170 "elapsed_ms".to_string(),
171 serde_json::json!(elapsed.as_millis()),
172 );
173 map.insert(
174 "total_issues".to_string(),
175 serde_json::json!(results.total_issues()),
176 );
177
178 if let Some(ref ep) = results.entry_point_summary {
180 let sources: serde_json::Map<String, serde_json::Value> = ep
181 .by_source
182 .iter()
183 .map(|(k, v)| (k.replace(' ', "_"), serde_json::json!(v)))
184 .collect();
185 map.insert(
186 "entry_points".to_string(),
187 serde_json::json!({
188 "total": ep.total,
189 "sources": sources,
190 }),
191 );
192 }
193
194 let summary = serde_json::json!({
196 "total_issues": results.total_issues(),
197 "unused_files": results.unused_files.len(),
198 "unused_exports": results.unused_exports.len(),
199 "unused_types": results.unused_types.len(),
200 "unused_dependencies": results.unused_dependencies.len()
201 + results.unused_dev_dependencies.len()
202 + results.unused_optional_dependencies.len(),
203 "unused_enum_members": results.unused_enum_members.len(),
204 "unused_class_members": results.unused_class_members.len(),
205 "unresolved_imports": results.unresolved_imports.len(),
206 "unlisted_dependencies": results.unlisted_dependencies.len(),
207 "duplicate_exports": results.duplicate_exports.len(),
208 "type_only_dependencies": results.type_only_dependencies.len(),
209 "test_only_dependencies": results.test_only_dependencies.len(),
210 "circular_dependencies": results.circular_dependencies.len(),
211 "boundary_violations": results.boundary_violations.len(),
212 "stale_suppressions": results.stale_suppressions.len(),
213 });
214 map.insert("summary".to_string(), summary);
215
216 if let serde_json::Value::Object(results_map) = results_value {
217 for (key, value) in results_map {
218 map.insert(key, value);
219 }
220 }
221
222 let mut output = serde_json::Value::Object(map);
223 let root_prefix = format!("{}/", root.display());
224 strip_root_prefix(&mut output, &root_prefix);
228 inject_actions(&mut output);
229 Ok(output)
230}
231
232pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
237 match value {
238 serde_json::Value::String(s) => {
239 if let Some(rest) = s.strip_prefix(prefix) {
240 *s = rest.to_string();
241 } else {
242 let normalized = normalize_uri(s);
243 let normalized_prefix = normalize_uri(prefix);
244 if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
245 *s = rest.to_string();
246 }
247 }
248 }
249 serde_json::Value::Array(arr) => {
250 for item in arr {
251 strip_root_prefix(item, prefix);
252 }
253 }
254 serde_json::Value::Object(map) => {
255 for (_, v) in map.iter_mut() {
256 strip_root_prefix(v, prefix);
257 }
258 }
259 _ => {}
260 }
261}
262
263enum SuppressKind {
267 InlineComment,
269 FileComment,
271 ConfigIgnoreDep,
273}
274
275struct ActionSpec {
277 fix_type: &'static str,
278 auto_fixable: bool,
279 description: &'static str,
280 note: Option<&'static str>,
281 suppress: SuppressKind,
282 issue_kind: &'static str,
283}
284
285fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
287 match key {
288 "unused_files" => Some(ActionSpec {
289 fix_type: "delete-file",
290 auto_fixable: false,
291 description: "Delete this file",
292 note: Some(
293 "File deletion may remove runtime functionality not visible to static analysis",
294 ),
295 suppress: SuppressKind::FileComment,
296 issue_kind: "unused-file",
297 }),
298 "unused_exports" => Some(ActionSpec {
299 fix_type: "remove-export",
300 auto_fixable: true,
301 description: "Remove the `export` keyword from the declaration",
302 note: None,
303 suppress: SuppressKind::InlineComment,
304 issue_kind: "unused-export",
305 }),
306 "unused_types" => Some(ActionSpec {
307 fix_type: "remove-export",
308 auto_fixable: true,
309 description: "Remove the `export` (or `export type`) keyword from the type declaration",
310 note: None,
311 suppress: SuppressKind::InlineComment,
312 issue_kind: "unused-type",
313 }),
314 "unused_dependencies" => Some(ActionSpec {
315 fix_type: "remove-dependency",
316 auto_fixable: true,
317 description: "Remove from dependencies in package.json",
318 note: None,
319 suppress: SuppressKind::ConfigIgnoreDep,
320 issue_kind: "unused-dependency",
321 }),
322 "unused_dev_dependencies" => Some(ActionSpec {
323 fix_type: "remove-dependency",
324 auto_fixable: true,
325 description: "Remove from devDependencies in package.json",
326 note: None,
327 suppress: SuppressKind::ConfigIgnoreDep,
328 issue_kind: "unused-dev-dependency",
329 }),
330 "unused_optional_dependencies" => Some(ActionSpec {
331 fix_type: "remove-dependency",
332 auto_fixable: true,
333 description: "Remove from optionalDependencies in package.json",
334 note: None,
335 suppress: SuppressKind::ConfigIgnoreDep,
336 issue_kind: "unused-dependency",
338 }),
339 "unused_enum_members" => Some(ActionSpec {
340 fix_type: "remove-enum-member",
341 auto_fixable: true,
342 description: "Remove this enum member",
343 note: None,
344 suppress: SuppressKind::InlineComment,
345 issue_kind: "unused-enum-member",
346 }),
347 "unused_class_members" => Some(ActionSpec {
348 fix_type: "remove-class-member",
349 auto_fixable: false,
350 description: "Remove this class member",
351 note: Some("Class member may be used via dependency injection or decorators"),
352 suppress: SuppressKind::InlineComment,
353 issue_kind: "unused-class-member",
354 }),
355 "unresolved_imports" => Some(ActionSpec {
356 fix_type: "resolve-import",
357 auto_fixable: false,
358 description: "Fix the import specifier or install the missing module",
359 note: Some("Verify the module path and check tsconfig paths configuration"),
360 suppress: SuppressKind::InlineComment,
361 issue_kind: "unresolved-import",
362 }),
363 "unlisted_dependencies" => Some(ActionSpec {
364 fix_type: "install-dependency",
365 auto_fixable: false,
366 description: "Add this package to dependencies in package.json",
367 note: Some("Verify this package should be a direct dependency before adding"),
368 suppress: SuppressKind::ConfigIgnoreDep,
369 issue_kind: "unlisted-dependency",
370 }),
371 "duplicate_exports" => Some(ActionSpec {
372 fix_type: "remove-duplicate",
373 auto_fixable: false,
374 description: "Keep one canonical export location and remove the others",
375 note: Some("Review all locations to determine which should be the canonical export"),
376 suppress: SuppressKind::InlineComment,
377 issue_kind: "duplicate-export",
378 }),
379 "type_only_dependencies" => Some(ActionSpec {
380 fix_type: "move-to-dev",
381 auto_fixable: false,
382 description: "Move to devDependencies (only type imports are used)",
383 note: Some(
384 "Type imports are erased at runtime so this dependency is not needed in production",
385 ),
386 suppress: SuppressKind::ConfigIgnoreDep,
387 issue_kind: "type-only-dependency",
388 }),
389 "test_only_dependencies" => Some(ActionSpec {
390 fix_type: "move-to-dev",
391 auto_fixable: false,
392 description: "Move to devDependencies (only test files import this)",
393 note: Some(
394 "Only test files import this package so it does not need to be a production dependency",
395 ),
396 suppress: SuppressKind::ConfigIgnoreDep,
397 issue_kind: "test-only-dependency",
398 }),
399 "circular_dependencies" => Some(ActionSpec {
400 fix_type: "refactor-cycle",
401 auto_fixable: false,
402 description: "Extract shared logic into a separate module to break the cycle",
403 note: Some(
404 "Circular imports can cause initialization issues and make code harder to reason about",
405 ),
406 suppress: SuppressKind::InlineComment,
407 issue_kind: "circular-dependency",
408 }),
409 "boundary_violations" => Some(ActionSpec {
410 fix_type: "refactor-boundary",
411 auto_fixable: false,
412 description: "Move the import through an allowed zone or restructure the dependency",
413 note: Some(
414 "This import crosses an architecture boundary that is not permitted by the configured rules",
415 ),
416 suppress: SuppressKind::InlineComment,
417 issue_kind: "boundary-violation",
418 }),
419 _ => None,
420 }
421}
422
423fn build_actions(
425 item: &serde_json::Value,
426 issue_key: &str,
427 spec: &ActionSpec,
428) -> serde_json::Value {
429 let mut actions = Vec::with_capacity(2);
430
431 let mut fix_action = serde_json::json!({
433 "type": spec.fix_type,
434 "auto_fixable": spec.auto_fixable,
435 "description": spec.description,
436 });
437 if let Some(note) = spec.note {
438 fix_action["note"] = serde_json::json!(note);
439 }
440 if (issue_key == "unused_exports" || issue_key == "unused_types")
442 && item
443 .get("is_re_export")
444 .and_then(serde_json::Value::as_bool)
445 == Some(true)
446 {
447 fix_action["note"] = serde_json::json!(
448 "This finding originates from a re-export; verify it is not part of your public API before removing"
449 );
450 }
451 actions.push(fix_action);
452
453 match spec.suppress {
455 SuppressKind::InlineComment => {
456 let mut suppress = serde_json::json!({
457 "type": "suppress-line",
458 "auto_fixable": false,
459 "description": "Suppress with an inline comment above the line",
460 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
461 });
462 if issue_key == "duplicate_exports" {
464 suppress["scope"] = serde_json::json!("per-location");
465 }
466 actions.push(suppress);
467 }
468 SuppressKind::FileComment => {
469 actions.push(serde_json::json!({
470 "type": "suppress-file",
471 "auto_fixable": false,
472 "description": "Suppress with a file-level comment at the top of the file",
473 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
474 }));
475 }
476 SuppressKind::ConfigIgnoreDep => {
477 let pkg = item
479 .get("package_name")
480 .and_then(serde_json::Value::as_str)
481 .unwrap_or("package-name");
482 actions.push(serde_json::json!({
483 "type": "add-to-config",
484 "auto_fixable": false,
485 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
486 "config_key": "ignoreDependencies",
487 "value": pkg,
488 }));
489 }
490 }
491
492 serde_json::Value::Array(actions)
493}
494
495fn inject_actions(output: &mut serde_json::Value) {
500 let Some(map) = output.as_object_mut() else {
501 return;
502 };
503
504 for (key, value) in map.iter_mut() {
505 let Some(spec) = actions_for_issue_type(key) else {
506 continue;
507 };
508 let Some(arr) = value.as_array_mut() else {
509 continue;
510 };
511 for item in arr {
512 let actions = build_actions(item, key, &spec);
513 if let serde_json::Value::Object(obj) = item {
514 obj.insert("actions".to_string(), actions);
515 }
516 }
517 }
518}
519
520pub fn build_baseline_deltas_json<'a>(
528 total_delta: i64,
529 per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
530) -> serde_json::Value {
531 let mut per_cat = serde_json::Map::new();
532 for (cat, current, baseline, delta) in per_category {
533 per_cat.insert(
534 cat.to_string(),
535 serde_json::json!({
536 "current": current,
537 "baseline": baseline,
538 "delta": delta,
539 }),
540 );
541 }
542 serde_json::json!({
543 "total_delta": total_delta,
544 "per_category": per_cat
545 })
546}
547
548#[allow(
553 clippy::redundant_pub_crate,
554 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
555)]
556pub(crate) fn inject_health_actions(output: &mut serde_json::Value) {
557 let Some(map) = output.as_object_mut() else {
558 return;
559 };
560
561 if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
563 for item in findings {
564 let actions = build_health_finding_actions(item);
565 if let serde_json::Value::Object(obj) = item {
566 obj.insert("actions".to_string(), actions);
567 }
568 }
569 }
570
571 if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
573 for item in targets {
574 let actions = build_refactoring_target_actions(item);
575 if let serde_json::Value::Object(obj) = item {
576 obj.insert("actions".to_string(), actions);
577 }
578 }
579 }
580
581 if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
583 for item in hotspots {
584 let actions = build_hotspot_actions(item);
585 if let serde_json::Value::Object(obj) = item {
586 obj.insert("actions".to_string(), actions);
587 }
588 }
589 }
590
591 if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
593 if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
594 for item in files {
595 let actions = build_untested_file_actions(item);
596 if let serde_json::Value::Object(obj) = item {
597 obj.insert("actions".to_string(), actions);
598 }
599 }
600 }
601 if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
602 for item in exports {
603 let actions = build_untested_export_actions(item);
604 if let serde_json::Value::Object(obj) = item {
605 obj.insert("actions".to_string(), actions);
606 }
607 }
608 }
609 }
610
611 }
616
617fn build_health_finding_actions(item: &serde_json::Value) -> serde_json::Value {
619 let name = item
620 .get("name")
621 .and_then(serde_json::Value::as_str)
622 .unwrap_or("function");
623
624 let mut actions = vec![serde_json::json!({
625 "type": "refactor-function",
626 "auto_fixable": false,
627 "description": format!("Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"),
628 "note": "Consider splitting into smaller functions with single responsibilities",
629 })];
630
631 actions.push(serde_json::json!({
632 "type": "suppress-line",
633 "auto_fixable": false,
634 "description": "Suppress with an inline comment above the function declaration",
635 "comment": "// fallow-ignore-next-line complexity",
636 "placement": "above-function-declaration",
637 }));
638
639 serde_json::Value::Array(actions)
640}
641
642fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
644 let path = item
645 .get("path")
646 .and_then(serde_json::Value::as_str)
647 .unwrap_or("file");
648
649 let mut actions = vec![
650 serde_json::json!({
651 "type": "refactor-file",
652 "auto_fixable": false,
653 "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
654 "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
655 }),
656 serde_json::json!({
657 "type": "add-tests",
658 "auto_fixable": false,
659 "description": format!("Add test coverage for `{path}` to reduce change risk"),
660 "note": "Frequently changed complex files benefit most from comprehensive test coverage",
661 }),
662 ];
663
664 if let Some(ownership) = item.get("ownership") {
665 if ownership
667 .get("bus_factor")
668 .and_then(serde_json::Value::as_u64)
669 == Some(1)
670 {
671 let top = ownership.get("top_contributor");
672 let owner = top
673 .and_then(|t| t.get("identifier"))
674 .and_then(serde_json::Value::as_str)
675 .unwrap_or("the sole contributor");
676 let commits = top
681 .and_then(|t| t.get("commits"))
682 .and_then(serde_json::Value::as_u64)
683 .unwrap_or(0);
684 let suggested: Vec<String> = ownership
690 .get("suggested_reviewers")
691 .and_then(serde_json::Value::as_array)
692 .map(|arr| {
693 arr.iter()
694 .filter_map(|r| {
695 r.get("identifier")
696 .and_then(serde_json::Value::as_str)
697 .map(String::from)
698 })
699 .collect()
700 })
701 .unwrap_or_default();
702 let mut low_bus_action = serde_json::json!({
703 "type": "low-bus-factor",
704 "auto_fixable": false,
705 "description": format!(
706 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
707 ),
708 });
709 if !suggested.is_empty() {
710 let list = suggested
711 .iter()
712 .map(|s| format!("@{s}"))
713 .collect::<Vec<_>>()
714 .join(", ");
715 low_bus_action["note"] =
716 serde_json::Value::String(format!("Candidate reviewers: {list}"));
717 } else if commits < 5 {
718 low_bus_action["note"] = serde_json::Value::String(
719 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
720 .to_string(),
721 );
722 }
723 actions.push(low_bus_action);
725 }
726
727 if ownership
730 .get("unowned")
731 .and_then(serde_json::Value::as_bool)
732 == Some(true)
733 {
734 actions.push(serde_json::json!({
735 "type": "unowned-hotspot",
736 "auto_fixable": false,
737 "description": format!("Add a CODEOWNERS entry for `{path}`"),
738 "note": "Frequently-changed files without declared owners create review bottlenecks",
739 "suggested_pattern": suggest_codeowners_pattern(path),
740 "heuristic": "directory-deepest",
741 }));
742 }
743
744 if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
747 let reason = ownership
748 .get("drift_reason")
749 .and_then(serde_json::Value::as_str)
750 .unwrap_or("ownership has shifted from the original author");
751 actions.push(serde_json::json!({
752 "type": "ownership-drift",
753 "auto_fixable": false,
754 "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
755 "note": "Drift suggests the declared or original owner is no longer the right reviewer",
756 }));
757 }
758 }
759
760 serde_json::Value::Array(actions)
761}
762
763fn suggest_codeowners_pattern(path: &str) -> String {
776 let normalized = path.replace('\\', "/");
777 let trimmed = normalized.trim_start_matches('/');
778 let mut components: Vec<&str> = trimmed.split('/').collect();
779 components.pop(); if components.is_empty() {
781 return format!("/{trimmed}");
782 }
783 format!("/{}/", components.join("/"))
784}
785
786fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
788 let recommendation = item
789 .get("recommendation")
790 .and_then(serde_json::Value::as_str)
791 .unwrap_or("Apply the recommended refactoring");
792
793 let category = item
794 .get("category")
795 .and_then(serde_json::Value::as_str)
796 .unwrap_or("refactoring");
797
798 let mut actions = vec![serde_json::json!({
799 "type": "apply-refactoring",
800 "auto_fixable": false,
801 "description": recommendation,
802 "category": category,
803 })];
804
805 if item.get("evidence").is_some() {
807 actions.push(serde_json::json!({
808 "type": "suppress-line",
809 "auto_fixable": false,
810 "description": "Suppress the underlying complexity finding",
811 "comment": "// fallow-ignore-next-line complexity",
812 }));
813 }
814
815 serde_json::Value::Array(actions)
816}
817
818fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
820 let path = item
821 .get("path")
822 .and_then(serde_json::Value::as_str)
823 .unwrap_or("file");
824
825 serde_json::Value::Array(vec![
826 serde_json::json!({
827 "type": "add-tests",
828 "auto_fixable": false,
829 "description": format!("Add test coverage for `{path}`"),
830 "note": "No test dependency path reaches this runtime file",
831 }),
832 serde_json::json!({
833 "type": "suppress-file",
834 "auto_fixable": false,
835 "description": format!("Suppress coverage gap reporting for `{path}`"),
836 "comment": "// fallow-ignore-file coverage-gaps",
837 }),
838 ])
839}
840
841fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
843 let path = item
844 .get("path")
845 .and_then(serde_json::Value::as_str)
846 .unwrap_or("file");
847 let export_name = item
848 .get("export_name")
849 .and_then(serde_json::Value::as_str)
850 .unwrap_or("export");
851
852 serde_json::Value::Array(vec![
853 serde_json::json!({
854 "type": "add-test-import",
855 "auto_fixable": false,
856 "description": format!("Import and test `{export_name}` from `{path}`"),
857 "note": "This export is runtime-reachable but no test-reachable module references it",
858 }),
859 serde_json::json!({
860 "type": "suppress-file",
861 "auto_fixable": false,
862 "description": format!("Suppress coverage gap reporting for `{path}`"),
863 "comment": "// fallow-ignore-file coverage-gaps",
864 }),
865 ])
866}
867
868#[allow(
875 clippy::redundant_pub_crate,
876 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
877)]
878pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
879 let Some(map) = output.as_object_mut() else {
880 return;
881 };
882
883 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
885 for item in families {
886 let actions = build_clone_family_actions(item);
887 if let serde_json::Value::Object(obj) = item {
888 obj.insert("actions".to_string(), actions);
889 }
890 }
891 }
892
893 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
895 for item in groups {
896 let actions = build_clone_group_actions(item);
897 if let serde_json::Value::Object(obj) = item {
898 obj.insert("actions".to_string(), actions);
899 }
900 }
901 }
902}
903
904fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
906 let group_count = item
907 .get("groups")
908 .and_then(|v| v.as_array())
909 .map_or(0, Vec::len);
910
911 let total_lines = item
912 .get("total_duplicated_lines")
913 .and_then(serde_json::Value::as_u64)
914 .unwrap_or(0);
915
916 let mut actions = vec![serde_json::json!({
917 "type": "extract-shared",
918 "auto_fixable": false,
919 "description": format!(
920 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
921 if group_count == 1 { "" } else { "s" }
922 ),
923 "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
924 })];
925
926 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
928 for suggestion in suggestions {
929 if let Some(desc) = suggestion
930 .get("description")
931 .and_then(serde_json::Value::as_str)
932 {
933 actions.push(serde_json::json!({
934 "type": "apply-suggestion",
935 "auto_fixable": false,
936 "description": desc,
937 }));
938 }
939 }
940 }
941
942 actions.push(serde_json::json!({
943 "type": "suppress-line",
944 "auto_fixable": false,
945 "description": "Suppress with an inline comment above the duplicated code",
946 "comment": "// fallow-ignore-next-line code-duplication",
947 }));
948
949 serde_json::Value::Array(actions)
950}
951
952fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
954 let instance_count = item
955 .get("instances")
956 .and_then(|v| v.as_array())
957 .map_or(0, Vec::len);
958
959 let line_count = item
960 .get("line_count")
961 .and_then(serde_json::Value::as_u64)
962 .unwrap_or(0);
963
964 let actions = vec![
965 serde_json::json!({
966 "type": "extract-shared",
967 "auto_fixable": false,
968 "description": format!(
969 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
970 if instance_count == 1 { "" } else { "s" }
971 ),
972 }),
973 serde_json::json!({
974 "type": "suppress-line",
975 "auto_fixable": false,
976 "description": "Suppress with an inline comment above the duplicated code",
977 "comment": "// fallow-ignore-next-line code-duplication",
978 }),
979 ];
980
981 serde_json::Value::Array(actions)
982}
983
984fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
986 if let serde_json::Value::Object(map) = output {
987 map.insert("_meta".to_string(), meta);
988 }
989}
990
991pub fn build_health_json(
999 report: &crate::health_types::HealthReport,
1000 root: &Path,
1001 elapsed: Duration,
1002 explain: bool,
1003) -> Result<serde_json::Value, serde_json::Error> {
1004 let report_value = serde_json::to_value(report)?;
1005 let mut output = build_json_envelope(report_value, elapsed);
1006 let root_prefix = format!("{}/", root.display());
1007 strip_root_prefix(&mut output, &root_prefix);
1008 inject_health_actions(&mut output);
1009 if explain {
1010 insert_meta(&mut output, explain::health_meta());
1011 }
1012 Ok(output)
1013}
1014
1015pub(super) fn print_health_json(
1016 report: &crate::health_types::HealthReport,
1017 root: &Path,
1018 elapsed: Duration,
1019 explain: bool,
1020) -> ExitCode {
1021 match build_health_json(report, root, elapsed, explain) {
1022 Ok(output) => emit_json(&output, "JSON"),
1023 Err(e) => {
1024 eprintln!("Error: failed to serialize health report: {e}");
1025 ExitCode::from(2)
1026 }
1027 }
1028}
1029
1030pub(super) fn print_duplication_json(
1031 report: &DuplicationReport,
1032 root: &Path,
1033 elapsed: Duration,
1034 explain: bool,
1035) -> ExitCode {
1036 let report_value = match serde_json::to_value(report) {
1037 Ok(v) => v,
1038 Err(e) => {
1039 eprintln!("Error: failed to serialize duplication report: {e}");
1040 return ExitCode::from(2);
1041 }
1042 };
1043
1044 let mut output = build_json_envelope(report_value, elapsed);
1045 let root_prefix = format!("{}/", root.display());
1046 strip_root_prefix(&mut output, &root_prefix);
1047 inject_dupes_actions(&mut output);
1048
1049 if explain {
1050 insert_meta(&mut output, explain::dupes_meta());
1051 }
1052
1053 emit_json(&output, "JSON")
1054}
1055
1056pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1057 match serde_json::to_string_pretty(value) {
1058 Ok(json) => println!("{json}"),
1059 Err(e) => {
1060 eprintln!("Error: failed to serialize trace output: {e}");
1061 #[expect(
1062 clippy::exit,
1063 reason = "fatal serialization error requires immediate exit"
1064 )]
1065 std::process::exit(2);
1066 }
1067 }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use crate::health_types::{
1074 ProductionCoverageAction, ProductionCoverageConfidence, ProductionCoverageEvidence,
1075 ProductionCoverageFinding, ProductionCoverageHotPath, ProductionCoverageMessage,
1076 ProductionCoverageReport, ProductionCoverageReportVerdict, ProductionCoverageSummary,
1077 ProductionCoverageVerdict, ProductionCoverageWatermark,
1078 };
1079 use crate::report::test_helpers::sample_results;
1080 use fallow_core::extract::MemberKind;
1081 use fallow_core::results::*;
1082 use std::path::PathBuf;
1083 use std::time::Duration;
1084
1085 #[test]
1086 fn json_output_has_metadata_fields() {
1087 let root = PathBuf::from("/project");
1088 let results = AnalysisResults::default();
1089 let elapsed = Duration::from_millis(123);
1090 let output = build_json(&results, &root, elapsed).expect("should serialize");
1091
1092 assert_eq!(output["schema_version"], 4);
1093 assert!(output["version"].is_string());
1094 assert_eq!(output["elapsed_ms"], 123);
1095 assert_eq!(output["total_issues"], 0);
1096 }
1097
1098 #[test]
1099 fn json_output_includes_issue_arrays() {
1100 let root = PathBuf::from("/project");
1101 let results = sample_results(&root);
1102 let elapsed = Duration::from_millis(50);
1103 let output = build_json(&results, &root, elapsed).expect("should serialize");
1104
1105 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1106 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1107 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1108 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1109 assert_eq!(
1110 output["unused_dev_dependencies"].as_array().unwrap().len(),
1111 1
1112 );
1113 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1114 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1115 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1116 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1117 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1118 assert_eq!(
1119 output["type_only_dependencies"].as_array().unwrap().len(),
1120 1
1121 );
1122 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1123 }
1124
1125 #[test]
1126 fn health_json_includes_production_coverage_with_relative_paths_and_actions() {
1127 let root = PathBuf::from("/project");
1128 let report = crate::health_types::HealthReport {
1129 production_coverage: Some(ProductionCoverageReport {
1130 verdict: ProductionCoverageReportVerdict::ColdCodeDetected,
1131 summary: ProductionCoverageSummary {
1132 functions_tracked: 3,
1133 functions_hit: 1,
1134 functions_unhit: 1,
1135 functions_untracked: 1,
1136 coverage_percent: 33.3,
1137 trace_count: 2_847_291,
1138 period_days: 30,
1139 deployments_seen: 14,
1140 capture_quality: Some(crate::health_types::ProductionCoverageCaptureQuality {
1141 window_seconds: 720,
1142 instances_observed: 1,
1143 lazy_parse_warning: true,
1144 untracked_ratio_percent: 42.5,
1145 }),
1146 },
1147 findings: vec![ProductionCoverageFinding {
1148 id: "fallow:prod:deadbeef".to_owned(),
1149 path: root.join("src/cold.ts"),
1150 function: "coldPath".to_owned(),
1151 line: 12,
1152 verdict: ProductionCoverageVerdict::ReviewRequired,
1153 invocations: Some(0),
1154 confidence: ProductionCoverageConfidence::Medium,
1155 evidence: ProductionCoverageEvidence {
1156 static_status: "used".to_owned(),
1157 test_coverage: "not_covered".to_owned(),
1158 v8_tracking: "tracked".to_owned(),
1159 untracked_reason: None,
1160 observation_days: 30,
1161 deployments_observed: 14,
1162 },
1163 actions: vec![ProductionCoverageAction {
1164 kind: "review-deletion".to_owned(),
1165 description: "Tracked in production coverage with zero invocations."
1166 .to_owned(),
1167 auto_fixable: false,
1168 }],
1169 }],
1170 hot_paths: vec![ProductionCoverageHotPath {
1171 id: "fallow:hot:cafebabe".to_owned(),
1172 path: root.join("src/hot.ts"),
1173 function: "hotPath".to_owned(),
1174 line: 3,
1175 invocations: 250,
1176 percentile: 99,
1177 actions: vec![],
1178 }],
1179 watermark: Some(ProductionCoverageWatermark::LicenseExpiredGrace),
1180 warnings: vec![ProductionCoverageMessage {
1181 code: "partial-merge".to_owned(),
1182 message: "Merged coverage omitted one chunk.".to_owned(),
1183 }],
1184 }),
1185 ..Default::default()
1186 };
1187
1188 let report_value = serde_json::to_value(&report).expect("should serialize health report");
1189 let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1190 strip_root_prefix(&mut output, "/project/");
1191 inject_health_actions(&mut output);
1192
1193 assert_eq!(
1194 output["production_coverage"]["verdict"],
1195 serde_json::Value::String("cold-code-detected".to_owned())
1196 );
1197 assert_eq!(
1198 output["production_coverage"]["summary"]["functions_tracked"],
1199 serde_json::Value::from(3)
1200 );
1201 assert_eq!(
1202 output["production_coverage"]["summary"]["coverage_percent"],
1203 serde_json::Value::from(33.3)
1204 );
1205 let finding = &output["production_coverage"]["findings"][0];
1206 assert_eq!(finding["path"], "src/cold.ts");
1207 assert_eq!(finding["verdict"], "review_required");
1208 assert_eq!(finding["id"], "fallow:prod:deadbeef");
1209 assert_eq!(finding["actions"][0]["type"], "review-deletion");
1210 let hot_path = &output["production_coverage"]["hot_paths"][0];
1211 assert_eq!(hot_path["path"], "src/hot.ts");
1212 assert_eq!(hot_path["function"], "hotPath");
1213 assert_eq!(hot_path["percentile"], 99);
1214 assert_eq!(
1215 output["production_coverage"]["watermark"],
1216 serde_json::Value::String("license-expired-grace".to_owned())
1217 );
1218 assert_eq!(
1219 output["production_coverage"]["warnings"][0]["code"],
1220 serde_json::Value::String("partial-merge".to_owned())
1221 );
1222 }
1223
1224 #[test]
1225 fn json_metadata_fields_appear_first() {
1226 let root = PathBuf::from("/project");
1227 let results = AnalysisResults::default();
1228 let elapsed = Duration::from_millis(0);
1229 let output = build_json(&results, &root, elapsed).expect("should serialize");
1230 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1231 assert_eq!(keys[0], "schema_version");
1232 assert_eq!(keys[1], "version");
1233 assert_eq!(keys[2], "elapsed_ms");
1234 assert_eq!(keys[3], "total_issues");
1235 }
1236
1237 #[test]
1238 fn json_total_issues_matches_results() {
1239 let root = PathBuf::from("/project");
1240 let results = sample_results(&root);
1241 let total = results.total_issues();
1242 let elapsed = Duration::from_millis(0);
1243 let output = build_json(&results, &root, elapsed).expect("should serialize");
1244
1245 assert_eq!(output["total_issues"], total);
1246 }
1247
1248 #[test]
1249 fn json_unused_export_contains_expected_fields() {
1250 let root = PathBuf::from("/project");
1251 let mut results = AnalysisResults::default();
1252 results.unused_exports.push(UnusedExport {
1253 path: root.join("src/utils.ts"),
1254 export_name: "helperFn".to_string(),
1255 is_type_only: false,
1256 line: 10,
1257 col: 4,
1258 span_start: 120,
1259 is_re_export: false,
1260 });
1261 let elapsed = Duration::from_millis(0);
1262 let output = build_json(&results, &root, elapsed).expect("should serialize");
1263
1264 let export = &output["unused_exports"][0];
1265 assert_eq!(export["export_name"], "helperFn");
1266 assert_eq!(export["line"], 10);
1267 assert_eq!(export["col"], 4);
1268 assert_eq!(export["is_type_only"], false);
1269 assert_eq!(export["span_start"], 120);
1270 assert_eq!(export["is_re_export"], false);
1271 }
1272
1273 #[test]
1274 fn json_serializes_to_valid_json() {
1275 let root = PathBuf::from("/project");
1276 let results = sample_results(&root);
1277 let elapsed = Duration::from_millis(42);
1278 let output = build_json(&results, &root, elapsed).expect("should serialize");
1279
1280 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1281 let reparsed: serde_json::Value =
1282 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1283 assert_eq!(reparsed, output);
1284 }
1285
1286 #[test]
1289 fn json_empty_results_produce_valid_structure() {
1290 let root = PathBuf::from("/project");
1291 let results = AnalysisResults::default();
1292 let elapsed = Duration::from_millis(0);
1293 let output = build_json(&results, &root, elapsed).expect("should serialize");
1294
1295 assert_eq!(output["total_issues"], 0);
1296 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1297 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1298 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1299 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1300 assert_eq!(
1301 output["unused_dev_dependencies"].as_array().unwrap().len(),
1302 0
1303 );
1304 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1305 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1306 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1307 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1308 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1309 assert_eq!(
1310 output["type_only_dependencies"].as_array().unwrap().len(),
1311 0
1312 );
1313 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1314 }
1315
1316 #[test]
1317 fn json_empty_results_round_trips_through_string() {
1318 let root = PathBuf::from("/project");
1319 let results = AnalysisResults::default();
1320 let elapsed = Duration::from_millis(0);
1321 let output = build_json(&results, &root, elapsed).expect("should serialize");
1322
1323 let json_str = serde_json::to_string(&output).expect("should stringify");
1324 let reparsed: serde_json::Value =
1325 serde_json::from_str(&json_str).expect("should parse back");
1326 assert_eq!(reparsed["total_issues"], 0);
1327 }
1328
1329 #[test]
1332 fn json_paths_are_relative_to_root() {
1333 let root = PathBuf::from("/project");
1334 let mut results = AnalysisResults::default();
1335 results.unused_files.push(UnusedFile {
1336 path: root.join("src/deep/nested/file.ts"),
1337 });
1338 let elapsed = Duration::from_millis(0);
1339 let output = build_json(&results, &root, elapsed).expect("should serialize");
1340
1341 let path = output["unused_files"][0]["path"].as_str().unwrap();
1342 assert_eq!(path, "src/deep/nested/file.ts");
1343 assert!(!path.starts_with("/project"));
1344 }
1345
1346 #[test]
1347 fn json_strips_root_from_nested_locations() {
1348 let root = PathBuf::from("/project");
1349 let mut results = AnalysisResults::default();
1350 results.unlisted_dependencies.push(UnlistedDependency {
1351 package_name: "chalk".to_string(),
1352 imported_from: vec![ImportSite {
1353 path: root.join("src/cli.ts"),
1354 line: 2,
1355 col: 0,
1356 }],
1357 });
1358 let elapsed = Duration::from_millis(0);
1359 let output = build_json(&results, &root, elapsed).expect("should serialize");
1360
1361 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1362 .as_str()
1363 .unwrap();
1364 assert_eq!(site_path, "src/cli.ts");
1365 }
1366
1367 #[test]
1368 fn json_strips_root_from_duplicate_export_locations() {
1369 let root = PathBuf::from("/project");
1370 let mut results = AnalysisResults::default();
1371 results.duplicate_exports.push(DuplicateExport {
1372 export_name: "Config".to_string(),
1373 locations: vec![
1374 DuplicateLocation {
1375 path: root.join("src/config.ts"),
1376 line: 15,
1377 col: 0,
1378 },
1379 DuplicateLocation {
1380 path: root.join("src/types.ts"),
1381 line: 30,
1382 col: 0,
1383 },
1384 ],
1385 });
1386 let elapsed = Duration::from_millis(0);
1387 let output = build_json(&results, &root, elapsed).expect("should serialize");
1388
1389 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1390 .as_str()
1391 .unwrap();
1392 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1393 .as_str()
1394 .unwrap();
1395 assert_eq!(loc0, "src/config.ts");
1396 assert_eq!(loc1, "src/types.ts");
1397 }
1398
1399 #[test]
1400 fn json_strips_root_from_circular_dependency_files() {
1401 let root = PathBuf::from("/project");
1402 let mut results = AnalysisResults::default();
1403 results.circular_dependencies.push(CircularDependency {
1404 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1405 length: 2,
1406 line: 1,
1407 col: 0,
1408 is_cross_package: false,
1409 });
1410 let elapsed = Duration::from_millis(0);
1411 let output = build_json(&results, &root, elapsed).expect("should serialize");
1412
1413 let files = output["circular_dependencies"][0]["files"]
1414 .as_array()
1415 .unwrap();
1416 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1417 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1418 }
1419
1420 #[test]
1421 fn json_path_outside_root_not_stripped() {
1422 let root = PathBuf::from("/project");
1423 let mut results = AnalysisResults::default();
1424 results.unused_files.push(UnusedFile {
1425 path: PathBuf::from("/other/project/src/file.ts"),
1426 });
1427 let elapsed = Duration::from_millis(0);
1428 let output = build_json(&results, &root, elapsed).expect("should serialize");
1429
1430 let path = output["unused_files"][0]["path"].as_str().unwrap();
1431 assert!(path.contains("/other/project/"));
1432 }
1433
1434 #[test]
1437 fn json_unused_file_contains_path() {
1438 let root = PathBuf::from("/project");
1439 let mut results = AnalysisResults::default();
1440 results.unused_files.push(UnusedFile {
1441 path: root.join("src/orphan.ts"),
1442 });
1443 let elapsed = Duration::from_millis(0);
1444 let output = build_json(&results, &root, elapsed).expect("should serialize");
1445
1446 let file = &output["unused_files"][0];
1447 assert_eq!(file["path"], "src/orphan.ts");
1448 }
1449
1450 #[test]
1451 fn json_unused_type_contains_expected_fields() {
1452 let root = PathBuf::from("/project");
1453 let mut results = AnalysisResults::default();
1454 results.unused_types.push(UnusedExport {
1455 path: root.join("src/types.ts"),
1456 export_name: "OldInterface".to_string(),
1457 is_type_only: true,
1458 line: 20,
1459 col: 0,
1460 span_start: 300,
1461 is_re_export: false,
1462 });
1463 let elapsed = Duration::from_millis(0);
1464 let output = build_json(&results, &root, elapsed).expect("should serialize");
1465
1466 let typ = &output["unused_types"][0];
1467 assert_eq!(typ["export_name"], "OldInterface");
1468 assert_eq!(typ["is_type_only"], true);
1469 assert_eq!(typ["line"], 20);
1470 assert_eq!(typ["path"], "src/types.ts");
1471 }
1472
1473 #[test]
1474 fn json_unused_dependency_contains_expected_fields() {
1475 let root = PathBuf::from("/project");
1476 let mut results = AnalysisResults::default();
1477 results.unused_dependencies.push(UnusedDependency {
1478 package_name: "axios".to_string(),
1479 location: DependencyLocation::Dependencies,
1480 path: root.join("package.json"),
1481 line: 10,
1482 });
1483 let elapsed = Duration::from_millis(0);
1484 let output = build_json(&results, &root, elapsed).expect("should serialize");
1485
1486 let dep = &output["unused_dependencies"][0];
1487 assert_eq!(dep["package_name"], "axios");
1488 assert_eq!(dep["line"], 10);
1489 }
1490
1491 #[test]
1492 fn json_unused_dev_dependency_contains_expected_fields() {
1493 let root = PathBuf::from("/project");
1494 let mut results = AnalysisResults::default();
1495 results.unused_dev_dependencies.push(UnusedDependency {
1496 package_name: "vitest".to_string(),
1497 location: DependencyLocation::DevDependencies,
1498 path: root.join("package.json"),
1499 line: 15,
1500 });
1501 let elapsed = Duration::from_millis(0);
1502 let output = build_json(&results, &root, elapsed).expect("should serialize");
1503
1504 let dep = &output["unused_dev_dependencies"][0];
1505 assert_eq!(dep["package_name"], "vitest");
1506 }
1507
1508 #[test]
1509 fn json_unused_optional_dependency_contains_expected_fields() {
1510 let root = PathBuf::from("/project");
1511 let mut results = AnalysisResults::default();
1512 results.unused_optional_dependencies.push(UnusedDependency {
1513 package_name: "fsevents".to_string(),
1514 location: DependencyLocation::OptionalDependencies,
1515 path: root.join("package.json"),
1516 line: 12,
1517 });
1518 let elapsed = Duration::from_millis(0);
1519 let output = build_json(&results, &root, elapsed).expect("should serialize");
1520
1521 let dep = &output["unused_optional_dependencies"][0];
1522 assert_eq!(dep["package_name"], "fsevents");
1523 assert_eq!(output["total_issues"], 1);
1524 }
1525
1526 #[test]
1527 fn json_unused_enum_member_contains_expected_fields() {
1528 let root = PathBuf::from("/project");
1529 let mut results = AnalysisResults::default();
1530 results.unused_enum_members.push(UnusedMember {
1531 path: root.join("src/enums.ts"),
1532 parent_name: "Color".to_string(),
1533 member_name: "Purple".to_string(),
1534 kind: MemberKind::EnumMember,
1535 line: 5,
1536 col: 2,
1537 });
1538 let elapsed = Duration::from_millis(0);
1539 let output = build_json(&results, &root, elapsed).expect("should serialize");
1540
1541 let member = &output["unused_enum_members"][0];
1542 assert_eq!(member["parent_name"], "Color");
1543 assert_eq!(member["member_name"], "Purple");
1544 assert_eq!(member["line"], 5);
1545 assert_eq!(member["path"], "src/enums.ts");
1546 }
1547
1548 #[test]
1549 fn json_unused_class_member_contains_expected_fields() {
1550 let root = PathBuf::from("/project");
1551 let mut results = AnalysisResults::default();
1552 results.unused_class_members.push(UnusedMember {
1553 path: root.join("src/api.ts"),
1554 parent_name: "ApiClient".to_string(),
1555 member_name: "deprecatedFetch".to_string(),
1556 kind: MemberKind::ClassMethod,
1557 line: 100,
1558 col: 4,
1559 });
1560 let elapsed = Duration::from_millis(0);
1561 let output = build_json(&results, &root, elapsed).expect("should serialize");
1562
1563 let member = &output["unused_class_members"][0];
1564 assert_eq!(member["parent_name"], "ApiClient");
1565 assert_eq!(member["member_name"], "deprecatedFetch");
1566 assert_eq!(member["line"], 100);
1567 }
1568
1569 #[test]
1570 fn json_unresolved_import_contains_expected_fields() {
1571 let root = PathBuf::from("/project");
1572 let mut results = AnalysisResults::default();
1573 results.unresolved_imports.push(UnresolvedImport {
1574 path: root.join("src/app.ts"),
1575 specifier: "@acme/missing-pkg".to_string(),
1576 line: 7,
1577 col: 0,
1578 specifier_col: 0,
1579 });
1580 let elapsed = Duration::from_millis(0);
1581 let output = build_json(&results, &root, elapsed).expect("should serialize");
1582
1583 let import = &output["unresolved_imports"][0];
1584 assert_eq!(import["specifier"], "@acme/missing-pkg");
1585 assert_eq!(import["line"], 7);
1586 assert_eq!(import["path"], "src/app.ts");
1587 }
1588
1589 #[test]
1590 fn json_unlisted_dependency_contains_import_sites() {
1591 let root = PathBuf::from("/project");
1592 let mut results = AnalysisResults::default();
1593 results.unlisted_dependencies.push(UnlistedDependency {
1594 package_name: "dotenv".to_string(),
1595 imported_from: vec![
1596 ImportSite {
1597 path: root.join("src/config.ts"),
1598 line: 1,
1599 col: 0,
1600 },
1601 ImportSite {
1602 path: root.join("src/server.ts"),
1603 line: 3,
1604 col: 0,
1605 },
1606 ],
1607 });
1608 let elapsed = Duration::from_millis(0);
1609 let output = build_json(&results, &root, elapsed).expect("should serialize");
1610
1611 let dep = &output["unlisted_dependencies"][0];
1612 assert_eq!(dep["package_name"], "dotenv");
1613 let sites = dep["imported_from"].as_array().unwrap();
1614 assert_eq!(sites.len(), 2);
1615 assert_eq!(sites[0]["path"], "src/config.ts");
1616 assert_eq!(sites[1]["path"], "src/server.ts");
1617 }
1618
1619 #[test]
1620 fn json_duplicate_export_contains_locations() {
1621 let root = PathBuf::from("/project");
1622 let mut results = AnalysisResults::default();
1623 results.duplicate_exports.push(DuplicateExport {
1624 export_name: "Button".to_string(),
1625 locations: vec![
1626 DuplicateLocation {
1627 path: root.join("src/ui.ts"),
1628 line: 10,
1629 col: 0,
1630 },
1631 DuplicateLocation {
1632 path: root.join("src/components.ts"),
1633 line: 25,
1634 col: 0,
1635 },
1636 ],
1637 });
1638 let elapsed = Duration::from_millis(0);
1639 let output = build_json(&results, &root, elapsed).expect("should serialize");
1640
1641 let dup = &output["duplicate_exports"][0];
1642 assert_eq!(dup["export_name"], "Button");
1643 let locs = dup["locations"].as_array().unwrap();
1644 assert_eq!(locs.len(), 2);
1645 assert_eq!(locs[0]["line"], 10);
1646 assert_eq!(locs[1]["line"], 25);
1647 }
1648
1649 #[test]
1650 fn json_type_only_dependency_contains_expected_fields() {
1651 let root = PathBuf::from("/project");
1652 let mut results = AnalysisResults::default();
1653 results.type_only_dependencies.push(TypeOnlyDependency {
1654 package_name: "zod".to_string(),
1655 path: root.join("package.json"),
1656 line: 8,
1657 });
1658 let elapsed = Duration::from_millis(0);
1659 let output = build_json(&results, &root, elapsed).expect("should serialize");
1660
1661 let dep = &output["type_only_dependencies"][0];
1662 assert_eq!(dep["package_name"], "zod");
1663 assert_eq!(dep["line"], 8);
1664 }
1665
1666 #[test]
1667 fn json_circular_dependency_contains_expected_fields() {
1668 let root = PathBuf::from("/project");
1669 let mut results = AnalysisResults::default();
1670 results.circular_dependencies.push(CircularDependency {
1671 files: vec![
1672 root.join("src/a.ts"),
1673 root.join("src/b.ts"),
1674 root.join("src/c.ts"),
1675 ],
1676 length: 3,
1677 line: 5,
1678 col: 0,
1679 is_cross_package: false,
1680 });
1681 let elapsed = Duration::from_millis(0);
1682 let output = build_json(&results, &root, elapsed).expect("should serialize");
1683
1684 let cycle = &output["circular_dependencies"][0];
1685 assert_eq!(cycle["length"], 3);
1686 assert_eq!(cycle["line"], 5);
1687 let files = cycle["files"].as_array().unwrap();
1688 assert_eq!(files.len(), 3);
1689 }
1690
1691 #[test]
1694 fn json_re_export_flagged_correctly() {
1695 let root = PathBuf::from("/project");
1696 let mut results = AnalysisResults::default();
1697 results.unused_exports.push(UnusedExport {
1698 path: root.join("src/index.ts"),
1699 export_name: "reExported".to_string(),
1700 is_type_only: false,
1701 line: 1,
1702 col: 0,
1703 span_start: 0,
1704 is_re_export: true,
1705 });
1706 let elapsed = Duration::from_millis(0);
1707 let output = build_json(&results, &root, elapsed).expect("should serialize");
1708
1709 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1710 }
1711
1712 #[test]
1715 fn json_schema_version_is_4() {
1716 let root = PathBuf::from("/project");
1717 let results = AnalysisResults::default();
1718 let elapsed = Duration::from_millis(0);
1719 let output = build_json(&results, &root, elapsed).expect("should serialize");
1720
1721 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1722 assert_eq!(output["schema_version"], 4);
1723 }
1724
1725 #[test]
1728 fn json_version_matches_cargo_pkg_version() {
1729 let root = PathBuf::from("/project");
1730 let results = AnalysisResults::default();
1731 let elapsed = Duration::from_millis(0);
1732 let output = build_json(&results, &root, elapsed).expect("should serialize");
1733
1734 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1735 }
1736
1737 #[test]
1740 fn json_elapsed_ms_zero_duration() {
1741 let root = PathBuf::from("/project");
1742 let results = AnalysisResults::default();
1743 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1744
1745 assert_eq!(output["elapsed_ms"], 0);
1746 }
1747
1748 #[test]
1749 fn json_elapsed_ms_large_duration() {
1750 let root = PathBuf::from("/project");
1751 let results = AnalysisResults::default();
1752 let elapsed = Duration::from_mins(2);
1753 let output = build_json(&results, &root, elapsed).expect("should serialize");
1754
1755 assert_eq!(output["elapsed_ms"], 120_000);
1756 }
1757
1758 #[test]
1759 fn json_elapsed_ms_sub_millisecond_truncated() {
1760 let root = PathBuf::from("/project");
1761 let results = AnalysisResults::default();
1762 let elapsed = Duration::from_micros(500);
1764 let output = build_json(&results, &root, elapsed).expect("should serialize");
1765
1766 assert_eq!(output["elapsed_ms"], 0);
1767 }
1768
1769 #[test]
1772 fn json_multiple_unused_files() {
1773 let root = PathBuf::from("/project");
1774 let mut results = AnalysisResults::default();
1775 results.unused_files.push(UnusedFile {
1776 path: root.join("src/a.ts"),
1777 });
1778 results.unused_files.push(UnusedFile {
1779 path: root.join("src/b.ts"),
1780 });
1781 results.unused_files.push(UnusedFile {
1782 path: root.join("src/c.ts"),
1783 });
1784 let elapsed = Duration::from_millis(0);
1785 let output = build_json(&results, &root, elapsed).expect("should serialize");
1786
1787 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1788 assert_eq!(output["total_issues"], 3);
1789 }
1790
1791 #[test]
1794 fn strip_root_prefix_on_string_value() {
1795 let mut value = serde_json::json!("/project/src/file.ts");
1796 strip_root_prefix(&mut value, "/project/");
1797 assert_eq!(value, "src/file.ts");
1798 }
1799
1800 #[test]
1801 fn strip_root_prefix_leaves_non_matching_string() {
1802 let mut value = serde_json::json!("/other/src/file.ts");
1803 strip_root_prefix(&mut value, "/project/");
1804 assert_eq!(value, "/other/src/file.ts");
1805 }
1806
1807 #[test]
1808 fn strip_root_prefix_recurses_into_arrays() {
1809 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1810 strip_root_prefix(&mut value, "/project/");
1811 assert_eq!(value[0], "a.ts");
1812 assert_eq!(value[1], "b.ts");
1813 assert_eq!(value[2], "/other/c.ts");
1814 }
1815
1816 #[test]
1817 fn strip_root_prefix_recurses_into_nested_objects() {
1818 let mut value = serde_json::json!({
1819 "outer": {
1820 "path": "/project/src/nested.ts"
1821 }
1822 });
1823 strip_root_prefix(&mut value, "/project/");
1824 assert_eq!(value["outer"]["path"], "src/nested.ts");
1825 }
1826
1827 #[test]
1828 fn strip_root_prefix_leaves_numbers_and_booleans() {
1829 let mut value = serde_json::json!({
1830 "line": 42,
1831 "is_type_only": false,
1832 "path": "/project/src/file.ts"
1833 });
1834 strip_root_prefix(&mut value, "/project/");
1835 assert_eq!(value["line"], 42);
1836 assert_eq!(value["is_type_only"], false);
1837 assert_eq!(value["path"], "src/file.ts");
1838 }
1839
1840 #[test]
1841 fn strip_root_prefix_normalizes_windows_separators() {
1842 let mut value = serde_json::json!(r"/project\src\file.ts");
1843 strip_root_prefix(&mut value, "/project/");
1844 assert_eq!(value, "src/file.ts");
1845 }
1846
1847 #[test]
1848 fn strip_root_prefix_handles_empty_string_after_strip() {
1849 let mut value = serde_json::json!("/project/");
1852 strip_root_prefix(&mut value, "/project/");
1853 assert_eq!(value, "");
1854 }
1855
1856 #[test]
1857 fn strip_root_prefix_deeply_nested_array_of_objects() {
1858 let mut value = serde_json::json!({
1859 "groups": [{
1860 "instances": [{
1861 "file": "/project/src/a.ts"
1862 }, {
1863 "file": "/project/src/b.ts"
1864 }]
1865 }]
1866 });
1867 strip_root_prefix(&mut value, "/project/");
1868 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1869 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1870 }
1871
1872 #[test]
1875 fn json_full_sample_results_total_issues_correct() {
1876 let root = PathBuf::from("/project");
1877 let results = sample_results(&root);
1878 let elapsed = Duration::from_millis(100);
1879 let output = build_json(&results, &root, elapsed).expect("should serialize");
1880
1881 assert_eq!(output["total_issues"], results.total_issues());
1887 }
1888
1889 #[test]
1890 fn json_full_sample_no_absolute_paths_in_output() {
1891 let root = PathBuf::from("/project");
1892 let results = sample_results(&root);
1893 let elapsed = Duration::from_millis(0);
1894 let output = build_json(&results, &root, elapsed).expect("should serialize");
1895
1896 let json_str = serde_json::to_string(&output).expect("should stringify");
1897 assert!(!json_str.contains("/project/src/"));
1899 assert!(!json_str.contains("/project/package.json"));
1900 }
1901
1902 #[test]
1905 fn json_output_is_deterministic() {
1906 let root = PathBuf::from("/project");
1907 let results = sample_results(&root);
1908 let elapsed = Duration::from_millis(50);
1909
1910 let output1 = build_json(&results, &root, elapsed).expect("first build");
1911 let output2 = build_json(&results, &root, elapsed).expect("second build");
1912
1913 assert_eq!(output1, output2);
1914 }
1915
1916 #[test]
1919 fn json_results_fields_do_not_shadow_metadata() {
1920 let root = PathBuf::from("/project");
1923 let results = AnalysisResults::default();
1924 let elapsed = Duration::from_millis(99);
1925 let output = build_json(&results, &root, elapsed).expect("should serialize");
1926
1927 assert_eq!(output["schema_version"], 4);
1929 assert_eq!(output["elapsed_ms"], 99);
1930 }
1931
1932 #[test]
1935 fn json_all_issue_type_arrays_present_in_empty_results() {
1936 let root = PathBuf::from("/project");
1937 let results = AnalysisResults::default();
1938 let elapsed = Duration::from_millis(0);
1939 let output = build_json(&results, &root, elapsed).expect("should serialize");
1940
1941 let expected_arrays = [
1942 "unused_files",
1943 "unused_exports",
1944 "unused_types",
1945 "unused_dependencies",
1946 "unused_dev_dependencies",
1947 "unused_optional_dependencies",
1948 "unused_enum_members",
1949 "unused_class_members",
1950 "unresolved_imports",
1951 "unlisted_dependencies",
1952 "duplicate_exports",
1953 "type_only_dependencies",
1954 "test_only_dependencies",
1955 "circular_dependencies",
1956 ];
1957 for key in &expected_arrays {
1958 assert!(
1959 output[key].is_array(),
1960 "expected '{key}' to be an array in JSON output"
1961 );
1962 }
1963 }
1964
1965 #[test]
1968 fn insert_meta_adds_key_to_object() {
1969 let mut output = serde_json::json!({ "foo": 1 });
1970 let meta = serde_json::json!({ "docs": "https://example.com" });
1971 insert_meta(&mut output, meta.clone());
1972 assert_eq!(output["_meta"], meta);
1973 }
1974
1975 #[test]
1976 fn insert_meta_noop_on_non_object() {
1977 let mut output = serde_json::json!([1, 2, 3]);
1978 let meta = serde_json::json!({ "docs": "https://example.com" });
1979 insert_meta(&mut output, meta);
1980 assert!(output.is_array());
1982 }
1983
1984 #[test]
1985 fn insert_meta_overwrites_existing_meta() {
1986 let mut output = serde_json::json!({ "_meta": "old" });
1987 let meta = serde_json::json!({ "new": true });
1988 insert_meta(&mut output, meta.clone());
1989 assert_eq!(output["_meta"], meta);
1990 }
1991
1992 #[test]
1995 fn build_json_envelope_has_metadata_fields() {
1996 let report = serde_json::json!({ "findings": [] });
1997 let elapsed = Duration::from_millis(42);
1998 let output = build_json_envelope(report, elapsed);
1999
2000 assert_eq!(output["schema_version"], 4);
2001 assert!(output["version"].is_string());
2002 assert_eq!(output["elapsed_ms"], 42);
2003 assert!(output["findings"].is_array());
2004 }
2005
2006 #[test]
2007 fn build_json_envelope_metadata_appears_first() {
2008 let report = serde_json::json!({ "data": "value" });
2009 let output = build_json_envelope(report, Duration::from_millis(10));
2010
2011 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2012 assert_eq!(keys[0], "schema_version");
2013 assert_eq!(keys[1], "version");
2014 assert_eq!(keys[2], "elapsed_ms");
2015 }
2016
2017 #[test]
2018 fn build_json_envelope_non_object_report() {
2019 let report = serde_json::json!("not an object");
2021 let output = build_json_envelope(report, Duration::from_millis(0));
2022
2023 let obj = output.as_object().unwrap();
2024 assert_eq!(obj.len(), 3);
2025 assert!(obj.contains_key("schema_version"));
2026 assert!(obj.contains_key("version"));
2027 assert!(obj.contains_key("elapsed_ms"));
2028 }
2029
2030 #[test]
2033 fn strip_root_prefix_null_unchanged() {
2034 let mut value = serde_json::Value::Null;
2035 strip_root_prefix(&mut value, "/project/");
2036 assert!(value.is_null());
2037 }
2038
2039 #[test]
2042 fn strip_root_prefix_empty_string() {
2043 let mut value = serde_json::json!("");
2044 strip_root_prefix(&mut value, "/project/");
2045 assert_eq!(value, "");
2046 }
2047
2048 #[test]
2051 fn strip_root_prefix_mixed_types() {
2052 let mut value = serde_json::json!({
2053 "path": "/project/src/file.ts",
2054 "line": 42,
2055 "flag": true,
2056 "nested": {
2057 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2058 "deep": { "path": "/project/c.ts" }
2059 }
2060 });
2061 strip_root_prefix(&mut value, "/project/");
2062 assert_eq!(value["path"], "src/file.ts");
2063 assert_eq!(value["line"], 42);
2064 assert_eq!(value["flag"], true);
2065 assert_eq!(value["nested"]["items"][0], "a.ts");
2066 assert_eq!(value["nested"]["items"][1], 99);
2067 assert!(value["nested"]["items"][2].is_null());
2068 assert_eq!(value["nested"]["items"][3], "b.ts");
2069 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2070 }
2071
2072 #[test]
2075 fn json_check_meta_integrates_correctly() {
2076 let root = PathBuf::from("/project");
2077 let results = AnalysisResults::default();
2078 let elapsed = Duration::from_millis(0);
2079 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2080 insert_meta(&mut output, crate::explain::check_meta());
2081
2082 assert!(output["_meta"]["docs"].is_string());
2083 assert!(output["_meta"]["rules"].is_object());
2084 }
2085
2086 #[test]
2089 fn json_unused_member_kind_serialized() {
2090 let root = PathBuf::from("/project");
2091 let mut results = AnalysisResults::default();
2092 results.unused_enum_members.push(UnusedMember {
2093 path: root.join("src/enums.ts"),
2094 parent_name: "Color".to_string(),
2095 member_name: "Red".to_string(),
2096 kind: MemberKind::EnumMember,
2097 line: 3,
2098 col: 2,
2099 });
2100 results.unused_class_members.push(UnusedMember {
2101 path: root.join("src/class.ts"),
2102 parent_name: "Foo".to_string(),
2103 member_name: "bar".to_string(),
2104 kind: MemberKind::ClassMethod,
2105 line: 10,
2106 col: 4,
2107 });
2108
2109 let elapsed = Duration::from_millis(0);
2110 let output = build_json(&results, &root, elapsed).expect("should serialize");
2111
2112 let enum_member = &output["unused_enum_members"][0];
2113 assert!(enum_member["kind"].is_string());
2114 let class_member = &output["unused_class_members"][0];
2115 assert!(class_member["kind"].is_string());
2116 }
2117
2118 #[test]
2121 fn json_unused_export_has_actions() {
2122 let root = PathBuf::from("/project");
2123 let mut results = AnalysisResults::default();
2124 results.unused_exports.push(UnusedExport {
2125 path: root.join("src/utils.ts"),
2126 export_name: "helperFn".to_string(),
2127 is_type_only: false,
2128 line: 10,
2129 col: 4,
2130 span_start: 120,
2131 is_re_export: false,
2132 });
2133 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2134
2135 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2136 assert_eq!(actions.len(), 2);
2137
2138 assert_eq!(actions[0]["type"], "remove-export");
2140 assert_eq!(actions[0]["auto_fixable"], true);
2141 assert!(actions[0].get("note").is_none());
2142
2143 assert_eq!(actions[1]["type"], "suppress-line");
2145 assert_eq!(
2146 actions[1]["comment"],
2147 "// fallow-ignore-next-line unused-export"
2148 );
2149 }
2150
2151 #[test]
2152 fn json_unused_file_has_file_suppress_and_note() {
2153 let root = PathBuf::from("/project");
2154 let mut results = AnalysisResults::default();
2155 results.unused_files.push(UnusedFile {
2156 path: root.join("src/dead.ts"),
2157 });
2158 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2159
2160 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2161 assert_eq!(actions[0]["type"], "delete-file");
2162 assert_eq!(actions[0]["auto_fixable"], false);
2163 assert!(actions[0]["note"].is_string());
2164 assert_eq!(actions[1]["type"], "suppress-file");
2165 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2166 }
2167
2168 #[test]
2169 fn json_unused_dependency_has_config_suppress_with_package_name() {
2170 let root = PathBuf::from("/project");
2171 let mut results = AnalysisResults::default();
2172 results.unused_dependencies.push(UnusedDependency {
2173 package_name: "lodash".to_string(),
2174 location: DependencyLocation::Dependencies,
2175 path: root.join("package.json"),
2176 line: 5,
2177 });
2178 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2179
2180 let actions = output["unused_dependencies"][0]["actions"]
2181 .as_array()
2182 .unwrap();
2183 assert_eq!(actions[0]["type"], "remove-dependency");
2184 assert_eq!(actions[0]["auto_fixable"], true);
2185
2186 assert_eq!(actions[1]["type"], "add-to-config");
2188 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2189 assert_eq!(actions[1]["value"], "lodash");
2190 }
2191
2192 #[test]
2193 fn json_empty_results_have_no_actions_in_empty_arrays() {
2194 let root = PathBuf::from("/project");
2195 let results = AnalysisResults::default();
2196 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2197
2198 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2200 assert!(output["unused_files"].as_array().unwrap().is_empty());
2201 }
2202
2203 #[test]
2204 fn json_all_issue_types_have_actions() {
2205 let root = PathBuf::from("/project");
2206 let results = sample_results(&root);
2207 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2208
2209 let issue_keys = [
2210 "unused_files",
2211 "unused_exports",
2212 "unused_types",
2213 "unused_dependencies",
2214 "unused_dev_dependencies",
2215 "unused_optional_dependencies",
2216 "unused_enum_members",
2217 "unused_class_members",
2218 "unresolved_imports",
2219 "unlisted_dependencies",
2220 "duplicate_exports",
2221 "type_only_dependencies",
2222 "test_only_dependencies",
2223 "circular_dependencies",
2224 ];
2225
2226 for key in &issue_keys {
2227 let arr = output[key].as_array().unwrap();
2228 if !arr.is_empty() {
2229 let actions = arr[0]["actions"].as_array();
2230 assert!(
2231 actions.is_some() && !actions.unwrap().is_empty(),
2232 "missing actions for {key}"
2233 );
2234 }
2235 }
2236 }
2237
2238 #[test]
2241 fn health_finding_has_actions() {
2242 let mut output = serde_json::json!({
2243 "findings": [{
2244 "path": "src/utils.ts",
2245 "name": "processData",
2246 "line": 10,
2247 "col": 0,
2248 "cyclomatic": 25,
2249 "cognitive": 30,
2250 "line_count": 150,
2251 "exceeded": "both"
2252 }]
2253 });
2254
2255 inject_health_actions(&mut output);
2256
2257 let actions = output["findings"][0]["actions"].as_array().unwrap();
2258 assert_eq!(actions.len(), 2);
2259 assert_eq!(actions[0]["type"], "refactor-function");
2260 assert_eq!(actions[0]["auto_fixable"], false);
2261 assert!(
2262 actions[0]["description"]
2263 .as_str()
2264 .unwrap()
2265 .contains("processData")
2266 );
2267 assert_eq!(actions[1]["type"], "suppress-line");
2268 assert_eq!(
2269 actions[1]["comment"],
2270 "// fallow-ignore-next-line complexity"
2271 );
2272 }
2273
2274 #[test]
2275 fn refactoring_target_has_actions() {
2276 let mut output = serde_json::json!({
2277 "targets": [{
2278 "path": "src/big-module.ts",
2279 "priority": 85.0,
2280 "efficiency": 42.5,
2281 "recommendation": "Split module: 12 exports, 4 unused",
2282 "category": "split_high_impact",
2283 "effort": "medium",
2284 "confidence": "high",
2285 "evidence": { "unused_exports": 4 }
2286 }]
2287 });
2288
2289 inject_health_actions(&mut output);
2290
2291 let actions = output["targets"][0]["actions"].as_array().unwrap();
2292 assert_eq!(actions.len(), 2);
2293 assert_eq!(actions[0]["type"], "apply-refactoring");
2294 assert_eq!(
2295 actions[0]["description"],
2296 "Split module: 12 exports, 4 unused"
2297 );
2298 assert_eq!(actions[0]["category"], "split_high_impact");
2299 assert_eq!(actions[1]["type"], "suppress-line");
2301 }
2302
2303 #[test]
2304 fn refactoring_target_without_evidence_has_no_suppress() {
2305 let mut output = serde_json::json!({
2306 "targets": [{
2307 "path": "src/simple.ts",
2308 "priority": 30.0,
2309 "efficiency": 15.0,
2310 "recommendation": "Consider extracting helper functions",
2311 "category": "extract_complex_functions",
2312 "effort": "small",
2313 "confidence": "medium"
2314 }]
2315 });
2316
2317 inject_health_actions(&mut output);
2318
2319 let actions = output["targets"][0]["actions"].as_array().unwrap();
2320 assert_eq!(actions.len(), 1);
2321 assert_eq!(actions[0]["type"], "apply-refactoring");
2322 }
2323
2324 #[test]
2325 fn health_empty_findings_no_actions() {
2326 let mut output = serde_json::json!({
2327 "findings": [],
2328 "targets": []
2329 });
2330
2331 inject_health_actions(&mut output);
2332
2333 assert!(output["findings"].as_array().unwrap().is_empty());
2334 assert!(output["targets"].as_array().unwrap().is_empty());
2335 }
2336
2337 #[test]
2338 fn hotspot_has_actions() {
2339 let mut output = serde_json::json!({
2340 "hotspots": [{
2341 "path": "src/utils.ts",
2342 "complexity_score": 45.0,
2343 "churn_score": 12,
2344 "hotspot_score": 540.0
2345 }]
2346 });
2347
2348 inject_health_actions(&mut output);
2349
2350 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2351 assert_eq!(actions.len(), 2);
2352 assert_eq!(actions[0]["type"], "refactor-file");
2353 assert!(
2354 actions[0]["description"]
2355 .as_str()
2356 .unwrap()
2357 .contains("src/utils.ts")
2358 );
2359 assert_eq!(actions[1]["type"], "add-tests");
2360 }
2361
2362 #[test]
2363 fn hotspot_low_bus_factor_emits_action() {
2364 let mut output = serde_json::json!({
2365 "hotspots": [{
2366 "path": "src/api.ts",
2367 "ownership": {
2368 "bus_factor": 1,
2369 "contributor_count": 1,
2370 "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
2371 "unowned": null,
2372 "drift": false,
2373 }
2374 }]
2375 });
2376
2377 inject_health_actions(&mut output);
2378
2379 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2380 assert!(
2381 actions
2382 .iter()
2383 .filter_map(|a| a["type"].as_str())
2384 .any(|t| t == "low-bus-factor"),
2385 "low-bus-factor action should be present",
2386 );
2387 let bus = actions
2388 .iter()
2389 .find(|a| a["type"] == "low-bus-factor")
2390 .unwrap();
2391 assert!(bus["description"].as_str().unwrap().contains("alice@x"));
2392 }
2393
2394 #[test]
2395 fn hotspot_unowned_emits_action_with_pattern() {
2396 let mut output = serde_json::json!({
2397 "hotspots": [{
2398 "path": "src/api/users.ts",
2399 "ownership": {
2400 "bus_factor": 2,
2401 "contributor_count": 4,
2402 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2403 "unowned": true,
2404 "drift": false,
2405 }
2406 }]
2407 });
2408
2409 inject_health_actions(&mut output);
2410
2411 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2412 let unowned = actions
2413 .iter()
2414 .find(|a| a["type"] == "unowned-hotspot")
2415 .expect("unowned-hotspot action should be present");
2416 assert_eq!(unowned["suggested_pattern"], "/src/api/");
2419 assert_eq!(unowned["heuristic"], "directory-deepest");
2420 }
2421
2422 #[test]
2423 fn hotspot_unowned_skipped_when_codeowners_missing() {
2424 let mut output = serde_json::json!({
2425 "hotspots": [{
2426 "path": "src/api.ts",
2427 "ownership": {
2428 "bus_factor": 2,
2429 "contributor_count": 4,
2430 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2431 "unowned": null,
2432 "drift": false,
2433 }
2434 }]
2435 });
2436
2437 inject_health_actions(&mut output);
2438
2439 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2440 assert!(
2441 !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
2442 "unowned action must not fire when CODEOWNERS file is absent"
2443 );
2444 }
2445
2446 #[test]
2447 fn hotspot_drift_emits_action() {
2448 let mut output = serde_json::json!({
2449 "hotspots": [{
2450 "path": "src/old.ts",
2451 "ownership": {
2452 "bus_factor": 1,
2453 "contributor_count": 2,
2454 "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
2455 "unowned": null,
2456 "drift": true,
2457 "drift_reason": "original author alice@x has 5% share",
2458 }
2459 }]
2460 });
2461
2462 inject_health_actions(&mut output);
2463
2464 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2465 let drift = actions
2466 .iter()
2467 .find(|a| a["type"] == "ownership-drift")
2468 .expect("ownership-drift action should be present");
2469 assert!(drift["description"].as_str().unwrap().contains("alice@x"));
2470 }
2471
2472 #[test]
2475 fn codeowners_pattern_uses_deepest_directory() {
2476 assert_eq!(
2479 suggest_codeowners_pattern("src/api/users/handlers.ts"),
2480 "/src/api/users/"
2481 );
2482 }
2483
2484 #[test]
2485 fn codeowners_pattern_for_root_file() {
2486 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
2487 }
2488
2489 #[test]
2490 fn codeowners_pattern_normalizes_backslashes() {
2491 assert_eq!(
2492 suggest_codeowners_pattern("src\\api\\users.ts"),
2493 "/src/api/"
2494 );
2495 }
2496
2497 #[test]
2498 fn codeowners_pattern_two_level_path() {
2499 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
2500 }
2501
2502 #[test]
2503 fn health_finding_suppress_has_placement() {
2504 let mut output = serde_json::json!({
2505 "findings": [{
2506 "path": "src/utils.ts",
2507 "name": "processData",
2508 "line": 10,
2509 "col": 0,
2510 "cyclomatic": 25,
2511 "cognitive": 30,
2512 "line_count": 150,
2513 "exceeded": "both"
2514 }]
2515 });
2516
2517 inject_health_actions(&mut output);
2518
2519 let suppress = &output["findings"][0]["actions"][1];
2520 assert_eq!(suppress["placement"], "above-function-declaration");
2521 }
2522
2523 #[test]
2526 fn clone_family_has_actions() {
2527 let mut output = serde_json::json!({
2528 "clone_families": [{
2529 "files": ["src/a.ts", "src/b.ts"],
2530 "groups": [
2531 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
2532 ],
2533 "total_duplicated_lines": 20,
2534 "total_duplicated_tokens": 100,
2535 "suggestions": [
2536 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
2537 ]
2538 }]
2539 });
2540
2541 inject_dupes_actions(&mut output);
2542
2543 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
2544 assert_eq!(actions.len(), 3);
2545 assert_eq!(actions[0]["type"], "extract-shared");
2546 assert_eq!(actions[0]["auto_fixable"], false);
2547 assert!(
2548 actions[0]["description"]
2549 .as_str()
2550 .unwrap()
2551 .contains("20 lines")
2552 );
2553 assert_eq!(actions[1]["type"], "apply-suggestion");
2555 assert!(
2556 actions[1]["description"]
2557 .as_str()
2558 .unwrap()
2559 .contains("validation logic")
2560 );
2561 assert_eq!(actions[2]["type"], "suppress-line");
2563 assert_eq!(
2564 actions[2]["comment"],
2565 "// fallow-ignore-next-line code-duplication"
2566 );
2567 }
2568
2569 #[test]
2570 fn clone_group_has_actions() {
2571 let mut output = serde_json::json!({
2572 "clone_groups": [{
2573 "instances": [
2574 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
2575 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
2576 ],
2577 "token_count": 50,
2578 "line_count": 10
2579 }]
2580 });
2581
2582 inject_dupes_actions(&mut output);
2583
2584 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
2585 assert_eq!(actions.len(), 2);
2586 assert_eq!(actions[0]["type"], "extract-shared");
2587 assert!(
2588 actions[0]["description"]
2589 .as_str()
2590 .unwrap()
2591 .contains("10 lines")
2592 );
2593 assert!(
2594 actions[0]["description"]
2595 .as_str()
2596 .unwrap()
2597 .contains("2 instances")
2598 );
2599 assert_eq!(actions[1]["type"], "suppress-line");
2600 }
2601
2602 #[test]
2603 fn dupes_empty_results_no_actions() {
2604 let mut output = serde_json::json!({
2605 "clone_families": [],
2606 "clone_groups": []
2607 });
2608
2609 inject_dupes_actions(&mut output);
2610
2611 assert!(output["clone_families"].as_array().unwrap().is_empty());
2612 assert!(output["clone_groups"].as_array().unwrap().is_empty());
2613 }
2614}