use anyhow::{Context, Result};
use console::style;
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use crate::run::{detect_node_runner, detect_project_type};
pub struct DoctorOptions {
pub json: bool,
}
#[derive(Debug, Serialize)]
struct DoctorReport {
project_type: String,
sections: Vec<Section>,
passed: usize,
failed: usize,
}
#[derive(Debug, Serialize)]
struct Section {
name: String,
checks: Vec<CheckResult>,
}
#[derive(Debug, Clone, Serialize)]
struct CheckResult {
name: String,
status: CheckStatus,
version: Option<String>,
detail: Option<String>,
fix: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
enum CheckStatus {
Ok,
Missing,
Error,
}
pub fn run(opts: DoctorOptions) -> Result<()> {
let project_dir = std::env::current_dir().context("getting current directory")?;
let project_type = detect_project_type(&project_dir);
let sections = vec![
check_toolchain(project_type, &project_dir),
check_dependencies(project_type, &project_dir),
check_git(&project_dir),
check_ai(),
];
let passed: usize = sections
.iter()
.flat_map(|s| &s.checks)
.filter(|c| c.status == CheckStatus::Ok)
.count();
let failed: usize = sections
.iter()
.flat_map(|s| &s.checks)
.filter(|c| c.status != CheckStatus::Ok)
.count();
if opts.json {
let report = DoctorReport {
project_type: project_type.to_string(),
sections,
passed,
failed,
};
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
println!("\n{}\n", style("fledge doctor").bold());
for section in §ions {
println!(" {}", style(§ion.name).bold());
for check in §ion.checks {
match &check.status {
CheckStatus::Ok => {
let label = match &check.version {
Some(v) => format!("{} {}", check.name, v),
None => check.name.clone(),
};
let label = match &check.detail {
Some(d) => format!("{} — {}", label, d),
None => label,
};
println!(" {} {}", style("✅").green().bold(), label);
}
CheckStatus::Missing => {
let detail = check.detail.as_deref().unwrap_or("not found");
println!(
" {} {} — {}",
style("❌").red().bold(),
check.name,
detail
);
if let Some(fix) = &check.fix {
println!(" {} {}", style("➡️").dim(), style(fix).cyan());
}
}
CheckStatus::Error => {
let detail = check.detail.as_deref().unwrap_or("error");
println!(
" {} {} — {}",
style("❌").red().bold(),
check.name,
detail
);
if let Some(fix) = &check.fix {
println!(" {} {}", style("➡️").dim(), style(fix).cyan());
}
}
}
}
println!();
}
println!(
" {} passed, {} found\n",
style(format!("{} checks", passed)).green().bold(),
style(format!("{} issues", failed)).red().bold(),
);
Ok(())
}
fn check_tool(name: &str, version_args: &[&str], fix: &str) -> CheckResult {
let child = Command::new(name)
.args(version_args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();
let mut child = match child {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return CheckResult {
name: name.to_string(),
status: CheckStatus::Missing,
version: None,
detail: Some("not found".to_string()),
fix: Some(fix.to_string()),
};
}
Err(e) => {
return CheckResult {
name: name.to_string(),
status: CheckStatus::Error,
version: None,
detail: Some(format!("error: {}", e)),
fix: Some(fix.to_string()),
};
}
};
let timeout = Duration::from_secs(10);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return CheckResult {
name: name.to_string(),
status: CheckStatus::Error,
version: None,
detail: Some("timed out after 10s".to_string()),
fix: Some(fix.to_string()),
};
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
return CheckResult {
name: name.to_string(),
status: CheckStatus::Error,
version: None,
detail: Some(format!("error: {}", e)),
fix: Some(fix.to_string()),
};
}
}
}
let output = child.wait_with_output();
match output {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = if text.trim().is_empty() {
stderr.to_string()
} else {
text.to_string()
};
let version = extract_version(&combined);
CheckResult {
name: name.to_string(),
status: CheckStatus::Ok,
version,
detail: None,
fix: None,
}
}
Err(e) => CheckResult {
name: name.to_string(),
status: CheckStatus::Error,
version: None,
detail: Some(format!("error: {}", e)),
fix: Some(fix.to_string()),
},
}
}
fn extract_version(text: &str) -> Option<String> {
text.split_whitespace().find_map(|word| {
let trimmed = word.trim_start_matches("go").trim_start_matches('v');
let parts: Vec<&str> = trimmed.split('.').collect();
if parts.len() >= 2
&& parts.iter().all(|p| {
let numeric_part = p.trim_end_matches(|c: char| !c.is_ascii_digit());
!numeric_part.is_empty() && numeric_part.chars().all(|c| c.is_ascii_digit())
})
{
Some(trimmed.trim_end_matches(',').to_string())
} else {
None
}
})
}
fn check_toolchain(project_type: &str, dir: &Path) -> Section {
let mut checks = Vec::new();
match project_type {
"rust" => {
checks.push(check_tool(
"rustc",
&["--version"],
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh",
));
checks.push(check_tool(
"cargo",
&["--version"],
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh",
));
checks.push(check_tool(
"cargo-clippy",
&["--version"],
"rustup component add clippy",
));
checks.push(check_tool(
"rustfmt",
&["--version"],
"rustup component add rustfmt",
));
}
"node" => {
let runner = detect_node_runner(dir);
if runner == "bun" {
checks.push(check_tool("bun", &["--version"], "https://bun.sh/"));
} else {
checks.push(check_tool(
"node",
&["--version"],
"https://nodejs.org/ or use nvm",
));
let tool_check = match runner {
"yarn" => check_tool("yarn", &["--version"], "npm install -g yarn"),
"pnpm" => check_tool("pnpm", &["--version"], "npm install -g pnpm"),
_ => check_tool(
"npm",
&["--version"],
"npm is bundled with node — reinstall node",
),
};
checks.push(tool_check);
}
}
"go" => {
checks.push(check_tool("go", &["version"], "https://go.dev/dl/"));
}
"python" => {
let py3 = check_tool(
"python3",
&["--version"],
"https://www.python.org/downloads/",
);
let py = check_tool(
"python",
&["--version"],
"https://www.python.org/downloads/",
);
if py3.status == CheckStatus::Ok {
checks.push(py3);
} else if py.status == CheckStatus::Ok {
checks.push(py);
} else {
checks.push(py3);
}
checks.push(check_tool("pip", &["--version"], "python3 -m ensurepip"));
}
"ruby" => {
checks.push(check_tool(
"ruby",
&["--version"],
"https://www.ruby-lang.org/en/downloads/",
));
checks.push(check_tool(
"gem",
&["--version"],
"gem is bundled with ruby — reinstall ruby",
));
checks.push(check_tool("bundler", &["--version"], "gem install bundler"));
}
"java-gradle" => {
checks.push(check_tool("java", &["-version"], "https://adoptium.net/"));
checks.push(check_tool(
"gradle",
&["--version"],
"https://gradle.org/install/",
));
}
"java-maven" => {
checks.push(check_tool("java", &["-version"], "https://adoptium.net/"));
checks.push(check_tool(
"mvn",
&["--version"],
"https://maven.apache.org/install.html",
));
}
"swift" => {
checks.push(check_tool(
"swift",
&["--version"],
"https://www.swift.org/install/",
));
let swiftlint = check_tool("swiftlint", &["version"], "brew install swiftlint");
if swiftlint.status == CheckStatus::Ok {
checks.push(swiftlint);
}
}
_ => {
}
}
Section {
name: "Toolchain".to_string(),
checks,
}
}
fn check_dependencies(project_type: &str, dir: &Path) -> Section {
let mut checks = Vec::new();
match project_type {
"rust" => {
checks.push(check_path_exists(
dir,
"Cargo.lock",
"Cargo.lock found",
"run `cargo generate-lockfile`",
));
checks.push(check_path_exists(
dir,
"target",
"target/ exists",
"run `cargo build`",
));
}
"node" => {
let runner = detect_node_runner(dir);
let install_cmd = match runner {
"bun" => "bun install",
"yarn" => "yarn install",
"pnpm" => "pnpm install",
_ => "npm install",
};
checks.push(check_path_exists(
dir,
"node_modules",
"node_modules/ exists",
&format!("run `{install_cmd}`"),
));
let (lock_file, lock_label) = match runner {
"bun" => {
if dir.join("bun.lockb").exists() {
("bun.lockb", "bun.lockb found")
} else {
("bun.lock", "bun.lock found")
}
}
"yarn" => ("yarn.lock", "yarn.lock found"),
"pnpm" => ("pnpm-lock.yaml", "pnpm-lock.yaml found"),
_ => ("package-lock.json", "package-lock.json found"),
};
checks.push(check_path_exists(
dir,
lock_file,
lock_label,
&format!("run `{install_cmd}`"),
));
}
"go" => {
checks.push(check_path_exists(
dir,
"go.sum",
"go.sum found",
"run `go mod tidy`",
));
}
"python" => {
let req = check_path_exists(
dir,
"requirements.txt",
"requirements.txt found",
"run `pip freeze > requirements.txt`",
);
let pipfile = check_path_exists(
dir,
"Pipfile.lock",
"Pipfile.lock found",
"run `pipenv install`",
);
let poetry = check_path_exists(
dir,
"poetry.lock",
"poetry.lock found",
"run `poetry install`",
);
if req.status == CheckStatus::Ok {
checks.push(req);
} else if pipfile.status == CheckStatus::Ok {
checks.push(pipfile);
} else if poetry.status == CheckStatus::Ok {
checks.push(poetry);
} else {
checks.push(req);
}
}
"ruby" => {
checks.push(check_path_exists(
dir,
"Gemfile.lock",
"Gemfile.lock found",
"run `bundle install`",
));
}
"java-gradle" => {
checks.push(check_path_exists(
dir,
"gradlew",
"gradle wrapper found",
"run `gradle wrapper`",
));
}
"java-maven" => {
checks.push(check_path_exists(
dir,
"target",
"target/ exists",
"run `mvn compile`",
));
}
_ => {}
}
Section {
name: "Dependencies".to_string(),
checks,
}
}
fn check_path_exists(dir: &Path, name: &str, ok_label: &str, fix: &str) -> CheckResult {
let path = dir.join(name);
if path.exists() {
CheckResult {
name: ok_label.to_string(),
status: CheckStatus::Ok,
version: None,
detail: None,
fix: None,
}
} else {
CheckResult {
name: name.to_string(),
status: CheckStatus::Missing,
version: None,
detail: Some("not found".to_string()),
fix: Some(fix.to_string()),
}
}
}
fn check_git(dir: &Path) -> Section {
let mut checks = Vec::new();
checks.push(check_tool(
"git",
&["--version"],
"https://git-scm.com/downloads",
));
let git_dir = dir.join(".git");
if git_dir.exists() {
checks.push(CheckResult {
name: "repository".to_string(),
status: CheckStatus::Ok,
version: None,
detail: Some("initialized".to_string()),
fix: None,
});
} else {
checks.push(CheckResult {
name: "repository".to_string(),
status: CheckStatus::Missing,
version: None,
detail: Some("not a git repository".to_string()),
fix: Some("git init".to_string()),
});
}
let remote_output = Command::new("git")
.args(["remote", "-v"])
.current_dir(dir)
.output();
match remote_output {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout);
let first_line = text.lines().next().unwrap_or("");
if first_line.is_empty() {
checks.push(CheckResult {
name: "remote".to_string(),
status: CheckStatus::Missing,
version: None,
detail: Some("no remote configured".to_string()),
fix: Some("git remote add origin <url>".to_string()),
});
} else {
let parts: Vec<&str> = first_line.split_whitespace().collect();
let remote_name = parts.first().unwrap_or(&"origin");
let remote_url = parts.get(1).unwrap_or(&"");
checks.push(CheckResult {
name: "remote".to_string(),
status: CheckStatus::Ok,
version: None,
detail: Some(format!("{} ➡️ {}", remote_name, remote_url)),
fix: None,
});
}
}
Err(_) => {
}
}
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(dir)
.output();
if let Ok(out) = status_output {
let text = String::from_utf8_lossy(&out.stdout);
let changed: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
if changed.is_empty() {
checks.push(CheckResult {
name: "working tree".to_string(),
status: CheckStatus::Ok,
version: None,
detail: Some("clean".to_string()),
fix: None,
});
} else {
checks.push(CheckResult {
name: "working tree".to_string(),
status: CheckStatus::Error,
version: None,
detail: Some(format!("uncommitted changes ({} files)", changed.len())),
fix: Some("git add . && git commit".to_string()),
});
}
}
Section {
name: "Git".to_string(),
checks,
}
}
fn check_ai() -> Section {
let mut checks = Vec::new();
let claude = check_tool(
"claude",
&["--version"],
"Install Claude CLI: https://docs.anthropic.com/en/docs/claude-code — then run `claude` to authenticate",
);
match claude.status {
CheckStatus::Ok => {
checks.push(claude);
checks.push(CheckResult {
name: "AI commands".to_string(),
status: CheckStatus::Ok,
version: None,
detail: Some("fledge review, fledge ask available".to_string()),
fix: None,
});
}
_ => {
checks.push(claude);
checks.push(CheckResult {
name: "AI commands".to_string(),
status: CheckStatus::Missing,
version: None,
detail: Some("fledge review, fledge ask disabled".to_string()),
fix: Some(
"Install Claude CLI to enable AI-powered code review and Q&A".to_string(),
),
});
}
}
Section {
name: "AI".to_string(),
checks,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_version_from_rustc() {
let v = extract_version("rustc 1.78.0 (9b00956e5 2024-04-29)");
assert_eq!(v, Some("1.78.0".to_string()));
}
#[test]
fn extract_version_from_node() {
let v = extract_version("v20.11.1");
assert_eq!(v, Some("20.11.1".to_string()));
}
#[test]
fn extract_version_from_go() {
let v = extract_version("go version go1.22.2 darwin/arm64");
assert_eq!(v, Some("1.22.2".to_string()));
}
#[test]
fn extract_version_from_git() {
let v = extract_version("git version 2.44.0");
assert_eq!(v, Some("2.44.0".to_string()));
}
#[test]
fn extract_version_none() {
let v = extract_version("no version here");
assert_eq!(v, None);
}
#[test]
fn check_path_exists_found() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.lock"), "").unwrap();
let result = check_path_exists(
dir.path(),
"Cargo.lock",
"Cargo.lock found",
"run cargo generate-lockfile",
);
assert_eq!(result.status, CheckStatus::Ok);
}
#[test]
fn check_path_exists_missing() {
let dir = tempfile::tempdir().unwrap();
let result = check_path_exists(
dir.path(),
"Cargo.lock",
"Cargo.lock found",
"run cargo generate-lockfile",
);
assert_eq!(result.status, CheckStatus::Missing);
assert!(result.fix.is_some());
}
#[test]
fn toolchain_rust_checks() {
let dir = tempfile::tempdir().unwrap();
let section = check_toolchain("rust", dir.path());
assert_eq!(section.name, "Toolchain");
let names: Vec<&str> = section.checks.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"rustc"));
assert!(names.contains(&"cargo"));
}
#[test]
fn toolchain_generic_empty() {
let dir = tempfile::tempdir().unwrap();
let section = check_toolchain("generic", dir.path());
assert!(section.checks.is_empty());
}
#[test]
fn toolchain_node_bun_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::write(dir.path().join("bun.lockb"), "").unwrap();
let section = check_toolchain("node", dir.path());
let names: Vec<&str> = section.checks.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"bun"));
assert!(!names.contains(&"npm"));
assert!(!names.contains(&"node"));
}
#[test]
fn dependencies_node_bun_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::write(dir.path().join("bun.lockb"), "").unwrap();
let section = check_dependencies("node", dir.path());
let names: Vec<&str> = section.checks.iter().map(|c| c.name.as_str()).collect();
assert!(names.iter().any(|n| n.contains("bun.lockb")));
assert!(!names.iter().any(|n| n.contains("package-lock")));
}
#[test]
fn dependencies_node_pnpm_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
let section = check_dependencies("node", dir.path());
let names: Vec<&str> = section.checks.iter().map(|c| c.name.as_str()).collect();
assert!(names.iter().any(|n| n.contains("pnpm-lock")));
assert!(!names.iter().any(|n| n.contains("package-lock")));
}
#[test]
fn dependencies_rust_checks() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
let section = check_dependencies("rust", dir.path());
assert_eq!(section.name, "Dependencies");
assert_eq!(section.checks.len(), 2);
}
#[test]
fn git_checks_not_repo() {
let dir = tempfile::tempdir().unwrap();
let section = check_git(dir.path());
assert_eq!(section.name, "Git");
assert!(section.checks.len() >= 2);
let repo_check = section
.checks
.iter()
.find(|c| c.name == "repository")
.unwrap();
assert_eq!(repo_check.status, CheckStatus::Missing);
}
#[test]
fn extract_version_java() {
let v = extract_version("openjdk version \"17.0.10\" 2024-01-16");
assert!(v.is_some() || v.is_none()); }
#[test]
fn section_serializes_to_json() {
let report = DoctorReport {
project_type: "rust".to_string(),
sections: vec![Section {
name: "Toolchain".to_string(),
checks: vec![CheckResult {
name: "rustc".to_string(),
status: CheckStatus::Ok,
version: Some("1.78.0".to_string()),
detail: None,
fix: None,
}],
}],
passed: 1,
failed: 0,
};
let json = serde_json::to_string_pretty(&report).unwrap();
assert!(json.contains("\"rustc\""));
assert!(json.contains("\"ok\""));
}
}