Skip to main content

ferro_cli/doctor/
check.rs

1//! Doctor framework: trait, status enum, result/report types, exit code logic.
2//!
3//! All concrete checks live in `super::checks`. The registry composes them
4//! in declared order (D-01).
5
6use serde::Serialize;
7use std::path::Path;
8
9/// Filter bucket for `ferro doctor --deploy` and MCP `deploy_check` (Phase 128 D-02).
10#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
11#[serde(rename_all = "lowercase")]
12pub enum CheckCategory {
13    General,
14    Deploy,
15}
16
17/// Status of a single doctor check (D-09).
18#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
19#[serde(rename_all = "lowercase")]
20pub enum CheckStatus {
21    Ok,
22    Warn,
23    Error,
24}
25
26/// Result of running a single doctor check.
27#[derive(Debug, Clone, Serialize)]
28pub struct CheckResult {
29    pub name: &'static str,
30    pub status: CheckStatus,
31    pub message: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub details: Option<String>,
34}
35
36impl CheckResult {
37    pub fn ok(name: &'static str, message: impl Into<String>) -> Self {
38        Self {
39            name,
40            status: CheckStatus::Ok,
41            message: message.into(),
42            details: None,
43        }
44    }
45
46    pub fn warn(name: &'static str, message: impl Into<String>) -> Self {
47        Self {
48            name,
49            status: CheckStatus::Warn,
50            message: message.into(),
51            details: None,
52        }
53    }
54
55    pub fn error(name: &'static str, message: impl Into<String>) -> Self {
56        Self {
57            name,
58            status: CheckStatus::Error,
59            message: message.into(),
60            details: None,
61        }
62    }
63
64    pub fn with_details(mut self, details: impl Into<String>) -> Self {
65        self.details = Some(details.into());
66        self
67    }
68}
69
70/// Trait every concrete check implements.
71pub trait DoctorCheck {
72    fn name(&self) -> &'static str;
73    fn run(&self, root: &Path) -> CheckResult;
74    /// Filter bucket for `ferro doctor --deploy` and MCP `deploy_check`
75    /// (Phase 128 D-02). Defaults to `General`.
76    fn category(&self) -> CheckCategory {
77        CheckCategory::General
78    }
79}
80
81/// Aggregate report — emitted as human or JSON output.
82#[derive(Debug, Serialize)]
83pub struct Report {
84    pub summary: ReportSummary,
85    pub checks: Vec<CheckResult>,
86}
87
88#[derive(Debug, Serialize)]
89pub struct ReportSummary {
90    pub overall: CheckStatus,
91    pub ok: usize,
92    pub warn: usize,
93    pub error: usize,
94}
95
96impl Report {
97    pub fn build(checks: Vec<CheckResult>) -> Self {
98        let mut ok = 0usize;
99        let mut warn = 0usize;
100        let mut error = 0usize;
101        for c in &checks {
102            match c.status {
103                CheckStatus::Ok => ok += 1,
104                CheckStatus::Warn => warn += 1,
105                CheckStatus::Error => error += 1,
106            }
107        }
108        let overall = if error > 0 {
109            CheckStatus::Error
110        } else if warn > 0 {
111            CheckStatus::Warn
112        } else {
113            CheckStatus::Ok
114        };
115        Self {
116            summary: ReportSummary {
117                overall,
118                ok,
119                warn,
120                error,
121            },
122            checks,
123        }
124    }
125
126    /// D-09: non-zero iff any check returned `error`. Warnings do not block.
127    pub fn exit_code(&self) -> i32 {
128        match self.summary.overall {
129            CheckStatus::Error => 1,
130            _ => 0,
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn check_status_serializes_as_lowercase() {
141        assert_eq!(serde_json::to_string(&CheckStatus::Ok).unwrap(), "\"ok\"");
142        assert_eq!(
143            serde_json::to_string(&CheckStatus::Warn).unwrap(),
144            "\"warn\""
145        );
146        assert_eq!(
147            serde_json::to_string(&CheckStatus::Error).unwrap(),
148            "\"error\""
149        );
150    }
151
152    #[test]
153    fn report_summary_overall_is_error_when_any_error() {
154        let report = Report::build(vec![
155            CheckResult::ok("a", "fine"),
156            CheckResult::warn("b", "meh"),
157            CheckResult::error("c", "boom"),
158        ]);
159        assert_eq!(report.summary.overall, CheckStatus::Error);
160        assert_eq!(report.summary.ok, 1);
161        assert_eq!(report.summary.warn, 1);
162        assert_eq!(report.summary.error, 1);
163    }
164
165    #[test]
166    fn report_summary_overall_is_warn_when_only_warns() {
167        let report = Report::build(vec![
168            CheckResult::ok("a", "fine"),
169            CheckResult::warn("b", "meh"),
170        ]);
171        assert_eq!(report.summary.overall, CheckStatus::Warn);
172    }
173
174    #[test]
175    fn report_summary_overall_is_ok_when_all_ok() {
176        let report = Report::build(vec![CheckResult::ok("a", "fine")]);
177        assert_eq!(report.summary.overall, CheckStatus::Ok);
178    }
179
180    #[test]
181    fn exit_code_is_zero_for_ok_and_warn() {
182        let ok_report = Report::build(vec![CheckResult::ok("a", "fine")]);
183        assert_eq!(ok_report.exit_code(), 0);
184
185        let warn_report = Report::build(vec![CheckResult::warn("a", "meh")]);
186        assert_eq!(warn_report.exit_code(), 0);
187    }
188
189    #[test]
190    fn exit_code_is_one_for_any_error() {
191        let report = Report::build(vec![
192            CheckResult::ok("a", "fine"),
193            CheckResult::error("b", "boom"),
194        ]);
195        assert_eq!(report.exit_code(), 1);
196    }
197
198    #[test]
199    fn report_serializes_with_stable_shape() {
200        let report = Report::build(vec![
201            CheckResult::ok("toolchain", "rustc 1.88"),
202            CheckResult::warn("artifacts", "missing files").with_details("Dockerfile"),
203        ]);
204        let json = serde_json::to_value(&report).unwrap();
205        assert!(json.get("summary").is_some());
206        assert!(json.get("checks").is_some());
207        let checks = json.get("checks").unwrap().as_array().unwrap();
208        assert_eq!(checks.len(), 2);
209        assert_eq!(checks[0].get("name").unwrap(), "toolchain");
210        assert_eq!(checks[0].get("status").unwrap(), "ok");
211        assert!(checks[0].get("details").is_none());
212        assert_eq!(checks[1].get("details").unwrap(), "Dockerfile");
213    }
214
215    #[test]
216    fn details_omitted_when_none() {
217        let result = CheckResult::ok("x", "fine");
218        let s = serde_json::to_string(&result).unwrap();
219        assert!(!s.contains("details"));
220    }
221
222    #[test]
223    fn non_deploy_checks_return_general_category() {
224        use crate::doctor::registry::default_checks;
225        let general_names = &[
226            "toolchain_match",
227            "db_connection",
228            "migrations_pending",
229            "local_env_parity",
230            "deploy_env_parity",
231            "generated_artifacts",
232            "database_url_sqlite_in_prod",
233            "git_clean_and_pushed",
234        ];
235        let deploy_names = &["copy_dirs_dockerignore_collision", "docker_template_drift"];
236        for check in default_checks() {
237            if general_names.contains(&check.name()) {
238                assert_eq!(
239                    check.category(),
240                    CheckCategory::General,
241                    "{} should be General",
242                    check.name()
243                );
244            }
245            if deploy_names.contains(&check.name()) {
246                assert_eq!(
247                    check.category(),
248                    CheckCategory::Deploy,
249                    "{} should be Deploy",
250                    check.name()
251                );
252            }
253        }
254    }
255}