Skip to main content

fallow_api/
json_output.rs

1//! Shared JSON output assembly for CLI and programmatic consumers.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::time::Duration;
6
7use fallow_engine::duplicates::DuplicationReport;
8use fallow_output::{
9    CHECK_SCHEMA_VERSION, CheckGroupedEntry, CheckGroupedOutput, CheckOutput, CheckOutputInput,
10    DupesOutput, DupesOutputInput, GroupByMode, RootEnvelopeMode,
11    apply_config_fixable_to_duplicate_exports, build_check_output, build_dupes_output,
12    strip_root_prefix,
13};
14use fallow_types::envelope::{
15    BaselineDeltas, BaselineMatch, ElapsedMs, Meta, RegressionResult, SchemaVersion, ToolVersion,
16};
17use fallow_types::output::NextStep;
18use fallow_types::results::AnalysisResults;
19use fallow_types::workspace::WorkspaceDiagnostic;
20
21use crate::{DupesReportPayload, DuplicationGroup, DuplicationGrouping, ResultGroup};
22
23type SuppressAnchor = (String, u64);
24
25/// Inputs for `fallow dead-code --format json` output assembly.
26pub struct CheckJsonOutputInput<'a> {
27    pub results: &'a AnalysisResults,
28    pub root: &'a Path,
29    pub elapsed: Duration,
30    pub config_fixable: bool,
31    pub meta: Option<Meta>,
32    pub extras: CheckJsonExtraOutputs,
33    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
34    pub next_steps: Vec<NextStep>,
35    pub envelope_mode: RootEnvelopeMode,
36    pub telemetry_analysis_run_id: Option<&'a str>,
37}
38
39/// Inputs for the dead-code JSON payload without a root envelope.
40pub struct CheckJsonPayloadInput<'a> {
41    pub results: &'a AnalysisResults,
42    pub root: &'a Path,
43    pub elapsed: Duration,
44    pub config_fixable: bool,
45    pub extras: CheckJsonExtraOutputs,
46    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
47}
48
49/// Optional root sections for dead-code JSON envelopes.
50///
51/// These fields are part of the output contract, but they are computed by
52/// caller-specific workflows such as baseline and regression gates.
53#[derive(Debug, Clone, Default)]
54pub struct CheckJsonExtraOutputs {
55    pub baseline_deltas: Option<BaselineDeltas>,
56    pub baseline: Option<BaselineMatch>,
57    pub regression: Option<RegressionResult>,
58}
59
60struct CheckJsonEnvelopeInput<'a> {
61    results: &'a AnalysisResults,
62    elapsed: Duration,
63    config_fixable: bool,
64    meta: Option<Meta>,
65    extras: CheckJsonExtraOutputs,
66    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
67    next_steps: Vec<NextStep>,
68}
69
70/// Inputs for grouped dead-code JSON output assembly.
71pub struct GroupedCheckJsonOutputInput<'a> {
72    pub groups: &'a [ResultGroup],
73    pub original: &'a AnalysisResults,
74    pub root: &'a Path,
75    pub elapsed: Duration,
76    pub grouped_by: GroupByMode,
77    pub config_fixable: bool,
78    pub meta: Option<Meta>,
79    pub next_steps: Vec<NextStep>,
80    pub envelope_mode: RootEnvelopeMode,
81    pub telemetry_analysis_run_id: Option<&'a str>,
82}
83
84/// Inputs for `fallow dupes --format json` output assembly.
85pub struct DuplicationJsonOutputInput<'a> {
86    pub report: &'a DuplicationReport,
87    pub root: &'a Path,
88    pub elapsed: Duration,
89    pub meta: Option<Meta>,
90    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
91    pub next_steps: Vec<NextStep>,
92    pub envelope_mode: RootEnvelopeMode,
93    pub telemetry_analysis_run_id: Option<&'a str>,
94}
95
96/// Inputs for grouped duplication JSON output assembly.
97pub struct GroupedDuplicationJsonOutputInput<'a> {
98    pub report: &'a DuplicationReport,
99    pub grouping: &'a DuplicationGrouping,
100    pub root: &'a Path,
101    pub elapsed: Duration,
102    pub meta: Option<Meta>,
103    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
104    pub next_steps: Vec<NextStep>,
105    pub envelope_mode: RootEnvelopeMode,
106    pub telemetry_analysis_run_id: Option<&'a str>,
107}
108
109/// Build and serialize dead-code JSON through the API-owned output boundary.
110///
111/// # Errors
112///
113/// Returns a serde error when the typed envelope cannot be converted to JSON.
114pub fn serialize_check_json(
115    input: CheckJsonOutputInput<'_>,
116) -> Result<serde_json::Value, serde_json::Error> {
117    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
118        results: input.results,
119        elapsed: input.elapsed,
120        config_fixable: input.config_fixable,
121        meta: input.meta,
122        extras: input.extras,
123        workspace_diagnostics: input.workspace_diagnostics,
124        next_steps: input.next_steps,
125    });
126    let mut output = fallow_output::serialize_check_json_output(
127        envelope,
128        input.envelope_mode,
129        input.telemetry_analysis_run_id,
130    )?;
131    postprocess_check_json(&mut output, input.root);
132    Ok(output)
133}
134
135/// Build a dead-code JSON payload without adding a root envelope.
136///
137/// # Errors
138///
139/// Returns a serde error when the typed envelope cannot be converted to JSON.
140pub fn serialize_check_json_payload(
141    input: CheckJsonPayloadInput<'_>,
142) -> Result<serde_json::Value, serde_json::Error> {
143    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
144        results: input.results,
145        elapsed: input.elapsed,
146        config_fixable: input.config_fixable,
147        meta: None,
148        extras: input.extras,
149        workspace_diagnostics: input.workspace_diagnostics,
150        next_steps: Vec::new(),
151    });
152    let mut output = serde_json::to_value(envelope)?;
153    postprocess_check_json(&mut output, input.root);
154    Ok(output)
155}
156
157/// Build and serialize grouped dead-code JSON through the API output boundary.
158///
159/// # Errors
160///
161/// Returns a serde error when the typed envelope cannot be converted to JSON.
162pub fn serialize_grouped_check_json(
163    input: GroupedCheckJsonOutputInput<'_>,
164) -> Result<serde_json::Value, serde_json::Error> {
165    let entries = input
166        .groups
167        .iter()
168        .map(|group| {
169            let mut results = group.results.clone();
170            apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
171            CheckGroupedEntry {
172                key: group.key.clone(),
173                owners: group.owners.clone(),
174                total_issues: results.total_issues(),
175                results,
176            }
177        })
178        .collect();
179
180    let envelope = CheckGroupedOutput {
181        schema_version: SchemaVersion(CHECK_SCHEMA_VERSION),
182        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
183        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
184        grouped_by: input.grouped_by,
185        total_issues: input.original.total_issues(),
186        groups: entries,
187        meta: input.meta,
188        next_steps: input.next_steps,
189    };
190
191    let mut output = fallow_output::serialize_check_grouped_json_output(
192        envelope,
193        input.envelope_mode,
194        input.telemetry_analysis_run_id,
195    )?;
196    let root_prefix = format!("{}/", input.root.display());
197    if let Some(arr) = output
198        .get_mut("groups")
199        .and_then(serde_json::Value::as_array_mut)
200    {
201        for entry in arr {
202            strip_root_prefix(entry, &root_prefix);
203            harmonize_multi_kind_suppress_line_actions(entry);
204        }
205    }
206    Ok(output)
207}
208
209/// Build and serialize duplication JSON through the API-owned output boundary.
210///
211/// # Errors
212///
213/// Returns a serde error when the typed envelope cannot be converted to JSON.
214pub fn serialize_duplication_json(
215    input: DuplicationJsonOutputInput<'_>,
216) -> Result<serde_json::Value, serde_json::Error> {
217    let payload = DupesReportPayload::from_report(input.report);
218    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
219        build_dupes_output(DupesOutputInput {
220            schema_version: CHECK_SCHEMA_VERSION,
221            version: env!("CARGO_PKG_VERSION").to_string(),
222            elapsed: input.elapsed,
223            report: payload,
224            grouped_by: None,
225            total_issues: None,
226            groups: None,
227            meta: input.meta,
228            workspace_diagnostics: input.workspace_diagnostics,
229            next_steps: input.next_steps,
230        });
231    let mut output = fallow_output::serialize_dupes_json_output(
232        envelope,
233        input.envelope_mode,
234        input.telemetry_analysis_run_id,
235    )?;
236    let root_prefix = format!("{}/", input.root.display());
237    strip_root_prefix(&mut output, &root_prefix);
238    Ok(output)
239}
240
241/// Build and serialize grouped duplication JSON through the API output boundary.
242///
243/// # Errors
244///
245/// Returns a serde error when the typed envelope cannot be converted to JSON.
246pub fn serialize_grouped_duplication_json(
247    input: GroupedDuplicationJsonOutputInput<'_>,
248) -> Result<serde_json::Value, serde_json::Error> {
249    let root_prefix = format!("{}/", input.root.display());
250    let payload = DupesReportPayload::from_report(input.report);
251    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
252        build_dupes_output(DupesOutputInput {
253            schema_version: CHECK_SCHEMA_VERSION,
254            version: env!("CARGO_PKG_VERSION").to_string(),
255            elapsed: input.elapsed,
256            report: payload,
257            grouped_by: Some(group_by_mode_from_label(input.grouping.mode)),
258            total_issues: Some(input.report.clone_groups.len()),
259            groups: None,
260            meta: input.meta,
261            workspace_diagnostics: input.workspace_diagnostics,
262            next_steps: input.next_steps,
263        });
264    let mut output = fallow_output::serialize_dupes_json_output(
265        envelope,
266        input.envelope_mode,
267        input.telemetry_analysis_run_id,
268    )?;
269    strip_root_prefix(&mut output, &root_prefix);
270
271    let group_values = input
272        .grouping
273        .groups
274        .iter()
275        .map(|group| {
276            let mut value = serde_json::to_value(group)?;
277            strip_root_prefix(&mut value, &root_prefix);
278            Ok(value)
279        })
280        .collect::<Result<Vec<_>, serde_json::Error>>()?;
281
282    if let serde_json::Value::Object(ref mut map) = output {
283        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
284    }
285
286    Ok(output)
287}
288
289fn build_check_json_envelope(input: CheckJsonEnvelopeInput<'_>) -> CheckOutput {
290    let mut output = build_check_output(CheckOutputInput {
291        schema_version: CHECK_SCHEMA_VERSION,
292        version: env!("CARGO_PKG_VERSION").to_string(),
293        elapsed: input.elapsed,
294        results: input.results.clone(),
295        config_fixable: input.config_fixable,
296        meta: input.meta,
297        workspace_diagnostics: input.workspace_diagnostics,
298        next_steps: input.next_steps,
299    });
300    output.baseline_deltas = input.extras.baseline_deltas;
301    output.baseline = input.extras.baseline;
302    output.regression = input.extras.regression;
303    output
304}
305
306fn postprocess_check_json(output: &mut serde_json::Value, root: &Path) {
307    let root_prefix = format!("{}/", root.display());
308    strip_root_prefix(output, &root_prefix);
309    harmonize_multi_kind_suppress_line_actions(output);
310}
311
312/// Merge same-line suppress actions so multi-kind findings share one comment.
313pub fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
314    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
315    collect_suppress_line_anchors(output, &mut anchors);
316
317    anchors.retain(|_, kinds| {
318        sort_suppression_kinds(kinds);
319        kinds.dedup();
320        kinds.len() > 1
321    });
322    if anchors.is_empty() {
323        return;
324    }
325
326    rewrite_suppress_line_actions(output, &anchors);
327}
328
329fn collect_suppress_line_anchors(
330    value: &serde_json::Value,
331    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
332) {
333    match value {
334        serde_json::Value::Object(map) => {
335            if let Some(anchor) = suppression_anchor(map)
336                && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
337            {
338                for action in actions {
339                    if let Some(comment) = suppress_line_comment(action) {
340                        for kind in parse_suppress_line_comment(comment) {
341                            let kinds = anchors.entry(anchor.clone()).or_default();
342                            if !kinds.iter().any(|existing| existing == &kind) {
343                                kinds.push(kind);
344                            }
345                        }
346                    }
347                }
348            }
349
350            for child in map.values() {
351                collect_suppress_line_anchors(child, anchors);
352            }
353        }
354        serde_json::Value::Array(items) => {
355            for item in items {
356                collect_suppress_line_anchors(item, anchors);
357            }
358        }
359        _ => {}
360    }
361}
362
363fn rewrite_suppress_line_actions(
364    value: &mut serde_json::Value,
365    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
366) {
367    match value {
368        serde_json::Value::Object(map) => {
369            if let Some(anchor) = suppression_anchor(map)
370                && let Some(kinds) = anchors.get(&anchor)
371            {
372                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
373                if let Some(actions) = map
374                    .get_mut("actions")
375                    .and_then(serde_json::Value::as_array_mut)
376                {
377                    for action in actions {
378                        if suppress_line_comment(action).is_some()
379                            && let serde_json::Value::Object(action_map) = action
380                        {
381                            action_map.insert("comment".to_string(), serde_json::json!(comment));
382                        }
383                    }
384                }
385            }
386
387            for child in map.values_mut() {
388                rewrite_suppress_line_actions(child, anchors);
389            }
390        }
391        serde_json::Value::Array(items) => {
392            for item in items {
393                rewrite_suppress_line_actions(item, anchors);
394            }
395        }
396        _ => {}
397    }
398}
399
400fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
401    let path = map
402        .get("path")
403        .or_else(|| map.get("from_path"))
404        .and_then(serde_json::Value::as_str)?;
405    let line = map.get("line").and_then(serde_json::Value::as_u64)?;
406    Some((path.to_string(), line))
407}
408
409fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
410    (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
411        .then_some(())
412        .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
413}
414
415fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
416    comment
417        .strip_prefix("// fallow-ignore-next-line ")
418        .map(|rest| {
419            rest.split(|c: char| c == ',' || c.is_whitespace())
420                .filter(|token| !token.is_empty())
421                .map(str::to_string)
422                .collect()
423        })
424        .unwrap_or_default()
425}
426
427fn sort_suppression_kinds(kinds: &mut [String]) {
428    kinds.sort_by_key(|kind| suppression_kind_rank(kind));
429}
430
431fn suppression_kind_rank(kind: &str) -> usize {
432    match kind {
433        "unused-file" => 0,
434        "unused-export" => 1,
435        "unused-type" => 2,
436        "private-type-leak" => 3,
437        "unused-enum-member" => 4,
438        "unused-class-member" => 5,
439        "unused-store-member" => 6,
440        "unresolved-import" => 7,
441        "unlisted-dependency" => 8,
442        "duplicate-export" => 9,
443        "circular-dependency" => 10,
444        "re-export-cycle" => 11,
445        "boundary-violation" => 12,
446        "code-duplication" => 13,
447        "complexity" => 14,
448        "unprovided-inject" => 15,
449        "unrendered-component" => 16,
450        "unused-server-action" => 17,
451        _ => usize::MAX,
452    }
453}
454
455fn group_by_mode_from_label(label: &str) -> GroupByMode {
456    match label {
457        "directory" => GroupByMode::Directory,
458        "package" => GroupByMode::Package,
459        "section" => GroupByMode::Section,
460        _ => GroupByMode::Owner,
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use serde_json::json;
467
468    use super::*;
469
470    #[test]
471    fn harmonize_suppress_actions_merges_same_line_issue_kinds() {
472        let mut output = json!({
473            "unused_exports": [{
474                "path": "src/api.ts",
475                "line": 4,
476                "actions": [{
477                    "type": "suppress-line",
478                    "comment": "// fallow-ignore-next-line unused-export"
479                }]
480            }],
481            "unused_types": [{
482                "path": "src/api.ts",
483                "line": 4,
484                "actions": [{
485                    "type": "suppress-line",
486                    "comment": "// fallow-ignore-next-line unused-type"
487                }]
488            }]
489        });
490
491        harmonize_multi_kind_suppress_line_actions(&mut output);
492
493        assert_eq!(
494            output["unused_exports"][0]["actions"][0]["comment"],
495            "// fallow-ignore-next-line unused-export, unused-type"
496        );
497        assert_eq!(
498            output["unused_types"][0]["actions"][0]["comment"],
499            "// fallow-ignore-next-line unused-export, unused-type"
500        );
501    }
502}