use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use colored::Colorize;
pub fn data_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not resolve $HOME")?;
let dir = home.join(".trusty-analyze");
std::fs::create_dir_all(&dir).with_context(|| format!("create data dir {}", dir.display()))?;
Ok(dir)
}
pub fn pid_file_path() -> Result<PathBuf> {
Ok(data_dir()?.join("daemon.pid"))
}
fn read_pid(path: &Path) -> Option<u32> {
let raw = std::fs::read_to_string(path).ok()?;
raw.trim().parse::<u32>().ok()
}
fn port_reachable(port: u16) -> bool {
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
}
pub fn handle_start(port: u16) -> Result<()> {
let pid_path = pid_file_path()?;
if let Some(pid) = read_pid(&pid_path) {
if port_reachable(port) {
println!(
"{} trusty-analyze already running (pid {pid}, port {port})",
"✓".green()
);
return Ok(());
}
let _ = std::fs::remove_file(&pid_path);
}
let exe = std::env::current_exe().context("resolve current executable")?;
let child = std::process::Command::new(&exe)
.arg("serve")
.arg("--port")
.arg(port.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.spawn()
.with_context(|| format!("spawn {} serve", exe.display()))?;
std::fs::write(&pid_path, child.id().to_string())
.with_context(|| format!("write pid file {}", pid_path.display()))?;
println!(
"{} trusty-analyze started (pid {}, port {port})",
"✓".green(),
child.id()
);
println!(
" Dashboard: {}",
format!("http://127.0.0.1:{port}/ui").cyan()
);
Ok(())
}
pub fn handle_stop(port: u16) -> Result<()> {
let pid_path = pid_file_path()?;
let Some(pid) = read_pid(&pid_path) else {
eprintln!(
"{} No PID file at {} — daemon not running?",
"✗".red(),
pid_path.display()
);
std::process::exit(1);
};
println!("{} Stopping trusty-analyze (pid {pid})…", "⟳".cyan());
let status = std::process::Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.status()
.context("invoke kill -TERM")?;
if !status.success() {
eprintln!(
"{} kill -TERM {pid} failed (process may already be gone)",
"✗".red()
);
let _ = std::fs::remove_file(&pid_path);
std::process::exit(1);
}
for _ in 0..50 {
std::thread::sleep(Duration::from_millis(100));
if !port_reachable(port) {
let _ = std::fs::remove_file(&pid_path);
println!("{} trusty-analyze stopped", "✓".green());
return Ok(());
}
}
println!(
"{} Daemon did not release port {port} within 5 s; PID file left in place",
"⚠".yellow()
);
Ok(())
}
pub async fn handle_status(port: u16) -> Result<()> {
let pid_path = pid_file_path()?;
let pid = read_pid(&pid_path);
let reachable = port_reachable(port);
if reachable {
println!("{} trusty-analyze: {}", "✓".green(), "RUNNING".green());
} else {
println!("{} trusty-analyze: {}", "✗".red(), "DOWN".red());
}
println!(" Port: {port}");
if let Some(pid) = pid {
println!(" PID: {pid} (from {})", pid_path.display());
} else {
println!(" PID: {}", "<no pid file>".dimmed());
}
if reachable {
let url = format!("http://127.0.0.1:{port}/health");
let client = reqwest::Client::new();
match client
.get(&url)
.timeout(Duration::from_secs(2))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(v) = body.get("version").and_then(|v| v.as_str()) {
println!(" Version: {v}");
}
}
}
Ok(resp) => println!(" Health: HTTP {}", resp.status()),
Err(e) => println!(" Health: probe failed: {e}"),
}
}
Ok(())
}
pub async fn handle_doctor(port: u16, facts_path: &Path) -> Result<()> {
let mut ok = true;
println!("trusty-analyze doctor:");
if port_reachable(port) {
println!(" {} daemon reachable on port {port}", "✓".green());
} else {
println!(
" {} daemon not reachable on port {port} (start it with `trusty-analyze start`)",
"✗".red()
);
ok = false;
}
match data_dir() {
Ok(dir) => {
let probe = dir.join(".doctor-probe");
match std::fs::write(&probe, b"ok") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
println!(" {} data dir writable: {}", "✓".green(), dir.display());
}
Err(e) => {
println!(
" {} data dir not writable ({}): {e}",
"✗".red(),
dir.display()
);
ok = false;
}
}
}
Err(e) => {
println!(" {} could not resolve data dir: {e}", "✗".red());
ok = false;
}
}
let facts_parent = facts_path.parent().unwrap_or(Path::new("."));
if facts_parent.as_os_str().is_empty() || facts_parent.exists() {
println!(
" {} facts path parent exists: {}",
"✓".green(),
facts_path.display()
);
} else {
match std::fs::create_dir_all(facts_parent) {
Ok(()) => println!(
" {} facts path parent created: {}",
"✓".green(),
facts_parent.display()
),
Err(e) => {
println!(
" {} could not create facts path parent {}: {e}",
"✗".red(),
facts_parent.display()
);
ok = false;
}
}
}
println!();
if ok {
println!("{} all checks passed", "✓".green());
Ok(())
} else {
eprintln!("{} one or more checks failed", "✗".red());
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_pid_handles_missing_file() {
let tmp = std::env::temp_dir().join("trusty-analyze-no-such-pid");
let _ = std::fs::remove_file(&tmp);
assert!(read_pid(&tmp).is_none());
}
#[test]
fn read_pid_handles_garbage() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("daemon.pid");
std::fs::write(&path, "not-a-pid\n").unwrap();
assert!(read_pid(&path).is_none());
}
#[test]
fn read_pid_parses_valid_pid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("daemon.pid");
std::fs::write(&path, "12345\n").unwrap();
assert_eq!(read_pid(&path), Some(12345));
}
#[test]
fn port_reachable_returns_false_for_free_port() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
assert!(!port_reachable(port));
}
}