Skip to main content

fallow_api/
json_output.rs

1//! Shared JSON output assembly for CLI and programmatic consumers.
2
3use std::path::Path;
4use std::time::Duration;
5
6use fallow_output::{
7    CHECK_SCHEMA_VERSION, CheckGroupedEntry, CheckGroupedOutput, CheckOutput, CheckOutputInput,
8    DupesOutput, DupesOutputInput, GroupByMode, RootEnvelopeMode,
9    apply_config_fixable_to_duplicate_exports, build_check_output, build_dupes_output,
10    harmonize_multi_kind_suppress_line_actions as harmonize_typed_suppress_line_actions,
11    strip_root_prefix,
12};
13use fallow_types::duplicates::DuplicationReport;
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
23/// Inputs for `fallow dead-code --format json` output assembly.
24pub struct CheckJsonOutputInput<'a> {
25    pub results: &'a AnalysisResults,
26    pub root: &'a Path,
27    pub elapsed: Duration,
28    pub config_fixable: bool,
29    pub meta: Option<Meta>,
30    pub extras: CheckJsonExtraOutputs,
31    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
32    pub next_steps: Vec<NextStep>,
33    pub envelope_mode: RootEnvelopeMode,
34    pub telemetry_analysis_run_id: Option<&'a str>,
35}
36
37/// Inputs for the dead-code JSON payload without a root envelope.
38pub struct CheckJsonPayloadInput<'a> {
39    pub results: &'a AnalysisResults,
40    pub root: &'a Path,
41    pub elapsed: Duration,
42    pub config_fixable: bool,
43    pub extras: CheckJsonExtraOutputs,
44    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
45}
46
47/// Optional root sections for dead-code JSON envelopes.
48///
49/// These fields are part of the output contract, but they are computed by
50/// caller-specific workflows such as baseline and regression gates.
51#[derive(Debug, Clone, Default)]
52pub struct CheckJsonExtraOutputs {
53    pub baseline_deltas: Option<BaselineDeltas>,
54    pub baseline: Option<BaselineMatch>,
55    pub regression: Option<RegressionResult>,
56}
57
58struct CheckJsonEnvelopeInput<'a> {
59    results: &'a AnalysisResults,
60    elapsed: Duration,
61    config_fixable: bool,
62    meta: Option<Meta>,
63    extras: CheckJsonExtraOutputs,
64    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
65    next_steps: Vec<NextStep>,
66}
67
68/// Inputs for grouped dead-code JSON output assembly.
69pub struct GroupedCheckJsonOutputInput<'a> {
70    pub groups: &'a [ResultGroup],
71    pub original: &'a AnalysisResults,
72    pub root: &'a Path,
73    pub elapsed: Duration,
74    pub grouped_by: GroupByMode,
75    pub config_fixable: bool,
76    pub meta: Option<Meta>,
77    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
78    pub next_steps: Vec<NextStep>,
79    pub envelope_mode: RootEnvelopeMode,
80    pub telemetry_analysis_run_id: Option<&'a str>,
81}
82
83/// Inputs for `fallow dupes --format json` output assembly.
84pub struct DuplicationJsonOutputInput<'a> {
85    pub report: &'a DuplicationReport,
86    pub root: &'a Path,
87    pub elapsed: Duration,
88    pub meta: Option<Meta>,
89    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
90    pub next_steps: Vec<NextStep>,
91    pub envelope_mode: RootEnvelopeMode,
92    pub telemetry_analysis_run_id: Option<&'a str>,
93}
94
95/// Inputs for grouped duplication JSON output assembly.
96pub struct GroupedDuplicationJsonOutputInput<'a> {
97    pub report: &'a DuplicationReport,
98    pub grouping: &'a DuplicationGrouping,
99    pub root: &'a Path,
100    pub elapsed: Duration,
101    pub meta: Option<Meta>,
102    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
103    pub next_steps: Vec<NextStep>,
104    pub envelope_mode: RootEnvelopeMode,
105    pub telemetry_analysis_run_id: Option<&'a str>,
106}
107
108/// Build and serialize dead-code JSON through the API-owned output boundary.
109///
110/// # Errors
111///
112/// Returns a serde error when the typed envelope cannot be converted to JSON.
113pub fn serialize_check_json(
114    input: CheckJsonOutputInput<'_>,
115) -> Result<serde_json::Value, serde_json::Error> {
116    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
117        results: input.results,
118        elapsed: input.elapsed,
119        config_fixable: input.config_fixable,
120        meta: input.meta,
121        extras: input.extras,
122        workspace_diagnostics: input.workspace_diagnostics,
123        next_steps: input.next_steps,
124    });
125    let mut output = fallow_output::serialize_check_json_output(
126        envelope,
127        input.envelope_mode,
128        input.telemetry_analysis_run_id,
129    )?;
130    strip_json_root_prefix(&mut output, input.root);
131    Ok(output)
132}
133
134/// Build a dead-code JSON payload without adding a root envelope.
135///
136/// # Errors
137///
138/// Returns a serde error when the typed envelope cannot be converted to JSON.
139pub fn serialize_check_json_payload(
140    input: CheckJsonPayloadInput<'_>,
141) -> Result<serde_json::Value, serde_json::Error> {
142    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
143        results: input.results,
144        elapsed: input.elapsed,
145        config_fixable: input.config_fixable,
146        meta: None,
147        extras: input.extras,
148        workspace_diagnostics: input.workspace_diagnostics,
149        next_steps: Vec::new(),
150    });
151    let mut output = serde_json::to_value(envelope)?;
152    strip_json_root_prefix(&mut output, input.root);
153    Ok(output)
154}
155
156/// Build and serialize grouped dead-code JSON through the API output boundary.
157///
158/// # Errors
159///
160/// Returns a serde error when the typed envelope cannot be converted to JSON.
161pub fn serialize_grouped_check_json(
162    input: GroupedCheckJsonOutputInput<'_>,
163) -> Result<serde_json::Value, serde_json::Error> {
164    let entries = input
165        .groups
166        .iter()
167        .map(|group| {
168            let mut results = group.results.clone();
169            apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
170            harmonize_typed_suppress_line_actions(&mut results);
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        workspace_diagnostics: input.workspace_diagnostics,
189        next_steps: input.next_steps,
190    };
191
192    let mut output = fallow_output::serialize_check_grouped_json_output(
193        envelope,
194        input.envelope_mode,
195        input.telemetry_analysis_run_id,
196    )?;
197    strip_json_root_prefix(&mut output, input.root);
198    Ok(output)
199}
200
201/// Build and serialize duplication JSON through the API-owned output boundary.
202///
203/// # Errors
204///
205/// Returns a serde error when the typed envelope cannot be converted to JSON.
206pub fn serialize_duplication_json(
207    input: DuplicationJsonOutputInput<'_>,
208) -> Result<serde_json::Value, serde_json::Error> {
209    let payload = DupesReportPayload::from_report(input.report);
210    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
211        build_dupes_output(DupesOutputInput {
212            schema_version: CHECK_SCHEMA_VERSION,
213            version: env!("CARGO_PKG_VERSION").to_string(),
214            elapsed: input.elapsed,
215            report: payload,
216            grouped_by: None,
217            total_issues: None,
218            groups: None,
219            meta: input.meta,
220            workspace_diagnostics: input.workspace_diagnostics,
221            next_steps: input.next_steps,
222        });
223    let mut output = fallow_output::serialize_dupes_json_output(
224        envelope,
225        input.envelope_mode,
226        input.telemetry_analysis_run_id,
227    )?;
228    let root_prefix = format!("{}/", input.root.display());
229    strip_root_prefix(&mut output, &root_prefix);
230    Ok(output)
231}
232
233/// Build and serialize grouped duplication JSON through the API output boundary.
234///
235/// # Errors
236///
237/// Returns a serde error when the typed envelope cannot be converted to JSON.
238pub fn serialize_grouped_duplication_json(
239    input: GroupedDuplicationJsonOutputInput<'_>,
240) -> Result<serde_json::Value, serde_json::Error> {
241    let root_prefix = format!("{}/", input.root.display());
242    let payload = DupesReportPayload::from_report(input.report);
243    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
244        build_dupes_output(DupesOutputInput {
245            schema_version: CHECK_SCHEMA_VERSION,
246            version: env!("CARGO_PKG_VERSION").to_string(),
247            elapsed: input.elapsed,
248            report: payload,
249            grouped_by: Some(group_by_mode_from_label(input.grouping.mode)),
250            total_issues: Some(input.report.clone_groups.len()),
251            groups: None,
252            meta: input.meta,
253            workspace_diagnostics: input.workspace_diagnostics,
254            next_steps: input.next_steps,
255        });
256    let mut output = fallow_output::serialize_dupes_json_output(
257        envelope,
258        input.envelope_mode,
259        input.telemetry_analysis_run_id,
260    )?;
261    strip_root_prefix(&mut output, &root_prefix);
262
263    let group_values = input
264        .grouping
265        .groups
266        .iter()
267        .map(|group| {
268            let mut value = serde_json::to_value(group)?;
269            strip_root_prefix(&mut value, &root_prefix);
270            Ok(value)
271        })
272        .collect::<Result<Vec<_>, serde_json::Error>>()?;
273
274    if let serde_json::Value::Object(ref mut map) = output {
275        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
276    }
277
278    Ok(output)
279}
280
281fn build_check_json_envelope(input: CheckJsonEnvelopeInput<'_>) -> CheckOutput {
282    let mut output = build_check_output(CheckOutputInput {
283        schema_version: CHECK_SCHEMA_VERSION,
284        version: env!("CARGO_PKG_VERSION").to_string(),
285        elapsed: input.elapsed,
286        results: input.results.clone(),
287        config_fixable: input.config_fixable,
288        meta: input.meta,
289        workspace_diagnostics: input.workspace_diagnostics,
290        next_steps: input.next_steps,
291    });
292    output.baseline_deltas = input.extras.baseline_deltas;
293    output.baseline = input.extras.baseline;
294    output.regression = input.extras.regression;
295    output
296}
297
298fn strip_json_root_prefix(output: &mut serde_json::Value, root: &Path) {
299    let root_prefix = format!("{}/", root.display());
300    strip_root_prefix(output, &root_prefix);
301}
302
303fn group_by_mode_from_label(label: &str) -> GroupByMode {
304    match label {
305        "directory" => GroupByMode::Directory,
306        "package" => GroupByMode::Package,
307        "section" => GroupByMode::Section,
308        _ => GroupByMode::Owner,
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use fallow_types::workspace::WorkspaceDiagnosticKind;
316
317    #[test]
318    fn grouped_check_json_carries_workspace_diagnostics_with_relative_paths() {
319        let root = Path::new("/project");
320        let output = serialize_grouped_check_json(GroupedCheckJsonOutputInput {
321            groups: &[],
322            original: &AnalysisResults::default(),
323            root,
324            elapsed: Duration::ZERO,
325            grouped_by: GroupByMode::Directory,
326            config_fixable: false,
327            meta: None,
328            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
329                root,
330                root.join("src/unreadable.ts"),
331                WorkspaceDiagnosticKind::SourceReadFailure {
332                    error: "permission denied".to_string(),
333                },
334            )],
335            next_steps: Vec::new(),
336            envelope_mode: RootEnvelopeMode::Tagged,
337            telemetry_analysis_run_id: None,
338        })
339        .expect("grouped check JSON serializes");
340
341        assert_eq!(
342            output["workspace_diagnostics"][0]["path"],
343            "src/unreadable.ts"
344        );
345        assert_eq!(
346            output["workspace_diagnostics"][0]["kind"],
347            "source-read-failure"
348        );
349    }
350}