use super::daemon_utils::daemon_base_url;
use crate::config::GlobalConfig;
use crate::detect::detect_project;
use anyhow::{bail, Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};
pub async fn handle_index_remove(
cli_path: Option<PathBuf>,
explicit_index_id: Option<String>,
) -> Result<()> {
let base = daemon_base_url();
crate::commands::daemon_guard::ensure_daemon_running_or_exit(&base).await?;
let client = trusty_common::server::daemon_http_client()?;
let (index_id, registered_path) = if let Some(ref id) = explicit_index_id {
find_index_by_id(&client, &base, id).await?
} else {
let target_path = resolve_target_path(cli_path)?;
find_index_by_path(&client, &base, &target_path).await?
};
let delete_url = format!("{}/indexes/{}", base, index_id);
match client.delete(&delete_url).send().await {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => bail!(
"daemon returned {} for DELETE {}",
resp.status(),
delete_url
),
Err(e) => bail!("could not reach daemon at {}: {e}", base),
}
match GlobalConfig::load() {
Ok(mut cfg) => {
let removed = cfg.remove_collection_by_path(®istered_path);
if removed.is_some() {
if let Err(e) = cfg.save() {
tracing::warn!("could not update global config after removal: {e:#}");
}
}
}
Err(e) => {
tracing::warn!("could not load global config to remove entry: {e:#}");
}
}
if let Err(e) = crate::allowlist::remove_from_allowlist(®istered_path, None) {
tracing::warn!(
path = %registered_path.display(),
error = %e,
"could not remove path from allowlist after index removal"
);
}
println!(
"{} Removed index {} ({})",
"✓".green(),
format!("\"{index_id}\"").bold(),
registered_path.display()
);
Ok(())
}
fn resolve_target_path(cli_path: Option<PathBuf>) -> Result<PathBuf> {
if let Some(p) = cli_path {
return Ok(p);
}
let cwd = std::env::current_dir().context("could not resolve current directory")?;
let ctx = detect_project(&cwd);
Ok(ctx.root_path)
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn resolve_index_id_source(explicit_index_id: Option<&str>) -> Option<String> {
explicit_index_id.map(|id| id.to_string())
}
async fn find_index_by_id(
client: &reqwest::Client,
base: &str,
id: &str,
) -> Result<(String, PathBuf)> {
let url = format!("{base}/indexes/{id}/status");
let resp = client
.get(&url)
.send()
.await
.with_context(|| format!("could not reach daemon at {base}"))?
.error_for_status()
.with_context(|| format!("daemon returned an error for {url}"))?;
let body: serde_json::Value = resp
.json()
.await
.context("could not parse status response")?;
let root = body
.get("root_path")
.and_then(|v| v.as_str())
.map(PathBuf::from)
.with_context(|| format!("status response for '{id}' is missing root_path"))?;
Ok((id.to_string(), root))
}
async fn find_index_by_path(
client: &reqwest::Client,
base: &str,
target: &Path,
) -> Result<(String, PathBuf)> {
let list_url = format!("{base}/indexes");
let list_body: serde_json::Value = client
.get(&list_url)
.send()
.await
.with_context(|| format!("could not reach daemon at {base}"))?
.error_for_status()
.with_context(|| format!("daemon error for {list_url}"))?
.json()
.await
.context("could not parse /indexes response")?;
let empty: Vec<serde_json::Value> = Vec::new();
let ids: Vec<String> = list_body
.get("indexes")
.and_then(|v| v.as_array())
.unwrap_or(&empty)
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
let canonical_target = std::fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf());
for id in ids {
let url = format!("{base}/indexes/{id}/status");
let resp = match client.get(&url).send().await {
Ok(r) if r.status().is_success() => r,
_ => continue,
};
let body: serde_json::Value = match resp.json().await {
Ok(b) => b,
Err(_) => continue,
};
let root = body
.get("root_path")
.and_then(|v| v.as_str())
.map(PathBuf::from);
let Some(root) = root else {
continue;
};
let canonical_root = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
if canonical_root == canonical_target {
return Ok((id, root));
}
}
bail!(
"no index registered for path {}; run `trusty-search list` to see registered indexes",
target.display()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn index_remove_resolves_path_uses_cli() {
let p = resolve_target_path(Some(PathBuf::from("/explicit/path"))).unwrap();
assert_eq!(p, PathBuf::from("/explicit/path"));
}
#[test]
fn index_remove_resolves_path_falls_back_to_cwd() {
let p = resolve_target_path(None).unwrap();
assert!(!p.as_os_str().is_empty());
}
#[test]
fn index_remove_resolves_path_uses_detected_root() {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp = std::env::temp_dir().join(format!("trusty-idxrm-{pid}-{nanos}"));
fs::create_dir_all(tmp.join(".git")).unwrap();
let nested = tmp.join("a");
fs::create_dir_all(&nested).unwrap();
let ctx = detect_project(&nested);
assert_eq!(ctx.root_path, tmp);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn index_remove_explicit_id_bypasses_path_lookup() {
let result = super::resolve_index_id_source(Some("other-project"));
assert_eq!(
result.as_deref(),
Some("other-project"),
"explicit id must be returned verbatim — CWD must not interfere"
);
let fallback = super::resolve_index_id_source(None);
assert!(
fallback.is_none(),
"no explicit id → None (path-based lookup will be used)"
);
let cwd_p = resolve_target_path(None).unwrap();
assert_ne!(
cwd_p.to_string_lossy().as_ref(),
"other-project",
"CWD fallback must not accidentally equal an explicit id string"
);
}
}