use anyhow::Result;
use colored::Colorize;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::core::registry::{IndexHandle, IndexId, IndexStages, StageState, StageStatus};
use crate::service::persistence::load_index_registry;
use crate::service::persistence_loader::build_indexer_with_persisted_state;
use crate::service::SearchAppState;
#[derive(Debug, Clone, Copy)]
pub(crate) struct WarmBootInputs {
pub chunk_count: usize,
pub hnsw_snapshot_ready: bool,
pub graph_node_count: usize,
pub lexical_only: bool,
}
pub(crate) fn derive_warm_boot_stages(inputs: WarmBootInputs) -> IndexStages {
let lexical = if inputs.chunk_count > 0 {
StageState {
status: StageStatus::Ready,
..Default::default()
}
} else {
StageState {
status: StageStatus::InProgress,
..Default::default()
}
};
let (semantic, graph) = if inputs.lexical_only {
(StageState::skipped(), StageState::skipped())
} else {
let semantic = if inputs.hnsw_snapshot_ready {
StageState {
status: StageStatus::Ready,
..Default::default()
}
} else {
StageState::pending()
};
let graph = if inputs.graph_node_count > 0 {
StageState {
status: StageStatus::Ready,
..Default::default()
}
} else {
StageState::pending()
};
(semantic, graph)
};
IndexStages {
lexical,
semantic,
graph,
}
}
async fn restore_indexes(state: &SearchAppState, embedder: &Arc<dyn crate::core::Embedder>) {
let entries = match load_index_registry() {
Ok(e) => e,
Err(e) => {
tracing::warn!("could not read indexes.toml at startup: {e}");
return;
}
};
if entries.is_empty() {
return;
}
tracing::info!(
"warm-boot: restoring {} index registration(s) from indexes.toml",
entries.len()
);
for entry in entries {
let id = IndexId::new(entry.id.clone());
if state.registry.get(&id).is_some() {
continue;
}
let mut indexer =
build_indexer_with_persisted_state(&entry.id, entry.root_path.clone(), embedder).await;
let include_paths: Vec<std::path::PathBuf> = entry
.include_paths
.iter()
.filter(|p| !p.trim().is_empty() && p.trim() != ".")
.map(|p| entry.root_path.join(p.trim()))
.collect();
let extensions: Vec<String> = entry
.extensions
.iter()
.map(|e| e.trim_start_matches('.').to_string())
.filter(|e| !e.is_empty())
.collect();
indexer.set_domain_terms(entry.domain_terms.clone());
let indexed_head_sha = crate::core::git::head_sha(&entry.root_path);
let lexical_only = entry.lexical_only;
let chunk_count = indexer
.corpus_store()
.and_then(|c| c.chunk_count().ok())
.unwrap_or(0);
let hnsw_snapshot_ready = crate::service::persistence::hnsw_path(&entry.id)
.map(|p| crate::service::persistence::has_persisted_hnsw(&p))
.unwrap_or(false);
let graph_node_count = indexer.snapshot_symbol_graph().await.node_count();
let stages = derive_warm_boot_stages(WarmBootInputs {
chunk_count,
hnsw_snapshot_ready,
graph_node_count,
lexical_only,
});
tracing::info!(
"warm-boot: index '{}' restored — chunks={} hnsw_snapshot={} graph_nodes={} \
lexical_only={} → stages(lexical={:?}, semantic={:?}, graph={:?})",
entry.id,
chunk_count,
hnsw_snapshot_ready,
graph_node_count,
lexical_only,
stages.lexical.status,
stages.semantic.status,
stages.graph.status,
);
let handle = IndexHandle {
id: id.clone(),
indexer: Arc::new(tokio::sync::RwLock::new(indexer)),
root_path: entry.root_path,
include_paths,
exclude_globs: entry.exclude_globs,
extensions,
domain_terms: entry.domain_terms,
include_docs: entry.include_docs,
respect_gitignore: entry.respect_gitignore,
path_filter: entry.path_filter,
context_embedding: Arc::new(tokio::sync::RwLock::new(None)),
context_summary: Arc::new(tokio::sync::RwLock::new(None)),
indexed_head_sha: Arc::new(tokio::sync::RwLock::new(indexed_head_sha)),
lexical_only,
stages: Arc::new(tokio::sync::RwLock::new(stages)),
search_pressure: Arc::new(tokio::sync::Notify::new()),
};
state.registry.register(handle);
}
}
async fn build_embedder() -> Result<std::sync::Arc<dyn crate::core::Embedder>> {
use crate::service::embedder_supervisor::{
locate_embedderd_binary, EmbedderSupervisor, SupervisorConfig,
};
let trusty_embedder_env = std::env::var("TRUSTY_EMBEDDER").unwrap_or_default();
#[cfg(feature = "candle")]
{
if trusty_embedder_env == "candle" {
let candle =
tokio::task::spawn_blocking(crate::service::candle_embedder::CandleEmbedder::new)
.await
.map_err(|e| anyhow::anyhow!("candle embedder init task panicked: {e}"))??;
let dim = candle.dimension();
tracing::info!("embedder initialized: model=all-MiniLM-L6-v2 dim={dim} backend=candle");
return Ok(std::sync::Arc::new(candle));
}
}
match trusty_embedder_env.as_str() {
"" | "auto" | "stdio" => {
let binary = locate_embedderd_binary().map_err(|e| {
anyhow::anyhow!(
"{e}\n\n\
ERROR: trusty-embedderd binary not found on PATH.\n\
\n\
trusty-search v0.13+ requires trusty-embedderd to be installed alongside it.\n\
\n\
Install it with:\n\
\x20 cargo install trusty-embedderd --locked\n\
\n\
Or set TRUSTY_EMBEDDERD_BIN to an absolute path:\n\
\x20 export TRUSTY_EMBEDDERD_BIN=/path/to/trusty-embedderd\n\
\n\
If you need to run without the sidecar (tests, debugging), use:\n\
\x20 TRUSTY_EMBEDDER=in-process trusty-search start"
)
})?;
let config = SupervisorConfig::from_env();
tracing::info!("embedder mode: stdio-sidecar (binary={})", binary.display());
let (supervisor, slot) = EmbedderSupervisor::spawn_stdio(binary, config.into_common())
.await
.map_err(|e| anyhow::anyhow!("failed to spawn trusty-embedderd --stdio: {e}"))?;
supervisor.start_supervisor_task();
Ok(Arc::new(SlotEmbedderAdapter { slot }))
}
"in-process" | "local" => {
tracing::info!("embedder mode: in-process (override via TRUSTY_EMBEDDER=in-process)");
build_in_process_embedder().await
}
addr if addr.starts_with("http://") || addr.starts_with("https://") => {
tracing::info!("embedder mode: remote http ({})", addr);
let client = trusty_common::embedder_client::RemoteEmbedderClient::new(addr.to_owned());
Ok(Arc::new(RemoteEmbedderAdapter {
client: EmbedderClientKind::Http(client),
}))
}
path if path.starts_with("unix:") => {
let sock = PathBuf::from(&path["unix:".len()..]);
tracing::info!("embedder mode: remote uds ({})", sock.display());
let client = trusty_common::embedder_client::UdsEmbedderClient::new(sock);
Ok(Arc::new(UdsEmbedderAdapter { client }))
}
other => anyhow::bail!(
"invalid TRUSTY_EMBEDDER value: {other:?}. \
Expected: unset (default stdio sidecar), 'auto', 'stdio', 'in-process', \
'http://...', or 'unix:/path/to/socket'"
),
}
}
async fn build_in_process_embedder() -> Result<Arc<dyn crate::core::Embedder>> {
let embedder = crate::core::FastEmbedder::new().await.map_err(|e| {
tracing::error!("FastEmbedder init failed: {e:#}");
anyhow::anyhow!("FastEmbedder init failed: {e}")
})?;
let dim = <crate::core::FastEmbedder as crate::core::Embedder>::dimension(&embedder);
let provider = embedder.provider();
let metal_hint = match provider {
trusty_common::embedder::ExecutionProvider::CoreML => " (Metal GPU + ANE + CPU)",
trusty_common::embedder::ExecutionProvider::CoreMLAne => " (Neural Engine + CPU)",
trusty_common::embedder::ExecutionProvider::Cuda => " (CUDA GPU)",
trusty_common::embedder::ExecutionProvider::Cpu => "",
};
tracing::info!(
"embedder initialized: model=AllMiniLML6V2(Q) dim={dim} provider={provider}{metal_hint}"
);
tune_batch_size_for_provider(provider);
Ok(Arc::new(embedder))
}
enum EmbedderClientKind {
Http(trusty_common::embedder_client::RemoteEmbedderClient),
}
struct RemoteEmbedderAdapter {
client: EmbedderClientKind,
}
#[async_trait::async_trait]
impl crate::core::Embedder for RemoteEmbedderAdapter {
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
use trusty_common::embedder_client::EmbedderClient as _;
let mut v = match &self.client {
EmbedderClientKind::Http(c) => c
.embed_batch(vec![text.to_string()])
.await
.map_err(|e| anyhow::anyhow!("remote embed failed: {e}"))?,
};
v.pop()
.ok_or_else(|| anyhow::anyhow!("remote embedder returned no vector"))
}
async fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
use trusty_common::embedder_client::EmbedderClient as _;
let owned: Vec<String> = texts.iter().map(|s| (*s).to_owned()).collect();
match &self.client {
EmbedderClientKind::Http(c) => c
.embed_batch(owned)
.await
.map_err(|e| anyhow::anyhow!("remote embed_batch failed: {e}")),
}
}
fn dimension(&self) -> usize {
trusty_common::embedder::EMBED_DIM
}
}
struct UdsEmbedderAdapter {
client: trusty_common::embedder_client::UdsEmbedderClient,
}
#[async_trait::async_trait]
impl crate::core::Embedder for UdsEmbedderAdapter {
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
use trusty_common::embedder_client::EmbedderClient as _;
let mut v = self
.client
.embed_batch(vec![text.to_string()])
.await
.map_err(|e| anyhow::anyhow!("uds embed failed: {e}"))?;
v.pop()
.ok_or_else(|| anyhow::anyhow!("uds embedder returned no vector"))
}
async fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
use trusty_common::embedder_client::EmbedderClient as _;
let owned: Vec<String> = texts.iter().map(|s| (*s).to_owned()).collect();
self.client
.embed_batch(owned)
.await
.map_err(|e| anyhow::anyhow!("uds embed_batch failed: {e}"))
}
fn dimension(&self) -> usize {
trusty_common::embedder::EMBED_DIM
}
}
struct SlotEmbedderAdapter {
slot: Arc<tokio::sync::RwLock<Arc<dyn trusty_common::embedder_client::EmbedderClient>>>,
}
#[async_trait::async_trait]
impl crate::core::Embedder for SlotEmbedderAdapter {
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
let client = self.slot.read().await.clone();
let mut v = client
.embed_batch(vec![text.to_string()])
.await
.map_err(|e| anyhow::anyhow!("stdio embed failed: {e}"))?;
v.pop()
.ok_or_else(|| anyhow::anyhow!("stdio embedder returned no vector"))
}
async fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
let client = self.slot.read().await.clone();
let owned: Vec<String> = texts.iter().map(|s| (*s).to_owned()).collect();
client
.embed_batch(owned)
.await
.map_err(|e| anyhow::anyhow!("stdio embed_batch failed: {e}"))
}
fn dimension(&self) -> usize {
trusty_common::embedder::EMBED_DIM
}
}
fn tune_batch_size_for_provider(provider: trusty_common::embedder::ExecutionProvider) {
const GPU_BATCH_DEFAULT: usize = 512;
if matches!(
provider,
trusty_common::embedder::ExecutionProvider::CoreML
| trusty_common::embedder::ExecutionProvider::CoreMLAne
) {
let coreml_bs = crate::core::resolve_coreml_batch_size();
tracing::info!(
"gpu_batch_tuning: provider={provider} → using TRUSTY_COREML_BATCH_SIZE={coreml_bs} for \
indexing batches (CoreML EP allocates per-batch buffers in the unified-memory pool)"
);
return;
}
let is_gpu = matches!(provider, trusty_common::embedder::ExecutionProvider::Cuda);
if !is_gpu {
return;
}
if std::env::var("TRUSTY_MAX_BATCH_SIZE_EXPLICIT")
.map(|v| v == "1")
.unwrap_or(false)
{
tracing::info!(
"gpu_batch_tuning: TRUSTY_MAX_BATCH_SIZE_EXPLICIT=1 set, leaving batch size unchanged"
);
return;
}
let current = std::env::var("TRUSTY_MAX_BATCH_SIZE")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(128);
if current >= GPU_BATCH_DEFAULT {
return;
}
unsafe {
std::env::set_var("TRUSTY_MAX_BATCH_SIZE", GPU_BATCH_DEFAULT.to_string());
}
tracing::info!(
"gpu_batch_tuning: provider={provider} → TRUSTY_MAX_BATCH_SIZE={GPU_BATCH_DEFAULT} (was {current}); \
set TRUSTY_MAX_BATCH_SIZE_EXPLICIT=1 to keep your value"
);
}
pub async fn handle_start(port: u16, foreground: bool, device: &str, verbose: bool) -> Result<()> {
if !foreground {
match device {
"auto" | "cpu" | "gpu" => {}
other => {
anyhow::bail!("invalid --device value '{other}'; expected one of: auto, cpu, gpu")
}
}
let exe = std::env::current_exe()
.map_err(|e| anyhow::anyhow!("could not resolve current_exe: {e}"))?;
let mut cmd = std::process::Command::new(&exe);
cmd.arg("start")
.arg("--foreground")
.arg("--port")
.arg(port.to_string())
.arg("--device")
.arg(device)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not spawn detached daemon: {e}"))?;
let pid = child.id();
eprintln!(
"{} Daemon starting in background (pid {pid}). Run `trusty-search status` to verify readiness.",
"✓".green()
);
return Ok(());
}
let log_buffer = trusty_common::init_tracing_with_buffer(
if verbose { 2 } else { 0 },
trusty_common::log_buffer::DEFAULT_LOG_CAPACITY,
);
if std::env::var_os("TRUSTY_DEVICE").is_none() {
match device {
"cpu" | "gpu" => unsafe {
std::env::set_var("TRUSTY_DEVICE", device);
},
"auto" => {}
other => {
anyhow::bail!("invalid --device value '{other}'; expected one of: auto, cpu, gpu")
}
}
}
crate::service::save_daemon_env();
crate::service::load_daemon_env();
let policy = crate::core::MemoryPolicy::detect();
const MIN_RAM_MB: u64 = 16 * 1024;
if policy.total_ram_mb < MIN_RAM_MB
&& std::env::var("TRUSTY_SKIP_RAM_CHECK").as_deref() != Ok("1")
{
anyhow::bail!(
"trusty-search requires at least 16 GB of RAM.\n\
Detected: {} MB ({:.1} GB)\n\
Indexing large codebases on machines with less memory is not supported.\n\
To bypass on this host set TRUSTY_SKIP_RAM_CHECK=1 in the daemon environment.",
policy.total_ram_mb,
policy.total_ram_mb as f64 / 1024.0
);
}
policy.log_summary();
let _ = foreground;
if let Some(pid) = crate::service::running_daemon_pid() {
tracing::warn!("daemon already running (pid {pid}); refusing to start a second instance");
anyhow::bail!(
"Daemon already running (pid {pid}).\n\
Stop it first with `trusty-search stop`, then re-run `trusty-search start`.\n\
Replacing the binary while the daemon is running causes macOS to SIGKILL \
the process (Code Signature Invalid)."
);
}
let orphans = crate::commands::stop::find_daemon_pids();
if !orphans.is_empty() {
tracing::warn!(
"found {} existing trusty-search daemon process(es) not tracked by lockfile: {:?} — terminating before start",
orphans.len(),
orphans
);
eprintln!(
"{} found {} existing trusty-search daemon process(es) not tracked by lockfile — stopping them first",
"⚠".yellow(),
orphans.len()
);
#[cfg(unix)]
for pid in &orphans {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(*pid as i32),
nix::sys::signal::Signal::SIGTERM,
);
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
#[cfg(unix)]
let any_alive = orphans.iter().any(|p| {
nix::sys::signal::kill(nix::unistd::Pid::from_raw(*p as i32), None).is_ok()
});
#[cfg(not(unix))]
let any_alive = false;
if !any_alive || std::time::Instant::now() >= deadline {
break;
}
}
#[cfg(unix)]
for pid in &orphans {
if nix::sys::signal::kill(nix::unistd::Pid::from_raw(*pid as i32), None).is_ok() {
tracing::warn!("orphan pid {pid} ignored SIGTERM — sending SIGKILL");
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(*pid as i32),
nix::sys::signal::Signal::SIGKILL,
);
}
}
if let Ok(lock) = crate::service::daemon_lock_path() {
let _ = std::fs::remove_file(&lock);
}
if let Some(port) = super::daemon_utils::daemon_port_path() {
let _ = std::fs::remove_file(&port);
}
}
let cfg = crate::service::load_user_config();
let metrics_state = match crate::service::metrics::install_recorder() {
Ok(state) => Some(state),
Err(e) => {
tracing::warn!("could not install prometheus recorder: {e} (metrics disabled)");
None
}
};
let mut state =
crate::service::SearchAppState::new(crate::core::registry::IndexRegistry::new())
.with_local_model(cfg.local_model)
.with_openrouter_model(cfg.openrouter_model)
.with_openrouter_api_key(cfg.openrouter_api_key)
.with_log_buffer(log_buffer);
if let Some(m) = metrics_state {
state = state.with_metrics(m);
}
let init_timeout_secs: u64 = std::env::var("TRUSTY_EMBEDDER_INIT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60);
let install_state = state.clone();
tokio::spawn(async move {
let runtime_handle = tokio::runtime::Handle::current();
let init_handle = tokio::task::spawn_blocking(move || {
runtime_handle.block_on(build_embedder())
});
let init_result =
tokio::time::timeout(Duration::from_secs(init_timeout_secs), init_handle).await;
match init_result {
Ok(Ok(Ok(embedder))) => {
install_state.install_embedder(Arc::clone(&embedder)).await;
let pool = std::sync::Arc::new(
crate::service::embed_pool::EmbedPool::with_autotune(Arc::clone(&embedder)),
);
install_state.install_embed_pool(pool).await;
tracing::info!("embedder ready — vector lane online");
restore_indexes(&install_state, &embedder).await;
if std::env::var("TRUSTY_DISABLE_MIGRATIONS").as_deref() != Ok("1") {
let registry =
std::sync::Arc::new(crate::core::migration::MigrationRegistry::new());
for index_id in install_state.registry.list() {
let Some(handle) = install_state.registry.get(&index_id) else {
continue;
};
let reg = std::sync::Arc::clone(®istry);
tokio::spawn(async move {
if let Err(e) =
crate::core::migration::run_migrations(&handle, ®).await
{
tracing::warn!(
index_id = %handle.id,
"schema migration failed (index kept at current schema): {e:#}"
);
}
});
}
}
crate::service::metrics::set_index_count(install_state.registry.list().len());
tokio::spawn(crate::commands::discover::auto_discover_and_index());
}
Ok(Ok(Err(e))) => {
let msg = format!("embedder init failed: {e}");
install_state.install_embedder_error(msg.clone()).await;
eprintln!(
"{} embedder failed to initialize: {e}\n\
Daemon is up but running BM25-only. Check the model cache at \
~/Library/Caches/trusty-search/models/ and network access.",
"✗".red()
);
}
Ok(Err(join_err)) => {
let msg = format!("embedder init task panicked: {join_err}");
install_state.install_embedder_error(msg).await;
}
Err(_elapsed) => {
tracing::error!(
"embedder init timed out after {init_timeout_secs}s — \
ONNX session did not start (try \
TRUSTY_EMBEDDER_INIT_TIMEOUT_SECS=120 or check ORT \
compatibility, see GitHub #121)"
);
let msg = format!("init timed out after {init_timeout_secs}s");
install_state.install_embedder_error(msg).await;
eprintln!(
"{} embedder init timed out after {init_timeout_secs}s — \
see GitHub #121. Daemon is up in BM25-only mode.",
"✗".red()
);
}
}
});
match crate::service::run_daemon(state, port).await {
Ok(()) => {}
Err(crate::service::DaemonError::AlreadyRunning(p)) => {
tracing::debug!(
"daemon already running (lock at {}), exiting non-zero to stop launchd respawn",
p.display()
);
std::process::exit(1);
}
Err(e) => anyhow::bail!("daemon failed: {e}"),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::registry::StageStatus;
fn inputs() -> WarmBootInputs {
WarmBootInputs {
chunk_count: 0,
hnsw_snapshot_ready: false,
graph_node_count: 0,
lexical_only: false,
}
}
#[test]
fn warm_boot_marks_lexical_ready_when_chunks_present() {
let stages = derive_warm_boot_stages(WarmBootInputs {
chunk_count: 14_823,
..inputs()
});
assert_eq!(stages.lexical.status, StageStatus::Ready);
assert!(stages.search_capabilities().contains(&"bm25"));
assert!(stages.search_capabilities().contains(&"literal"));
assert!(stages.search_capabilities().contains(&"exact_match"));
}
#[test]
fn warm_boot_marks_semantic_ready_when_hnsw_snapshot_exists() {
let stages = derive_warm_boot_stages(WarmBootInputs {
chunk_count: 14_823,
hnsw_snapshot_ready: true,
..inputs()
});
assert_eq!(stages.semantic.status, StageStatus::Ready);
assert!(stages.search_capabilities().contains(&"vector"));
}
#[test]
fn warm_boot_marks_graph_ready_when_symbol_graph_nonempty() {
let stages = derive_warm_boot_stages(WarmBootInputs {
chunk_count: 14_823,
hnsw_snapshot_ready: true,
graph_node_count: 7_402,
..inputs()
});
assert_eq!(stages.graph.status, StageStatus::Ready);
assert!(stages.search_capabilities().contains(&"kg"));
}
#[test]
fn warm_boot_marks_semantic_pending_when_no_snapshot() {
let stages = derive_warm_boot_stages(WarmBootInputs {
chunk_count: 14_823,
hnsw_snapshot_ready: false,
..inputs()
});
assert_eq!(stages.lexical.status, StageStatus::Ready);
assert_eq!(stages.semantic.status, StageStatus::Pending);
assert!(!stages.search_capabilities().contains(&"vector"));
}
#[test]
fn warm_boot_respects_lexical_only_flag() {
let stages = derive_warm_boot_stages(WarmBootInputs {
chunk_count: 14_823,
hnsw_snapshot_ready: true,
graph_node_count: 7_402,
lexical_only: true,
});
assert_eq!(stages.lexical.status, StageStatus::Ready);
assert_eq!(stages.semantic.status, StageStatus::Skipped);
assert_eq!(stages.graph.status, StageStatus::Skipped);
let caps = stages.search_capabilities();
assert!(caps.contains(&"bm25"));
assert!(!caps.contains(&"vector"));
assert!(!caps.contains(&"kg"));
}
#[test]
fn warm_boot_marks_mid_reindex_as_in_progress() {
let stages = derive_warm_boot_stages(WarmBootInputs {
chunk_count: 0,
hnsw_snapshot_ready: false,
graph_node_count: 0,
lexical_only: false,
});
assert_eq!(stages.lexical.status, StageStatus::InProgress);
assert_eq!(stages.lifecycle_status(), "walking");
assert!(stages.search_capabilities().is_empty());
}
#[test]
fn missing_binary_fails_fast_with_install_hint() {
use crate::service::embedder_supervisor::locate_embedderd_binary;
let prev = std::env::var("TRUSTY_EMBEDDERD_BIN").ok();
unsafe {
std::env::set_var(
"TRUSTY_EMBEDDERD_BIN",
"/nonexistent/path/trusty-embedderd-missing",
);
}
let result = locate_embedderd_binary();
unsafe {
match prev {
Some(v) => std::env::set_var("TRUSTY_EMBEDDERD_BIN", v),
None => std::env::remove_var("TRUSTY_EMBEDDERD_BIN"),
}
}
assert!(
result.is_err(),
"locate_embedderd_binary must return Err when binary is absent"
);
let locate_err = result.unwrap_err();
let wrapped = format!(
"{locate_err}\n\n\
ERROR: trusty-embedderd binary not found on PATH.\n\
\n\
trusty-search v0.13+ requires trusty-embedderd to be installed alongside it.\n\
\n\
Install it with:\n\
\x20 cargo install trusty-embedderd --locked\n\
\n\
Or set TRUSTY_EMBEDDERD_BIN to an absolute path:\n\
\x20 export TRUSTY_EMBEDDERD_BIN=/path/to/trusty-embedderd\n\
\n\
If you need to run without the sidecar (tests, debugging), use:\n\
\x20 TRUSTY_EMBEDDER=in-process trusty-search start"
);
assert!(
wrapped.contains("cargo install trusty-embedderd"),
"install hint must contain 'cargo install trusty-embedderd'; got: {wrapped}"
);
assert!(
wrapped.contains("TRUSTY_EMBEDDER=in-process"),
"escape hatch hint must mention TRUSTY_EMBEDDER=in-process; got: {wrapped}"
);
}
}