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    /// Whether each clone instance carries its verbatim source text.
125    pub include_fragments: bool,
126    /// Optional explain metadata block for the envelope.
127    pub meta: Option<Meta>,
128    /// Non-fatal per-file diagnostics collected during the workspace walk.
129    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
130    /// Suggested follow-up commands for the consumer.
131    pub next_steps: Vec<NextStep>,
132    /// Whether the root envelope carries a `kind` discriminant.
133    pub envelope_mode: RootEnvelopeMode,
134    /// Analysis run id stamped into telemetry metadata when present.
135    pub telemetry_analysis_run_id: Option<&'a str>,
136}
137
138/// Inputs for grouped duplication JSON output assembly.
139pub struct GroupedDuplicationJsonOutputInput<'a> {
140    /// Typed duplication report to serialize.
141    pub report: &'a DuplicationReport,
142    /// Precomputed grouping whose groups replace the flat `groups` array.
143    pub grouping: &'a DuplicationGrouping,
144    /// Project root; its prefix is stripped from every path in the output.
145    pub root: &'a Path,
146    /// Analysis wall time, emitted as `elapsed_ms`.
147    pub elapsed: Duration,
148    /// Whether each clone instance carries its verbatim source text.
149    pub include_fragments: bool,
150    /// Optional explain metadata block for the envelope.
151    pub meta: Option<Meta>,
152    /// Non-fatal per-file diagnostics collected during the workspace walk.
153    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
154    /// Suggested follow-up commands for the consumer.
155    pub next_steps: Vec<NextStep>,
156    /// Whether the root envelope carries a `kind` discriminant.
157    pub envelope_mode: RootEnvelopeMode,
158    /// Analysis run id stamped into telemetry metadata when present.
159    pub telemetry_analysis_run_id: Option<&'a str>,
160}
161
162/// Build and serialize dead-code JSON through the API-owned output boundary.
163///
164/// # Errors
165///
166/// Returns a serde error when the typed envelope cannot be converted to JSON.
167pub fn serialize_check_json(
168    input: CheckJsonOutputInput<'_>,
169) -> Result<serde_json::Value, serde_json::Error> {
170    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
171        results: input.results,
172        elapsed: input.elapsed,
173        config_fixable: input.config_fixable,
174        meta: input.meta,
175        extras: input.extras,
176        workspace_diagnostics: input.workspace_diagnostics,
177        next_steps: input.next_steps,
178    });
179    let mut output = fallow_output::serialize_check_json_output(
180        envelope,
181        input.envelope_mode,
182        input.telemetry_analysis_run_id,
183    )?;
184    strip_json_root_prefix(&mut output, input.root);
185    Ok(output)
186}
187
188/// Build a dead-code JSON payload without adding a root envelope.
189///
190/// # Errors
191///
192/// Returns a serde error when the typed envelope cannot be converted to JSON.
193pub fn serialize_check_json_payload(
194    input: CheckJsonPayloadInput<'_>,
195) -> Result<serde_json::Value, serde_json::Error> {
196    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
197        results: input.results,
198        elapsed: input.elapsed,
199        config_fixable: input.config_fixable,
200        meta: None,
201        extras: input.extras,
202        workspace_diagnostics: input.workspace_diagnostics,
203        next_steps: Vec::new(),
204    });
205    let mut output = serde_json::to_value(envelope)?;
206    strip_json_root_prefix(&mut output, input.root);
207    Ok(output)
208}
209
210/// Build and serialize grouped dead-code JSON through the API output boundary.
211///
212/// # Errors
213///
214/// Returns a serde error when the typed envelope cannot be converted to JSON.
215pub fn serialize_grouped_check_json(
216    input: GroupedCheckJsonOutputInput<'_>,
217) -> Result<serde_json::Value, serde_json::Error> {
218    let entries = input
219        .groups
220        .iter()
221        .map(|group| {
222            let mut results = group.results.clone();
223            apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
224            harmonize_typed_suppress_line_actions(&mut results);
225            CheckGroupedEntry {
226                key: group.key.clone(),
227                owners: group.owners.clone(),
228                total_issues: results.total_issues(),
229                results,
230            }
231        })
232        .collect();
233
234    let envelope = CheckGroupedOutput {
235        schema_version: SchemaVersion(CHECK_SCHEMA_VERSION),
236        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
237        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
238        grouped_by: input.grouped_by,
239        total_issues: input.original.total_issues(),
240        groups: entries,
241        meta: input.meta,
242        workspace_diagnostics: input.workspace_diagnostics,
243        next_steps: input.next_steps,
244    };
245
246    let mut output = fallow_output::serialize_check_grouped_json_output(
247        envelope,
248        input.envelope_mode,
249        input.telemetry_analysis_run_id,
250    )?;
251    strip_json_root_prefix(&mut output, input.root);
252    Ok(output)
253}
254
255/// Build and serialize duplication JSON through the API-owned output boundary.
256///
257/// # Errors
258///
259/// Returns a serde error when the typed envelope cannot be converted to JSON.
260pub fn serialize_duplication_json(
261    input: DuplicationJsonOutputInput<'_>,
262) -> Result<serde_json::Value, serde_json::Error> {
263    let payload =
264        DupesReportPayload::from_report_with_fragments(input.report, input.include_fragments);
265    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
266        build_dupes_output(DupesOutputInput {
267            schema_version: DUPES_SCHEMA_VERSION,
268            version: env!("CARGO_PKG_VERSION").to_string(),
269            elapsed: input.elapsed,
270            report: payload,
271            clone_groups_shown: input.report.clone_groups_shown(),
272            clone_groups_omitted: input.report.clone_groups_omitted(),
273            clone_families_shown: input.report.clone_families_shown(),
274            clone_families_omitted: input.report.clone_families_omitted(),
275            grouped_by: None,
276            total_issues: None,
277            groups: None,
278            meta: input.meta,
279            workspace_diagnostics: input.workspace_diagnostics,
280            next_steps: input.next_steps,
281        });
282    let mut output = fallow_output::serialize_dupes_json_output(
283        envelope,
284        input.envelope_mode,
285        input.telemetry_analysis_run_id,
286    )?;
287    let root_prefix = format!("{}/", input.root.display());
288    strip_root_prefix(&mut output, &root_prefix);
289    Ok(output)
290}
291
292/// Build and serialize grouped duplication JSON through the API output boundary.
293///
294/// # Errors
295///
296/// Returns a serde error when the typed envelope cannot be converted to JSON.
297pub fn serialize_grouped_duplication_json(
298    input: GroupedDuplicationJsonOutputInput<'_>,
299) -> Result<serde_json::Value, serde_json::Error> {
300    let root_prefix = format!("{}/", input.root.display());
301    let payload =
302        DupesReportPayload::from_report_with_fragments(input.report, input.include_fragments);
303    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
304        build_dupes_output(DupesOutputInput {
305            schema_version: DUPES_SCHEMA_VERSION,
306            version: env!("CARGO_PKG_VERSION").to_string(),
307            elapsed: input.elapsed,
308            report: payload,
309            clone_groups_shown: input.report.clone_groups_shown(),
310            clone_groups_omitted: input.report.clone_groups_omitted(),
311            clone_families_shown: input.report.clone_families_shown(),
312            clone_families_omitted: input.report.clone_families_omitted(),
313            grouped_by: Some(group_by_mode_from_label(input.grouping.mode)),
314            total_issues: Some(input.report.clone_groups.len()),
315            groups: None,
316            meta: input.meta,
317            workspace_diagnostics: input.workspace_diagnostics,
318            next_steps: input.next_steps,
319        });
320    let mut output = fallow_output::serialize_dupes_json_output(
321        envelope,
322        input.envelope_mode,
323        input.telemetry_analysis_run_id,
324    )?;
325    strip_root_prefix(&mut output, &root_prefix);
326
327    let group_values = input
328        .grouping
329        .groups
330        .iter()
331        .map(|group| {
332            let mut value = if input.include_fragments {
333                serde_json::to_value(group)?
334            } else {
335                let mut stripped = group.clone();
336                stripped.strip_fragments();
337                serde_json::to_value(&stripped)?
338            };
339            strip_root_prefix(&mut value, &root_prefix);
340            Ok(value)
341        })
342        .collect::<Result<Vec<_>, serde_json::Error>>()?;
343
344    if let serde_json::Value::Object(ref mut map) = output {
345        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
346    }
347
348    Ok(output)
349}
350
351fn build_check_json_envelope(input: CheckJsonEnvelopeInput<'_>) -> CheckOutput {
352    let mut output = build_check_output(CheckOutputInput {
353        schema_version: CHECK_SCHEMA_VERSION,
354        version: env!("CARGO_PKG_VERSION").to_string(),
355        elapsed: input.elapsed,
356        results: input.results.clone(),
357        config_fixable: input.config_fixable,
358        meta: input.meta,
359        workspace_diagnostics: input.workspace_diagnostics,
360        next_steps: input.next_steps,
361    });
362    output.baseline_deltas = input.extras.baseline_deltas;
363    output.baseline = input.extras.baseline;
364    output.regression = input.extras.regression;
365    output
366}
367
368fn strip_json_root_prefix(output: &mut serde_json::Value, root: &Path) {
369    let root_prefix = format!("{}/", root.display());
370    strip_root_prefix(output, &root_prefix);
371}
372
373fn group_by_mode_from_label(label: &str) -> GroupByMode {
374    match label {
375        "directory" => GroupByMode::Directory,
376        "package" => GroupByMode::Package,
377        "section" => GroupByMode::Section,
378        _ => GroupByMode::Owner,
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use fallow_types::workspace::WorkspaceDiagnosticKind;
386
387    #[test]
388    fn grouped_check_json_carries_workspace_diagnostics_with_relative_paths() {
389        let root = Path::new("/project");
390        let output = serialize_grouped_check_json(GroupedCheckJsonOutputInput {
391            groups: &[],
392            original: &AnalysisResults::default(),
393            root,
394            elapsed: Duration::ZERO,
395            grouped_by: GroupByMode::Directory,
396            config_fixable: false,
397            meta: None,
398            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
399                root,
400                root.join("src/unreadable.ts"),
401                WorkspaceDiagnosticKind::SourceReadFailure {
402                    error: "permission denied".to_string(),
403                },
404            )],
405            next_steps: Vec::new(),
406            envelope_mode: RootEnvelopeMode::Tagged,
407            telemetry_analysis_run_id: None,
408        })
409        .expect("grouped check JSON serializes");
410
411        assert_eq!(
412            output["workspace_diagnostics"][0]["path"],
413            "src/unreadable.ts"
414        );
415        assert_eq!(
416            output["workspace_diagnostics"][0]["kind"],
417            "source-read-failure"
418        );
419    }
420}