use super::daemon_utils::daemon_base_url;
use super::reindex_engine::register_index_with_daemon;
use crate::config::GlobalConfig;
use std::path::{Path, PathBuf};
use std::time::Duration;
const TRUSTY_TOOLS_DIR: &str = ".trusty-tools";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProjectMarker {
Claude,
ClaudeMd,
Git,
TrustyTools,
None,
}
fn detect_project_marker(dir: &Path) -> ProjectMarker {
if dir.join(".claude").is_dir() {
return ProjectMarker::Claude;
}
if dir.join("CLAUDE.md").is_file() {
return ProjectMarker::ClaudeMd;
}
if dir.join(".git").exists() {
return ProjectMarker::Git;
}
if dir.join(TRUSTY_TOOLS_DIR).is_dir() {
return ProjectMarker::TrustyTools;
}
ProjectMarker::None
}
fn default_scan_paths() -> Vec<PathBuf> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
["Projects", "code", "src"]
.iter()
.map(|p| home.join(p))
.filter(|p| p.is_dir())
.collect()
}
pub async fn auto_discover_and_index() {
let cfg = match GlobalConfig::load() {
Ok(c) => c,
Err(e) => {
tracing::warn!("auto-discover: could not load global config: {e:#} — skipping");
return;
}
};
let scan_paths = if cfg.scan_paths.is_empty() {
default_scan_paths()
} else {
cfg.scan_paths.clone()
};
if scan_paths.is_empty() {
tracing::debug!("auto-discover: no scan paths configured and no defaults found — skipping");
return;
}
let base = daemon_base_url();
let client = match trusty_common::server::daemon_http_client() {
Ok(c) => c,
Err(e) => {
tracing::warn!("auto-discover: could not build HTTP client: {e:#} — skipping");
return;
}
};
if !wait_for_daemon_ready(&client, &base, Duration::from_secs(15)).await {
tracing::warn!(
"auto-discover: daemon at {base} did not become ready within 15s — skipping"
);
return;
}
let known: std::collections::HashSet<String> = match fetch_known_index_ids(&client, &base).await
{
Ok(s) => s,
Err(e) => {
tracing::warn!("auto-discover: could not list indexes: {e:#} — skipping");
return;
}
};
let mut discovered = 0usize;
let mut indexed = 0usize;
for root in &scan_paths {
if !root.is_dir() {
tracing::debug!(
"auto-discover: skipping non-directory scan path {}",
root.display()
);
continue;
}
let entries = match std::fs::read_dir(root) {
Ok(it) => it,
Err(e) => {
tracing::warn!("auto-discover: could not read {}: {e}", root.display());
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let marker = detect_project_marker(&path);
if marker == ProjectMarker::None {
continue;
}
discovered += 1;
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) if !n.is_empty() => n.to_string(),
_ => {
tracing::debug!(
"auto-discover: skipping {} (no usable name)",
path.display()
);
continue;
}
};
if known.contains(&name) {
tracing::debug!(
"auto-discover: skipping {} (index '{}' already registered)",
path.display(),
name
);
continue;
}
tracing::info!(
"auto-discover: indexing {} as '{}' (marker={:?})",
path.display(),
name,
marker
);
match register_index_with_daemon(&name, &path).await {
Ok((_created, true)) => {
if !path.exists() {
tracing::info!(
"auto-discover: skipping reindex of '{}' — root path '{}' \
does not exist on disk (dead/moved project)",
name,
path.display()
);
continue;
}
let reindex_url = format!("{base}/indexes/{name}/reindex");
match client
.post(&reindex_url)
.json(&serde_json::json!({ "background": true }))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
indexed += 1;
}
Ok(resp) => {
tracing::warn!(
"auto-discover: reindex of '{name}' returned HTTP {}",
resp.status()
);
}
Err(e) => {
tracing::warn!(
"auto-discover: could not POST reindex for '{name}': {e}"
);
}
}
}
Ok((_, false)) => {
tracing::warn!(
"auto-discover: daemon unreachable while registering '{name}' — aborting"
);
return;
}
Err(e) => {
tracing::warn!("auto-discover: could not register '{name}': {e:#}");
}
}
}
}
if discovered > 0 {
tracing::info!(
"auto-discover: scanned {} root(s); discovered {} project(s); queued {} for indexing",
scan_paths.len(),
discovered,
indexed
);
}
}
async fn wait_for_daemon_ready(client: &reqwest::Client, base: &str, timeout: Duration) -> bool {
let url = format!("{base}/health");
let deadline = std::time::Instant::now() + timeout;
loop {
if client
.get(&url)
.send()
.await
.ok()
.map(|r| r.status().is_success())
.unwrap_or(false)
{
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
async fn fetch_known_index_ids(
client: &reqwest::Client,
base: &str,
) -> anyhow::Result<std::collections::HashSet<String>> {
let url = format!("{base}/indexes");
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("daemon returned {} for {url}", resp.status());
}
let body: serde_json::Value = resp.json().await?;
let empty: Vec<serde_json::Value> = Vec::new();
let set = 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();
Ok(set)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tempdir_unique(label: &str) -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("trusty-discover-{label}-{pid}-{nanos}"));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn detect_project_marker_claude_dir_wins() {
let dir = tempdir_unique("claude");
fs::create_dir_all(dir.join(".claude")).unwrap();
fs::write(dir.join("CLAUDE.md"), "x").unwrap();
fs::create_dir_all(dir.join(".git")).unwrap();
assert_eq!(detect_project_marker(&dir), ProjectMarker::Claude);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_project_marker_claude_md_beats_git() {
let dir = tempdir_unique("claudemd");
fs::write(dir.join("CLAUDE.md"), "x").unwrap();
fs::create_dir_all(dir.join(".git")).unwrap();
assert_eq!(detect_project_marker(&dir), ProjectMarker::ClaudeMd);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_project_marker_git_when_only_git() {
let dir = tempdir_unique("git");
fs::create_dir_all(dir.join(".git")).unwrap();
assert_eq!(detect_project_marker(&dir), ProjectMarker::Git);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_project_marker_none_when_empty() {
let dir = tempdir_unique("empty");
assert_eq!(detect_project_marker(&dir), ProjectMarker::None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_project_marker_ignores_claude_md_as_dir() {
let dir = tempdir_unique("claudedir");
fs::create_dir_all(dir.join("CLAUDE.md")).unwrap();
assert_eq!(detect_project_marker(&dir), ProjectMarker::None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_project_marker_trusty_tools_dir() {
let dir = tempdir_unique("trustytools");
fs::create_dir_all(dir.join(TRUSTY_TOOLS_DIR)).unwrap();
assert_eq!(
detect_project_marker(&dir),
ProjectMarker::TrustyTools,
".trusty-tools/ dir must yield TrustyTools marker"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_project_marker_claude_wins_over_trusty_tools() {
let dir = tempdir_unique("claude-plus-trusty");
fs::create_dir_all(dir.join(".claude")).unwrap();
fs::create_dir_all(dir.join(TRUSTY_TOOLS_DIR)).unwrap();
assert_eq!(
detect_project_marker(&dir),
ProjectMarker::Claude,
".claude/ must take priority over .trusty-tools/"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_project_marker_trusty_tools_file_not_dir_is_none() {
let dir = tempdir_unique("trustytools-file");
fs::write(dir.join(TRUSTY_TOOLS_DIR), "not a dir").unwrap();
assert_eq!(
detect_project_marker(&dir),
ProjectMarker::None,
".trusty-tools as a file must not trigger TrustyTools marker"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_project_marker_none_when_no_markers_after_trusty_tools_added() {
let dir = tempdir_unique("no-markers");
assert_eq!(
detect_project_marker(&dir),
ProjectMarker::None,
"directory with no markers must still return None"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn default_scan_paths_does_not_panic() {
let _ = default_scan_paths();
}
#[test]
fn root_path_exists_true_for_real_dir() {
let dir = tempdir_unique("exists");
assert!(dir.exists(), "freshly-created dir must exist");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn root_path_exists_false_after_removal() {
let dir = tempdir_unique("gone");
fs::remove_dir_all(&dir).ok();
assert!(
!dir.exists(),
"deleted dir must not exist — the dead-root skip predicate would fire"
);
}
#[test]
fn root_path_exists_false_for_never_created() {
let phantom =
std::env::temp_dir().join("trusty-discover-phantom-path-that-will-never-exist-12345");
let _ = fs::remove_dir_all(&phantom);
assert!(
!phantom.exists(),
"phantom path must not exist — dead-root skip would fire for this index"
);
}
}