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)
}
pub fn index_files_best_effort(project_root: &Path, paths: &[std::path::PathBuf]) {
if paths.is_empty() {
return;
}
let project_root = project_root.to_path_buf();
let paths = paths.to_vec();
std::thread::spawn(move || {
index_files_inner(&project_root, &paths);
});
}
fn index_files_inner(project_root: &Path, paths: &[std::path::PathBuf]) {
if paths.is_empty() {
return;
}
let root = crate::resolve_project_root(project_root);
let index_id = crate::derive_index_id(&root);
if index_id.trim().is_empty() {
tracing::debug!(
"skipping incremental trusty-search index update: empty index id for {}",
root.display()
);
return;
}
let Some(base) = crate::resolve_daemon_base_url("trusty-search") else {
tracing::debug!(
"trusty-search daemon address not found; skipping incremental index \
update for '{index_id}' ({} file(s))",
paths.len()
);
return;
};
let client = match build_index_client() {
Ok(c) => c,
Err(e) => {
tracing::warn!("skipping incremental index update: could not build HTTP client: {e}");
return;
}
};
for path in paths {
let abs = if path.is_absolute() {
path.clone()
} else {
root.join(path)
};
let rel = relative_index_path(&root, &abs);
let content = match std::fs::read_to_string(&abs) {
Ok(c) => c,
Err(e) => {
tracing::debug!(
"skipping incremental index update for {}: {e}",
abs.display()
);
continue;
}
};
best_effort_index_one_file(&client, &base, &index_id, &rel, &content);
}
}
fn relative_index_path(root: &Path, abs: &Path) -> String {
abs.strip_prefix(root)
.unwrap_or(abs)
.to_string_lossy()
.replace('\\', "/")
}
fn build_index_client() -> reqwest::Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.connect_timeout(std::time::Duration::from_millis(750))
.build()
}
const MAX_INDEX_ATTEMPTS: u32 = 3;
fn retry_backoff(attempt: u32) -> std::time::Duration {
let factor = 3u64.saturating_pow(attempt.saturating_sub(1));
let millis = 50u64.saturating_mul(factor).min(1000);
std::time::Duration::from_millis(millis)
}
#[derive(Debug, PartialEq, Eq)]
enum IndexOutcome {
Indexed,
HttpStatus(u16),
SendFailed,
}
fn post_index_file_with_retries(
client: &reqwest::blocking::Client,
url: &str,
body: &serde_json::Value,
) -> IndexOutcome {
let mut last_err: Option<reqwest::Error> = None;
for attempt in 0..MAX_INDEX_ATTEMPTS {
if attempt > 0 {
std::thread::sleep(retry_backoff(attempt));
}
match client.post(url).json(body).send() {
Ok(resp) if resp.status().is_success() => return IndexOutcome::Indexed,
Ok(resp) => return IndexOutcome::HttpStatus(resp.status().as_u16()),
Err(e) => last_err = Some(e),
}
}
if let Some(e) = &last_err {
tracing::debug!(
"per-file index POST to {url} failed after {MAX_INDEX_ATTEMPTS} attempts: {e}"
);
}
IndexOutcome::SendFailed
}
fn best_effort_index_one_file(
client: &reqwest::blocking::Client,
base: &str,
index_id: &str,
rel_path: &str,
content: &str,
) {
let url = format!("{base}/indexes/{index_id}/index-file");
let body = index_file_request_body(rel_path, content);
match post_index_file_with_retries(client, &url, &body) {
IndexOutcome::Indexed => {
tracing::debug!("incrementally indexed '{rel_path}' into '{index_id}'");
}
IndexOutcome::HttpStatus(status) => {
tracing::warn!(
"incremental index update for '{rel_path}' in '{index_id}' returned HTTP {status}"
);
}
IndexOutcome::SendFailed => {
tracing::warn!(
"incremental index update for '{rel_path}' in '{index_id}' failed after \
{MAX_INDEX_ATTEMPTS} attempts"
);
}
}
}
fn index_file_request_body(rel_path: &str, content: &str) -> serde_json::Value {
serde_json::json!({
"path": rel_path,
"content": content,
})
}
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 index_files_inner_is_noop_for_empty_paths() {
index_files_inner(Path::new("/"), &[]);
}
#[test]
fn index_files_inner_skips_when_index_id_empty() {
index_files_inner(Path::new("/"), &[PathBuf::from("some/file.rs")]);
}
#[test]
fn index_files_inner_skips_gracefully_when_daemon_down() {
let _guard = crate::data_dir::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let data_dir = scratch_dir("data-incr");
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-incr");
fs::create_dir_all(project.join(".git")).unwrap();
fs::write(project.join("main.rs"), "fn main() {}\n").unwrap();
index_files_inner(&project, &[PathBuf::from("main.rs")]);
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);
}
#[test]
fn relative_index_path_strips_root_prefix() {
let root = Path::new("/Users/dev/my-project");
let abs = root.join("src/main.rs");
assert_eq!(relative_index_path(root, &abs), "src/main.rs");
}
#[test]
fn relative_index_path_falls_back_for_paths_outside_root() {
let root = Path::new("/Users/dev/my-project");
let elsewhere = Path::new("/somewhere/else/file.py");
assert_eq!(
relative_index_path(root, elsewhere),
"/somewhere/else/file.py"
);
}
#[test]
fn index_file_request_body_targets_relative_path_and_content() {
let body = index_file_request_body("src/main.rs", "fn main() {}\n");
assert_eq!(
body.get("path").and_then(serde_json::Value::as_str),
Some("src/main.rs")
);
assert_eq!(
body.get("content").and_then(serde_json::Value::as_str),
Some("fn main() {}\n")
);
assert!(
body.get("allow_sensitive_path").is_none(),
"the per-file endpoint does not re-check the denylist, so no bypass \
flag should be sent: {body:?}"
);
}
#[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!({})));
}
#[test]
fn retry_backoff_is_bounded_and_increasing() {
use std::time::Duration;
assert_eq!(retry_backoff(1), Duration::from_millis(50));
assert_eq!(retry_backoff(2), Duration::from_millis(150));
assert_eq!(retry_backoff(3), Duration::from_millis(450));
assert!(retry_backoff(2) > retry_backoff(1));
assert!(retry_backoff(3) > retry_backoff(2));
assert_eq!(retry_backoff(100), Duration::from_millis(1000));
}
fn drive_retry_test(
server_fn: impl FnOnce(std::net::TcpListener, std::sync::mpsc::Sender<usize>) + Send + 'static,
) -> (IndexOutcome, usize) {
use std::net::TcpListener;
use std::sync::mpsc;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
let server = std::thread::spawn(move || server_fn(listener, tx));
let client = build_index_client().unwrap();
let url = format!("http://{addr}/indexes/test-index/index-file");
let body = index_file_request_body("src/main.rs", "fn main() {}\n");
let outcome = post_index_file_with_retries(&client, &url, &body);
let accepted = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("server thread should have reported an accepted-connection count");
let _ = server.join();
(outcome, accepted)
}
#[test]
fn post_index_file_retries_transient_send_failure() {
use std::io::{Read, Write};
let (outcome, accepted) = drive_retry_test(|listener, tx| {
let mut accepted = 0usize;
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
accepted += 1;
if accepted == 1 {
drop(stream);
continue;
}
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
);
let _ = stream.flush();
let _ = tx.send(accepted);
break;
}
});
assert_eq!(outcome, IndexOutcome::Indexed);
assert_eq!(accepted, 2);
}
#[test]
fn post_index_file_exhausts_retries_and_returns_send_failed() {
let (outcome, accepted) = drive_retry_test(|listener, tx| {
let mut accepted = 0usize;
for stream in listener.incoming() {
let Ok(stream) = stream else { break };
accepted += 1;
drop(stream);
if accepted >= MAX_INDEX_ATTEMPTS as usize {
let _ = tx.send(accepted);
break;
}
}
});
assert_eq!(outcome, IndexOutcome::SendFailed);
assert_eq!(accepted, MAX_INDEX_ATTEMPTS as usize);
}
}