1use crate::types::{DoctorCheck, RepoInfo};
7use serde_json::{Map, Value, json};
8use std::fmt::Write as _;
9
10#[derive(Debug, Clone)]
12pub struct DoctorReport {
13 header: String,
14 checks: Vec<DoctorCheck>,
15 errors: Vec<String>,
16 warnings: Vec<String>,
17 info: Vec<String>,
18 version: Option<String>,
19 details: Map<String, Value>,
20}
21
22impl DoctorReport {
23 #[must_use]
25 pub fn new(header: impl Into<String>) -> Self {
26 Self {
27 header: header.into(),
28 checks: Vec::new(),
29 errors: Vec::new(),
30 warnings: Vec::new(),
31 info: Vec::new(),
32 version: None,
33 details: Map::new(),
34 }
35 }
36
37 #[must_use]
39 pub fn for_tool<T: DoctorChecks>(tool: &T) -> Self {
40 Self::with_tool_header(tool, format!("đĨ {} health check", T::repo_info().name))
41 }
42
43 #[must_use]
45 pub fn with_tool_header<T: DoctorChecks>(tool: &T, header: impl Into<String>) -> Self {
46 Self::new(header)
47 .with_checks(tool.tool_checks())
48 .with_version(T::current_version())
49 }
50
51 #[must_use]
53 pub fn with_checks(mut self, checks: Vec<DoctorCheck>) -> Self {
54 self.checks = checks;
55 self
56 }
57
58 #[must_use]
60 pub fn with_version(mut self, version: impl Into<String>) -> Self {
61 self.version = Some(version.into());
62 self
63 }
64
65 #[must_use]
67 pub fn with_error(mut self, error: impl Into<String>) -> Self {
68 self.errors.push(error.into());
69 self
70 }
71
72 #[must_use]
74 pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
75 self.warnings.push(warning.into());
76 self
77 }
78
79 #[must_use]
81 pub fn with_info(mut self, info: impl Into<String>) -> Self {
82 self.info.push(info.into());
83 self
84 }
85
86 #[must_use]
88 pub fn with_detail(mut self, key: impl Into<String>, value: Value) -> Self {
89 self.details.insert(key.into(), value);
90 self
91 }
92
93 #[must_use]
95 pub fn checks(&self) -> &[DoctorCheck] {
96 &self.checks
97 }
98
99 fn failed_checks(&self) -> usize {
100 self.checks.iter().filter(|check| !check.passed).count()
101 }
102
103 #[must_use]
105 pub fn exit_code(&self) -> i32 {
106 if self.failed_checks() > 0 || !self.errors.is_empty() {
107 1
108 } else {
109 0
110 }
111 }
112
113 #[must_use]
115 pub fn to_json_value(&self) -> Value {
116 let mut value = json!({
117 "ok": self.exit_code() == 0,
118 "header": self.header,
119 "checks": self
120 .checks
121 .iter()
122 .map(|check| json!({
123 "name": check.name,
124 "passed": check.passed,
125 "message": check.message,
126 }))
127 .collect::<Vec<_>>(),
128 "errors": self.errors,
129 "warnings": self.warnings,
130 "info": self.info,
131 "version": self.version,
132 });
133
134 let object = value
135 .as_object_mut()
136 .expect("doctor report JSON must be an object");
137 for (key, detail) in &self.details {
138 object.insert(key.clone(), detail.clone());
139 }
140 value
141 }
142
143 #[must_use]
145 pub fn render_text(&self) -> String {
146 let mut output = String::new();
147 writeln!(&mut output, "{}", self.header).expect("write to string");
148 writeln!(&mut output, "{}", "=".repeat(self.header.chars().count()))
149 .expect("write to string");
150 writeln!(&mut output).expect("write to string");
151
152 if !self.checks.is_empty() {
153 writeln!(&mut output, "Configuration:").expect("write to string");
154 for check in &self.checks {
155 if check.passed {
156 writeln!(&mut output, " â
{}", check.name).expect("write to string");
157 } else {
158 writeln!(&mut output, " â {}", check.name).expect("write to string");
159 if let Some(message) = &check.message {
160 writeln!(&mut output, " {message}").expect("write to string");
161 }
162 }
163 }
164 writeln!(&mut output).expect("write to string");
165 }
166
167 if !self.info.is_empty() {
168 writeln!(&mut output, "Info:").expect("write to string");
169 for info in &self.info {
170 writeln!(&mut output, " âšī¸ {info}").expect("write to string");
171 }
172 writeln!(&mut output).expect("write to string");
173 }
174
175 if !self.warnings.is_empty() {
176 writeln!(&mut output, "Warnings:").expect("write to string");
177 for warning in &self.warnings {
178 writeln!(&mut output, " â ī¸ {warning}").expect("write to string");
179 }
180 writeln!(&mut output).expect("write to string");
181 }
182
183 if self.exit_code() == 0 {
184 writeln!(&mut output, "⨠Everything looks healthy!").expect("write to string");
185 } else {
186 writeln!(&mut output, "â Issues found - see above for details")
187 .expect("write to string");
188 }
189
190 output
191 }
192
193 #[must_use]
195 pub fn emit(&self, json: bool) -> i32 {
196 if json {
197 print_doctor_report_json(self)
198 } else {
199 print_doctor_report_text(self)
200 }
201 }
202}
203
204pub trait DoctorChecks {
208 fn repo_info() -> RepoInfo;
210
211 fn current_version() -> &'static str;
213
214 fn tool_checks(&self) -> Vec<DoctorCheck> {
218 Vec::new()
219 }
220}
221
222pub fn run_doctor<T: DoctorChecks>(tool: &T) -> i32 {
229 let header = format!("đĨ {} health check", T::repo_info().name);
230 run_doctor_with_header(tool, &header)
231}
232
233fn build_doctor_report<T: DoctorChecks>(tool: &T, header: &str) -> DoctorReport {
234 DoctorReport::with_tool_header(tool, header)
235}
236
237fn render_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> (String, i32) {
238 let report = build_doctor_report(tool, header);
239 (report.render_text(), report.exit_code())
240}
241
242pub fn run_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> i32 {
244 let (output, exit_code) = render_doctor_with_header(tool, header);
245 print!("{output}");
246 exit_code
247}
248
249#[must_use]
251pub fn print_doctor_report_json(report: &DoctorReport) -> i32 {
252 println!(
253 "{}",
254 serde_json::to_string_pretty(&report.to_json_value())
255 .expect("doctor report JSON must serialize")
256 );
257 report.exit_code()
258}
259
260#[must_use]
262pub fn print_doctor_report_text(report: &DoctorReport) -> i32 {
263 print!("{}", report.render_text());
264 report.exit_code()
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 struct TestTool;
272
273 impl DoctorChecks for TestTool {
274 fn repo_info() -> RepoInfo {
275 RepoInfo::new("workhelix", "test-tool")
276 }
277
278 fn current_version() -> &'static str {
279 "1.0.0"
280 }
281
282 fn tool_checks(&self) -> Vec<DoctorCheck> {
283 vec![
284 DoctorCheck::pass("Test check 1"),
285 DoctorCheck::fail("Test check 2", "This is a failure"),
286 ]
287 }
288 }
289
290 #[test]
291 fn test_run_doctor() {
292 let tool = TestTool;
293 let exit_code = run_doctor(&tool);
294 assert_eq!(exit_code, 1);
296 }
297
298 #[test]
299 fn test_run_doctor_with_custom_header() {
300 let tool = TestTool;
301 let (output, exit_code) = render_doctor_with_header(&tool, "Custom Header");
302 assert!(output.contains("Custom Header"));
303 assert_eq!(exit_code, 1);
304 }
305
306 #[test]
307 fn doctor_report_json_includes_details() {
308 let report = DoctorReport::new("Header")
309 .with_checks(vec![DoctorCheck::pass("check")])
310 .with_detail("config_file_exists", json!(true));
311
312 let value = report.to_json_value();
313 assert_eq!(value["config_file_exists"], json!(true));
314 assert_eq!(value["ok"], json!(true));
315 }
316
317 #[test]
318 fn doctor_report_for_tool_uses_repo_name_version_and_checks() {
319 let report = DoctorReport::for_tool(&TestTool);
320 let value = report.to_json_value();
321
322 assert_eq!(value["header"], json!("đĨ test-tool health check"));
323 assert_eq!(value["version"], json!("1.0.0"));
324 assert_eq!(value["checks"].as_array().map(Vec::len), Some(2));
325 }
326
327 #[test]
328 fn doctor_report_emit_returns_exit_code_for_selected_format() {
329 let report = DoctorReport::for_tool(&TestTool);
330 assert_eq!(report.emit(true), 1);
331 }
332}