use std::io;
use std::path::PathBuf;
use std::process::Command;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use colored::Colorize;
use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DoctorError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("{fail} check(s) failed ({pass} passed, {warn} warning(s))")]
ChecksFailed {
pass: u32,
warn: u32,
fail: u32,
},
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Serialize)]
pub struct CheckResult {
pub name: String,
pub status: CheckStatus,
pub version: Option<String>,
pub detail: String,
}
fn run_cmd_with_timeout(cmd: &str, args: &[&str], timeout_secs: u64) -> Option<String> {
let cmd_owned = cmd.to_string();
let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = Command::new(&cmd_owned).args(&args_owned).output();
let _ = tx.send(result);
});
match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
Ok(Ok(output)) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
Some(stdout)
}
_ => None,
}
}
fn extract_version(output: &str) -> String {
let first_line = output.lines().next().unwrap_or(output);
for word in first_line.split_whitespace().rev() {
let clean = word.trim_start_matches('v');
if clean.contains('.') && clean.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return clean.to_string();
}
}
first_line.to_string()
}
fn check_git() -> CheckResult {
let version = run_cmd_with_timeout("git", &["--version"], 3);
match version {
Some(output) => {
let ver = extract_version(&output);
let user = run_cmd_with_timeout("git", &["config", "user.name"], 3);
let detail = match user {
Some(ref name) if !name.is_empty() => format!("user.name = {name}"),
_ => "user.name not configured".to_string(),
};
let status = if user.is_some() && !user.as_deref().unwrap_or_default().is_empty() {
CheckStatus::Pass
} else {
CheckStatus::Warn
};
CheckResult {
name: "Git".to_string(),
status,
version: Some(ver),
detail,
}
}
None => CheckResult {
name: "Git".to_string(),
status: CheckStatus::Fail,
version: None,
detail: "not installed".to_string(),
},
}
}
fn check_node() -> CheckResult {
let node_ver = run_cmd_with_timeout("node", &["--version"], 3);
match node_ver {
Some(output) => {
let ver = extract_version(&output);
let npm = run_cmd_with_timeout("npm", &["--version"], 3);
let detail = match npm {
Some(ref npm_ver) => format!("npm {}", npm_ver.trim()),
None => "npm not found".to_string(),
};
let status = if npm.is_some() {
CheckStatus::Pass
} else {
CheckStatus::Warn
};
CheckResult {
name: "Node.js".to_string(),
status,
version: Some(ver),
detail,
}
}
None => CheckResult {
name: "Node.js".to_string(),
status: CheckStatus::Fail,
version: None,
detail: "not installed".to_string(),
},
}
}
fn check_rust() -> CheckResult {
let rustc_ver = run_cmd_with_timeout("rustc", &["--version"], 3);
match rustc_ver {
Some(output) => {
let ver = extract_version(&output);
let cargo = run_cmd_with_timeout("cargo", &["--version"], 3);
let detail = match cargo {
Some(ref c) => format!("cargo {}", extract_version(c)),
None => "cargo not found".to_string(),
};
let status = if cargo.is_some() {
CheckStatus::Pass
} else {
CheckStatus::Warn
};
CheckResult {
name: "Rust".to_string(),
status,
version: Some(ver),
detail,
}
}
None => CheckResult {
name: "Rust".to_string(),
status: CheckStatus::Fail,
version: None,
detail: "not installed".to_string(),
},
}
}
fn check_python() -> CheckResult {
let candidates = ["python3", "python", "py"];
let mut python_output = None;
let mut python_cmd = "";
for cmd in &candidates {
if let Some(output) = run_cmd_with_timeout(cmd, &["--version"], 3) {
python_output = Some(output);
python_cmd = cmd;
break;
}
}
match python_output {
Some(output) => {
let ver = extract_version(&output);
let pip = run_cmd_with_timeout("pip3", &["--version"], 3)
.or_else(|| run_cmd_with_timeout("pip", &["--version"], 3));
let detail = match pip {
Some(_) => format!("pip available (via {python_cmd})"),
None => "pip not found".to_string(),
};
let status = if pip.is_some() {
CheckStatus::Pass
} else {
CheckStatus::Warn
};
CheckResult {
name: "Python".to_string(),
status,
version: Some(ver),
detail,
}
}
None => CheckResult {
name: "Python".to_string(),
status: CheckStatus::Fail,
version: None,
detail: "not installed".to_string(),
},
}
}
fn check_docker() -> CheckResult {
let docker_ver = run_cmd_with_timeout("docker", &["--version"], 3);
match docker_ver {
Some(output) => {
let ver = extract_version(&output);
let info = run_cmd_with_timeout("docker", &["info"], 5);
let (status, detail) = match info {
Some(_) => (CheckStatus::Pass, "daemon running".to_string()),
None => (CheckStatus::Warn, "daemon not running".to_string()),
};
CheckResult {
name: "Docker".to_string(),
status,
version: Some(ver),
detail,
}
}
None => CheckResult {
name: "Docker".to_string(),
status: CheckStatus::Fail,
version: None,
detail: "not installed".to_string(),
},
}
}
fn check_disk() -> CheckResult {
use sysinfo::Disks;
let disks = Disks::new_with_refreshed_list();
let cwd = std::env::current_dir().ok();
let mut best_match: Option<(String, u64)> = None;
for disk in disks.list() {
let mount = disk.mount_point().to_string_lossy().to_string();
let available = disk.available_space();
if let Some(ref dir) = cwd {
let dir_str = dir.to_string_lossy();
if dir_str.starts_with(&mount) {
match &best_match {
Some((prev_mount, _)) if mount.len() > prev_mount.len() => {
best_match = Some((mount.clone(), available));
}
None => {
best_match = Some((mount.clone(), available));
}
_ => {}
}
}
}
}
let (mount, available) = best_match.unwrap_or_else(|| {
disks
.list()
.first()
.map(|d| {
(
d.mount_point().to_string_lossy().to_string(),
d.available_space(),
)
})
.unwrap_or_else(|| ("unknown".to_string(), 0))
});
let gb = available as f64 / 1_073_741_824.0;
let detail = format!("{gb:.0} GB free on {mount}");
let status = if gb >= 10.0 {
CheckStatus::Pass
} else if gb >= 5.0 {
CheckStatus::Warn
} else {
CheckStatus::Fail
};
CheckResult {
name: "Disk".to_string(),
status,
version: None,
detail,
}
}
fn check_ssh() -> CheckResult {
let home = dirs_from_env();
let ssh_dir = home.join(".ssh");
if !ssh_dir.exists() {
return CheckResult {
name: "SSH".to_string(),
status: CheckStatus::Warn,
version: None,
detail: "~/.ssh not found".to_string(),
};
}
let key_count = std::fs::read_dir(&ssh_dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with("id_") && !name.ends_with(".pub")
})
.count()
})
.unwrap_or(0);
if key_count > 0 {
CheckResult {
name: "SSH".to_string(),
status: CheckStatus::Pass,
version: None,
detail: format!("{key_count} key(s) found"),
}
} else {
CheckResult {
name: "SSH".to_string(),
status: CheckStatus::Warn,
version: None,
detail: "no keys found in ~/.ssh".to_string(),
}
}
}
fn dirs_from_env() -> PathBuf {
#[cfg(windows)]
{
std::env::var("USERPROFILE")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("C:\\"))
}
#[cfg(not(windows))]
{
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/"))
}
}
pub fn collect_checks() -> Result<Vec<CheckResult>, DoctorError> {
let mut results = Vec::with_capacity(7);
std::thread::scope(|s| {
let handles: Vec<_> = vec![
s.spawn(|| check_git()),
s.spawn(|| check_node()),
s.spawn(|| check_rust()),
s.spawn(|| check_python()),
s.spawn(|| check_docker()),
s.spawn(|| check_disk()),
s.spawn(|| check_ssh()),
];
for handle in handles {
results.push(handle.join().expect("doctor check thread panicked"));
}
});
Ok(results)
}
pub fn run(json: bool) -> Result<(), DoctorError> {
let checks = collect_checks()?;
if json {
let json_str = serde_json::to_string_pretty(&checks).map_err(io::Error::other)?;
println!("{json_str}");
return Ok(());
}
println!();
println!(
" {} {} {} {} {}",
"devpulse".bold(),
"──".dimmed(),
"Doctor".bold(),
"──".dimmed(),
"Environment Health".dimmed()
);
println!();
let mut pass_count = 0;
let mut warn_count = 0;
let mut fail_count = 0;
for check in &checks {
let status_str = match check.status {
CheckStatus::Pass => {
pass_count += 1;
"[PASS]".green().bold().to_string()
}
CheckStatus::Warn => {
warn_count += 1;
"[WARN]".yellow().bold().to_string()
}
CheckStatus::Fail => {
fail_count += 1;
"[FAIL]".red().bold().to_string()
}
};
let version_str = check.version.as_deref().unwrap_or("—").to_string();
println!(
" {} {:<12} {:<10} {}",
status_str,
check.name.bold(),
version_str,
check.detail.dimmed()
);
}
println!();
println!(
" Result: {} passed, {} warning(s), {} failure(s)",
pass_count.to_string().green().bold(),
warn_count.to_string().yellow().bold(),
fail_count.to_string().red().bold()
);
println!();
if fail_count > 0 {
return Err(DoctorError::ChecksFailed {
pass: pass_count,
warn: warn_count,
fail: fail_count,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_version_git() {
assert_eq!(extract_version("git version 2.43.0"), "2.43.0");
}
#[test]
fn test_extract_version_node() {
assert_eq!(extract_version("v22.1.0"), "22.1.0");
}
#[test]
fn test_extract_version_rustc() {
assert_eq!(
extract_version("rustc 1.77.0 (aedd173a2 2024-03-17)"),
"1.77.0"
);
}
#[test]
fn test_extract_version_fallback() {
assert_eq!(extract_version("unknown"), "unknown");
}
#[test]
fn test_check_status_serialization() {
let result = CheckResult {
name: "Test".to_string(),
status: CheckStatus::Pass,
version: Some("1.0".to_string()),
detail: "all good".to_string(),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"pass\""));
assert!(json.contains("\"Test\""));
}
#[test]
fn test_dirs_from_env_returns_path() {
let home = dirs_from_env();
assert!(!home.as_os_str().is_empty());
}
}