use anyhow::Result;
use colored::Colorize;
use std::process::Stdio;
use tokio::process::Command;
struct Check {
name: &'static str,
result: CheckResult,
}
enum CheckResult {
Ok(String),
Warn(String),
Fail(String),
}
pub async fn run() -> Result<()> {
println!(
"\n{}\n",
"stacksdapp doctor — checking prerequisites".bold()
);
let checks = vec![
check_rust().await,
check_node().await,
check_clarinet().await,
check_docker().await,
check_git().await,
check_stacksdapp().await,
];
let mut all_ok = true;
for check in &checks {
match &check.result {
CheckResult::Ok(msg) => {
println!(
" {} {} {}",
"✔".green().bold(),
check.name.white(),
msg.dimmed()
);
}
CheckResult::Warn(msg) => {
println!(
" {} {} {}",
"⚠".yellow().bold(),
check.name.white(),
msg.yellow()
);
all_ok = false;
}
CheckResult::Fail(msg) => {
println!(
" {} {} {}",
"✗".red().bold(),
check.name.white().bold(),
msg.red()
);
all_ok = false;
}
}
}
println!();
if all_ok {
println!(
"{}",
" All checks passed. You're ready to build on Stacks!"
.green()
.bold()
);
} else {
println!(
"{}",
" Some checks failed. Fix the issues above before running stacksdapp new.".yellow()
);
}
println!();
Ok(())
}
async fn check_rust() -> Check {
match version_output("rustc", &["--version"]).await {
Some(v) => {
let version = v
.trim_start_matches("rustc ")
.split_whitespace()
.next()
.unwrap_or("?")
.to_string();
if meets_semver(&version, 1, 75) {
Check {
name: "Rust",
result: CheckResult::Ok(version),
}
} else {
Check {
name: "Rust",
result: CheckResult::Warn(format!(
"{version} — Rust 1.75+ required. Run: rustup update"
)),
}
}
}
None => Check {
name: "Rust",
result: CheckResult::Fail("not found. Install from https://rustup.rs".into()),
},
}
}
async fn check_node() -> Check {
match version_output("node", &["--version"]).await {
Some(v) => {
let version = v.trim().trim_start_matches('v').to_string();
let major: u32 = version
.split('.')
.next()
.unwrap_or("0")
.parse()
.unwrap_or(0);
if major >= 20 {
Check {
name: "Node.js",
result: CheckResult::Ok(version),
}
} else {
Check {
name: "Node.js",
result: CheckResult::Fail(format!(
"{version} — Node.js 20+ required. Install from https://nodejs.org"
)),
}
}
}
None => Check {
name: "Node.js",
result: CheckResult::Fail(
"not found — Node.js 20+ required. Install from https://nodejs.org".into(),
),
},
}
}
async fn check_clarinet() -> Check {
match version_output("clarinet", &["--version"]).await {
Some(v) => {
let version = v
.trim()
.trim_start_matches("clarinet ")
.split_whitespace()
.next()
.unwrap_or("?")
.to_string();
let major: u32 = version
.split('.')
.next()
.unwrap_or("0")
.parse()
.unwrap_or(0);
if major >= 3 {
Check {
name: "Clarinet",
result: CheckResult::Ok(version),
}
} else {
Check {
name: "Clarinet",
result: CheckResult::Warn(format!(
"{version} — Clarinet 3.x required. \
Run: brew install clarinet OR cargo install clarinet"
)),
}
}
}
None => Check {
name: "Clarinet",
result: CheckResult::Fail(
"not found. Install: brew install clarinet OR cargo install clarinet".into(),
),
},
}
}
async fn check_docker() -> Check {
let bin_exists = Command::new("docker")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
let found = match &bin_exists {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
_ => true,
};
if !found {
return Check {
name: "Docker",
result: CheckResult::Warn(
"not found — only required for local devnet. Install from https://docker.com"
.into(),
),
};
}
let running = Command::new("docker")
.args(["info"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if running {
let version = version_output("docker", &["--version"])
.await
.map(|v| {
v.trim()
.trim_start_matches("Docker version ")
.split(',')
.next()
.unwrap_or("?")
.to_string()
})
.unwrap_or_else(|| "?".into());
Check {
name: "Docker",
result: CheckResult::Ok(version),
}
} else {
Check {
name: "Docker",
result: CheckResult::Warn(
"not running — Start Docker Desktop first (required for devnet only)".into(),
),
}
}
}
async fn check_git() -> Check {
match version_output("git", &["--version"]).await {
Some(v) => {
let version = v.trim().trim_start_matches("git version ").to_string();
Check {
name: "git",
result: CheckResult::Ok(version),
}
}
None => Check {
name: "git",
result: CheckResult::Warn(
"not found — optional but recommended. Install from https://git-scm.com".into(),
),
},
}
}
async fn check_stacksdapp() -> Check {
let version = env!("CARGO_PKG_VERSION").to_string();
Check {
name: "stacksdapp",
result: CheckResult::Ok(version),
}
}
async fn version_output(cmd: &str, args: &[&str]) -> Option<String> {
Command::new(cmd)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn meets_semver(version: &str, req_major: u32, req_minor: u32) -> bool {
let mut parts = version.split('.');
let major: u32 = parts.next().unwrap_or("0").parse().unwrap_or(0);
let minor: u32 = parts.next().unwrap_or("0").parse().unwrap_or(0);
(major, minor) >= (req_major, req_minor)
}