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    /// This run's view of that baseline: counts, advisory verdict and the
76    /// `--fail-on-stale-baseline` verdict.
77    pub baseline_staleness: Option<fallow_output::BaselineStaleness>,
78    /// Outcome of the regression gate against the baseline.
79    pub regression: Option<RegressionResult>,
80    /// Every gate this run evaluated. The programmatic route runs no CLI-layer
81    /// gate, so a caller that computes none leaves this `None` and the envelope
82    /// key stays absent. An empty set is never emitted: it would assert that
83    /// gates were evaluated and none tripped, which is a different claim.
84    pub gate_outcomes: Option<fallow_output::GateOutcomes>,
85    /// Every narrowing or shaping request this run received, absent when it
86    /// was asked for nothing. The programmatic route resolves no CLI flag and
87    /// leaves this `None`; an entry whose `status` is not `applied` means the
88    /// report is wider than what was asked for.
89    pub request_outcomes: Option<fallow_output::RequestOutcomes>,
90}
91
92struct CheckJsonEnvelopeInput<'a> {
93    results: &'a AnalysisResults,
94    elapsed: Duration,
95    config_fixable: bool,
96    meta: Option<Meta>,
97    extras: CheckJsonExtraOutputs,
98    workspace_diagnostics: Vec<WorkspaceDiagnostic>,
99    next_steps: Vec<NextStep>,
100}
101
102/// Inputs for grouped dead-code JSON output assembly.
103pub struct GroupedCheckJsonOutputInput<'a> {
104    /// This run's view of the loaded baseline, for baseline runs.
105    pub baseline_staleness: Option<fallow_output::BaselineStaleness>,
106    /// Every gate this run evaluated. The programmatic route runs no CLI-layer
107    /// gate, so a caller that computes none leaves this `None` and the envelope
108    /// key stays absent. An empty set is never emitted: it would assert that
109    /// gates were evaluated and none tripped, which is a different claim.
110    pub gate_outcomes: Option<fallow_output::GateOutcomes>,
111    /// Every narrowing or shaping request this run received, absent when it
112    /// was asked for nothing. The programmatic route resolves no CLI flag and
113    /// leaves this `None`; an entry whose `status` is not `applied` means the
114    /// report is wider than what was asked for.
115    pub request_outcomes: Option<fallow_output::RequestOutcomes>,
116
117    /// Results already partitioned into groups, in output order.
118    pub groups: &'a [ResultGroup],
119    /// Ungrouped results, used for the envelope's `total_issues` count.
120    pub original: &'a AnalysisResults,
121    /// Project root; its prefix is stripped from every path in the output.
122    pub root: &'a Path,
123    /// Analysis wall time, emitted as `elapsed_ms`.
124    pub elapsed: Duration,
125    /// Grouping axis recorded as `grouped_by` in the envelope.
126    pub grouped_by: GroupByMode,
127    /// Whether duplicate-export findings can be auto-fixed through config;
128    /// propagated onto their fix actions per group.
129    pub config_fixable: bool,
130    /// Optional explain metadata block for the envelope.
131    pub meta: Option<Meta>,
132    /// Non-fatal per-file diagnostics collected during the workspace walk.
133    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
134    /// Suggested follow-up commands for the consumer.
135    pub next_steps: Vec<NextStep>,
136    /// Whether the root envelope carries a `kind` discriminant.
137    pub envelope_mode: RootEnvelopeMode,
138    /// Analysis run id stamped into telemetry metadata when present.
139    pub telemetry_analysis_run_id: Option<&'a str>,
140}
141
142/// Inputs for `fallow dupes --format json` output assembly.
143pub struct DuplicationJsonOutputInput<'a> {
144    /// This run's view of the loaded duplication baseline, for baseline runs.
145    pub baseline_staleness: Option<fallow_output::BaselineStaleness>,
146    /// Every gate this run evaluated. The programmatic route runs no CLI-layer
147    /// gate, so a caller that computes none leaves this `None` and the envelope
148    /// key stays absent. An empty set is never emitted: it would assert that
149    /// gates were evaluated and none tripped, which is a different claim.
150    pub gate_outcomes: Option<fallow_output::GateOutcomes>,
151    /// Every narrowing or shaping request this run received, absent when it
152    /// was asked for nothing. The programmatic route resolves no CLI flag and
153    /// leaves this `None`; an entry whose `status` is not `applied` means the
154    /// report is wider than what was asked for.
155    pub request_outcomes: Option<fallow_output::RequestOutcomes>,
156
157    /// Typed duplication report to serialize.
158    pub report: &'a DuplicationReport,
159    /// Project root; its prefix is stripped from every path in the output.
160    pub root: &'a Path,
161    /// Analysis wall time, emitted as `elapsed_ms`.
162    pub elapsed: Duration,
163    /// Whether each clone instance carries its verbatim source text.
164    pub include_fragments: bool,
165    /// Optional explain metadata block for the envelope.
166    pub meta: Option<Meta>,
167    /// Non-fatal per-file diagnostics collected during the workspace walk.
168    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
169    /// Suggested follow-up commands for the consumer.
170    pub next_steps: Vec<NextStep>,
171    /// Whether the root envelope carries a `kind` discriminant.
172    pub envelope_mode: RootEnvelopeMode,
173    /// Analysis run id stamped into telemetry metadata when present.
174    pub telemetry_analysis_run_id: Option<&'a str>,
175}
176
177/// Inputs for grouped duplication JSON output assembly.
178pub struct GroupedDuplicationJsonOutputInput<'a> {
179    /// This run's view of the loaded duplication baseline, for baseline runs.
180    pub baseline_staleness: Option<fallow_output::BaselineStaleness>,
181    /// Every gate this run evaluated. The programmatic route runs no CLI-layer
182    /// gate, so a caller that computes none leaves this `None` and the envelope
183    /// key stays absent. An empty set is never emitted: it would assert that
184    /// gates were evaluated and none tripped, which is a different claim.
185    pub gate_outcomes: Option<fallow_output::GateOutcomes>,
186    /// Every narrowing or shaping request this run received, absent when it
187    /// was asked for nothing. The programmatic route resolves no CLI flag and
188    /// leaves this `None`; an entry whose `status` is not `applied` means the
189    /// report is wider than what was asked for.
190    pub request_outcomes: Option<fallow_output::RequestOutcomes>,
191
192    /// Typed duplication report to serialize.
193    pub report: &'a DuplicationReport,
194    /// Precomputed grouping whose groups replace the flat `groups` array.
195    pub grouping: &'a DuplicationGrouping,
196    /// Project root; its prefix is stripped from every path in the output.
197    pub root: &'a Path,
198    /// Analysis wall time, emitted as `elapsed_ms`.
199    pub elapsed: Duration,
200    /// Whether each clone instance carries its verbatim source text.
201    pub include_fragments: bool,
202    /// Optional explain metadata block for the envelope.
203    pub meta: Option<Meta>,
204    /// Non-fatal per-file diagnostics collected during the workspace walk.
205    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
206    /// Suggested follow-up commands for the consumer.
207    pub next_steps: Vec<NextStep>,
208    /// Whether the root envelope carries a `kind` discriminant.
209    pub envelope_mode: RootEnvelopeMode,
210    /// Analysis run id stamped into telemetry metadata when present.
211    pub telemetry_analysis_run_id: Option<&'a str>,
212}
213
214/// Build and serialize dead-code JSON through the API-owned output boundary.
215///
216/// # Errors
217///
218/// Returns a serde error when the typed envelope cannot be converted to JSON.
219pub fn serialize_check_json(
220    input: CheckJsonOutputInput<'_>,
221) -> Result<serde_json::Value, serde_json::Error> {
222    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
223        results: input.results,
224        elapsed: input.elapsed,
225        config_fixable: input.config_fixable,
226        meta: input.meta,
227        extras: input.extras,
228        workspace_diagnostics: input.workspace_diagnostics,
229        next_steps: input.next_steps,
230    });
231    let mut output = fallow_output::serialize_check_json_output(
232        envelope,
233        input.envelope_mode,
234        input.telemetry_analysis_run_id,
235    )?;
236    strip_json_root_prefix(&mut output, input.root);
237    Ok(output)
238}
239
240/// Build a dead-code JSON payload without adding a root envelope.
241///
242/// # Errors
243///
244/// Returns a serde error when the typed envelope cannot be converted to JSON.
245pub fn serialize_check_json_payload(
246    input: CheckJsonPayloadInput<'_>,
247) -> Result<serde_json::Value, serde_json::Error> {
248    let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
249        results: input.results,
250        elapsed: input.elapsed,
251        config_fixable: input.config_fixable,
252        meta: None,
253        extras: input.extras,
254        workspace_diagnostics: input.workspace_diagnostics,
255        next_steps: Vec::new(),
256    });
257    let mut output = serde_json::to_value(envelope)?;
258    strip_json_root_prefix(&mut output, input.root);
259    Ok(output)
260}
261
262/// Build and serialize grouped dead-code JSON through the API output boundary.
263///
264/// # Errors
265///
266/// Returns a serde error when the typed envelope cannot be converted to JSON.
267pub fn serialize_grouped_check_json(
268    input: GroupedCheckJsonOutputInput<'_>,
269) -> Result<serde_json::Value, serde_json::Error> {
270    let entries = input
271        .groups
272        .iter()
273        .map(|group| {
274            let mut results = group.results.clone();
275            apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
276            harmonize_typed_suppress_line_actions(&mut results);
277            CheckGroupedEntry {
278                key: group.key.clone(),
279                owners: group.owners.clone(),
280                total_issues: results.total_issues(),
281                results,
282            }
283        })
284        .collect();
285
286    let envelope = CheckGroupedOutput {
287        request_outcomes: input.request_outcomes,
288        schema_version: SchemaVersion(CHECK_SCHEMA_VERSION),
289        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
290        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
291        grouped_by: input.grouped_by,
292        total_issues: input.original.total_issues(),
293        groups: entries,
294        baseline_staleness: input.baseline_staleness,
295        gate_outcomes: input.gate_outcomes,
296        meta: input.meta,
297        workspace_diagnostics: input.workspace_diagnostics,
298        next_steps: input.next_steps,
299    };
300
301    let mut output = fallow_output::serialize_check_grouped_json_output(
302        envelope,
303        input.envelope_mode,
304        input.telemetry_analysis_run_id,
305    )?;
306    strip_json_root_prefix(&mut output, input.root);
307    Ok(output)
308}
309
310/// Build and serialize duplication JSON through the API-owned output boundary.
311///
312/// # Errors
313///
314/// Returns a serde error when the typed envelope cannot be converted to JSON.
315pub fn serialize_duplication_json(
316    input: DuplicationJsonOutputInput<'_>,
317) -> Result<serde_json::Value, serde_json::Error> {
318    let payload =
319        DupesReportPayload::from_report_with_fragments(input.report, input.include_fragments);
320    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
321        build_dupes_output(DupesOutputInput {
322            gate_outcomes: input.gate_outcomes,
323            request_outcomes: input.request_outcomes,
324            schema_version: DUPES_SCHEMA_VERSION,
325            version: env!("CARGO_PKG_VERSION").to_string(),
326            elapsed: input.elapsed,
327            report: payload,
328            clone_groups_shown: input.report.clone_groups_shown(),
329            clone_groups_omitted: input.report.clone_groups_omitted(),
330            clone_families_shown: input.report.clone_families_shown(),
331            clone_families_omitted: input.report.clone_families_omitted(),
332            grouped_by: None,
333            total_issues: None,
334            groups: None,
335            baseline_staleness: input.baseline_staleness,
336            meta: input.meta,
337            workspace_diagnostics: input.workspace_diagnostics,
338            next_steps: input.next_steps,
339        });
340    let mut output = fallow_output::serialize_dupes_json_output(
341        envelope,
342        input.envelope_mode,
343        input.telemetry_analysis_run_id,
344    )?;
345    let root_prefix = format!("{}/", input.root.display());
346    strip_root_prefix(&mut output, &root_prefix);
347    Ok(output)
348}
349
350/// Build and serialize grouped duplication JSON through the API output boundary.
351///
352/// # Errors
353///
354/// Returns a serde error when the typed envelope cannot be converted to JSON.
355pub fn serialize_grouped_duplication_json(
356    input: GroupedDuplicationJsonOutputInput<'_>,
357) -> Result<serde_json::Value, serde_json::Error> {
358    let root_prefix = format!("{}/", input.root.display());
359    let payload =
360        DupesReportPayload::from_report_with_fragments(input.report, input.include_fragments);
361    let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
362        build_dupes_output(DupesOutputInput {
363            gate_outcomes: input.gate_outcomes,
364            request_outcomes: input.request_outcomes,
365            schema_version: DUPES_SCHEMA_VERSION,
366            version: env!("CARGO_PKG_VERSION").to_string(),
367            elapsed: input.elapsed,
368            report: payload,
369            clone_groups_shown: input.report.clone_groups_shown(),
370            clone_groups_omitted: input.report.clone_groups_omitted(),
371            clone_families_shown: input.report.clone_families_shown(),
372            clone_families_omitted: input.report.clone_families_omitted(),
373            grouped_by: Some(group_by_mode_from_label(input.grouping.mode)),
374            total_issues: Some(input.report.clone_groups.len()),
375            groups: None,
376            baseline_staleness: input.baseline_staleness,
377            meta: input.meta,
378            workspace_diagnostics: input.workspace_diagnostics,
379            next_steps: input.next_steps,
380        });
381    let mut output = fallow_output::serialize_dupes_json_output(
382        envelope,
383        input.envelope_mode,
384        input.telemetry_analysis_run_id,
385    )?;
386    strip_root_prefix(&mut output, &root_prefix);
387
388    let group_values = input
389        .grouping
390        .groups
391        .iter()
392        .map(|group| {
393            let mut value = if input.include_fragments {
394                serde_json::to_value(group)?
395            } else {
396                let mut stripped = group.clone();
397                stripped.strip_fragments();
398                serde_json::to_value(&stripped)?
399            };
400            strip_root_prefix(&mut value, &root_prefix);
401            Ok(value)
402        })
403        .collect::<Result<Vec<_>, serde_json::Error>>()?;
404
405    if let serde_json::Value::Object(ref mut map) = output {
406        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
407    }
408
409    Ok(output)
410}
411
412fn build_check_json_envelope(input: CheckJsonEnvelopeInput<'_>) -> CheckOutput {
413    let mut output = build_check_output(CheckOutputInput {
414        schema_version: CHECK_SCHEMA_VERSION,
415        version: env!("CARGO_PKG_VERSION").to_string(),
416        elapsed: input.elapsed,
417        results: input.results.clone(),
418        config_fixable: input.config_fixable,
419        meta: input.meta,
420        workspace_diagnostics: input.workspace_diagnostics,
421        next_steps: input.next_steps,
422    });
423    output.baseline_deltas = input.extras.baseline_deltas;
424    output.baseline = input.extras.baseline;
425    output.baseline_staleness = input.extras.baseline_staleness;
426    output.regression = input.extras.regression;
427    output.gate_outcomes = input.extras.gate_outcomes;
428    output.request_outcomes = input.extras.request_outcomes;
429    output
430}
431
432fn strip_json_root_prefix(output: &mut serde_json::Value, root: &Path) {
433    let root_prefix = format!("{}/", root.display());
434    strip_root_prefix(output, &root_prefix);
435}
436
437fn group_by_mode_from_label(label: &str) -> GroupByMode {
438    match label {
439        "directory" => GroupByMode::Directory,
440        "package" => GroupByMode::Package,
441        "section" => GroupByMode::Section,
442        _ => GroupByMode::Owner,
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use fallow_types::workspace::WorkspaceDiagnosticKind;
450
451    #[test]
452    fn grouped_check_json_carries_workspace_diagnostics_with_relative_paths() {
453        let root = Path::new("/project");
454        let output = serialize_grouped_check_json(GroupedCheckJsonOutputInput {
455            gate_outcomes: None,
456            request_outcomes: None,
457            baseline_staleness: None,
458            groups: &[],
459            original: &AnalysisResults::default(),
460            root,
461            elapsed: Duration::ZERO,
462            grouped_by: GroupByMode::Directory,
463            config_fixable: false,
464            meta: None,
465            workspace_diagnostics: vec![WorkspaceDiagnostic::new(
466                root,
467                root.join("src/unreadable.ts"),
468                WorkspaceDiagnosticKind::SourceReadFailure {
469                    error: "permission denied".to_string(),
470                },
471            )],
472            next_steps: Vec::new(),
473            envelope_mode: RootEnvelopeMode::Tagged,
474            telemetry_analysis_run_id: None,
475        })
476        .expect("grouped check JSON serializes");
477
478        assert_eq!(
479            output["workspace_diagnostics"][0]["path"],
480            "src/unreadable.ts"
481        );
482        assert_eq!(
483            output["workspace_diagnostics"][0]["kind"],
484            "source-read-failure"
485        );
486    }
487}