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