Skip to main content

fallow_cli/report/
json.rs

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/// Render grouped analysis results as a single JSON document.
39///
40/// Produces an envelope with `grouped_by` and `total_issues` at the top level,
41/// then a `groups` array where each element contains the group `key`,
42/// `total_issues`, and all the normal result fields with paths relativized.
43#[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                // Insert key and total_issues at the front by rebuilding the map
63                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
95/// JSON output schema version as an integer (independent of tool version).
96///
97/// Bump this when the structure of the JSON output changes in a
98/// backwards-incompatible way (removing/renaming fields, changing types).
99/// Adding new fields is always backwards-compatible and does not require a bump.
100const SCHEMA_VERSION: u32 = 3;
101
102/// Build a JSON envelope with standard metadata fields at the top.
103///
104/// Creates a JSON object with `schema_version`, `version`, and `elapsed_ms`,
105/// then merges all fields from `report_value` into the envelope.
106/// Fields from `report_value` appear after the metadata header.
107fn 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
129/// Build the JSON output value for analysis results.
130///
131/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
132/// appear first in the output for readability. Paths are made relative to `root`.
133///
134/// # Errors
135///
136/// Returns an error if the results cannot be serialized to JSON.
137pub 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    // Entry-point detection summary (metadata, not serialized via serde)
163    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    // Per-category summary counts for CI dashboard consumption
179    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 must run before inject_actions so that injected
208    // action fields (static strings and package names) are not processed
209    // by the path stripper.
210    strip_root_prefix(&mut output, &root_prefix);
211    inject_actions(&mut output);
212    Ok(output)
213}
214
215/// Recursively strip the root prefix from all string values in the JSON tree.
216///
217/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
218/// to relative paths (`src/utils.ts`) for all output fields.
219pub 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
240// ── Fix action injection ────────────────────────────────────────
241
242/// Suppress mechanism for an issue type.
243enum SuppressKind {
244    /// `// fallow-ignore-next-line <type>` on the line before.
245    InlineComment,
246    /// `// fallow-ignore-file <type>` at the top of the file.
247    FileComment,
248    /// Add to `ignoreDependencies` in fallow config.
249    ConfigIgnoreDep,
250}
251
252/// Specification for actions to inject per issue type.
253struct 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
262/// Map an issue array key to its action specification.
263fn 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            // No IssueKind variant exists for optional deps — uses config suppress only.
314            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
400/// Build the `actions` array for a single issue item.
401fn 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    // Primary fix action
409    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    // Warn about re-exports that may be part of the public API surface.
418    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    // Suppress action — every action carries `auto_fixable` for uniform filtering.
431    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            // duplicate_exports has N locations, not one — flag multi-location scope.
440            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            // Extract the package name from the item for a concrete suggestion.
455            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
472/// Inject `actions` arrays into every issue item in the JSON output.
473///
474/// Walks each known issue-type array and appends an `actions` field
475/// to every item, providing machine-actionable fix and suppress hints.
476fn 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
497// ── Health action injection ─────────────────────────────────────
498
499/// Build a JSON representation of baseline deltas for the combined JSON envelope.
500///
501/// Accepts a total delta and an iterator of per-category entries to avoid
502/// coupling the report module (compiled in both lib and bin) to the
503/// binary-only `baseline` module.
504pub 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/// Inject `actions` arrays into complexity findings in a health JSON output.
526///
527/// Walks `findings` and `targets` arrays, appending machine-actionable
528/// fix and suppress hints to each item.
529#[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    // Complexity findings: refactor the function to reduce complexity
539    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    // Refactoring targets: apply the recommended refactoring
549    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    // Hotspots: files that are both complex and frequently changing
559    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    // Coverage gaps: untested files and exports
569    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
589/// Build the `actions` array for a single complexity finding.
590fn 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
614/// Build the `actions` array for a single hotspot entry.
615fn 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
639/// Build the `actions` array for a single refactoring target.
640fn 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    // Targets with evidence linking to specific functions get a suppress action
659    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
671/// Build the `actions` array for an untested file.
672fn 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
694/// Build the `actions` array for an untested export.
695fn 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// ── Duplication action injection ────────────────────────────────
722
723/// Inject `actions` arrays into clone families/groups in a duplication JSON output.
724///
725/// Walks `clone_families` and `clone_groups` arrays, appending
726/// machine-actionable fix and config hints to each item.
727#[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    // Clone families: extract shared module/function
737    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    // Clone groups: extract shared code
747    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
757/// Build the `actions` array for a single clone family.
758fn 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    // Include any refactoring suggestions from the family
780    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
805/// Build the `actions` array for a single clone group.
806fn 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
837/// Insert a `_meta` key into a JSON object value.
838fn 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    // ── Empty results ───────────────────────────────────────────────
1022
1023    #[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    // ── Path stripping ──────────────────────────────────────────────
1065
1066    #[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    // ── Individual issue type field verification ────────────────────
1170
1171    #[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    // ── Re-export tagging ───────────────────────────────────────────
1427
1428    #[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    // ── Schema version stability ────────────────────────────────────
1448
1449    #[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    // ── Version string ──────────────────────────────────────────────
1461
1462    #[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    // ── Elapsed time encoding ───────────────────────────────────────
1473
1474    #[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        // 500 microseconds = 0 milliseconds (truncated)
1498        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    // ── Multiple issues of same type ────────────────────────────────
1505
1506    #[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    // ── strip_root_prefix unit tests ────────────────────────────────
1527
1528    #[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        // Edge case: the string IS the prefix (without trailing content).
1578        // This shouldn't happen in practice but should not panic.
1579        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    // ── Full sample results round-trip ──────────────────────────────
1601
1602    #[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        // sample_results adds one of each issue type (12 total).
1610        // unused_files + unused_exports + unused_types + unused_dependencies
1611        // + unused_dev_dependencies + unused_enum_members + unused_class_members
1612        // + unresolved_imports + unlisted_dependencies + duplicate_exports
1613        // + type_only_dependencies + circular_dependencies
1614        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        // The root prefix should be stripped from all paths.
1626        assert!(!json_str.contains("/project/src/"));
1627        assert!(!json_str.contains("/project/package.json"));
1628    }
1629
1630    // ── JSON output is deterministic ────────────────────────────────
1631
1632    #[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    // ── Metadata not overwritten by results fields ──────────────────
1645
1646    #[test]
1647    fn json_results_fields_do_not_shadow_metadata() {
1648        // Ensure that serialized results don't contain keys like "schema_version"
1649        // that could overwrite the metadata fields we insert first.
1650        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        // Metadata should reflect our explicit values, not anything from AnalysisResults.
1656        assert_eq!(output["schema_version"], 3);
1657        assert_eq!(output["elapsed_ms"], 99);
1658    }
1659
1660    // ── All 14 issue type arrays present ────────────────────────────
1661
1662    #[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    // ── insert_meta ─────────────────────────────────────────────────
1694
1695    #[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        // Should not panic or add anything
1709        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    // ── build_json_envelope ─────────────────────────────────────────
1721
1722    #[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        // If report_value is not an Object, only metadata fields appear
1748        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    // ── strip_root_prefix with null value ──
1759
1760    #[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    // ── strip_root_prefix with empty string ──
1768
1769    #[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    // ── strip_root_prefix on mixed nested structure ──
1777
1778    #[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    // ── JSON with explain meta for check ──
1801
1802    #[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    // ── JSON unused member kind serialization ──
1815
1816    #[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    // ── Actions injection ──────────────────────────────────────────
1847
1848    #[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        // Fix action
1867        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        // Suppress action
1872        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        // Config suppress includes actual package name
1915        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        // Empty arrays should remain empty
1927        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    // ── Health actions injection ───────────────────────────────────
1967
1968    #[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        // Target with evidence gets suppress action
2028        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    // ── Duplication actions injection ─────────────────────────────
2112
2113    #[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        // Suggestion forwarded as action
2142        assert_eq!(actions[1]["type"], "apply-suggestion");
2143        assert!(
2144            actions[1]["description"]
2145                .as_str()
2146                .unwrap()
2147                .contains("validation logic")
2148        );
2149        // Suppress action
2150        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}