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