use crate::{Result, discover, platform};
use anyhow::Context;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
pub struct DoctorOpts<'a> {
pub manifest_dir: &'a Path,
pub min_rustc_version: &'a str,
}
#[derive(Debug, Default)]
pub struct DoctorReport {
pub rows: Vec<Row>,
pub critical: bool,
}
#[derive(Debug)]
pub struct Row {
pub label: String,
pub value: String,
pub severity: Severity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Ok,
Warn,
Critical,
}
impl Severity {
fn symbol(self) -> &'static str {
match self {
Self::Ok => "OK",
Self::Warn => "WARN",
Self::Critical => "FAIL",
}
}
}
pub fn run_doctor(opts: &DoctorOpts<'_>) -> Result<DoctorReport> {
let mut report = DoctorReport::default();
push_rustc_row(&mut report, opts.min_rustc_version);
report.rows.push(Row {
label: "cargo-plushie".to_string(),
value: env!("CARGO_PKG_VERSION").to_string(),
severity: Severity::Ok,
});
report.rows.push(Row {
label: "host".to_string(),
value: format!("{}-{}", platform::os_name(), platform::arch_name()),
severity: Severity::Ok,
});
for var in [
"PLUSHIE_BINARY_PATH",
"PLUSHIE_RUST_SOURCE_PATH",
"PLUSHIE_MODE",
"PLUSHIE_SOCKET",
] {
let (value, severity) = match std::env::var(var) {
Ok(v) => (v, Severity::Ok),
Err(_) => ("(unset)".to_string(), Severity::Ok),
};
report.rows.push(Row {
label: var.to_string(),
value,
severity,
});
}
let discovered = discover_renderer(opts.manifest_dir);
match &discovered {
Some(path) => report.rows.push(Row {
label: "renderer".to_string(),
value: path.display().to_string(),
severity: Severity::Ok,
}),
None => {
report.critical = true;
report.rows.push(Row {
label: "renderer".to_string(),
value: renderer_not_found_hint(),
severity: Severity::Critical,
});
}
}
if let Some(path) = discovered.as_deref() {
push_arch_row(&mut report, path);
}
push_metadata_rows(&mut report, opts.manifest_dir)?;
if let Some(path) = discovered.as_deref() {
push_version_skew_row(&mut report, path, opts.manifest_dir);
}
Ok(report)
}
pub fn write_report<W: Write>(report: &DoctorReport, writer: &mut W) -> std::io::Result<()> {
let max_label = report.rows.iter().map(|r| r.label.len()).max().unwrap_or(0);
for row in &report.rows {
let pad = " ".repeat(max_label.saturating_sub(row.label.len()));
let symbol = row.severity.symbol();
let mut lines = row.value.lines();
let first = lines.next().unwrap_or("");
writeln!(
writer,
" [{symbol:^4}] {label}{pad} {first}",
label = row.label
)?;
let indent = " ".repeat(11 + max_label);
for line in lines {
writeln!(writer, "{indent}{line}")?;
}
}
if report.critical {
writeln!(writer)?;
writeln!(writer, "Critical issues detected; see entries marked FAIL.")?;
}
Ok(())
}
fn push_rustc_row(report: &mut DoctorReport, min_version: &str) {
let output = Command::new("rustc").arg("--version").output();
let Ok(output) = output else {
report.critical = true;
report.rows.push(Row {
label: "rustc".to_string(),
value: "rustc not found on PATH".to_string(),
severity: Severity::Critical,
});
return;
};
if !output.status.success() {
report.critical = true;
report.rows.push(Row {
label: "rustc".to_string(),
value: "rustc --version returned a non-zero status".to_string(),
severity: Severity::Critical,
});
return;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let version = parse_rustc_version(&stdout);
let severity = match &version {
Some(v) if !version_at_least(v, min_version) => Severity::Critical,
_ => Severity::Ok,
};
let value = match (&version, severity) {
(Some(v), Severity::Critical) => {
format!("{stdout} (below supported {min_version}; host rustc reports {v})")
}
_ => stdout,
};
if severity == Severity::Critical {
report.critical = true;
}
report.rows.push(Row {
label: "rustc".to_string(),
value,
severity,
});
}
fn parse_rustc_version(line: &str) -> Option<String> {
let rest = line.strip_prefix("rustc ")?;
let version = rest.split_whitespace().next()?;
Some(version.to_string())
}
fn version_at_least(actual: &str, min: &str) -> bool {
fn parts(s: &str) -> Option<Vec<u64>> {
s.split('.').map(|p| p.parse::<u64>().ok()).collect()
}
let Some(a) = parts(actual) else { return false };
let Some(m) = parts(min) else { return false };
for i in 0..a.len().max(m.len()) {
let av = a.get(i).copied().unwrap_or(0);
let mv = m.get(i).copied().unwrap_or(0);
if av > mv {
return true;
}
if av < mv {
return false;
}
}
true
}
fn push_arch_row(report: &mut DoctorReport, binary: &Path) {
let host = platform::arch_name();
match detect_binary_arch(binary) {
Some(arch) if arch == host => report.rows.push(Row {
label: "arch".to_string(),
value: format!("{arch} (matches host)"),
severity: Severity::Ok,
}),
Some(arch) => {
report.critical = true;
report.rows.push(Row {
label: "arch".to_string(),
value: format!("{arch} (host is {host}; runtime will mis-spawn)"),
severity: Severity::Critical,
});
}
None => report.rows.push(Row {
label: "arch".to_string(),
value: "unknown (file(1) unavailable or unrecognised output)".to_string(),
severity: Severity::Warn,
}),
}
}
fn detect_binary_arch(path: &Path) -> Option<String> {
if cfg!(not(unix)) {
return None;
}
let output = Command::new("file").arg(path).output().ok()?;
if !output.status.success() {
return None;
}
let lower = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
if lower.contains("x86-64") || lower.contains("x86_64") || lower.contains("amd64") {
Some("x86_64".to_string())
} else if lower.contains("aarch64") || lower.contains("arm64") {
Some("aarch64".to_string())
} else {
None
}
}
fn push_metadata_rows(report: &mut DoctorReport, manifest_dir: &Path) -> Result<()> {
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.exec()
.with_context(|| "cargo metadata failed")?;
let widgets = discover::discover_widgets(manifest_dir)?;
let widgets_row = if widgets.is_empty() {
"(none)".to_string()
} else {
widgets
.iter()
.map(|w| format!("{} ({})", w.crate_name, w.type_name))
.collect::<Vec<_>>()
.join("\n")
};
report.rows.push(Row {
label: "native widgets".to_string(),
value: widgets_row,
severity: Severity::Ok,
});
let renderer_version = metadata
.packages
.iter()
.find(|p| p.name == "plushie-renderer-lib")
.map(|p| p.version.to_string());
let value = renderer_version.unwrap_or_else(|| "(not in dep graph)".to_string());
report.rows.push(Row {
label: "renderer-lib".to_string(),
value,
severity: Severity::Ok,
});
Ok(())
}
fn push_version_skew_row(report: &mut DoctorReport, binary: &Path, manifest_dir: &Path) {
let Ok(metadata) = cargo_metadata::MetadataCommand::new()
.manifest_path(manifest_dir.join("Cargo.toml"))
.exec()
else {
return;
};
let expected = metadata
.packages
.iter()
.find(|p| p.name == "plushie-renderer-lib")
.map(|p| p.version.to_string());
let Some(expected) = expected else {
return;
};
match probe_renderer_version(binary) {
Some(actual) if actual == expected => report.rows.push(Row {
label: "version skew".to_string(),
value: format!("matched ({actual})"),
severity: Severity::Ok,
}),
Some(actual) => {
report.critical = true;
report.rows.push(Row {
label: "version skew".to_string(),
value: format!(
"app expects {expected} but binary reports {actual}; \
handshake will reject incompatible protocol versions"
),
severity: Severity::Critical,
});
}
None => report.rows.push(Row {
label: "version skew".to_string(),
value: "could not probe binary (mock handshake failed)".to_string(),
severity: Severity::Warn,
}),
}
}
fn probe_renderer_version(binary: &Path) -> Option<String> {
let mut child = Command::new(binary)
.args(["--mock", "--json"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let settings = format!(
r#"{{"type":"settings","session":"","protocol_version":{},"codec":"json"}}{}"#,
plushie_core::protocol::PROTOCOL_VERSION,
"\n"
);
{
let mut stdin = child.stdin.take()?;
let _ = stdin.write_all(settings.as_bytes());
let _ = stdin.flush();
}
let mut buf = Vec::with_capacity(1024);
let mut stdout = child.stdout.take()?;
let mut byte = [0u8; 1];
let start = std::time::Instant::now();
loop {
if start.elapsed() > Duration::from_secs(5) {
let _ = child.kill();
return None;
}
match stdout.read(&mut byte) {
Ok(0) => break,
Ok(_) => {
buf.push(byte[0]);
if byte[0] == b'\n' {
break;
}
if buf.len() > 4096 {
break;
}
}
Err(_) => break,
}
}
let _ = child.kill();
let _ = child.wait();
let line = String::from_utf8(buf).ok()?;
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
value.get("version")?.as_str().map(str::to_string)
}
fn discover_renderer(manifest_dir: &Path) -> Option<PathBuf> {
if let Some(env) = std::env::var_os("PLUSHIE_BINARY_PATH") {
let p = PathBuf::from(env);
if p.is_file() {
return Some(p);
}
}
let target_dir = std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| manifest_dir.join("target"));
for profile in ["release", "debug"] {
let profile_dir = target_dir.join("plushie-renderer/target").join(profile);
if let Ok(entries) = std::fs::read_dir(&profile_dir) {
for entry in entries.flatten() {
let path = entry.path();
if is_executable_file(&path)
&& path.extension().is_none_or(|e| e != "d" && e != "rlib")
{
return Some(path);
}
}
}
}
let download = target_dir
.join("plushie/bin")
.join(platform::download_name());
if is_executable_file(&download) {
return Some(download);
}
let name = if cfg!(target_os = "windows") {
"plushie-renderer.exe"
} else {
"plushie-renderer"
};
if let Some(path_var) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
}
None
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(meta) => meta.is_file() && (meta.permissions().mode() & 0o111) != 0,
Err(_) => false,
}
}
#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}
fn renderer_not_found_hint() -> String {
"not found. Try one of:\n \
cargo plushie build (widget-aware custom build)\n \
cargo plushie download (precompiled stock binary)\n \
cargo install plushie-renderer (build stock from source)\n\
or set PLUSHIE_BINARY_PATH to an existing binary."
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_rustc_version_standard_shape() {
let v = parse_rustc_version("rustc 1.92.0 (abcdef0 2025-10-31)");
assert_eq!(v.as_deref(), Some("1.92.0"));
}
#[test]
fn parse_rustc_version_rejects_garbage() {
assert!(parse_rustc_version("").is_none());
assert!(parse_rustc_version("gcc 14.1.0").is_none());
}
#[test]
fn version_at_least_compares_numerically() {
assert!(version_at_least("1.92.0", "1.92"));
assert!(version_at_least("1.92.0", "1.92.0"));
assert!(version_at_least("2.0.0", "1.92.0"));
assert!(!version_at_least("1.91.9", "1.92.0"));
assert!(!version_at_least("1.91", "1.92.0"));
}
#[test]
fn write_report_renders_aligned_columns() {
let mut report = DoctorReport::default();
report.rows.push(Row {
label: "short".to_string(),
value: "value1".to_string(),
severity: Severity::Ok,
});
report.rows.push(Row {
label: "longer-label".to_string(),
value: "value2\ncontinuation".to_string(),
severity: Severity::Warn,
});
let mut buf = Vec::new();
write_report(&report, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("OK"));
assert!(out.contains("WARN"));
assert!(out.contains("continuation"));
}
#[test]
fn write_report_mentions_critical_when_flagged() {
let mut report = DoctorReport {
critical: true,
..Default::default()
};
report.rows.push(Row {
label: "renderer".to_string(),
value: "missing".to_string(),
severity: Severity::Critical,
});
let mut buf = Vec::new();
write_report(&report, &mut buf).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("Critical issues detected"));
}
}