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    DUPES_SCHEMA_VERSION, 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    /// Typed dead-code results to serialize.
26    pub results: &'a AnalysisResults,
27    /// Project root; its prefix is stripped from every path in the output.
28    pub root: &'a Path,
29    /// Analysis wall time, emitted as `elapsed_ms`.
30    pub elapsed: Duration,
31    /// Whether duplicate-export findings can be auto-fixed through config;
32    /// propagated onto their fix actions.
33    pub config_fixable: bool,
34    /// Optional explain metadata block for the envelope.
35    pub meta: Option<Meta>,
36    /// Caller-computed baseline and regression sections.
37    pub extras: CheckJsonExtraOutputs,
38    /// Non-fatal per-file diagnostics collected during the workspace walk.
39    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
40    /// Suggested follow-up commands for the consumer.
41    pub next_steps: Vec<NextStep>,
42    /// Whether the root envelope carries a `kind` discriminant.
43    pub envelope_mode: RootEnvelopeMode,
44    /// Analysis run id stamped into telemetry metadata when present.
45    pub telemetry_analysis_run_id: Option<&'a str>,
46}
47
48/// Inputs for the dead-code JSON payload without a root envelope.
49pub struct CheckJsonPayloadInput<'a> {
50    /// Typed dead-code results to serialize.
51    pub results: &'a AnalysisResults,
52    /// Project root; its prefix is stripped from every path in the output.
53    pub root: &'a Path,
54    /// Analysis wall time, emitted as `elapsed_ms`.
55    pub elapsed: Duration,
56    /// Whether duplicate-export findings can be auto-fixed through config;
57    /// propagated onto their fix actions.
58    pub config_fixable: bool,
59    /// Caller-computed baseline and regression sections.
60    pub extras: CheckJsonExtraOutputs,
61    /// Non-fatal per-file diagnostics collected during the workspace walk.
62    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
63}
64
65/// Optional root sections for dead-code JSON envelopes.
66///
67/// These fields are part of the output contract, but they are computed by
68/// caller-specific workflows such as baseline and regression gates.
69#[derive(Debug, Clone, Default)]
70pub struct CheckJsonExtraOutputs {
71    /// Per-category issue count changes against the matched baseline.
72    pub baseline_deltas: Option<BaselineDeltas>,
73    /// Which baseline snapshot the run was compared against.
74    pub baseline: Option<BaselineMatch>,
75    /// Outcome of the regression gate against the baseline.
76    pub regression: Option<RegressionResult>,
77}
78
79struct CheckJsonEnvelopeInput<'a> {
80    results: &'a AnalysisResults,
81    elapsed: Duration,
82    config_fixable: bool,
83    meta: Option<Meta>,
84    extras: CheckJsonExtraOutputs,
85    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
86    next_steps: Vec<NextStep>,
87}
88
89/// Inputs for grouped dead-code JSON output assembly.
90pub struct GroupedCheckJsonOutputInput<'a> {
91    /// Results already partitioned into groups, in output order.
92    pub groups: &'a [ResultGroup],
93    /// Ungrouped results, used for the envelope's `total_issues` count.
94    pub original: &'a AnalysisResults,
95    /// Project root; its prefix is stripped from every path in the output.
96    pub root: &'a Path,
97    /// Analysis wall time, emitted as `elapsed_ms`.
98    pub elapsed: Duration,
99    /// Grouping axis recorded as `grouped_by` in the envelope.
100    pub grouped_by: GroupByMode,
101    /// Whether duplicate-export findings can be auto-fixed through config;
102    /// propagated onto their fix actions per group.
103    pub config_fixable: bool,
104    /// Optional explain metadata block for the envelope.
105    pub meta: Option<Meta>,
106    /// Non-fatal per-file diagnostics collected during the workspace walk.
107    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
108    /// Suggested follow-up commands for the consumer.
109    pub next_steps: Vec<NextStep>,
110    /// Whether the root envelope carries a `kind` discriminant.
111    pub envelope_mode: RootEnvelopeMode,
112    /// Analysis run id stamped into telemetry metadata when present.
113    pub telemetry_analysis_run_id: Option<&'a str>,
114}
115
116/// Inputs for `fallow dupes --format json` output assembly.
117pub struct DuplicationJsonOutputInput<'a> {
118    /// Typed duplication report to serialize.
119    pub report: &'a DuplicationReport,
120    /// Project root; its prefix is stripped from every path in the output.
121    pub root: &'a Path,
122    /// Analysis wall time, emitted as `elapsed_ms`.
123    pub elapsed: Duration,
124    /// Optional explain metadata block for the envelope.
125    pub meta: Option<Meta>,
126    /// Non-fatal per-file diagnostics collected during the workspace walk.
127    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
128    /// Suggested follow-up commands for the consumer.
129    pub next_steps: Vec<NextStep>,
130    /// Whether the root envelope carries a `kind` discriminant.
131    pub envelope_mode: RootEnvelopeMode,
132    /// Analysis run id stamped into telemetry metadata when present.
133    pub telemetry_analysis_run_id: Option<&'a str>,
134}
135
136/// Inputs for grouped duplication JSON output assembly.
137pub struct GroupedDuplicationJsonOutputInput<'a> {
138    /// Typed duplication report to serialize.
139    pub report: &'a DuplicationReport,
140    /// Precomputed grouping whose groups replace the flat `groups` array.
141    pub grouping: &'a DuplicationGrouping,
142    /// Project root; its prefix is stripped from every path in the output.
143    pub root: &'a Path,
144    /// Analysis wall time, emitted as `elapsed_ms`.
145    pub elapsed: Duration,
146    /// Optional explain metadata block for the envelope.
147    pub meta: Option<Meta>,
148    /// Non-fatal per-file diagnostics collected during the workspace walk.
149    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
150    /// Suggested follow-up commands for the consumer.
151    pub next_steps: Vec<NextStep>,
152    /// Whether the root envelope carries a `kind` discriminant.
153    pub envelope_mode: RootEnvelopeMode,
154    /// Analysis run id stamped into telemetry metadata when present.
155    pub telemetry_analysis_run_id: Option<&'a str>,
156}
157
158/// Build and serialize dead-code JSON through the API-owned output boundary.
159///
160/// # Errors
161///
162/// Returns a serde error when the typed envelope cannot be converted to JSON.
163pub fn serialize_check_json(
164    input: CheckJsonOutputInput<'_>,
165) -> Result<serde_json::Value, serde_json::Error> {
166    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
167        results: input.results,
168        elapsed: input.elapsed,
169        config_fixable: input.config_fixable,
170        meta: input.meta,
171        extras: input.extras,
172        workspace_diagnostics: input.workspace_diagnostics,
173        next_steps: input.next_steps,
174    });
175    let mut output = fallow_output::serialize_check_json_output(
176        envelope,
177        input.envelope_mode,
178        input.telemetry_analysis_run_id,
179    )?;
180    strip_json_root_prefix(&mut output, input.root);
181    Ok(output)
182}
183
184/// Build a dead-code JSON payload without adding a root envelope.
185///
186/// # Errors
187///
188/// Returns a serde error when the typed envelope cannot be converted to JSON.
189pub fn serialize_check_json_payload(
190    input: CheckJsonPayloadInput<'_>,
191) -> Result<serde_json::Value, serde_json::Error> {
192    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
193        results: input.results,
194        elapsed: input.elapsed,
195        config_fixable: input.config_fixable,
196        meta: None,
197        extras: input.extras,
198        workspace_diagnostics: input.workspace_diagnostics,
199        next_steps: Vec::new(),
200    });
201    let mut output = serde_json::to_value(envelope)?;
202    strip_json_root_prefix(&mut output, input.root);
203    Ok(output)
204}
205
206/// Build and serialize grouped dead-code JSON through the API output boundary.
207///
208/// # Errors
209///
210/// Returns a serde error when the typed envelope cannot be converted to JSON.
211pub fn serialize_grouped_check_json(
212    input: GroupedCheckJsonOutputInput<'_>,
213) -> Result<serde_json::Value, serde_json::Error> {
214    let entries = input
215        .groups
216        .iter()
217        .map(|group| {
218            let mut results = group.results.clone();
219            apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
220            harmonize_typed_suppress_line_actions(&mut results);
221            CheckGroupedEntry {
222                key: group.key.clone(),
223                owners: group.owners.clone(),
224                total_issues: results.total_issues(),
225                results,
226            }
227        })
228        .collect();
229
230    let envelope = CheckGroupedOutput {
231        schema_version: SchemaVersion(CHECK_SCHEMA_VERSION),
232        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
233        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
234        grouped_by: input.grouped_by,
235        total_issues: input.original.total_issues(),
236        groups: entries,
237        meta: input.meta,
238        workspace_diagnostics: input.workspace_diagnostics,
239        next_steps: input.next_steps,
240    };
241
242    let mut output = fallow_output::serialize_check_grouped_json_output(
243        envelope,
244        input.envelope_mode,
245        input.telemetry_analysis_run_id,
246    )?;
247    strip_json_root_prefix(&mut output, input.root);
248    Ok(output)
249}
250
251/// Build and serialize duplication JSON through the API-owned output boundary.
252///
253/// # Errors
254///
255/// Returns a serde error when the typed envelope cannot be converted to JSON.
256pub fn serialize_duplication_json(
257    input: DuplicationJsonOutputInput<'_>,
258) -> Result<serde_json::Value, serde_json::Error> {
259    let payload = DupesReportPayload::from_report(input.report);
260    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
261        build_dupes_output(DupesOutputInput {
262            schema_version: DUPES_SCHEMA_VERSION,
263            version: env!("CARGO_PKG_VERSION").to_string(),
264            elapsed: input.elapsed,
265            report: payload,
266            grouped_by: None,
267            total_issues: None,
268            groups: None,
269            meta: input.meta,
270            workspace_diagnostics: input.workspace_diagnostics,
271            next_steps: input.next_steps,
272        });
273    let mut output = fallow_output::serialize_dupes_json_output(
274        envelope,
275        input.envelope_mode,
276        input.telemetry_analysis_run_id,
277    )?;
278    let root_prefix = format!("{}/", input.root.display());
279    strip_root_prefix(&mut output, &root_prefix);
280    Ok(output)
281}
282
283/// Build and serialize grouped duplication JSON through the API output boundary.
284///
285/// # Errors
286///
287/// Returns a serde error when the typed envelope cannot be converted to JSON.
288pub fn serialize_grouped_duplication_json(
289    input: GroupedDuplicationJsonOutputInput<'_>,
290) -> Result<serde_json::Value, serde_json::Error> {
291    let root_prefix = format!("{}/", input.root.display());
292    let payload = DupesReportPayload::from_report(input.report);
293    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
294        build_dupes_output(DupesOutputInput {
295            schema_version: DUPES_SCHEMA_VERSION,
296            version: env!("CARGO_PKG_VERSION").to_string(),
297            elapsed: input.elapsed,
298            report: payload,
299            grouped_by: Some(group_by_mode_from_label(input.grouping.mode)),
300            total_issues: Some(input.report.clone_groups.len()),
301            groups: None,
302            meta: input.meta,
303            workspace_diagnostics: input.workspace_diagnostics,
304            next_steps: input.next_steps,
305        });
306    let mut output = fallow_output::serialize_dupes_json_output(
307        envelope,
308        input.envelope_mode,
309        input.telemetry_analysis_run_id,
310    )?;
311    strip_root_prefix(&mut output, &root_prefix);
312
313    let group_values = input
314        .grouping
315        .groups
316        .iter()
317        .map(|group| {
318            let mut value = serde_json::to_value(group)?;
319            strip_root_prefix(&mut value, &root_prefix);
320            Ok(value)
321        })
322        .collect::<Result<Vec<_>, serde_json::Error>>()?;
323
324    if let serde_json::Value::Object(ref mut map) = output {
325        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
326    }
327
328    Ok(output)
329}
330
331fn build_check_json_envelope(input: CheckJsonEnvelopeInput<'_>) -> CheckOutput {
332    let mut output = build_check_output(CheckOutputInput {
333        schema_version: CHECK_SCHEMA_VERSION,
334        version: env!("CARGO_PKG_VERSION").to_string(),
335        elapsed: input.elapsed,
336        results: input.results.clone(),
337        config_fixable: input.config_fixable,
338        meta: input.meta,
339        workspace_diagnostics: input.workspace_diagnostics,
340        next_steps: input.next_steps,
341    });
342    output.baseline_deltas = input.extras.baseline_deltas;
343    output.baseline = input.extras.baseline;
344    output.regression = input.extras.regression;
345    output
346}
347
348fn strip_json_root_prefix(output: &mut serde_json::Value, root: &Path) {
349    let root_prefix = format!("{}/", root.display());
350    strip_root_prefix(output, &root_prefix);
351}
352
353fn group_by_mode_from_label(label: &str) -> GroupByMode {
354    match label {
355        "directory" => GroupByMode::Directory,
356        "package" => GroupByMode::Package,
357        "section" => GroupByMode::Section,
358        _ => GroupByMode::Owner,
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use fallow_types::workspace::WorkspaceDiagnosticKind;
366
367    #[test]
368    fn grouped_check_json_carries_workspace_diagnostics_with_relative_paths() {
369        let root = Path::new("/project");
370        let output = serialize_grouped_check_json(GroupedCheckJsonOutputInput {
371            groups: &[],
372            original: &AnalysisResults::default(),
373            root,
374            elapsed: Duration::ZERO,
375            grouped_by: GroupByMode::Directory,
376            config_fixable: false,
377            meta: None,
378            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
379                root,
380                root.join("src/unreadable.ts"),
381                WorkspaceDiagnosticKind::SourceReadFailure {
382                    error: "permission denied".to_string(),
383                },
384            )],
385            next_steps: Vec::new(),
386            envelope_mode: RootEnvelopeMode::Tagged,
387            telemetry_analysis_run_id: None,
388        })
389        .expect("grouped check JSON serializes");
390
391        assert_eq!(
392            output["workspace_diagnostics"][0]["path"],
393            "src/unreadable.ts"
394        );
395        assert_eq!(
396            output["workspace_diagnostics"][0]["kind"],
397            "source-read-failure"
398        );
399    }
400}