use std::net::SocketAddr;
use std::path::{Path, PathBuf};
pub mod chat;
pub mod claude_config;
pub mod project_discovery;
pub mod shutdown;
pub use shutdown::shutdown_signal;
pub mod log_buffer;
pub mod sys_metrics;
#[cfg(target_os = "macos")]
pub mod launchd;
#[cfg(feature = "axum-server")]
pub mod server;
#[cfg(feature = "mcp")]
pub mod mcp;
#[cfg(feature = "rpc")]
pub mod rpc;
#[cfg(feature = "embedder")]
pub mod embedder;
#[cfg(feature = "embedder-client")]
pub mod embedder_client;
#[cfg(feature = "bm25")]
pub mod bm25;
#[cfg(feature = "migrations")]
pub mod migrations;
#[cfg(feature = "bm25-client")]
pub mod bm25_client;
#[cfg(feature = "symgraph")]
pub mod symgraph;
#[cfg(feature = "memory-core")]
pub mod memory_core;
#[cfg(feature = "tickets")]
pub mod tickets;
#[cfg(feature = "cli-help")]
pub mod help;
#[cfg(feature = "monitor-tui")]
pub mod monitor;
#[cfg(feature = "update-check")]
pub mod update;
#[cfg(feature = "bug-capture")]
pub mod error_capture;
pub use chat::{
BedrockProvider, ChatEvent, ChatProvider, DEFAULT_BEDROCK_MODEL, LocalModelConfig,
OllamaProvider, OpenRouterProvider, ToolCall, ToolDef, auto_detect_local_provider,
};
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
pub async fn bind_with_auto_port(addr: SocketAddr, max_attempts: u16) -> Result<TcpListener> {
use std::io::ErrorKind;
let mut current = addr;
for attempt in 0..=max_attempts {
match TcpListener::bind(current).await {
Ok(l) => return Ok(l),
Err(e) if e.kind() == ErrorKind::AddrInUse && attempt < max_attempts => {
let next_port = current.port().saturating_add(1);
if next_port == 0 {
anyhow::bail!("ran out of ports while searching for free slot");
}
tracing::warn!("port {} in use, trying {}", current.port(), next_port);
current.set_port(next_port);
}
Err(e) => return Err(e.into()),
}
}
anyhow::bail!("could not find free port after {max_attempts} attempts")
}
pub const DATA_DIR_OVERRIDE_ENV: &str = "TRUSTY_DATA_DIR_OVERRIDE";
pub fn sanitize_data_root(candidate: PathBuf, app_name: &str) -> PathBuf {
let safe_fallback = || std::env::temp_dir().join(format!("trusty-{app_name}"));
if !candidate.is_absolute() {
tracing::error!(
path = %candidate.display(),
app = app_name,
"resolved data root is not absolute; \
falling back to temp dir to prevent CWD-relative palace creation. \
Check HOME and TRUSTY_DATA_DIR_OVERRIDE in the daemon environment."
);
return safe_fallback();
}
if candidate == Path::new("/") {
tracing::error!(
app = app_name,
"resolved data root is the filesystem root (/); \
falling back to temp dir. \
Check HOME and TRUSTY_DATA_DIR_OVERRIDE in the daemon environment."
);
return safe_fallback();
}
if candidate.parent() == Some(Path::new("/")) {
tracing::error!(
path = %candidate.display(),
app = app_name,
"resolved data root is a direct child of the filesystem root; \
this usually means HOME or XDG_DATA_HOME is set to '/'. \
Falling back to temp dir to prevent data scatter under /."
);
return safe_fallback();
}
candidate
}
pub fn resolve_data_dir(app_name: &str) -> Result<PathBuf> {
let base = match std::env::var(DATA_DIR_OVERRIDE_ENV) {
Ok(raw) if raw.trim().is_empty() => {
tracing::warn!(
env = DATA_DIR_OVERRIDE_ENV,
"TRUSTY_DATA_DIR_OVERRIDE is set but empty; ignoring and using \
the platform data directory instead. An empty override would \
produce a relative path that resolves against the daemon's \
working directory (/ under launchd), which is never correct."
);
dirs::data_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(format!(".{app_name}"))))
.context("could not resolve data directory or home directory")?
}
Ok(raw) => {
let p = PathBuf::from(&raw);
if !p.is_absolute() {
anyhow::bail!(
"TRUSTY_DATA_DIR_OVERRIDE={raw:?} is a relative path; only \
absolute paths are accepted to prevent the data directory \
from depending on the daemon's working directory"
);
}
if p == Path::new("/") {
anyhow::bail!(
"TRUSTY_DATA_DIR_OVERRIDE={raw:?} resolves to the filesystem \
root (/); refusing to create palace directories directly \
under / as that would scatter data across the root filesystem"
);
}
p
}
Err(_) => dirs::data_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(format!(".{app_name}"))))
.context("could not resolve data directory or home directory")?,
};
let dir = if base.ends_with(format!(".{app_name}")) {
base
} else {
base.join(app_name)
};
let dir = sanitize_data_root(dir, app_name);
std::fs::create_dir_all(&dir)
.with_context(|| format!("create data directory {}", dir.display()))?;
Ok(dir)
}
const DAEMON_ADDR_FILENAME: &str = "http_addr";
pub fn write_daemon_addr(app_name: &str, addr: &str) -> Result<()> {
let dir = resolve_data_dir(app_name)?;
let path = dir.join(DAEMON_ADDR_FILENAME);
std::fs::write(&path, addr).with_context(|| format!("write daemon addr to {}", path.display()))
}
pub fn read_daemon_addr(app_name: &str) -> Result<Option<String>> {
let dir = resolve_data_dir(app_name)?;
let path = dir.join(DAEMON_ADDR_FILENAME);
match std::fs::read_to_string(&path) {
Ok(s) => Ok(Some(s.trim().to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::new(e))
.with_context(|| format!("read daemon addr from {}", path.display())),
}
}
pub async fn probe_health(base_url: &str, health_path: &str) -> bool {
let probe = format!("{base_url}{health_path}");
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(1))
.build()
{
Ok(c) => c,
Err(_) => return false,
};
matches!(client.get(&probe).send().await, Ok(resp) if resp.status().is_success())
}
pub async fn check_already_running(addr_file: &Path, health_path: &str) -> Option<String> {
let raw = match std::fs::read_to_string(addr_file) {
Ok(s) => s,
Err(_) => return None,
};
let addr = raw.trim();
if addr.is_empty() {
let _ = std::fs::remove_file(addr_file);
return None;
}
let url = format!("http://{addr}");
if probe_health(&url, health_path).await {
Some(url)
} else {
let _ = std::fs::remove_file(addr_file);
None
}
}
pub fn init_tracing(verbose_count: u8) {
let default_filter = match verbose_count {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.try_init();
}
#[must_use]
pub fn init_tracing_with_buffer(verbose_count: u8, capacity: usize) -> log_buffer::LogBuffer {
use tracing_subscriber::Layer as _;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let default_filter = match verbose_count {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
let stderr_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
let buffer_filter = tracing_subscriber::EnvFilter::try_from_env("RUST_LOG_BUFFER")
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let buffer = log_buffer::LogBuffer::new(capacity);
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_target(false)
.with_filter(stderr_filter);
let buf_layer = log_buffer::LogBufferLayer::new(buffer.clone()).with_filter(buffer_filter);
let _ = tracing_subscriber::registry()
.with(fmt_layer)
.with(buf_layer)
.try_init();
buffer
}
#[cfg(feature = "bug-capture")]
#[must_use]
pub fn init_tracing_with_buffer_and_capture(
verbose_count: u8,
capacity: usize,
app_name: &str,
crate_version: impl Into<String>,
) -> (log_buffer::LogBuffer, error_capture::ErrorStore) {
use tracing_subscriber::Layer as _;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let default_filter = match verbose_count {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
let stderr_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
let buffer_filter = tracing_subscriber::EnvFilter::try_from_env("RUST_LOG_BUFFER")
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let buffer = log_buffer::LogBuffer::new(capacity);
let (capture_layer, store) = error_capture::bug_capture_layer(
app_name,
error_capture::DEFAULT_CAPTURE_CAPACITY,
crate_version,
);
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_target(false)
.with_filter(stderr_filter);
let buf_layer = log_buffer::LogBufferLayer::new(buffer.clone()).with_filter(buffer_filter);
let _ = tracing_subscriber::registry()
.with(fmt_layer)
.with(buf_layer)
.with(capture_layer)
.try_init();
(buffer, store)
}
pub fn maybe_disable_color(no_color: bool) {
let env_says_no =
std::env::var("NO_COLOR").is_ok() || std::env::var("TERM").as_deref() == Ok("dumb");
if no_color || env_says_no {
colored::control::set_override(false);
}
}
const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
const HTTP_REFERER: &str = "https://github.com/bobmatnyc/trusty-common";
const X_TITLE: &str = "trusty-common";
const OPENROUTER_CONNECT_TIMEOUT_SECS: u64 = 10;
const OPENROUTER_REQUEST_TIMEOUT_SECS: u64 = 120;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tool_calls: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: &'a [ChatMessage],
stream: bool,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
}
#[derive(Debug, Deserialize)]
struct ResponseMessage {
#[serde(default)]
content: String,
}
#[deprecated(since = "0.3.1", note = "Use OpenRouterProvider::chat_stream instead")]
pub async fn openrouter_chat(
api_key: &str,
model: &str,
messages: Vec<ChatMessage>,
) -> Result<String> {
if api_key.is_empty() {
return Err(anyhow!("openrouter api key is empty"));
}
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(
OPENROUTER_CONNECT_TIMEOUT_SECS,
))
.timeout(std::time::Duration::from_secs(
OPENROUTER_REQUEST_TIMEOUT_SECS,
))
.build()
.context("build reqwest client for openrouter_chat")?;
let body = ChatRequest {
model,
messages: &messages,
stream: false,
};
let resp = client
.post(OPENROUTER_URL)
.bearer_auth(api_key)
.header("HTTP-Referer", HTTP_REFERER)
.header("X-Title", X_TITLE)
.json(&body)
.send()
.await
.context("POST openrouter chat completions")?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow!("openrouter HTTP {status}: {text}"));
}
let payload: ChatResponse = resp.json().await.context("decode openrouter response")?;
payload
.choices
.into_iter()
.next()
.map(|c| c.message.content)
.ok_or_else(|| anyhow!("openrouter returned no choices"))
}
#[deprecated(since = "0.3.1", note = "Use OpenRouterProvider::chat_stream instead")]
pub async fn openrouter_chat_stream(
api_key: &str,
model: &str,
messages: Vec<ChatMessage>,
tx: tokio::sync::mpsc::Sender<String>,
) -> Result<()> {
use futures_util::StreamExt;
if api_key.is_empty() {
return Err(anyhow!("openrouter api key is empty"));
}
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(
OPENROUTER_CONNECT_TIMEOUT_SECS,
))
.timeout(std::time::Duration::from_secs(
OPENROUTER_REQUEST_TIMEOUT_SECS,
))
.build()
.context("build reqwest client for openrouter_chat_stream")?;
let body = ChatRequest {
model,
messages: &messages,
stream: true,
};
let resp = client
.post(OPENROUTER_URL)
.bearer_auth(api_key)
.header("HTTP-Referer", HTTP_REFERER)
.header("X-Title", X_TITLE)
.json(&body)
.send()
.await
.context("POST openrouter chat completions (stream)")?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow!("openrouter HTTP {status}: {text}"));
}
let mut buf = String::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let bytes = chunk.context("read openrouter stream chunk")?;
let text = match std::str::from_utf8(&bytes) {
Ok(s) => s,
Err(_) => continue,
};
buf.push_str(text);
while let Some(idx) = buf.find('\n') {
let line: String = buf.drain(..=idx).collect();
let line = line.trim();
let Some(payload) = line.strip_prefix("data:").map(str::trim) else {
continue;
};
if payload.is_empty() || payload == "[DONE]" {
continue;
}
let v: serde_json::Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(delta) = v
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("delta"))
.and_then(|d| d.get("content"))
.and_then(|c| c.as_str())
&& !delta.is_empty()
&& tx.send(delta.to_string()).await.is_err()
{
return Ok(());
}
}
}
Ok(())
}
pub fn is_dir(path: &Path) -> bool {
path.metadata().map(|m| m.is_dir()).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[tokio::test]
async fn auto_port_walks_forward() {
let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = occupied.local_addr().unwrap().port();
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let next = bind_with_auto_port(addr, 8).await.unwrap();
let got = next.local_addr().unwrap().port();
assert_ne!(got, port, "expected walk-forward to a different port");
}
#[tokio::test]
async fn auto_port_zero_attempts_still_binds_free() {
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let l = bind_with_auto_port(addr, 0).await.unwrap();
assert!(l.local_addr().unwrap().port() > 0);
}
#[test]
fn resolve_data_dir_creates_directory() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile_like_dir();
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
}
let dir = resolve_data_dir("trusty-test-xyz").unwrap();
assert!(
dir.exists(),
"data dir should be created at {}",
dir.display()
);
assert!(dir.is_dir());
assert!(
dir.starts_with(&tmp),
"data dir {} should live under override {}",
dir.display(),
tmp.display()
);
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
}
#[test]
fn resolve_data_dir_empty_override_uses_platform_dir() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, "");
}
let result = resolve_data_dir("trusty-test-empty-override");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
let dir = result.expect("empty override should fall back to platform dir");
assert!(
dir.is_absolute(),
"resolved dir should be absolute, got {}",
dir.display()
);
assert_ne!(
dir,
std::path::PathBuf::from("/"),
"resolved dir must not be filesystem root"
);
}
#[test]
fn resolve_data_dir_whitespace_override_uses_platform_dir() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, " ");
}
let result = resolve_data_dir("trusty-test-ws-override");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
let dir = result.expect("whitespace override should fall back to platform dir");
assert!(dir.is_absolute(), "resolved dir should be absolute");
}
#[test]
fn resolve_data_dir_relative_override_errors() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, "relative/path");
}
let result = resolve_data_dir("trusty-test-relative");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
assert!(
result.is_err(),
"relative override should be rejected, but got Ok({})",
result.unwrap().display()
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("relative"),
"error should mention 'relative', got: {msg}"
);
}
#[test]
fn resolve_data_dir_root_override_errors() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, "/");
}
let result = resolve_data_dir("trusty-test-root");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
assert!(
result.is_err(),
"root '/' override should be rejected, but got Ok({})",
result.unwrap().display()
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains('/'),
"error should mention the path, got: {msg}"
);
}
#[test]
fn resolve_data_dir_valid_absolute_override_is_honoured() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile_like_dir();
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
}
let result = resolve_data_dir("trusty-test-abs-override");
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
let dir = result.expect("valid absolute override should succeed");
assert!(
dir.starts_with(&tmp),
"resolved dir {} should be under override {}",
dir.display(),
tmp.display()
);
assert!(dir.is_absolute(), "resolved dir must be absolute");
}
#[test]
fn sanitize_data_root_rejects_relative() {
let result = sanitize_data_root(PathBuf::from("relative/path"), "myapp");
assert!(result.is_absolute(), "fallback must be absolute");
let name = result.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with("trusty-"),
"fallback dir name should start with trusty-, got {name}"
);
}
#[test]
fn sanitize_data_root_rejects_root() {
let result = sanitize_data_root(PathBuf::from("/"), "myapp");
assert!(result.is_absolute(), "fallback must be absolute");
assert_ne!(result, PathBuf::from("/"), "must not still be /");
let name = result.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with("trusty-"),
"fallback should start with trusty-"
);
}
#[test]
fn sanitize_data_root_rejects_bare_root_child() {
let result = sanitize_data_root(PathBuf::from("/bare-child"), "myapp");
assert!(result.is_absolute(), "fallback must be absolute");
assert_ne!(
result,
PathBuf::from("/bare-child"),
"bare root-child must be replaced"
);
let name = result.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with("trusty-"),
"fallback should start with trusty-"
);
}
#[test]
fn sanitize_data_root_passes_valid_path() {
let tmp = tempfile_like_dir();
let candidate = tmp.join("trusty-myapp");
let result = sanitize_data_root(candidate.clone(), "myapp");
assert_eq!(
result, candidate,
"valid absolute path should be returned unchanged"
);
}
#[test]
fn daemon_addr_round_trips() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile_like_dir();
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
}
let app = format!(
"trusty-test-daemon-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
write_daemon_addr(&app, "127.0.0.1:12345").unwrap();
let got = read_daemon_addr(&app).unwrap();
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
assert_eq!(got.as_deref(), Some("127.0.0.1:12345"));
}
#[test]
fn read_daemon_addr_missing_returns_none() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile_like_dir();
unsafe {
std::env::set_var(DATA_DIR_OVERRIDE_ENV, &tmp);
}
let app = format!(
"trusty-test-daemon-missing-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let got = read_daemon_addr(&app).unwrap();
unsafe {
std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
}
assert!(got.is_none(), "expected None when file absent, got {got:?}");
}
#[test]
fn is_dir_recognises_directories() {
let tmp = tempfile_like_dir();
assert!(is_dir(&tmp));
assert!(!is_dir(&tmp.join("nope")));
}
#[test]
fn chat_message_round_trips() {
let m = ChatMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
};
let s = serde_json::to_string(&m).unwrap();
let back: ChatMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back.role, "user");
assert_eq!(back.content, "hello");
}
#[tokio::test]
#[allow(deprecated)]
async fn openrouter_chat_rejects_empty_key() {
let err = openrouter_chat("", "x", vec![]).await.unwrap_err();
assert!(err.to_string().contains("api key"));
}
#[tokio::test]
async fn check_already_running_returns_none_when_file_missing() {
let tmp = tempfile_like_dir();
let missing = tmp.join("does-not-exist");
let got = check_already_running(&missing, "/health").await;
assert!(got.is_none());
}
#[tokio::test]
async fn check_already_running_returns_none_when_file_empty() {
let tmp = tempfile_like_dir();
let path = tmp.join("http_addr");
std::fs::write(&path, " \n ").unwrap();
let got = check_already_running(&path, "/health").await;
assert!(got.is_none());
assert!(
!path.exists(),
"empty address file should be cleaned up by check_already_running"
);
}
#[tokio::test]
async fn check_already_running_returns_none_when_address_dead() {
let tmp = tempfile_like_dir();
let path = tmp.join("http_addr");
std::fs::write(&path, "127.0.0.1:1\n").unwrap();
let got = check_already_running(&path, "/health").await;
assert!(got.is_none(), "dead address should map to None");
assert!(
!path.exists(),
"stale address file should be cleaned up by check_already_running"
);
}
#[tokio::test]
async fn check_already_running_returns_url_when_health_ok() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let local = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
if let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.await;
let _ = sock.shutdown().await;
}
});
let tmp = tempfile_like_dir();
let path = tmp.join("http_addr");
std::fs::write(&path, format!("{local}\n")).unwrap();
let got = check_already_running(&path, "/health").await;
assert_eq!(got.as_deref(), Some(format!("http://{local}").as_str()));
assert!(
path.exists(),
"address file must be preserved when the daemon is healthy"
);
let _ = server.await;
}
fn tempfile_like_dir() -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let p = std::env::temp_dir().join(format!("trusty-common-test-{pid}-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
p
}
}