use std::env;
use std::fs::{self, OpenOptions};
use std::net::ToSocketAddrs;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use fs2::available_space;
use crate::config::load_config_with_overlays;
use crate::diagnostics::proxy::configuration_summary;
use crate::model::DiagnosticCheck;
use crate::paths::{app_home, journal_dir, managed_bin_dir};
use crate::state::cache::status;
use crate::state::journal::load_pending;
use crate::util::resolve_command;
const NETWORK_TIMEOUT: Duration = Duration::from_secs(3);
const DISK_WARNING_BYTES: u64 = 512 * 1024 * 1024;
pub(crate) fn doctor_checks(explicit_config: Option<&Path>) -> Vec<DiagnosticCheck> {
let mut checks = vec![
architecture_check(),
command_check("command.git", "git"),
command_check("command.cargo", "cargo"),
command_check("command.rustup", "rustup"),
];
if cfg!(windows) {
checks.push(command_check("command.package-manager", "winget"));
} else if cfg!(target_os = "linux") {
checks.push(command_check("command.package-manager", "apt-get"));
} else if cfg!(target_os = "macos") {
checks.push(command_check_with_args(
"command.package-manager",
"brew",
&["--version"],
));
checks.push(command_check_with_args(
"command.xcode-tools",
"xcrun",
&["--find", "clang"],
));
checks.push(macos_translation_check());
}
checks.extend([
proxy_check(),
shell_check(),
data_directory_check(),
managed_bin_directory_check(),
disk_space_check(),
lock_directory_check(),
cache_check(),
pending_journal_check(),
config_source_check(explicit_config),
dns_check(),
tls_check(),
]);
checks
}
fn proxy_check() -> DiagnosticCheck {
check(
"network.proxy-config",
true,
configuration_summary(),
None,
0,
)
}
fn lock_directory_check() -> DiagnosticCheck {
directory_summary_check("filesystem.locks", &app_home().join("locks"))
}
fn cache_check() -> DiagnosticCheck {
let started = Instant::now();
match status() {
Ok(status) => check(
"cache.health",
true,
format!(
"{} files / {} MiB",
status.files,
status.bytes / 1024 / 1024
),
None,
started.elapsed().as_millis(),
),
Err(error) => check(
"cache.health",
false,
"cache inspection failed".to_string(),
Some(error.to_string()),
started.elapsed().as_millis(),
),
}
}
fn pending_journal_check() -> DiagnosticCheck {
let started = Instant::now();
let pending = load_pending();
match pending {
Ok(checkpoints) => check(
"state.pending-journals",
checkpoints.is_empty(),
format!(
"{} pending in {}",
checkpoints.len(),
journal_dir().display()
),
(!checkpoints.is_empty())
.then(|| "run bot-forge resume to inspect or resume it".to_string()),
started.elapsed().as_millis(),
),
Err(error) => check(
"state.pending-journals",
false,
"journal inspection failed".to_string(),
Some(error.to_string()),
started.elapsed().as_millis(),
),
}
}
fn config_source_check(explicit: Option<&Path>) -> DiagnosticCheck {
match load_config_with_overlays(explicit, &[]) {
Ok(loaded) => check(
"config.source",
true,
loaded
.path
.map(|path| path.display().to_string())
.unwrap_or_else(|| "builtin rust-dev catalog".to_string()),
None,
0,
),
Err(error) => check(
"config.source",
false,
explicit
.map(|path| path.display().to_string())
.unwrap_or_else(|| "configuration discovery failed".to_string()),
Some(error.to_string()),
0,
),
}
}
fn directory_summary_check(id: &str, directory: &std::path::Path) -> DiagnosticCheck {
let result = fs::create_dir_all(directory);
check(
id,
result.is_ok(),
directory.display().to_string(),
result
.err()
.map(|error| format!("failed to access directory: {error}")),
0,
)
}
fn architecture_check() -> DiagnosticCheck {
check(
"system.architecture",
true,
format!("{} / {}", env::consts::OS, env::consts::ARCH),
None,
0,
)
}
fn command_check(id: &str, command: &str) -> DiagnosticCheck {
command_check_with_args(id, command, &["--version"])
}
fn command_check_with_args(id: &str, command: &str, args: &[&str]) -> DiagnosticCheck {
let started = Instant::now();
let resolved = resolve_command(command);
let available = Command::new(&resolved)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success());
check(
id,
available,
format!(
"{command} {}",
if available {
"available"
} else {
"unavailable"
}
),
(!available).then(|| format!("install {command} or add it to PATH")),
started.elapsed().as_millis(),
)
}
fn macos_translation_check() -> DiagnosticCheck {
let translated = Command::new("/usr/sbin/sysctl")
.args(["-in", "sysctl.proc_translated"])
.output()
.ok()
.filter(|output| output.status.success())
.is_some_and(|output| String::from_utf8_lossy(&output.stdout).trim() == "1");
check(
"system.macos-translation",
!translated,
if translated {
"running under Rosetta 2"
} else {
"native process architecture"
}
.to_string(),
translated.then(|| {
"rerun the installer to select the native Apple Silicon bot-forge and avoid the Intel toolchain".to_string()
}),
0,
)
}
fn shell_check() -> DiagnosticCheck {
let shell = env::var("SHELL")
.or_else(|_| env::var("COMSPEC"))
.unwrap_or_else(|_| "unknown".to_string());
check("system.shell", shell != "unknown", shell, None, 0)
}
fn data_directory_check() -> DiagnosticCheck {
writable_directory_check("filesystem.data-dir-permission", &app_home())
}
fn managed_bin_directory_check() -> DiagnosticCheck {
writable_directory_check("filesystem.managed-bin", &managed_bin_dir())
}
fn writable_directory_check(id: &str, directory: &Path) -> DiagnosticCheck {
let started = Instant::now();
let result = fs::create_dir_all(directory).and_then(|()| {
let probe = directory.join(format!(".write-probe-{}", std::process::id()));
OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
.and_then(|_| fs::remove_file(probe))
});
check(
id,
result.is_ok(),
if result.is_ok() {
format!("{} writable", directory.display())
} else {
format!("{} not writable", directory.display())
},
result
.err()
.map(|error| format!("check directory permissions: {error}")),
started.elapsed().as_millis(),
)
}
fn disk_space_check() -> DiagnosticCheck {
let started = Instant::now();
let directory = app_home();
match available_space(&directory) {
Ok(bytes) => check(
"filesystem.disk-space",
bytes >= DISK_WARNING_BYTES,
format!("{} MiB available", bytes / 1024 / 1024),
(bytes < DISK_WARNING_BYTES).then(|| "free at least 512 MiB and retry".to_string()),
started.elapsed().as_millis(),
),
Err(error) => check(
"filesystem.disk-space",
false,
"available space unknown".to_string(),
Some(format!("failed to read available space: {error}")),
started.elapsed().as_millis(),
),
}
}
fn dns_check() -> DiagnosticCheck {
let started = Instant::now();
let resolved = ("github.com", 443)
.to_socket_addrs()
.is_ok_and(|mut addresses| addresses.next().is_some());
check(
"network.dns",
resolved,
if resolved {
"github.com resolved"
} else {
"github.com resolution failed"
}
.to_string(),
(!resolved).then(|| "check DNS, proxy, or network connectivity".to_string()),
started.elapsed().as_millis(),
)
}
fn tls_check() -> DiagnosticCheck {
let started = Instant::now();
let connected = reqwest::blocking::Client::builder()
.timeout(NETWORK_TIMEOUT)
.build()
.ok()
.and_then(|client| client.head("https://github.com/").send().ok())
.is_some_and(|response| response.status().is_success());
check(
"network.tls-endpoint",
connected,
if connected {
"github.com:443 reachable"
} else {
"github.com:443 unreachable"
}
.to_string(),
(!connected).then(|| "check outbound TLS, firewall, or HTTPS_PROXY".to_string()),
started.elapsed().as_millis(),
)
}
fn check(
id: &str,
healthy: bool,
summary: String,
suggestion: Option<String>,
duration_ms: u128,
) -> DiagnosticCheck {
DiagnosticCheck {
id: id.to_string(),
severity: if healthy { "info" } else { "warning" }.to_string(),
summary,
suggestion,
duration_ms,
}
}
#[cfg(test)]
mod tests {
use crate::diagnostics::doctor_checks;
#[test]
fn diagnostics_have_stable_unique_ids_and_timings() {
let checks = doctor_checks(None);
let ids = checks
.iter()
.map(|check| check.id.as_str())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(ids.len(), checks.len());
assert!(ids.contains("filesystem.data-dir-permission"));
assert!(ids.contains("filesystem.managed-bin"));
assert!(ids.contains("filesystem.disk-space"));
assert!(ids.contains("network.dns"));
assert!(ids.contains("network.tls-endpoint"));
assert!(ids.contains("network.proxy-config"));
assert!(ids.contains("cache.health"));
assert!(ids.contains("state.pending-journals"));
assert!(ids.contains("config.source"));
assert_eq!(
ids.contains("command.package-manager"),
cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "macos")
);
}
}