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