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/// Current schema version for the standalone health JSON envelope.
14///
15/// Version 11 expands the required semantic omission reason-code enum embedded
16/// by type-aware metadata.
17pub const HEALTH_SCHEMA_VERSION: u32 = 11;
18
19/// Exact schema version for [`HealthOutput`].
20#[cfg(feature = "schema")]
21#[allow(dead_code, reason = "schema-only type used by the field projection")]
22#[derive(schemars::JsonSchema)]
23#[schemars(extend("const" = HEALTH_SCHEMA_VERSION))]
24struct HealthSchemaVersion(u32);
25
26/// Envelope emitted by `fallow health --format json` (plus the `health` block
27/// inside the combined and audit envelopes).
28///
29/// The body is `HealthReport` flattened into the envelope so every report
30/// field (`findings`, `summary`, `vital_signs`, `hotspots`, `actions_meta`,
31/// ...) lives at the top level. Grouped runs populate `grouped_by` +
32/// `groups` with per-bucket recomputed metrics. The `actions_meta`
33/// breadcrumb is modeled on `HealthReport` as an `Option<HealthActionsMeta>`
34/// and is set at construction time by the report builder when the active
35/// `HealthActionContext` requests suppress-line omission, so the schema
36/// documents the field and serde populates it natively.
37#[derive(Debug, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
40pub struct HealthOutput<Report, Group> {
41    /// Health output schema version.
42    #[cfg_attr(feature = "schema", schemars(with = "HealthSchemaVersion"))]
43    pub schema_version: SchemaVersion,
44    /// Fallow CLI version that produced this output.
45    pub version: ToolVersion,
46    /// Wall-clock analysis duration in milliseconds.
47    pub elapsed_ms: ElapsedMs,
48    /// Health report body, flattened into the envelope root.
49    #[serde(flatten)]
50    pub report: Report,
51    /// Grouping mode when `--group-by` was passed.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub grouped_by: Option<GroupByMode>,
54    /// Per-bucket recomputed metrics; present only in grouped output.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub groups: Option<Vec<Group>>,
57    /// `_meta` block with metric definitions, when `--explain` was passed.
58    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
59    pub meta: Option<Meta>,
60    /// Workspace-discovery, source-discovery, and analysis-stage diagnostics
61    /// for the run. See `CheckOutput::workspace_diagnostics` for the full
62    /// contract: the kinds each stage records, project-root-relative paths,
63    /// omitted when empty.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
66    /// Read-only follow-up commands computed from this run's findings. See
67    /// `CheckOutput::next_steps` for the contract.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub next_steps: Vec<NextStep>,
70}
71
72/// Inputs for constructing a [`HealthOutput`] without exposing envelope
73/// assembly details to callers.
74#[derive(Debug, Clone)]
75pub struct HealthOutputInput<Report, Group> {
76    /// Health output schema version to report.
77    pub schema_version: u32,
78    /// Fallow CLI version to report.
79    pub version: String,
80    /// Wall-clock analysis duration; serialized as whole milliseconds.
81    pub elapsed: Duration,
82    /// Health report body to flatten into the envelope root.
83    pub report: Report,
84    /// Grouping mode when `--group-by` was passed.
85    pub grouped_by: Option<GroupByMode>,
86    /// Per-bucket recomputed metrics, for grouped output.
87    pub groups: Option<Vec<Group>>,
88    /// `_meta` block to attach when `--explain` was passed.
89    pub meta: Option<Meta>,
90    /// Workspace-discovery, source-discovery, and analysis-stage diagnostics.
91    /// See `CheckOutput::workspace_diagnostics` for the contract.
92    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
93    /// Read-only follow-up commands computed from this run's findings.
94    pub next_steps: Vec<NextStep>,
95}
96
97/// Inputs for serializing a health report into the root JSON contract.
98#[derive(Debug, Clone)]
99pub struct HealthJsonOutputInput<'a, Report, Group> {
100    /// Envelope construction inputs.
101    pub output: HealthOutputInput<Report, Group>,
102    /// Absolute root prefix to strip from every emitted path, when set.
103    pub root_prefix: Option<&'a str>,
104    /// Root discriminator policy.
105    pub envelope_mode: RootEnvelopeMode,
106    /// Telemetry run id to attach under `_meta.telemetry`, when available.
107    pub analysis_run_id: Option<&'a str>,
108}
109
110/// Build a health JSON envelope from caller-owned report data.
111#[must_use]
112pub fn build_health_output<Report, Group>(
113    input: HealthOutputInput<Report, Group>,
114) -> HealthOutput<Report, Group> {
115    HealthOutput {
116        schema_version: SchemaVersion(input.schema_version),
117        version: ToolVersion(input.version),
118        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
119        report: input.report,
120        grouped_by: input.grouped_by,
121        groups: input.groups,
122        meta: input.meta,
123        workspace_diagnostics: input.workspace_diagnostics,
124        next_steps: input.next_steps,
125    }
126}
127
128/// Build and serialize a health root JSON envelope.
129///
130/// This keeps the health contract serialization in `fallow-output` while
131/// callers still own report assembly, workspace diagnostics, and follow-up
132/// suggestion policy.
133///
134/// # Errors
135///
136/// Returns a serde error when the provided report or group payload cannot be
137/// converted to JSON.
138pub fn serialize_health_json_output<Report, Group>(
139    input: HealthJsonOutputInput<'_, Report, Group>,
140) -> Result<serde_json::Value, serde_json::Error>
141where
142    Report: Serialize,
143    Group: Serialize,
144{
145    let envelope = build_health_output(input.output);
146    let mut output = serde_json::to_value(envelope)?;
147    apply_root_kind(&mut output, "health", input.envelope_mode);
148    if let Some(root_prefix) = input.root_prefix {
149        strip_root_prefix(&mut output, root_prefix);
150    }
151    attach_telemetry_meta(&mut output, input.analysis_run_id);
152    Ok(output)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn serialize_health_json_output_tags_and_strips_root_paths() {
161        let output = serialize_health_json_output(HealthJsonOutputInput {
162            output: HealthOutputInput {
163                schema_version: 7,
164                version: "test".to_string(),
165                elapsed: Duration::ZERO,
166                report: serde_json::json!({ "findings": [{ "path": "/repo/src/a.ts" }] }),
167                grouped_by: None,
168                groups: None::<Vec<serde_json::Value>>,
169                meta: None,
170                workspace_diagnostics: Vec::new(),
171                next_steps: Vec::new(),
172            },
173            root_prefix: Some("/repo/"),
174            envelope_mode: RootEnvelopeMode::Tagged,
175            analysis_run_id: Some("run-health"),
176        })
177        .expect("health output should serialize");
178
179        assert_eq!(output["kind"], "health");
180        assert_eq!(output["findings"][0]["path"], "src/a.ts");
181        assert_eq!(
182            output["_meta"]["telemetry"]["analysis_run_id"],
183            "run-health"
184        );
185    }
186}