use std::path::Path;
const CAP_LEXICAL: &str = "bm25";
const CAP_SEMANTIC: &str = "vector";
const CAP_GRAPH: &str = "kg";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexReadiness {
pub index_id: String,
pub lifecycle_status: String,
pub chunk_count: u64,
pub lexical_ready: bool,
pub semantic_ready: bool,
pub graph_ready: bool,
}
impl IndexReadiness {
pub fn semantic_search_ready(&self) -> bool {
self.semantic_ready
}
pub fn summary(&self) -> String {
let lane = |ready: bool| if ready { "ready" } else { "pending" };
format!(
"trusty-search index '{}': {} ({} chunks) — lexical {}, semantic {}, graph {}",
self.index_id,
self.lifecycle_status,
self.chunk_count,
lane(self.lexical_ready),
lane(self.semantic_ready),
lane(self.graph_ready),
)
}
}
pub fn parse_readiness(index_id: &str, status: &serde_json::Value) -> IndexReadiness {
let lifecycle_status = status
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
let chunk_count = status
.get("chunk_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let has_cap = |token: &str| {
status
.get("search_capabilities")
.and_then(serde_json::Value::as_array)
.is_some_and(|caps| {
caps.iter()
.filter_map(serde_json::Value::as_str)
.any(|c| c == token)
})
};
IndexReadiness {
index_id: index_id.to_string(),
lifecycle_status,
chunk_count,
lexical_ready: has_cap(CAP_LEXICAL),
semantic_ready: has_cap(CAP_SEMANTIC),
graph_ready: has_cap(CAP_GRAPH),
}
}
pub fn probe_index_readiness(project_root: &Path) -> Option<IndexReadiness> {
let root = crate::resolve_project_root(project_root);
let index_id = crate::derive_index_id(&root);
if index_id.trim().is_empty() {
return None;
}
let base = crate::resolve_daemon_base_url("trusty-search")?;
let status_url = format!("{base}/indexes/{index_id}/status");
let body: serde_json::Value = std::thread::spawn(move || {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(1500))
.connect_timeout(std::time::Duration::from_millis(750))
.build()
.ok()?;
let resp = client.get(&status_url).send().ok()?;
if !resp.status().is_success() {
return None;
}
resp.json::<serde_json::Value>().ok()
})
.join()
.ok()
.flatten()?;
Some(parse_readiness(&index_id, &body))
}
pub fn log_index_readiness(project_root: &Path) {
if let Some(readiness) = probe_index_readiness(project_root) {
log_readiness(&readiness);
}
}
pub fn log_readiness(readiness: &IndexReadiness) {
if readiness.semantic_search_ready() {
tracing::info!("{}", readiness.summary());
} else {
tracing::warn!(
"{} — semantic search still warming; expect lexical-only results until it is ready",
readiness.summary()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_readiness_reads_capabilities_and_status() {
let body = json!({
"status": "indexed_lexical",
"chunk_count": 4096,
"search_capabilities": ["bm25", "literal", "exact_match"],
});
let r = parse_readiness("trusty-tools", &body);
assert_eq!(r.index_id, "trusty-tools");
assert_eq!(r.lifecycle_status, "indexed_lexical");
assert_eq!(r.chunk_count, 4096);
assert!(r.lexical_ready, "bm25 present ⇒ lexical ready");
assert!(!r.semantic_ready, "no vector cap ⇒ semantic not ready");
assert!(!r.graph_ready);
}
#[test]
fn parse_readiness_all_lanes_ready() {
let body = json!({
"status": "ready",
"chunk_count": 50_000,
"search_capabilities": ["bm25", "literal", "exact_match", "vector", "kg"],
});
let r = parse_readiness("big-repo", &body);
assert!(r.lexical_ready && r.semantic_ready && r.graph_ready);
assert!(r.semantic_search_ready());
}
#[test]
fn parse_readiness_defaults_when_fields_absent() {
let r = parse_readiness("x", &json!({}));
assert_eq!(r.lifecycle_status, "unknown");
assert_eq!(r.chunk_count, 0);
assert!(!r.lexical_ready && !r.semantic_ready && !r.graph_ready);
}
#[test]
fn parse_readiness_ignores_malformed_capabilities() {
let r = parse_readiness("x", &json!({ "search_capabilities": "bm25" }));
assert!(!r.lexical_ready);
}
#[test]
fn summary_reports_all_lanes_ready() {
let r = IndexReadiness {
index_id: "repo".into(),
lifecycle_status: "ready".into(),
chunk_count: 10,
lexical_ready: true,
semantic_ready: true,
graph_ready: true,
};
let s = r.summary();
assert!(s.contains("'repo'"));
assert!(s.contains("ready (10 chunks)"));
assert!(s.contains("semantic ready"));
}
#[test]
fn summary_flags_semantic_pending_during_warmup() {
let r = IndexReadiness {
index_id: "repo".into(),
lifecycle_status: "indexed_lexical".into(),
chunk_count: 7,
lexical_ready: true,
semantic_ready: false,
graph_ready: false,
};
assert!(!r.semantic_search_ready());
let s = r.summary();
assert!(s.contains("lexical ready"));
assert!(s.contains("semantic pending"));
}
#[test]
fn log_readiness_does_not_panic_for_either_lane_state() {
let mut r = IndexReadiness {
index_id: "repo".into(),
lifecycle_status: "indexed_lexical".into(),
chunk_count: 7,
lexical_ready: true,
semantic_ready: false,
graph_ready: false,
};
log_readiness(&r); r.semantic_ready = true;
log_readiness(&r); }
#[test]
fn probe_index_readiness_none_for_root() {
assert_eq!(probe_index_readiness(Path::new("/")), None);
}
#[test]
fn probe_index_readiness_none_when_daemon_down() {
let _guard = crate::data_dir::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
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 data_dir = std::env::temp_dir().join(format!("trusty-readiness-data-{pid}-{nanos}"));
std::fs::create_dir_all(&data_dir).unwrap();
unsafe {
std::env::set_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV, &data_dir);
}
let project = data_dir.join("proj");
std::fs::create_dir_all(project.join(".git")).unwrap();
let readiness = probe_index_readiness(&project);
unsafe {
std::env::remove_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV);
}
let _ = std::fs::remove_dir_all(&data_dir);
assert_eq!(readiness, None, "daemon down ⇒ None, never an error");
}
}