Skip to main content

fallow_api/
combined_output.rs

1//! Combined JSON output assembly shared by CLI and programmatic consumers.
2
3use std::path::Path;
4use std::time::Duration;
5
6use fallow_output::{
7    COMBINED_SCHEMA_VERSION, CombinedMeta, CombinedOutput, HealthReport, RootEnvelopeMode,
8    check_meta, dupes_meta, harmonize_dead_code_health_suppress_line_actions, health_meta,
9    serialize_combined_json_output, strip_root_prefix,
10};
11use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
12use fallow_types::output::NextStep;
13use fallow_types::results::AnalysisResults;
14
15use crate::{
16    CheckJsonExtraOutputs, CheckJsonPayloadInput, DupesReportPayload, serialize_check_json_payload,
17};
18
19/// Dead-code section inputs for a bare combined JSON report.
20pub struct CombinedCheckJsonSection<'a> {
21    /// Typed dead-code results serialized into the `check` section.
22    pub results: &'a AnalysisResults,
23    /// Project root; its prefix is stripped from paths inside the section.
24    pub root: &'a Path,
25    /// Dead-code analysis wall time, emitted as the section's `elapsed_ms`.
26    pub elapsed: Duration,
27    /// Whether duplicate-export findings can be auto-fixed through config;
28    /// propagated onto their fix actions.
29    pub config_fixable: bool,
30    /// Caller-computed baseline and regression sections.
31    pub extras: CheckJsonExtraOutputs,
32}
33
34/// Inputs for bare `fallow --format json` output assembly.
35pub struct CombinedJsonOutputInput<'a> {
36    /// Dead-code section; `None` omits `check` from the envelope.
37    pub check: Option<CombinedCheckJsonSection<'a>>,
38    /// Duplication section; `None` omits `dupes` from the envelope.
39    pub dupes: Option<&'a DupesReportPayload>,
40    /// Health section; `None` omits `health` from the envelope.
41    pub health: Option<&'a HealthReport>,
42    /// Project root; its prefix is stripped from paths in every section.
43    pub root: &'a Path,
44    /// Total wall time across sections, emitted as the root `elapsed_ms`.
45    pub elapsed: Duration,
46    /// Emit per-section explain metadata under `meta`.
47    pub explain: bool,
48    /// Type-aware pass metadata, merged into the check section's meta even
49    /// when `explain` is off.
50    pub type_aware: Option<fallow_types::envelope::TypeAwareMeta>,
51    /// Suggested follow-up commands for the consumer.
52    pub next_steps: Vec<NextStep>,
53    /// Whether the root envelope carries a `kind` discriminant.
54    pub envelope_mode: RootEnvelopeMode,
55    /// Analysis run id stamped into telemetry metadata when present.
56    pub telemetry_analysis_run_id: Option<&'a str>,
57}
58
59/// Build and serialize bare combined JSON through the API output boundary.
60///
61/// # Errors
62///
63/// Returns a serde error when any typed section cannot be converted to JSON.
64pub fn serialize_combined_json(
65    input: CombinedJsonOutputInput<'_>,
66) -> Result<serde_json::Value, serde_json::Error> {
67    let mut check_results = input.check.as_ref().map(|section| section.results.clone());
68    let mut health_report = input.health.cloned();
69    harmonize_dead_code_health_suppress_line_actions(
70        check_results.as_mut(),
71        health_report.as_mut(),
72    );
73
74    let check = if let Some(section) = input.check {
75        if let Some(results) = check_results.as_ref() {
76            Some(serialize_combined_check_json(section, results)?)
77        } else {
78            None
79        }
80    } else {
81        None
82    };
83    let dupes = serialize_combined_dupes_json(input.dupes, input.root)?;
84    let health = serialize_combined_health_json(health_report.as_ref(), input.root)?;
85
86    let mut meta = input
87        .explain
88        .then(|| combined_meta_for_output(check.is_some(), dupes.is_some(), health.is_some()));
89    if let Some(type_aware) = input.type_aware {
90        let combined_meta = meta.get_or_insert(CombinedMeta {
91            check: None,
92            dupes: None,
93            health: None,
94            telemetry: None,
95        });
96        let check_meta = combined_meta
97            .check
98            .get_or_insert_with(fallow_types::envelope::Meta::default);
99        check_meta.type_aware = Some(type_aware);
100    }
101
102    let output = CombinedOutput {
103        schema_version: SchemaVersion(COMBINED_SCHEMA_VERSION),
104        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
105        elapsed_ms: ElapsedMs(elapsed_ms_for_output(input.elapsed)),
106        meta,
107        check,
108        dupes,
109        health,
110        next_steps: input.next_steps,
111    };
112
113    serialize_combined_json_output(output, input.envelope_mode, input.telemetry_analysis_run_id)
114}
115
116fn serialize_combined_check_json(
117    section: CombinedCheckJsonSection<'_>,
118    results: &AnalysisResults,
119) -> Result<serde_json::Value, serde_json::Error> {
120    serialize_check_json_payload(CheckJsonPayloadInput {
121        results,
122        root: section.root,
123        elapsed: section.elapsed,
124        config_fixable: section.config_fixable,
125        extras: section.extras,
126        workspace_diagnostics: Vec::new(),
127    })
128}
129
130/// Build a combined duplication section without adding a nested root envelope.
131///
132/// # Errors
133///
134/// Returns a serde error when the typed duplication payload cannot be
135/// serialized.
136pub fn serialize_combined_dupes_json(
137    dupes: Option<&DupesReportPayload>,
138    root: &Path,
139) -> Result<Option<serde_json::Value>, serde_json::Error> {
140    let Some(payload) = dupes else {
141        return Ok(None);
142    };
143    let mut json = serde_json::to_value(payload)?;
144    let root_prefix = format!("{}/", root.display());
145    strip_root_prefix(&mut json, &root_prefix);
146    Ok(Some(json))
147}
148
149/// Build a combined health section without adding a nested root envelope.
150///
151/// # Errors
152///
153/// Returns a serde error when the typed health payload cannot be serialized.
154pub fn serialize_combined_health_json(
155    health: Option<&HealthReport>,
156    root: &Path,
157) -> Result<Option<serde_json::Value>, serde_json::Error> {
158    let Some(report) = health else {
159        return Ok(None);
160    };
161    let mut json = serde_json::to_value(report)?;
162    let root_prefix = format!("{}/", root.display());
163    strip_root_prefix(&mut json, &root_prefix);
164    Ok(Some(json))
165}
166
167fn elapsed_ms_for_output(elapsed: Duration) -> u64 {
168    u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
169}
170
171fn combined_meta_for_output(
172    include_check: bool,
173    include_dupes: bool,
174    include_health: bool,
175) -> CombinedMeta {
176    CombinedMeta {
177        check: include_check.then(check_meta),
178        dupes: include_dupes.then(dupes_meta),
179        health: include_health.then(health_meta),
180        telemetry: None,
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use std::time::Duration;
187
188    use fallow_output::{
189        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
190        RootEnvelopeMode,
191    };
192    use fallow_types::output_dead_code::UnusedExportFinding;
193    use fallow_types::output_health::{HealthFindingAction, HealthFindingActionType};
194    use fallow_types::results::{AnalysisResults, UnusedExport};
195
196    use super::{CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_json};
197
198    #[test]
199    fn combined_json_root_contains_stable_envelope_fields() {
200        let root = serialize_combined_json(CombinedJsonOutputInput {
201            check: None,
202            dupes: None,
203            health: None,
204            root: std::path::Path::new("."),
205            elapsed: Duration::from_millis(42),
206            explain: false,
207            type_aware: None,
208            next_steps: Vec::new(),
209            envelope_mode: RootEnvelopeMode::Tagged,
210            telemetry_analysis_run_id: None,
211        })
212        .expect("combined JSON root");
213
214        assert_eq!(
215            root.get("kind").and_then(serde_json::Value::as_str),
216            Some("combined")
217        );
218        assert_eq!(
219            root.get("elapsed_ms").and_then(serde_json::Value::as_u64),
220            Some(42)
221        );
222        assert!(root.get("schema_version").is_some());
223        assert!(root.get("version").is_some());
224    }
225
226    #[test]
227    fn combined_json_harmonizes_dead_code_and_health_suppress_actions_before_serialization() {
228        let root = std::path::Path::new("/project");
229        let path = root.join("src/shared.ts");
230        let mut results = AnalysisResults::default();
231        results
232            .unused_exports
233            .push(UnusedExportFinding::with_actions(UnusedExport {
234                path: path.clone(),
235                export_name: "value".to_string(),
236                is_type_only: false,
237                line: 7,
238                col: 0,
239                span_start: 0,
240                is_re_export: false,
241            }));
242        let health = HealthReport {
243            findings: vec![HealthFinding::new(
244                ComplexityViolation {
245                    path,
246                    name: "expensive".to_string(),
247                    line: 7,
248                    col: 0,
249                    cyclomatic: 22,
250                    cognitive: 18,
251                    line_count: 40,
252                    param_count: 1,
253                    react_hook_count: 0,
254                    react_jsx_max_depth: 0,
255                    react_prop_count: 0,
256                    react_hook_profile: None,
257                    exceeded: ExceededThreshold::Both,
258                    severity: FindingSeverity::High,
259                    crap: None,
260                    coverage_pct: None,
261                    coverage_tier: None,
262                    coverage_source: None,
263                    inherited_from: None,
264                    component_rollup: None,
265                    contributions: Vec::new(),
266                    effective_thresholds: None,
267                    threshold_source: None,
268                },
269                vec![HealthFindingAction {
270                    kind: HealthFindingActionType::SuppressLine,
271                    auto_fixable: false,
272                    description: "Suppress with an inline comment above the function declaration"
273                        .to_string(),
274                    note: None,
275                    comment: Some("// fallow-ignore-next-line complexity".to_string()),
276                    placement: Some("above-function-declaration".to_string()),
277                    target_path: None,
278                }],
279                None,
280            )],
281            ..HealthReport::default()
282        };
283
284        let output = serialize_combined_json(CombinedJsonOutputInput {
285            check: Some(CombinedCheckJsonSection {
286                results: &results,
287                root,
288                elapsed: Duration::ZERO,
289                config_fixable: false,
290                extras: crate::CheckJsonExtraOutputs::default(),
291            }),
292            dupes: None,
293            health: Some(&health),
294            root,
295            elapsed: Duration::ZERO,
296            explain: false,
297            type_aware: None,
298            next_steps: Vec::new(),
299            envelope_mode: RootEnvelopeMode::Tagged,
300            telemetry_analysis_run_id: None,
301        })
302        .expect("combined JSON");
303
304        assert_eq!(
305            output["check"]["unused_exports"][0]["actions"][1]["comment"],
306            "// fallow-ignore-next-line unused-export, complexity"
307        );
308        assert_eq!(
309            output["health"]["findings"][0]["actions"][0]["comment"],
310            "// fallow-ignore-next-line unused-export, complexity"
311        );
312    }
313}