use hatchery_core::{MachineEnvironment, ProviderCliReport};
use std::fs;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi", "kimi-cli", "bash"];
const CACHE_TTL: Duration = Duration::from_secs(60);
struct Cached {
env: MachineEnvironment,
at: Instant,
}
fn cache() -> &'static Mutex<Option<Cached>> {
static CACHE: OnceLock<Mutex<Option<Cached>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(None))
}
fn refreshing() -> &'static AtomicBool {
static FLAG: OnceLock<AtomicBool> = OnceLock::new();
FLAG.get_or_init(|| AtomicBool::new(false))
}
pub fn collect() -> MachineEnvironment {
{
let guard = cache().lock().unwrap_or_else(|p| p.into_inner());
if let Some(c) = guard.as_ref() {
let age = c.at.elapsed();
let env = c.env.clone();
drop(guard);
if age >= CACHE_TTL {
kick_refresh();
}
return env;
}
}
let env = collect_fresh();
*cache().lock().unwrap_or_else(|p| p.into_inner()) = Some(Cached {
env: env.clone(),
at: Instant::now(),
});
env
}
fn kick_refresh() {
if refreshing()
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
std::thread::Builder::new()
.name("machine-env-refresh".into())
.spawn(|| {
let env = collect_fresh();
*cache().lock().unwrap_or_else(|p| p.into_inner()) = Some(Cached {
env,
at: Instant::now(),
});
refreshing().store(false, Ordering::SeqCst);
})
.ok();
}
fn collect_fresh() -> MachineEnvironment {
let (os, kernel, arch) = uname();
let hostname = fs::read_to_string("/etc/hostname")
.or_else(|_| fs::read_to_string("/proc/sys/kernel/hostname"))
.map(|s| s.trim().to_owned())
.unwrap_or_else(|_| "unknown".into());
let uptime_secs = fs::read_to_string("/proc/uptime")
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|s| s as u64)
.unwrap_or(0);
let cpu_cores = std::thread::available_parallelism()
.map(|n| n.get() as u32)
.unwrap_or(1);
let cpu_model = cpu_model();
let (ram_total_bytes, ram_available_bytes) = meminfo();
let (disk_total_bytes, disk_available_bytes) = disk_root();
let provider_clis = PROVIDERS
.iter()
.filter_map(|id| probe_cli(id))
.collect();
MachineEnvironment {
os,
kernel,
arch,
hostname,
uptime_secs,
cpu_cores,
cpu_model,
ram_total_bytes,
ram_available_bytes,
disk_total_bytes,
disk_available_bytes,
provider_clis,
}
}
fn uname() -> (String, String, String) {
let out = hidden(Command::new("uname")).args(["-srm"]).output().ok();
let text = out
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
let mut parts = text.split_whitespace();
let os = parts.next().unwrap_or(std::env::consts::OS).to_owned();
let kernel = parts.next().unwrap_or("unknown").to_owned();
let arch = parts
.next()
.unwrap_or(std::env::consts::ARCH)
.to_owned();
(os, kernel, arch)
}
fn cpu_model() -> Option<String> {
let text = fs::read_to_string("/proc/cpuinfo").ok()?;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("model name") {
return Some(rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace()).to_owned());
}
}
None
}
fn meminfo() -> (u64, u64) {
let text = match fs::read_to_string("/proc/meminfo") {
Ok(t) => t,
Err(_) => return (0, 0),
};
let mut total_kb = 0u64;
let mut avail_kb = 0u64;
for line in text.lines() {
if let Some(v) = line.strip_prefix("MemTotal:") {
total_kb = parse_kb(v);
} else if let Some(v) = line.strip_prefix("MemAvailable:") {
avail_kb = parse_kb(v);
}
}
(total_kb.saturating_mul(1024), avail_kb.saturating_mul(1024))
}
fn parse_kb(v: &str) -> u64 {
v.split_whitespace()
.next()
.and_then(|n| n.parse().ok())
.unwrap_or(0)
}
fn disk_root() -> (u64, u64) {
let out = hidden(Command::new("df")).args(["-B1", "/"]).output().ok();
let text = out.and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
for line in text.lines().skip(1) {
let cols: Vec<_> = line.split_whitespace().collect();
if cols.len() >= 4 {
let total = cols[1].parse().unwrap_or(0);
let avail = cols[3].parse().unwrap_or(0);
return (total, avail);
}
}
(0, 0)
}
fn probe_cli(id: &str) -> Option<ProviderCliReport> {
let path = hidden(Command::new("sh"))
.args(["-c", &format!("command -v {id}")])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok().map(|s| s.trim().to_owned())
} else {
None
}
})
.filter(|s| !s.is_empty())?;
let version = hidden(Command::new(&path))
.arg("--version")
.output()
.ok()
.and_then(|o| {
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
if text.trim().is_empty() {
text = String::from_utf8_lossy(&o.stderr).to_string();
}
let line = text.lines().next()?.trim().to_owned();
if line.is_empty() { None } else { Some(line) }
});
Some(ProviderCliReport {
id: id.to_owned(),
version,
path: Some(path),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collect_returns_nonzero_cores_on_linux() {
let env = collect();
assert!(env.cpu_cores >= 1);
assert!(!env.os.is_empty());
assert!(!env.arch.is_empty());
}
#[test]
fn second_collect_is_cached_and_fast() {
let _ = collect();
let t0 = Instant::now();
let _ = collect();
assert!(
t0.elapsed() < Duration::from_millis(50),
"cached collect must not re-probe CLIs"
);
}
}
fn hidden(mut command: Command) -> Command {
crate::windows_process::hide_console(&mut command);
command
}