1use serde::Serialize;
7use std::path::Path;
8
9#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
11#[serde(rename_all = "lowercase")]
12pub enum CheckCategory {
13 General,
14 Deploy,
15}
16
17#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
19#[serde(rename_all = "lowercase")]
20pub enum CheckStatus {
21 Ok,
22 Warn,
23 Error,
24}
25
26#[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
70pub trait DoctorCheck {
72 fn name(&self) -> &'static str;
73 fn run(&self, root: &Path) -> CheckResult;
74 fn category(&self) -> CheckCategory {
77 CheckCategory::General
78 }
79}
80
81#[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 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}