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, health_meta, serialize_combined_json_output, strip_root_prefix,
9};
10use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
11use fallow_types::output::NextStep;
12use fallow_types::results::AnalysisResults;
13
14use crate::{
15    CheckJsonExtraOutputs, CheckJsonPayloadInput, DupesReportPayload, serialize_check_json_payload,
16};
17
18/// Dead-code section inputs for a bare combined JSON report.
19pub struct CombinedCheckJsonSection<'a> {
20    pub results: &'a AnalysisResults,
21    pub root: &'a Path,
22    pub elapsed: Duration,
23    pub config_fixable: bool,
24    pub extras: CheckJsonExtraOutputs,
25}
26
27/// Inputs for bare `fallow --format json` output assembly.
28pub struct CombinedJsonOutputInput<'a> {
29    pub check: Option<CombinedCheckJsonSection<'a>>,
30    pub dupes: Option<&'a DupesReportPayload>,
31    pub health: Option<&'a HealthReport>,
32    pub root: &'a Path,
33    pub elapsed: Duration,
34    pub explain: bool,
35    pub next_steps: Vec<NextStep>,
36    pub envelope_mode: RootEnvelopeMode,
37    pub telemetry_analysis_run_id: Option<&'a str>,
38}
39
40/// Build and serialize bare combined JSON through the API output boundary.
41///
42/// # Errors
43///
44/// Returns a serde error when any typed section cannot be converted to JSON.
45pub fn serialize_combined_json(
46    input: CombinedJsonOutputInput<'_>,
47) -> Result<serde_json::Value, serde_json::Error> {
48    let check = input.check.map(serialize_combined_check_json).transpose()?;
49    let dupes = serialize_combined_dupes_json(input.dupes, input.root)?;
50    let health = serialize_combined_health_json(input.health, input.root)?;
51
52    let output = CombinedOutput {
53        schema_version: SchemaVersion(CHECK_SCHEMA_VERSION),
54        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
55        elapsed_ms: ElapsedMs(elapsed_ms_for_output(input.elapsed)),
56        meta: input
57            .explain
58            .then(|| combined_meta_for_output(check.is_some(), dupes.is_some(), health.is_some())),
59        check,
60        dupes,
61        health,
62        next_steps: input.next_steps,
63    };
64
65    serialize_combined_json_output(output, input.envelope_mode, input.telemetry_analysis_run_id)
66}
67
68fn serialize_combined_check_json(
69    section: CombinedCheckJsonSection<'_>,
70) -> Result<serde_json::Value, serde_json::Error> {
71    serialize_check_json_payload(CheckJsonPayloadInput {
72        results: section.results,
73        root: section.root,
74        elapsed: section.elapsed,
75        config_fixable: section.config_fixable,
76        extras: section.extras,
77        workspace_diagnostics: Vec::new(),
78    })
79}
80
81/// Build a combined duplication section without adding a nested root envelope.
82///
83/// # Errors
84///
85/// Returns a serde error when the typed duplication payload cannot be
86/// serialized.
87pub fn serialize_combined_dupes_json(
88    dupes: Option<&DupesReportPayload>,
89    root: &Path,
90) -> Result<Option<serde_json::Value>, serde_json::Error> {
91    let Some(payload) = dupes else {
92        return Ok(None);
93    };
94    let mut json = serde_json::to_value(payload)?;
95    let root_prefix = format!("{}/", root.display());
96    strip_root_prefix(&mut json, &root_prefix);
97    Ok(Some(json))
98}
99
100/// Build a combined health section without adding a nested root envelope.
101///
102/// # Errors
103///
104/// Returns a serde error when the typed health payload cannot be serialized.
105pub fn serialize_combined_health_json(
106    health: Option<&HealthReport>,
107    root: &Path,
108) -> Result<Option<serde_json::Value>, serde_json::Error> {
109    let Some(report) = health else {
110        return Ok(None);
111    };
112    let mut json = serde_json::to_value(report)?;
113    let root_prefix = format!("{}/", root.display());
114    strip_root_prefix(&mut json, &root_prefix);
115    Ok(Some(json))
116}
117
118fn elapsed_ms_for_output(elapsed: Duration) -> u64 {
119    u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
120}
121
122fn combined_meta_for_output(
123    include_check: bool,
124    include_dupes: bool,
125    include_health: bool,
126) -> CombinedMeta {
127    CombinedMeta {
128        check: include_check.then(check_meta),
129        dupes: include_dupes.then(dupes_meta),
130        health: include_health.then(health_meta),
131        telemetry: None,
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use std::time::Duration;
138
139    use fallow_output::RootEnvelopeMode;
140
141    use super::{CombinedJsonOutputInput, serialize_combined_json};
142
143    #[test]
144    fn combined_json_root_contains_stable_envelope_fields() {
145        let root = serialize_combined_json(CombinedJsonOutputInput {
146            check: None,
147            dupes: None,
148            health: None,
149            root: std::path::Path::new("."),
150            elapsed: Duration::from_millis(42),
151            explain: false,
152            next_steps: Vec::new(),
153            envelope_mode: RootEnvelopeMode::Tagged,
154            telemetry_analysis_run_id: None,
155        })
156        .expect("combined JSON root");
157
158        assert_eq!(
159            root.get("kind").and_then(serde_json::Value::as_str),
160            Some("combined")
161        );
162        assert_eq!(
163            root.get("elapsed_ms").and_then(serde_json::Value::as_u64),
164            Some(42)
165        );
166        assert!(root.get("schema_version").is_some());
167        assert!(root.get("version").is_some());
168    }
169}