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