use crate::{
JsonOutput,
types::{DoctorCheck, RepoInfo},
};
use serde_json::{Map, Value, json};
use std::fmt::Write as _;
#[derive(Debug, Clone)]
pub struct DoctorReport {
header: String,
checks: Vec<DoctorCheck>,
errors: Vec<String>,
warnings: Vec<String>,
info: Vec<String>,
version: Option<String>,
details: Map<String, Value>,
}
impl DoctorReport {
#[must_use]
pub fn new(header: impl Into<String>) -> Self {
Self {
header: header.into(),
checks: Vec::new(),
errors: Vec::new(),
warnings: Vec::new(),
info: Vec::new(),
version: None,
details: Map::new(),
}
}
#[must_use]
pub fn for_tool<T: DoctorChecks>(tool: &T) -> Self {
Self::with_tool_header(tool, format!("🏥 {} health check", T::repo_info().name))
}
#[must_use]
pub fn with_tool_header<T: DoctorChecks>(tool: &T, header: impl Into<String>) -> Self {
Self::new(header)
.with_checks(tool.tool_checks())
.with_version(T::current_version())
}
#[must_use]
pub fn with_checks(mut self, checks: Vec<DoctorCheck>) -> Self {
self.checks = checks;
self
}
#[must_use]
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
#[must_use]
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.errors.push(error.into());
self
}
#[must_use]
pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
self.warnings.push(warning.into());
self
}
#[must_use]
pub fn with_info(mut self, info: impl Into<String>) -> Self {
self.info.push(info.into());
self
}
#[must_use]
pub fn with_detail(mut self, key: impl Into<String>, value: Value) -> Self {
self.details.insert(key.into(), value);
self
}
#[must_use]
pub fn checks(&self) -> &[DoctorCheck] {
&self.checks
}
fn failed_checks(&self) -> usize {
self.checks.iter().filter(|check| !check.passed).count()
}
#[must_use]
pub fn exit_code(&self) -> i32 {
if self.failed_checks() > 0 || !self.errors.is_empty() {
1
} else {
0
}
}
#[must_use]
pub fn to_json_value(&self) -> Value {
let mut value = json!({
"ok": self.exit_code() == 0,
"header": self.header,
"checks": self
.checks
.iter()
.map(|check| json!({
"name": check.name,
"passed": check.passed,
"message": check.message,
}))
.collect::<Vec<_>>(),
"errors": self.errors,
"warnings": self.warnings,
"info": self.info,
"version": self.version,
});
let object = value.as_object_mut().expect(
"the report is serialized from a struct, so the JSON value is always an object",
);
for (key, detail) in &self.details {
object.insert(key.clone(), detail.clone());
}
value
}
#[must_use]
pub fn render_text(&self) -> String {
let mut output = String::new();
writeln!(&mut output, "{}", self.header).expect("write to String is infallible");
writeln!(&mut output, "{}", "=".repeat(self.header.chars().count()))
.expect("write to String is infallible");
writeln!(&mut output).expect("write to String is infallible");
if !self.checks.is_empty() {
writeln!(&mut output, "Configuration:").expect("write to String is infallible");
for check in &self.checks {
if check.passed {
writeln!(&mut output, " ✅ {}", check.name)
.expect("write to String is infallible");
} else {
writeln!(&mut output, " ❌ {}", check.name)
.expect("write to String is infallible");
if let Some(message) = &check.message {
writeln!(&mut output, " {message}")
.expect("write to String is infallible");
}
}
}
writeln!(&mut output).expect("write to String is infallible");
}
if !self.info.is_empty() {
writeln!(&mut output, "Info:").expect("write to String is infallible");
for info in &self.info {
writeln!(&mut output, " ℹ️ {info}").expect("write to String is infallible");
}
writeln!(&mut output).expect("write to String is infallible");
}
if !self.warnings.is_empty() {
writeln!(&mut output, "Warnings:").expect("write to String is infallible");
for warning in &self.warnings {
writeln!(&mut output, " ⚠️ {warning}").expect("write to String is infallible");
}
writeln!(&mut output).expect("write to String is infallible");
}
if self.exit_code() == 0 {
writeln!(&mut output, "✨ Everything looks healthy!")
.expect("write to String is infallible");
} else {
writeln!(&mut output, "❌ Issues found - see above for details")
.expect("write to String is infallible");
}
output
}
#[must_use]
pub fn emit_output(&self, output: JsonOutput) -> i32 {
if output.is_json() {
print_doctor_report_json(self)
} else {
print_doctor_report_text(self)
}
}
}
pub trait DoctorChecks {
fn repo_info() -> RepoInfo;
fn current_version() -> &'static str;
fn tool_checks(&self) -> Vec<DoctorCheck> {
Vec::new()
}
}
pub fn run_doctor<T: DoctorChecks>(tool: &T) -> i32 {
let header = format!("🏥 {} health check", T::repo_info().name);
run_doctor_with_output_and_header(tool, &header, JsonOutput::Text)
}
fn build_doctor_report<T: DoctorChecks>(tool: &T, header: &str) -> DoctorReport {
DoctorReport::with_tool_header(tool, header)
}
fn render_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> (String, i32) {
let report = build_doctor_report(tool, header);
(report.render_text(), report.exit_code())
}
pub fn run_doctor_with_header<T: DoctorChecks>(tool: &T, header: &str) -> i32 {
run_doctor_with_output_and_header(tool, header, JsonOutput::Text)
}
pub fn run_doctor_with_output<T: DoctorChecks>(tool: &T, output: JsonOutput) -> i32 {
let header = format!("🏥 {} health check", T::repo_info().name);
run_doctor_with_output_and_header(tool, &header, output)
}
pub fn run_doctor_with_output_and_header<T: DoctorChecks>(
tool: &T,
header: &str,
output: JsonOutput,
) -> i32 {
if output.is_json() {
let report = build_doctor_report(tool, header);
print_doctor_report_json(&report)
} else {
let (rendered, exit_code) = render_doctor_with_header(tool, header);
print!("{rendered}");
exit_code
}
}
#[must_use]
pub fn print_doctor_report_json(report: &DoctorReport) -> i32 {
println!(
"{}",
serde_json::to_string_pretty(&report.to_json_value())
.expect("DoctorReport contains only serializable fields")
);
report.exit_code()
}
#[must_use]
pub fn print_doctor_report_text(report: &DoctorReport) -> i32 {
print!("{}", report.render_text());
report.exit_code()
}
#[cfg(test)]
mod tests {
use super::*;
struct TestTool;
impl DoctorChecks for TestTool {
fn repo_info() -> RepoInfo {
RepoInfo::new("workhelix", "test-tool")
}
fn current_version() -> &'static str {
"1.0.0"
}
fn tool_checks(&self) -> Vec<DoctorCheck> {
vec![
DoctorCheck::pass("Test check 1"),
DoctorCheck::fail("Test check 2", "This is a failure"),
]
}
}
#[test]
fn test_run_doctor() {
let tool = TestTool;
let exit_code = run_doctor(&tool);
assert_eq!(exit_code, 1);
}
#[test]
fn test_run_doctor_with_custom_header() {
let tool = TestTool;
let (output, exit_code) = render_doctor_with_header(&tool, "Custom Header");
assert!(output.contains("Custom Header"));
assert_eq!(exit_code, 1);
}
#[test]
fn doctor_report_json_includes_details() {
let report = DoctorReport::new("Header")
.with_checks(vec![DoctorCheck::pass("check")])
.with_detail("config_file_exists", json!(true));
let value = report.to_json_value();
assert_eq!(value["config_file_exists"], json!(true));
assert_eq!(value["ok"], json!(true));
}
#[test]
fn doctor_report_for_tool_uses_repo_name_version_and_checks() {
let report = DoctorReport::for_tool(&TestTool);
let value = report.to_json_value();
assert_eq!(value["header"], json!("🏥 test-tool health check"));
assert_eq!(value["version"], json!("1.0.0"));
assert_eq!(value["checks"].as_array().map(Vec::len), Some(2));
}
#[test]
fn doctor_report_emit_returns_exit_code_for_selected_format() {
let report = DoctorReport::for_tool(&TestTool);
assert_eq!(report.emit_output(JsonOutput::Json), 1);
}
#[test]
fn run_doctor_with_output_supports_json_mode() {
let tool = TestTool;
let exit_code = run_doctor_with_output(&tool, JsonOutput::Json);
assert_eq!(exit_code, 1);
}
}