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