use std::path::Path;
pub fn ensure_project_indexed(project_root: &Path) -> Option<String> {
let root = crate::resolve_project_root(project_root);
let index_id = crate::derive_index_id(&root);
if index_id.trim().is_empty() {
tracing::warn!(
"skipping trusty-search index registration: empty index id for {}",
root.display()
);
return None;
}
match crate::resolve_daemon_base_url("trusty-search") {
Some(base) => {
best_effort_create_index(&base, &index_id, &root);
best_effort_trigger_reindex(&base, &index_id);
}
None => {
tracing::warn!(
"trusty-search daemon address not found; pinning index '{index_id}' \
without pre-registering it (it will be created on first reindex)"
);
}
}
Some(index_id)
}
fn create_index_request_body(index_id: &str, root: &Path) -> serde_json::Value {
serde_json::json!({
"id": index_id,
"root_path": root.to_string_lossy(),
"allow_sensitive_path": true,
})
}
fn best_effort_create_index(base: &str, index_id: &str, root: &Path) {
let url = format!("{base}/indexes");
let body = create_index_request_body(index_id, root);
let index_id = index_id.to_string();
let root_display = root.display().to_string();
let result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(1))
.connect_timeout(std::time::Duration::from_millis(750))
.build()?;
let resp = client.post(&url).json(&body).send()?;
Ok::<reqwest::StatusCode, reqwest::Error>(resp.status())
})
.join();
match result {
Ok(Ok(status)) if status.is_success() => {
tracing::debug!("registered trusty-search index '{index_id}' (root={root_display})");
}
Ok(Ok(status)) => {
tracing::warn!(
"trusty-search index registration for '{index_id}' returned HTTP {status}"
);
}
Ok(Err(e)) => {
tracing::warn!("trusty-search index registration for '{index_id}' failed: {e}");
}
Err(_) => {
tracing::warn!("trusty-search index registration thread for '{index_id}' panicked");
}
}
}
fn best_effort_trigger_reindex(base: &str, index_id: &str) {
let status_url = format!("{base}/indexes/{index_id}/status");
let reindex_url = format!("{base}/indexes/{index_id}/reindex");
let index_id = index_id.to_string();
let result = std::thread::spawn(move || -> Result<&'static str, reqwest::Error> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.connect_timeout(std::time::Duration::from_millis(750))
.build()?;
let already_fresh = client
.get(&status_url)
.send()
.ok()
.filter(|resp| resp.status().is_success())
.and_then(|resp| resp.json::<serde_json::Value>().ok())
.is_some_and(|body| index_is_fresh(&body));
if already_fresh {
return Ok("skipped: index already fresh");
}
let resp = client.post(&reindex_url).send()?;
Ok(if resp.status().is_success() {
"triggered"
} else {
"reindex request returned non-2xx"
})
})
.join();
match result {
Ok(Ok(outcome)) => {
tracing::debug!("trusty-search reindex for '{index_id}': {outcome}");
}
Ok(Err(e)) => {
tracing::warn!("trusty-search reindex trigger for '{index_id}' failed: {e}");
}
Err(_) => {
tracing::warn!("trusty-search reindex trigger thread for '{index_id}' panicked");
}
}
}
pub fn index_is_fresh(status: &serde_json::Value) -> bool {
let chunk_count = status
.get("chunk_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
if chunk_count == 0 {
return false;
}
let Some(last_indexed) = status
.get("last_indexed")
.and_then(serde_json::Value::as_str)
else {
return false;
};
let Ok(indexed_at) = chrono::DateTime::parse_from_rfc3339(last_indexed) else {
return false;
};
let age = chrono::Utc::now().signed_duration_since(indexed_at.with_timezone(&chrono::Utc));
age >= chrono::Duration::zero() && age <= chrono::Duration::hours(1)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn scratch_dir(tag: &str) -> 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-search-index-{tag}-{pid}-{nanos}"));
let _ = fs::remove_dir_all(&p);
p
}
#[test]
fn ensure_project_indexed_returns_derived_id_when_daemon_down() {
let _guard = crate::data_dir::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let data_dir = scratch_dir("data");
fs::create_dir_all(&data_dir).unwrap();
unsafe {
std::env::set_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV, &data_dir);
}
let project = scratch_dir("git");
fs::create_dir_all(project.join(".git")).unwrap();
let nested = project.join("crates/inner");
fs::create_dir_all(&nested).unwrap();
let id = ensure_project_indexed(&nested);
let expected = crate::derive_index_id(&project);
unsafe {
std::env::remove_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV);
}
let _ = fs::remove_dir_all(&project);
let _ = fs::remove_dir_all(&data_dir);
assert_eq!(id, Some(expected), "id is the git-root basename");
}
#[test]
fn ensure_project_indexed_none_for_root() {
assert_eq!(ensure_project_indexed(Path::new("/")), None);
}
#[test]
fn create_index_request_body_sets_allow_sensitive_path() {
for root in [
Path::new("/Users/dev/projects/my-repo"),
Path::new("/private/var/folders/xx/scratch-project"),
] {
let body = create_index_request_body("my-index", root);
assert_eq!(
body.get("allow_sensitive_path"),
Some(&serde_json::Value::Bool(true)),
"request body for root {root:?} must set allow_sensitive_path: true"
);
assert_eq!(
body.get("id").and_then(serde_json::Value::as_str),
Some("my-index")
);
}
}
#[test]
fn index_is_fresh_true_when_recently_indexed_with_chunks() {
let now = chrono::Utc::now();
let status = serde_json::json!({
"chunk_count": 42,
"last_indexed": now.to_rfc3339(),
});
assert!(index_is_fresh(&status));
}
#[test]
fn index_is_fresh_false_when_no_chunks() {
let now = chrono::Utc::now();
let status = serde_json::json!({
"chunk_count": 0,
"last_indexed": now.to_rfc3339(),
});
assert!(!index_is_fresh(&status));
}
#[test]
fn index_is_fresh_false_when_stale() {
let stale = chrono::Utc::now() - chrono::Duration::hours(2);
let status = serde_json::json!({
"chunk_count": 10,
"last_indexed": stale.to_rfc3339(),
});
assert!(!index_is_fresh(&status));
}
#[test]
fn index_is_fresh_false_when_last_indexed_missing_or_malformed() {
assert!(!index_is_fresh(&serde_json::json!({ "chunk_count": 10 })));
assert!(!index_is_fresh(&serde_json::json!({
"chunk_count": 10,
"last_indexed": "not-a-timestamp",
})));
assert!(!index_is_fresh(&serde_json::json!({})));
}
}