use anyhow::{anyhow, Result};
use colored::Colorize;
use std::io::Write;
use std::time::{Duration, Instant};
const READY_TIMEOUT: Duration = Duration::from_secs(60);
const POLL_INTERVAL: Duration = Duration::from_millis(500);
const PROBE_TIMEOUT: Duration = Duration::from_millis(750);
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
async fn probe_health(base: &str) -> bool {
let client = match reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.connect_timeout(PROBE_TIMEOUT)
.build()
{
Ok(c) => c,
Err(_) => return false,
};
match client.get(format!("{}/health", base)).send().await {
Ok(r) => r.status().is_success(),
Err(_) => false,
}
}
pub(crate) fn spawn_daemon() -> Result<u32> {
let exe = std::env::current_exe().map_err(|e| anyhow!("could not resolve current_exe: {e}"))?;
let child = std::process::Command::new(&exe)
.arg("start")
.arg("--foreground")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| {
anyhow!(
"could not spawn `{} start --foreground`: {e}",
exe.display()
)
})?;
Ok(child.id())
}
pub async fn ensure_daemon_running(base: &str) -> Result<()> {
if probe_health(base).await {
return Ok(());
}
let already_running = crate::service::running_daemon_pid().is_some();
if already_running {
eprintln!(
"{} trusty-search daemon already running, waiting for it to become ready…",
"◉".cyan()
);
} else {
eprintln!("{} Starting trusty-search daemon…", "◉".cyan());
spawn_daemon()?;
}
let deadline = Instant::now() + READY_TIMEOUT;
let start = Instant::now();
let mut frame = 0usize;
loop {
let elapsed = start.elapsed().as_secs();
let glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()];
eprint!(
"\r{} Waiting for daemon to become ready… ({}s) ",
glyph.cyan(),
elapsed
);
let _ = std::io::stderr().flush();
frame = frame.wrapping_add(1);
tokio::time::sleep(POLL_INTERVAL).await;
if probe_health(base).await {
eprint!("\r\x1b[2K");
let _ = std::io::stderr().flush();
eprintln!(
"{} Daemon ready ({}s)",
"✓".green(),
start.elapsed().as_secs()
);
return Ok(());
}
if Instant::now() >= deadline {
eprint!("\r\x1b[2K");
let _ = std::io::stderr().flush();
return Err(anyhow!(
"daemon did not become ready within {}s at {} — \
try `trusty-search start` manually to see the error",
READY_TIMEOUT.as_secs(),
base
));
}
}
}
pub async fn ensure_daemon_running_or_exit(base: &str) -> Result<()> {
ensure_daemon_running(base).await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn probe_health_returns_false_on_connection_refused() {
let base = "http://127.0.0.1:65535";
let started = Instant::now();
let ok = probe_health(base).await;
assert!(!ok, "probe should fail against an unbound port");
assert!(
started.elapsed() < Duration::from_secs(3),
"probe took too long: {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn probe_health_returns_false_on_bad_url() {
let ok = probe_health("not-a-valid-url").await;
assert!(!ok);
}
#[tokio::test]
async fn probe_health_respects_short_timeout() {
let started = Instant::now();
let _ = probe_health("http://127.0.0.1:1").await;
assert!(started.elapsed() < Duration::from_secs(2));
}
}