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