1use crate::{
7 JsonOutput,
8 types::{DoctorCheck, RepoInfo},
9};
10use serde_json::{Map, Value, json};
11use std::fmt::Write as _;
12
13#[derive(Debug, Clone)]
15pub struct DoctorReport {
16 header: String,
17 checks: Vec<DoctorCheck>,
18 errors: Vec<String>,
19 warnings: Vec<String>,
20 info: Vec<String>,
21 version: Option<String>,
22 details: Map<String, Value>,
23}
24
25impl DoctorReport {
26 #[must_use]
28 pub fn new(header: impl Into<String>) -> Self {
29 Self {
30 header: header.into(),
31 checks: Vec::new(),
32 errors: Vec::new(),
33 warnings: Vec::new(),
34 info: Vec::new(),
35 version: None,
36 details: Map::new(),
37 }
38 }
39
40 #[must_use]
42 pub fn for_tool<T: DoctorChecks>(tool: &T) -> Self {
43 Self::with_tool_header(tool, format!("đĨ {} health check", T::repo_info().name))
44 }
45
46 #[must_use]
48 pub fn with_tool_header<T: DoctorChecks>(tool: &T, header: impl Into<String>) -> Self {
49 Self::new(header)
50 .with_checks(tool.tool_checks())
51 .with_version(T::current_version())
52 }
53
54 #[must_use]
56 pub fn with_checks(mut self, checks: Vec<DoctorCheck>) -> Self {
57 self.checks = checks;
58 self
59 }
60
61 #[must_use]
63 pub fn with_version(mut self, version: impl Into<String>) -> Self {
64 self.version = Some(version.into());
65 self
66 }
67
68 #[must_use]
70 pub fn with_error(mut self, error: impl Into<String>) -> Self {
71 self.errors.push(error.into());
72 self
73 }
74
75 #[must_use]
77 pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
78 self.warnings.push(warning.into());
79 self
80 }
81
82 #[must_use]
84 pub fn with_info(mut self, info: impl Into<String>) -> Self {
85 self.info.push(info.into());
86 self
87 }
88
89 #[must_use]
91 pub fn with_detail(mut self, key: impl Into<String>, value: Value) -> Self {
92 self.details.insert(key.into(), value);
93 self
94 }
95
96 #[must_use]
98 pub fn checks(&self) -> &[DoctorCheck] {
99 &self.checks
100 }
101
102 fn failed_checks(&self) -> usize {
103 self.checks.iter().filter(|check| !check.passed).count()
104 }
105
106 #[must_use]
108 pub fn exit_code(&self) -> i32 {
109 if self.failed_checks() > 0 || !self.errors.is_empty() {
110 1
111 } else {
112 0
113 }
114 }
115
116 #[must_use]
118 pub fn to_json_value(&self) -> Value {
119 let mut value = json!({
120 "ok": self.exit_code() == 0,
121 "header": self.header,
122 "checks": self
123 .checks
124 .iter()
125 .map(|check| json!({
126 "name": check.name,
127 "passed": check.passed,
128 "message": check.message,
129 }))
130 .collect::<Vec<_>>(),
131 "errors": self.errors,
132 "warnings": self.warnings,
133 "info": self.info,
134 "version": self.version,
135 });
136
137 let object = value.as_object_mut().expect(
138 "the report is serialized from a struct, so the JSON value is always an object",
139 );
140 for (key, detail) in &self.details {
141 object.insert(key.clone(), detail.clone());
142 }
143 value
144 }
145
146 #[must_use]
148 pub fn render_text(&self) -> String {
149 let mut output = String::new();
150 writeln!(&mut output, "{}", self.header).expect("write to String is infallible");
151 writeln!(&mut output, "{}", "=".repeat(self.header.chars().count()))
152 .expect("write to String is infallible");
153 writeln!(&mut output).expect("write to String is infallible");
154
155 if !self.checks.is_empty() {
156 writeln!(&mut output, "Configuration:").expect("write to String is infallible");
157 for check in &self.checks {
158 if check.passed {
159 writeln!(&mut output, " â
{}", check.name)
160 .expect("write to String is infallible");
161 } else {
162 writeln!(&mut output, " â {}", check.name)
163 .expect("write to String is infallible");
164 if let Some(message) = &check.message {
165 writeln!(&mut output, " {message}")
166 .expect("write to String is infallible");
167 }
168 }
169 }
170 writeln!(&mut output).expect("write to String is infallible");
171 }
172
173 if !self.info.is_empty() {
174 writeln!(&mut output, "Info:").expect("write to String is infallible");
175 for info in &self.info {
176 writeln!(&mut output, " âšī¸ {info}").expect("write to String is infallible");
177 }
178 writeln!(&mut output).expect("write to String is infallible");
179 }
180
181 if !self.warnings.is_empty() {
182 writeln!(&mut output, "Warnings:").expect("write to String is infallible");
183 for warning in &self.warnings {
184 writeln!(&mut output, " â ī¸ {warning}").expect("write to String is infallible");
185 }
186 writeln!(&mut output).expect("write to String is infallible");
187 }
188
189 if self.exit_code() == 0 {
190 writeln!(&mut output, "⨠Everything looks healthy!")
191 .expect("write to String is infallible");
192 } else {
193 writeln!(&mut output, "â Issues found - see above for details")
194 .expect("write to String is infallible");
195 }
196
197 output
198 }
199
200 #[must_use]
202 pub fn emit_output(&self, output: JsonOutput) -> i32 {
203 if output.is_json() {
204 print_doctor_report_json(self)
205 } else {
206 print_doctor_report_text(self)
207 }
208 }
209}
210
211pub trait DoctorChecks {
215 fn repo_info() -> RepoInfo;
217
218 fn current_version() -> &'static str;
220
221 fn tool_checks(&self) -> Vec<DoctorCheck> {
225 Vec::new()
226 }
227}
228
229pub fn run_doctor<T: DoctorChecks>(tool: &T) -> i32 {
236 let header = format!("đĨ {} health check", T::repo_info().name);
237 run_doctor_with_output_and_header(tool, &header, JsonOutput::Text)
238}
239
240fn build_doctor_report<T: DoctorChecks>(tool: &T, header: &str) -> DoctorReport {
241 DoctorReport::with_tool_header(tool, header)
242}
243
244fn render_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> (String, i32) {
245 let report = build_doctor_report(tool, header);
246 (report.render_text(), report.exit_code())
247}
248
249pub fn run_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> i32 {
251 run_doctor_with_output_and_header(tool, header, JsonOutput::Text)
252}
253
254pub fn run_doctor_with_output<T: DoctorChecks>(tool: &T, output: JsonOutput) -> i32 {
256 let header = format!("đĨ {} health check", T::repo_info().name);
257 run_doctor_with_output_and_header(tool, &header, output)
258}
259
260pub fn run_doctor_with_output_and_header<T: DoctorChecks>(
262 tool: &T,
263 header: &str,
264 output: JsonOutput,
265) -> i32 {
266 if output.is_json() {
267 let report = build_doctor_report(tool, header);
268 print_doctor_report_json(&report)
269 } else {
270 let (rendered, exit_code) = render_doctor_with_header(tool, header);
271 print!("{rendered}");
272 exit_code
273 }
274}
275
276#[must_use]
278pub fn print_doctor_report_json(report: &DoctorReport) -> i32 {
279 println!(
280 "{}",
281 serde_json::to_string_pretty(&report.to_json_value())
282 .expect("DoctorReport contains only serializable fields")
283 );
284 report.exit_code()
285}
286
287#[must_use]
289pub fn print_doctor_report_text(report: &DoctorReport) -> i32 {
290 print!("{}", report.render_text());
291 report.exit_code()
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 struct TestTool;
299
300 impl DoctorChecks for TestTool {
301 fn repo_info() -> RepoInfo {
302 RepoInfo::new("workhelix", "test-tool")
303 }
304
305 fn current_version() -> &'static str {
306 "1.0.0"
307 }
308
309 fn tool_checks(&self) -> Vec<DoctorCheck> {
310 vec![
311 DoctorCheck::pass("Test check 1"),
312 DoctorCheck::fail("Test check 2", "This is a failure"),
313 ]
314 }
315 }
316
317 #[test]
318 fn test_run_doctor() {
319 let tool = TestTool;
320 let exit_code = run_doctor(&tool);
321 assert_eq!(exit_code, 1);
323 }
324
325 #[test]
326 fn test_run_doctor_with_custom_header() {
327 let tool = TestTool;
328 let (output, exit_code) = render_doctor_with_header(&tool, "Custom Header");
329 assert!(output.contains("Custom Header"));
330 assert_eq!(exit_code, 1);
331 }
332
333 #[test]
334 fn doctor_report_json_includes_details() {
335 let report = DoctorReport::new("Header")
336 .with_checks(vec![DoctorCheck::pass("check")])
337 .with_detail("config_file_exists", json!(true));
338
339 let value = report.to_json_value();
340 assert_eq!(value["config_file_exists"], json!(true));
341 assert_eq!(value["ok"], json!(true));
342 }
343
344 #[test]
345 fn doctor_report_for_tool_uses_repo_name_version_and_checks() {
346 let report = DoctorReport::for_tool(&TestTool);
347 let value = report.to_json_value();
348
349 assert_eq!(value["header"], json!("đĨ test-tool health check"));
350 assert_eq!(value["version"], json!("1.0.0"));
351 assert_eq!(value["checks"].as_array().map(Vec::len), Some(2));
352 }
353
354 #[test]
355 fn doctor_report_emit_returns_exit_code_for_selected_format() {
356 let report = DoctorReport::for_tool(&TestTool);
357 assert_eq!(report.emit_output(JsonOutput::Json), 1);
358 }
359
360 #[test]
361 fn run_doctor_with_output_supports_json_mode() {
362 let tool = TestTool;
363 let exit_code = run_doctor_with_output(&tool, JsonOutput::Json);
364 assert_eq!(exit_code, 1);
365 }
366}