use std::path::{Path, PathBuf};
use crate::error::{CliError, CliResult};
const MIN_RUST_VERSION: (u32, u32) = (1, 96);
pub struct DoctorOptions {
pub path: Option<PathBuf>,
}
#[derive(Debug, Default)]
pub struct DoctorReport {
pub rustc_ok: bool,
pub rustc_version: Option<String>,
pub cargo_ok: bool,
pub in_nestrs_workspace: bool,
pub workspace_error: Option<String>,
pub env_database: bool,
pub env_queue: bool,
pub env_http_host: bool,
pub env_http_port: bool,
}
pub fn run(opts: DoctorOptions) -> CliResult<DoctorReport> {
let start = opts
.path
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
let mut report = DoctorReport::default();
report.rustc_version = rustc_version();
report.rustc_ok = report
.rustc_version
.as_ref()
.is_some_and(|v| version_at_least(v, MIN_RUST_VERSION));
report.cargo_ok = which("cargo");
match crate::context::NestrsWorkspace::discover(&start) {
Ok(Some(_)) => report.in_nestrs_workspace = true,
Ok(None) => {}
Err(e) => report.workspace_error = Some(e.to_string()),
}
let cascade = cascade_text(&start);
report.env_database = env_present(&cascade, "NESTRS_DATABASE__URL");
report.env_queue = env_present(&cascade, "NESTRS_QUEUE__URL");
report.env_http_host = env_present(&cascade, "NESTRS_HTTP__HOST");
report.env_http_port = env_present(&cascade, "NESTRS_HTTP__PORT");
print_report(&report);
if !report.rustc_ok || !report.cargo_ok {
return Err(CliError::Anyhow(anyhow::anyhow!(
"doctor found blocking issues — fix them before continuing"
)));
}
Ok(report)
}
fn print_report(report: &DoctorReport) {
println!("nestrs doctor");
println!();
status_line(
"Rust toolchain",
report.rustc_ok,
report.rustc_version.as_deref().unwrap_or("rustc not found"),
);
status_line(
"cargo",
report.cargo_ok,
if report.cargo_ok { "ok" } else { "not found" },
);
if let Some(err) = &report.workspace_error {
println!(" nestrs workspace: detection failed: {err}");
} else if report.in_nestrs_workspace {
println!(" nestrs workspace: yes");
} else {
println!(" nestrs workspace: no (standalone project or outside a clone)");
}
println!();
println!("Environment (optional — only needed for DB/Redis apps):");
print_env_hint("NESTRS_DATABASE__URL", report.env_database);
print_env_hint("NESTRS_QUEUE__URL", report.env_queue);
if report.env_http_host {
println!(" NESTRS_HTTP__HOST: set");
}
if report.env_http_port {
println!(" NESTRS_HTTP__PORT: set");
}
if !report.env_database && !report.env_queue && !report.env_http_host && !report.env_http_port {
println!(" (none set — fine for bare HTTP apps on defaults)");
}
println!();
}
fn status_line(label: &str, ok: bool, detail: &str) {
let mark = if ok { "ok" } else { "FAIL" };
println!(" [{mark}] {label}: {detail}");
}
fn print_env_hint(name: &str, present: bool) {
println!(" {name}: {}", if present { "set" } else { "not set" });
}
fn env_present(cascade: &str, name: &str) -> bool {
matches!(std::env::var(name), Ok(v) if !v.trim().is_empty()) || file_defines(cascade, name)
}
fn cascade_text(dir: &Path) -> String {
let env = std::env::var("NESTRS_ENV").unwrap_or_else(|_| "development".to_owned());
let env = env.trim().to_owned();
let mut files = vec![format!(".env.{env}.local")];
if env != "test" {
files.push(".env.local".to_owned());
}
files.push(format!(".env.{env}"));
files.push(".env".to_owned());
files
.iter()
.filter_map(|file| std::fs::read_to_string(dir.join(file)).ok())
.collect::<Vec<_>>()
.join("\n")
}
fn file_defines(contents: &str, name: &str) -> bool {
contents.lines().any(|line| {
let line = line.trim();
if line.starts_with('#') {
return false;
}
let line = line.strip_prefix("export ").unwrap_or(line);
line.split_once('=')
.is_some_and(|(key, value)| key.trim() == name && !value.trim().is_empty())
})
}
fn rustc_version() -> Option<String> {
let output = std::process::Command::new("rustc")
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn which(program: &str) -> bool {
std::process::Command::new(program)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn version_at_least(version_line: &str, min: (u32, u32)) -> bool {
let Some(rest) = version_line.strip_prefix("rustc ") else {
return false;
};
let version_token = rest.split_whitespace().next().unwrap_or("");
let mut parts = version_token.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) >= min
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_rustc_version() {
assert!(version_at_least("rustc 1.96.0 (abc 2025-01-01)", (1, 96)));
assert!(!version_at_least("rustc 1.95.0 (abc 2025-01-01)", (1, 96)));
}
#[test]
fn a_cascade_file_counts_as_set() {
assert!(file_defines(
"NESTRS_DATABASE__URL=postgres://x",
"NESTRS_DATABASE__URL"
));
assert!(file_defines(
"export NESTRS_DATABASE__URL=postgres://x",
"NESTRS_DATABASE__URL"
));
assert!(file_defines(
"# a comment\nNESTRS_QUEUE__URL=redis://x\n",
"NESTRS_QUEUE__URL"
));
}
#[test]
fn a_commented_or_empty_assignment_does_not_count() {
assert!(!file_defines(
"# NESTRS_DATABASE__URL=postgres://x",
"NESTRS_DATABASE__URL"
));
assert!(!file_defines(
"NESTRS_DATABASE__URL=",
"NESTRS_DATABASE__URL"
));
assert!(!file_defines(
"NESTRS_DATABASE__URL= ",
"NESTRS_DATABASE__URL"
));
assert!(!file_defines(
"NESTRS_DATABASE__URL_EXTRA=x",
"NESTRS_DATABASE__URL"
));
}
#[test]
fn the_cascade_is_consulted_from_the_starting_directory() {
let dir = std::env::temp_dir().join(format!("nestrs-doctor-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
std::fs::write(dir.join(".env"), "NESTRS_DATABASE__URL=postgres://x\n").expect("write");
let cascade = cascade_text(&dir);
assert!(env_present(&cascade, "NESTRS_DATABASE__URL"));
assert!(!env_present(&cascade, "NESTRS_QUEUE__URL"));
std::fs::remove_dir_all(&dir).ok();
}
}