Skip to main content

fallow_output/
health.rs

1use std::time::Duration;
2
3use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, ToolVersion};
4use fallow_types::output::NextStep;
5use serde::Serialize;
6
7use fallow_types::workspace::WorkspaceDiagnostic;
8
9use crate::{
10    GroupByMode, RootEnvelopeMode, apply_root_kind, attach_telemetry_meta, strip_root_prefix,
11};
12
13/// Envelope emitted by `fallow health --format json` (plus the `health` block
14/// inside the combined and audit envelopes).
15///
16/// The body is `HealthReport` flattened into the envelope so every report
17/// field (`findings`, `summary`, `vital_signs`, `hotspots`, `actions_meta`,
18/// ...) lives at the top level. Grouped runs populate `grouped_by` +
19/// `groups` with per-bucket recomputed metrics. The `actions_meta`
20/// breadcrumb is modeled on `HealthReport` as an `Option<HealthActionsMeta>`
21/// and is set at construction time by the report builder when the active
22/// `HealthActionContext` requests suppress-line omission, so the schema
23/// documents the field and serde populates it natively.
24#[derive(Debug, Clone, Serialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
27pub struct HealthOutput<Report, Group> {
28    pub schema_version: SchemaVersion,
29    pub version: ToolVersion,
30    pub elapsed_ms: ElapsedMs,
31    #[serde(flatten)]
32    pub report: Report,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub grouped_by: Option<GroupByMode>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub groups: Option<Vec<Group>>,
37    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
38    pub meta: Option<Meta>,
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
41    /// Read-only follow-up commands computed from this run's findings. See
42    /// `CheckOutput::next_steps` for the contract.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub next_steps: Vec<NextStep>,
45}
46
47/// Inputs for constructing a [`HealthOutput`] without exposing envelope
48/// assembly details to callers.
49#[derive(Debug, Clone)]
50pub struct HealthOutputInput<Report, Group> {
51    pub schema_version: u32,
52    pub version: String,
53    pub elapsed: Duration,
54    pub report: Report,
55    pub grouped_by: Option<GroupByMode>,
56    pub groups: Option<Vec<Group>>,
57    pub meta: Option<Meta>,
58    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
59    pub next_steps: Vec<NextStep>,
60}
61
62/// Inputs for serializing a health report into the root JSON contract.
63#[derive(Debug, Clone)]
64pub struct HealthJsonOutputInput<'a, Report, Group> {
65    pub output: HealthOutputInput<Report, Group>,
66    pub root_prefix: Option<&'a str>,
67    pub envelope_mode: RootEnvelopeMode,
68    pub analysis_run_id: Option<&'a str>,
69}
70
71/// Build a health JSON envelope from caller-owned report data.
72#[must_use]
73pub fn build_health_output<Report, Group>(
74    input: HealthOutputInput<Report, Group>,
75) -> HealthOutput<Report, Group> {
76    HealthOutput {
77        schema_version: SchemaVersion(input.schema_version),
78        version: ToolVersion(input.version),
79        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
80        report: input.report,
81        grouped_by: input.grouped_by,
82        groups: input.groups,
83        meta: input.meta,
84        workspace_diagnostics: input.workspace_diagnostics,
85        next_steps: input.next_steps,
86    }
87}
88
89/// Build and serialize a health root JSON envelope.
90///
91/// This keeps the health contract serialization in `fallow-output` while
92/// callers still own report assembly, workspace diagnostics, and follow-up
93/// suggestion policy.
94///
95/// # Errors
96///
97/// Returns a serde error when the provided report or group payload cannot be
98/// converted to JSON.
99pub fn serialize_health_json_output<Report, Group>(
100    input: HealthJsonOutputInput<'_, Report, Group>,
101) -> Result<serde_json::Value, serde_json::Error>
102where
103    Report: Serialize,
104    Group: Serialize,
105{
106    let envelope = build_health_output(input.output);
107    let mut output = serde_json::to_value(envelope)?;
108    apply_root_kind(&mut output, "health", input.envelope_mode);
109    if let Some(root_prefix) = input.root_prefix {
110        strip_root_prefix(&mut output, root_prefix);
111    }
112    attach_telemetry_meta(&mut output, input.analysis_run_id);
113    Ok(output)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn serialize_health_json_output_tags_and_strips_root_paths() {
122        let output = serialize_health_json_output(HealthJsonOutputInput {
123            output: HealthOutputInput {
124                schema_version: 7,
125                version: "test".to_string(),
126                elapsed: Duration::ZERO,
127                report: serde_json::json!({ "findings": [{ "path": "/repo/src/a.ts" }] }),
128                grouped_by: None,
129                groups: None::<Vec<serde_json::Value>>,
130                meta: None,
131                workspace_diagnostics: Vec::new(),
132                next_steps: Vec::new(),
133            },
134            root_prefix: Some("/repo/"),
135            envelope_mode: RootEnvelopeMode::Tagged,
136            analysis_run_id: Some("run-health"),
137        })
138        .expect("health output should serialize");
139
140        assert_eq!(output["kind"], "health");
141        assert_eq!(output["findings"][0]["path"], "src/a.ts");
142        assert_eq!(
143            output["_meta"]["telemetry"]["analysis_run_id"],
144            "run-health"
145        );
146    }
147
148    #[test]
149    fn serialize_health_json_output_can_keep_legacy_root_shape() {
150        let output = serialize_health_json_output(HealthJsonOutputInput {
151            output: HealthOutputInput {
152                schema_version: 7,
153                version: "test".to_string(),
154                elapsed: Duration::ZERO,
155                report: serde_json::json!({ "summary": {} }),
156                grouped_by: None,
157                groups: None::<Vec<serde_json::Value>>,
158                meta: None,
159                workspace_diagnostics: Vec::new(),
160                next_steps: Vec::new(),
161            },
162            root_prefix: None,
163            envelope_mode: RootEnvelopeMode::Legacy,
164            analysis_run_id: None,
165        })
166        .expect("health output should serialize");
167
168        assert!(output.get("kind").is_none());
169        assert_eq!(output["schema_version"], 7);
170    }
171}