use super::daemon_utils::daemon_base_url;
use super::reindex_engine::{
register_index_with_daemon, register_index_with_daemon_filtered, run_reindex_force_opts,
run_reindex_opts, RegisterFilters,
};
use crate::core::project_config::{ProjectConfig, PROJECT_CONFIG_FILENAME};
use crate::core::repo_config::{language_to_exts, IndexConfig, RepoConfig, CONFIG_FILENAME};
use anyhow::Result;
use colored::Colorize;
pub async fn handle_index(
cli_path: Option<std::path::PathBuf>,
cli_name: Option<String>,
force: bool,
cli_exclude: Vec<String>,
timeout: Option<u64>,
lexical_only: bool,
no_kg: bool,
) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_default();
let project_path = resolve_project_path(cli_path, &cwd)?;
if let Some(reason) = crate::allowlist::is_denied(&project_path) {
anyhow::bail!(
"indexing refused: {reason}\n\
(the daemon will also refuse this root — \
choose a project directory instead)"
);
}
crate::commands::daemon_guard::ensure_daemon_running_for_indexing(&daemon_base_url()).await?;
let project_cfg = match ProjectConfig::load(&cwd) {
Ok(Some(cfg)) => {
tracing::debug!(
"loaded {} from {}: name={:?} path={:?} (ignored) exclude={:?}",
PROJECT_CONFIG_FILENAME,
cwd.display(),
cfg.name,
cfg.path,
cfg.exclude,
);
Some(cfg)
}
Ok(None) => None,
Err(e) => anyhow::bail!("could not parse {}: {e}", PROJECT_CONFIG_FILENAME),
};
match RepoConfig::load(&project_path) {
Ok(Some(cfg)) => {
println!(
"{} loaded {} ({} index{} declared)",
"→".cyan(),
CONFIG_FILENAME.bold(),
cfg.indexes.len(),
if cfg.indexes.len() == 1 { "" } else { "es" },
);
if cli_name.is_some() {
eprintln!(
"{} --name is ignored when {} is present",
"ℹ".yellow(),
CONFIG_FILENAME
);
}
let n_indexes = cfg.indexes.len();
for (i, idx) in cfg.indexes.iter().enumerate() {
println!(
"{} [{}/{}] indexing '{}'",
"\u{2192}".cyan(),
i + 1,
n_indexes,
idx.name.bold()
);
let mut filters = filters_from_index_config(idx);
filters.lexical_only = lexical_only;
filters.skip_kg = filters.skip_kg || no_kg;
index_one_with_filters(&idx.name, &project_path, force, timeout, &filters).await?;
}
return Ok(());
}
Ok(None) => {
}
Err(e) => {
anyhow::bail!("could not parse {}: {e}", CONFIG_FILENAME);
}
}
let index_name = resolve_index_name(cli_name, project_cfg.as_ref(), &project_path);
let exclude_globs = resolve_excludes(cli_exclude, project_cfg.as_ref());
if exclude_globs.is_empty() && !lexical_only && !no_kg {
index_one(&index_name, &project_path, force, timeout).await
} else {
let filters = RegisterFilters {
exclude_globs,
lexical_only,
skip_kg: no_kg,
..RegisterFilters::default()
};
index_one_with_filters(&index_name, &project_path, force, timeout, &filters).await
}
}
fn resolve_project_path(
cli_path: Option<std::path::PathBuf>,
cwd: &std::path::Path,
) -> anyhow::Result<std::path::PathBuf> {
let raw = cli_path.unwrap_or_else(|| cwd.to_path_buf());
raw.canonicalize()
.map_err(|e| anyhow::anyhow!("cannot resolve index path {}: {}", raw.display(), e))
}
fn resolve_index_name(
cli_name: Option<String>,
cfg: Option<&ProjectConfig>,
project_path: &std::path::Path,
) -> String {
if let Some(n) = cli_name {
return n;
}
if let Some(n) = cfg.and_then(|c| c.name.clone()) {
return n;
}
project_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
}
fn resolve_excludes(cli_exclude: Vec<String>, cfg: Option<&ProjectConfig>) -> Vec<String> {
if !cli_exclude.is_empty() {
return cli_exclude;
}
cfg.and_then(|c| c.exclude.clone()).unwrap_or_default()
}
pub(crate) fn filters_from_index_config(idx: &IndexConfig) -> RegisterFilters {
let mut extensions: Vec<String> = Vec::new();
for lang in &idx.languages {
for e in language_to_exts(lang) {
extensions.push((*e).to_string());
}
}
extensions.sort();
extensions.dedup();
RegisterFilters {
include_paths: idx.paths.clone(),
exclude_globs: idx.exclude.clone(),
extensions,
domain_terms: idx.domain_terms.clone(),
lexical_only: false,
skip_kg: idx.skip_kg,
defer_embed: idx.defer_embed,
}
}
async fn index_one(
index_name: &str,
project_path: &std::path::Path,
force: bool,
timeout: Option<u64>,
) -> Result<()> {
index_one_with_filters(
index_name,
project_path,
force,
timeout,
&RegisterFilters::default(),
)
.await
}
async fn index_one_with_filters(
index_name: &str,
project_path: &std::path::Path,
force: bool,
timeout: Option<u64>,
filters: &RegisterFilters,
) -> Result<()> {
let result = if filters.include_paths.is_empty()
&& filters.exclude_globs.is_empty()
&& filters.extensions.is_empty()
&& filters.domain_terms.is_empty()
&& !filters.lexical_only
&& filters.defer_embed
{
register_index_with_daemon(index_name, project_path).await
} else {
register_index_with_daemon_filtered(index_name, project_path, filters).await
};
let (created, daemon_reachable) = result?;
if !daemon_reachable {
anyhow::bail!(
"Daemon not reachable at {}. Start it with `trusty-search start`.",
daemon_base_url(),
);
}
if created {
println!(
"{} '{}' registered at {}",
"✓".green(),
index_name.bold(),
project_path.display()
);
}
persist_collection_to_global_config(index_name, project_path, filters);
let (timeout_secs, timeout_explicit) = match timeout {
Some(n) => (n, true),
None => (0, false),
};
if force {
run_reindex_force_opts(index_name, project_path, timeout_secs, timeout_explicit).await?;
} else {
run_reindex_opts(index_name, project_path, timeout_secs, timeout_explicit).await?;
}
Ok(())
}
use super::index_persist::persist_collection_to_global_config;
#[cfg(test)]
mod tests {
use super::*;
use crate::core::project_config::ProjectConfig;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
fn cfg(name: Option<&str>, path: Option<&str>, exclude: Option<Vec<&str>>) -> ProjectConfig {
ProjectConfig {
name: name.map(str::to_string),
path: path.map(PathBuf::from),
exclude: exclude.map(|v| v.into_iter().map(str::to_string).collect()),
}
}
#[test]
fn merge_path_cli_wins() {
let tmp = tempdir().unwrap();
let canonical = tmp.path().canonicalize().unwrap();
let got = resolve_project_path(Some(tmp.path().to_path_buf()), Path::new("/repo")).unwrap();
assert_eq!(got, canonical);
}
#[test]
fn merge_path_config_path_field_ignored() {
let tmp = tempdir().unwrap();
let cwd = tmp.path().canonicalize().unwrap();
let got = resolve_project_path(None, &cwd).unwrap();
assert_eq!(got, cwd, "cfg.path must NOT narrow the root");
assert_ne!(got, cwd.join("app"), "test fixture sanity check");
}
#[test]
fn merge_path_default_is_cwd() {
let tmp = tempdir().unwrap();
let canonical = tmp.path().canonicalize().unwrap();
let got = resolve_project_path(None, tmp.path()).unwrap();
assert_eq!(got, canonical);
}
#[test]
fn merge_path_config_present_but_no_path_field() {
let tmp = tempdir().unwrap();
let canonical = tmp.path().canonicalize().unwrap();
let got = resolve_project_path(None, tmp.path()).unwrap();
assert_eq!(got, canonical);
}
#[test]
fn resolve_project_path_nonexistent_errors() {
let bad = PathBuf::from("/this/path/definitely/does/not/exist/trusty-test-999");
let err = resolve_project_path(Some(bad.clone()), Path::new("/repo"))
.expect_err("non-existent path should be an error");
let msg = err.to_string();
assert!(
msg.contains("cannot resolve index path"),
"error message should mention 'cannot resolve index path', got: {msg}"
);
assert!(
msg.contains(bad.to_str().unwrap()),
"error message should contain the bad path, got: {msg}"
);
}
#[test]
fn merge_name_cli_wins() {
let c = cfg(Some("from-config"), None, None);
let got = resolve_index_name(Some("from-cli".into()), Some(&c), Path::new("/repo/myproj"));
assert_eq!(got, "from-cli");
}
#[test]
fn merge_name_from_config() {
let c = cfg(Some("from-config"), None, None);
let got = resolve_index_name(None, Some(&c), Path::new("/repo/myproj"));
assert_eq!(got, "from-config");
}
#[test]
fn merge_name_default_is_basename() {
let got = resolve_index_name(None, None, Path::new("/repo/myproj"));
assert_eq!(got, "myproj");
}
#[test]
fn merge_exclude_cli_wins() {
let c = cfg(None, None, Some(vec!["data/", "docs/"]));
let got = resolve_excludes(vec!["only-cli/".into()], Some(&c));
assert_eq!(got, vec!["only-cli/".to_string()]);
}
#[test]
fn merge_exclude_from_config() {
let c = cfg(None, None, Some(vec!["data/", "*.db"]));
let got = resolve_excludes(Vec::new(), Some(&c));
assert_eq!(got, vec!["data/".to_string(), "*.db".to_string()]);
}
#[test]
fn merge_exclude_default_empty() {
assert!(resolve_excludes(Vec::new(), None).is_empty());
let c = cfg(Some("foo"), None, None);
assert!(resolve_excludes(Vec::new(), Some(&c)).is_empty());
}
}