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