Skip to main content

fallow_cli/report/
json.rs

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