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::root_envelopes::apply_root_kind;
10use crate::{GroupByMode, attach_telemetry_meta, strip_root_prefix};
11
12/// Current schema version for the standalone health JSON envelope.
13///
14/// Version 11 expands the required semantic omission reason-code enum embedded
15/// by type-aware metadata.
16pub const HEALTH_SCHEMA_VERSION: u32 = 11;
17
18/// Exact schema version for [`HealthOutput`].
19#[cfg(feature = "schema")]
20#[allow(dead_code, reason = "schema-only type used by the field projection")]
21#[derive(schemars::JsonSchema)]
22#[schemars(extend("const" = HEALTH_SCHEMA_VERSION))]
23struct HealthSchemaVersion(u32);
24
25/// Envelope emitted by `fallow health --format json` (plus the `health` block
26/// inside the combined and audit envelopes).
27///
28/// The body is `HealthReport` flattened into the envelope so every report
29/// field (`findings`, `summary`, `vital_signs`, `hotspots`, `actions_meta`,
30/// ...) lives at the top level. Grouped runs populate `grouped_by` +
31/// `groups` with per-bucket recomputed metrics. The `actions_meta`
32/// breadcrumb is modeled on `HealthReport` as an `Option<HealthActionsMeta>`
33/// and is set at construction time by the report builder when the active
34/// `HealthActionContext` requests suppress-line omission, so the schema
35/// documents the field and serde populates it natively.
36#[derive(Debug, Clone, Serialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
39pub struct HealthOutput<Report, Group> {
40    /// Health output schema version.
41    #[cfg_attr(feature = "schema", schemars(with = "HealthSchemaVersion"))]
42    pub schema_version: SchemaVersion,
43    /// Fallow CLI version that produced this output.
44    pub version: ToolVersion,
45    /// Wall-clock analysis duration in milliseconds.
46    pub elapsed_ms: ElapsedMs,
47    /// Health report body, flattened into the envelope root.
48    #[serde(flatten)]
49    pub report: Report,
50    /// Grouping mode when `--group-by` was passed.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub grouped_by: Option<GroupByMode>,
53    /// Per-bucket recomputed metrics; present only in grouped output.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub groups: Option<Vec<Group>>,
56    /// The `--group` selector patterns, as given, when the run kept only some
57    /// groups. A group that is not in `groups` was filtered out by this
58    /// selector. The project-level sections are not filtered.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub group_filter: Option<Vec<String>>,
61    /// The verdict of every gate this run evaluated, keyed by name. The CLI
62    /// always emits it, with the command's default exit rule in it also when
63    /// no flag armed a gate, so a CI integration reads the verdict instead of
64    /// guessing from a process status it usually cannot see. A gate fails the
65    /// build when `status` is `fail` AND `enforced` is true. The typed
66    /// programmatic API runs no CLI gate and leaves it absent. See
67    /// [`crate::GateOutcomes`].
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub gate_outcomes: Option<crate::GateOutcomes>,
70    /// Every narrowing or shaping request this run RECEIVED, keyed by name,
71    /// absent when it was asked for nothing. An entry whose `status` is not
72    /// `applied` means the run could not do what it was asked and reported
73    /// something WIDER instead, so what follows is a valid report of a scope
74    /// nobody requested. Honoured requests are published too, with
75    /// `status: "applied"`, so an absent object means "nothing was asked for",
76    /// never "nothing failed". See [`crate::RequestOutcomes`].
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub request_outcomes: Option<crate::RequestOutcomes>,
79    /// `_meta` block with metric definitions, when `--explain` was passed.
80    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
81    pub meta: Option<Meta>,
82    /// Workspace-discovery, source-discovery, and analysis-stage diagnostics
83    /// for the run. See `CheckOutput::workspace_diagnostics` for the full
84    /// contract: the kinds each stage records, project-root-relative paths,
85    /// omitted when empty.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
88    /// Read-only follow-up commands computed from this run's findings. See
89    /// `CheckOutput::next_steps` for the contract.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub next_steps: Vec<NextStep>,
92}
93
94/// Inputs for constructing a [`HealthOutput`] without exposing envelope
95/// assembly details to callers.
96#[derive(Debug, Clone)]
97pub struct HealthOutputInput<Report, Group> {
98    /// Health output schema version to report.
99    pub schema_version: u32,
100    /// Fallow CLI version to report.
101    pub version: String,
102    /// Wall-clock analysis duration; serialized as whole milliseconds.
103    pub elapsed: Duration,
104    /// Health report body to flatten into the envelope root.
105    pub report: Report,
106    /// Grouping mode when `--group-by` was passed.
107    pub grouped_by: Option<GroupByMode>,
108    /// Per-bucket recomputed metrics, for grouped output.
109    pub groups: Option<Vec<Group>>,
110    /// The `--group` selector patterns, for grouped output.
111    pub group_filter: Option<Vec<String>>,
112    /// Every gate this run evaluated, absent when it evaluated none.
113    pub gate_outcomes: Option<crate::GateOutcomes>,
114    /// Every narrowing or shaping request this run received, absent when it
115    /// was asked for nothing.
116    pub request_outcomes: Option<crate::RequestOutcomes>,
117    /// `_meta` block to attach when `--explain` was passed.
118    pub meta: Option<Meta>,
119    /// Workspace-discovery, source-discovery, and analysis-stage diagnostics.
120    /// See `CheckOutput::workspace_diagnostics` for the contract.
121    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
122    /// Read-only follow-up commands computed from this run's findings.
123    pub next_steps: Vec<NextStep>,
124}
125
126/// Inputs for serializing a health report into the root JSON contract.
127#[derive(Debug, Clone)]
128pub struct HealthJsonOutputInput<'a, Report, Group> {
129    /// Envelope construction inputs.
130    pub output: HealthOutputInput<Report, Group>,
131    /// Absolute root prefix to strip from every emitted path, when set.
132    pub root_prefix: Option<&'a str>,
133    /// Root discriminator policy.
134    /// Telemetry run id to attach under `_meta.telemetry`, when available.
135    pub analysis_run_id: Option<&'a str>,
136}
137
138/// Build a health JSON envelope from caller-owned report data.
139#[must_use]
140pub fn build_health_output<Report, Group>(
141    input: HealthOutputInput<Report, Group>,
142) -> HealthOutput<Report, Group> {
143    HealthOutput {
144        schema_version: SchemaVersion(input.schema_version),
145        version: ToolVersion(input.version),
146        elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
147        report: input.report,
148        grouped_by: input.grouped_by,
149        groups: input.groups,
150        group_filter: input.group_filter,
151        gate_outcomes: input.gate_outcomes,
152        request_outcomes: input.request_outcomes,
153        meta: input.meta,
154        workspace_diagnostics: input.workspace_diagnostics,
155        next_steps: input.next_steps,
156    }
157}
158
159/// Build and serialize a health root JSON envelope.
160///
161/// This keeps the health contract serialization in `fallow-output` while
162/// callers still own report assembly, workspace diagnostics, and follow-up
163/// suggestion policy.
164///
165/// # Errors
166///
167/// Returns a serde error when the provided report or group payload cannot be
168/// converted to JSON.
169pub fn serialize_health_json_output<Report, Group>(
170    input: HealthJsonOutputInput<'_, Report, Group>,
171) -> Result<serde_json::Value, serde_json::Error>
172where
173    Report: Serialize,
174    Group: Serialize,
175{
176    let envelope = build_health_output(input.output);
177    let mut output = serde_json::to_value(envelope)?;
178    apply_root_kind(&mut output, "health");
179    if let Some(root_prefix) = input.root_prefix {
180        strip_root_prefix(&mut output, root_prefix);
181    }
182    attach_telemetry_meta(&mut output, input.analysis_run_id);
183    Ok(output)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn serialize_health_json_output_tags_and_strips_root_paths() {
192        let output = serialize_health_json_output(HealthJsonOutputInput {
193            output: HealthOutputInput {
194                gate_outcomes: None,
195                request_outcomes: None,
196                schema_version: 7,
197                version: "test".to_string(),
198                elapsed: Duration::ZERO,
199                report: serde_json::json!({ "findings": [{ "path": "/repo/src/a.ts" }] }),
200                grouped_by: None,
201                groups: None::<Vec<serde_json::Value>>,
202                group_filter: None,
203                meta: None,
204                workspace_diagnostics: Vec::new(),
205                next_steps: Vec::new(),
206            },
207            root_prefix: Some("/repo/"),
208            analysis_run_id: Some("run-health"),
209        })
210        .expect("health output should serialize");
211
212        assert_eq!(output["kind"], "health");
213        assert_eq!(output["findings"][0]["path"], "src/a.ts");
214        assert_eq!(
215            output["_meta"]["telemetry"]["analysis_run_id"],
216            "run-health"
217        );
218    }
219}