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