Skip to main content

fallow_output/
doctor.rs

1//! Typed readiness report emitted by `fallow doctor`.
2
3use crate::root_envelopes::{RootEnvelopeMode, serialize_named_json_output};
4use fallow_types::envelope::{SchemaVersion, ToolVersion};
5use serde::Serialize;
6
7/// Current schema version for `fallow doctor --format json`.
8///
9/// Bumped to 2 for the `dependencies`, `cache`, and `graph-cache` checks. All
10/// three are appended after the existing five, so the previous order is
11/// unchanged, but a consumer that enumerates `checks[]` sees three new `id`
12/// values.
13pub const DOCTOR_SCHEMA_VERSION: u32 = 2;
14
15/// Schema projection for the exact doctor envelope version.
16#[cfg(feature = "schema")]
17#[allow(dead_code, reason = "schema-only type used by the field projection")]
18#[derive(schemars::JsonSchema)]
19#[schemars(extend("const" = DOCTOR_SCHEMA_VERSION))]
20struct DoctorSchemaVersion(u32);
21
22/// Schema projection for `.` as the privacy-safe diagnosed project root.
23#[cfg(feature = "schema")]
24#[allow(dead_code, reason = "schema-only type used by the field projection")]
25#[derive(schemars::JsonSchema)]
26#[schemars(extend("const" = "."))]
27struct DoctorProjectRoot(String);
28
29/// Aggregate readiness outcome.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[serde(rename_all = "kebab-case")]
33pub enum DoctorStatus {
34    /// Every applicable check passed.
35    Pass,
36    /// Readiness is usable, with one or more advisory warnings.
37    Warn,
38    /// At least one required readiness check failed.
39    Fail,
40}
41
42/// Per-check readiness outcome.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45#[serde(rename_all = "kebab-case")]
46pub enum DoctorCheckStatus {
47    /// The check completed successfully.
48    Pass,
49    /// The check completed with an advisory concern.
50    Warn,
51    /// The check found a blocking readiness problem.
52    Fail,
53    /// The check does not apply or an earlier prerequisite failed.
54    Skipped,
55}
56
57/// Stable identifier for a doctor check. Declaration order is output order.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(rename_all = "kebab-case")]
61pub enum DoctorCheckId {
62    /// Project root availability.
63    Root,
64    /// Fallow configuration resolution.
65    Config,
66    /// Workspace declaration discovery.
67    Workspaces,
68    /// External plugin configuration and activation.
69    Plugins,
70    /// Optional type-aware companion discovery.
71    TypeAware,
72    /// Installed dependency tree availability.
73    Dependencies,
74    /// Persisted extraction-cache reuse.
75    Cache,
76    /// Persisted module-graph reuse.
77    ///
78    /// Separate from [`Cache`](Self::Cache) because a warm run reuses the two
79    /// blobs independently: the extraction cache can be perfectly reusable
80    /// while the graph is discarded on every run, and the graph is the larger
81    /// of the two on a real project.
82    GraphCache,
83}
84
85/// Stable category for a doctor check.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[serde(rename_all = "kebab-case")]
89pub enum DoctorCheckCategory {
90    /// Project filesystem readiness.
91    Project,
92    /// Fallow configuration readiness.
93    Configuration,
94    /// Workspace topology readiness.
95    Workspace,
96    /// Plugin readiness.
97    Plugin,
98    /// Optional companion readiness.
99    Companion,
100    /// Warm-run readiness of persisted analysis state.
101    Cache,
102}
103
104/// Actionable remediation attached to a doctor check.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107pub struct DoctorRemediation {
108    /// Command the user or agent can choose to run.
109    pub command: String,
110    /// Working directory for the command, relative to the diagnosed root.
111    #[cfg_attr(feature = "schema", schemars(with = "DoctorProjectRoot"))]
112    pub cwd: String,
113    /// Whether running the command can modify project files or dependencies.
114    pub mutating: bool,
115}
116
117/// One deterministic doctor check result.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct DoctorCheck {
121    /// Stable check identifier.
122    pub id: DoctorCheckId,
123    /// Readiness area this check covers.
124    pub category: DoctorCheckCategory,
125    /// Check outcome.
126    pub status: DoctorCheckStatus,
127    /// Whether failure makes the project not ready.
128    pub required: bool,
129    /// Human-readable result with no host-specific absolute paths.
130    pub message: String,
131    /// Optional actionable next command.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub remediation: Option<DoctorRemediation>,
134}
135
136/// Counts for every per-check status.
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139pub struct DoctorSummary {
140    /// Successful checks.
141    pub pass: usize,
142    /// Advisory checks.
143    pub warn: usize,
144    /// Failed checks.
145    pub fail: usize,
146    /// Inapplicable or prerequisite-blocked checks.
147    pub skipped: usize,
148}
149
150/// Versioned readiness envelope emitted by `fallow doctor --format json`.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153#[cfg_attr(feature = "schema", schemars(title = "fallow doctor --format json"))]
154pub struct DoctorOutput {
155    /// Independent doctor contract version.
156    #[cfg_attr(feature = "schema", schemars(with = "DoctorSchemaVersion"))]
157    pub schema_version: SchemaVersion,
158    /// Fallow version that produced the report.
159    pub version: ToolVersion,
160    /// Stable project-root identifier. Always `.` to avoid exposing host paths.
161    #[cfg_attr(feature = "schema", schemars(with = "DoctorProjectRoot"))]
162    pub root: String,
163    /// Aggregate readiness outcome.
164    pub status: DoctorStatus,
165    /// Counts derived from `checks`.
166    pub summary: DoctorSummary,
167    /// Checks in stable contract order.
168    pub checks: Vec<DoctorCheck>,
169}
170
171/// Serialize a doctor report with its root discriminator.
172///
173/// # Errors
174///
175/// Returns a serde error if the typed report cannot be converted to JSON.
176pub fn serialize_doctor_json_output(
177    output: DoctorOutput,
178    mode: RootEnvelopeMode,
179) -> Result<serde_json::Value, serde_json::Error> {
180    serialize_named_json_output(output, "doctor", mode)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn tagged_json_uses_the_stable_privacy_safe_contract() {
189        let value = serialize_doctor_json_output(
190            DoctorOutput {
191                schema_version: SchemaVersion(DOCTOR_SCHEMA_VERSION),
192                version: ToolVersion("1.2.3".to_string()),
193                root: ".".to_string(),
194                status: DoctorStatus::Warn,
195                summary: DoctorSummary {
196                    pass: 4,
197                    warn: 1,
198                    fail: 0,
199                    skipped: 0,
200                },
201                checks: vec![DoctorCheck {
202                    id: DoctorCheckId::TypeAware,
203                    category: DoctorCheckCategory::Companion,
204                    status: DoctorCheckStatus::Warn,
205                    required: false,
206                    message: "Companion is unavailable.".to_string(),
207                    remediation: Some(DoctorRemediation {
208                        command: "npm install --save-dev fallow-type-aware@1.2.3".to_string(),
209                        cwd: ".".to_string(),
210                        mutating: true,
211                    }),
212                }],
213            },
214            RootEnvelopeMode::Tagged,
215        )
216        .expect("serialize doctor output");
217
218        assert_eq!(value["kind"], "doctor");
219        assert_eq!(value["schema_version"], DOCTOR_SCHEMA_VERSION);
220        assert_eq!(value["root"], ".");
221        assert_eq!(value["checks"][0]["remediation"]["cwd"], ".");
222        assert_eq!(value["checks"][0]["remediation"]["mutating"], true);
223        assert!(
224            value["checks"][0]["remediation"]
225                .get("mutates_project")
226                .is_none()
227        );
228    }
229}