use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::app::config::Config;
use crate::app::daemon::{DaemonRequest, DaemonRequestPayload, IndexRequest, client_for};
use crate::app::input_error::InputError;
use crate::domain::common::CorpusConfig;
pub const AD_HOC_CORPUS_NAME: &str = "ad-hoc";
#[derive(Debug, Default, Clone)]
pub struct IndexArgs {
pub corpus: Option<String>,
pub paths_from: Option<PathBuf>,
pub config: Option<PathBuf>,
pub socket: Option<PathBuf>,
pub strict: bool,
}
pub async fn cmd_index(args: IndexArgs) -> anyhow::Result<()> {
let report = run_index(args).await?;
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
pub async fn run_index(args: IndexArgs) -> anyhow::Result<IndexReport> {
if let Some(paths_from) = args.paths_from.as_deref() {
return Err(ad_hoc_corpus_unsupported(paths_from));
}
let client = client_for(args.socket.as_deref()).await?;
let cwd = std::env::current_dir().context("capture current_dir for daemon request")?;
let req = DaemonRequest {
cwd,
payload: DaemonRequestPayload::Index(IndexRequest {
corpus: args.corpus.clone(),
paths_from: None,
strict: args.strict,
}),
};
let report: IndexReport = client.call(req).await?;
Ok(report)
}
pub fn select_corpora(
cfg: &Config,
requested: Option<&str>,
paths_from: Option<&Path>,
) -> anyhow::Result<Vec<CorpusConfig>> {
if let Some(file) = paths_from {
return Ok(vec![ad_hoc_corpus(file)?]);
}
let effective = cfg.effective_corpora()?;
if let Some(name) = requested {
let hit = effective
.iter()
.find(|c| c.name == name)
.ok_or_else(|| InputError::new(format!("corpus {name:?} not found in config")))?;
return Ok(vec![hit.clone()]);
}
if effective.is_empty() {
return Err(InputError::new(
"no corpora configured; add [[corpus]] or [[repository]] to config",
)
.into());
}
Ok(effective)
}
fn ad_hoc_corpus(file: &Path) -> anyhow::Result<CorpusConfig> {
let text = fs::read_to_string(file)
.map_err(|e| InputError::new(format!("read paths-from file {}: {e}", file.display())))?;
let mut paths: Vec<String> = Vec::new();
for line in text.lines() {
let line = line.trim();
if !line.is_empty() {
paths.push(line.to_string());
}
}
if paths.is_empty() {
return Err(InputError::new(format!("paths-from file {} is empty", file.display())).into());
}
Ok(CorpusConfig {
name: AD_HOC_CORPUS_NAME.into(),
paths,
globs: vec![],
exclude: vec![],
global: false,
})
}
fn ad_hoc_corpus_unsupported(file: &Path) -> anyhow::Error {
InputError::new(format!(
"--paths-from is not supported via the daemon yet ({})",
file.display()
))
.into()
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct IndexReport {
pub corpora: Vec<CorpusReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorpusReport {
pub name: String,
pub files_upserted: usize,
pub files_touched: usize,
pub files_deleted: usize,
pub files_skipped_empty: usize,
pub chunks_inserted: usize,
pub embeddings_inserted: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::repository::RepositoryConfig;
#[test]
fn select_corpora_uses_paths_from_when_set() {
let dir = tempfile::tempdir().unwrap();
let list = dir.path().join("paths.txt");
fs::write(&list, "/some/path\n /another/path \n\n").unwrap();
let cfg = Config::default();
let out = select_corpora(&cfg, None, Some(list.as_path())).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, AD_HOC_CORPUS_NAME);
assert_eq!(out[0].paths, vec!["/some/path", "/another/path"]);
}
#[test]
fn select_corpora_filters_by_name_when_corpus_arg_set() {
let cfg = Config {
corpora: vec![
CorpusConfig {
name: "docs".into(),
paths: vec!["/d".into()],
..Default::default()
},
CorpusConfig {
name: "notes".into(),
paths: vec!["/n".into()],
..Default::default()
},
],
..Default::default()
};
let out = select_corpora(&cfg, Some("notes"), None).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "notes");
}
#[test]
fn select_corpora_errors_when_named_corpus_missing() {
let cfg = Config::default();
let err = select_corpora(&cfg, Some("ghost"), None).unwrap_err();
assert!(err.to_string().contains("ghost"), "{err}");
}
#[test]
fn select_corpora_errors_when_no_corpora_and_no_filters() {
let cfg = Config::default();
let err = select_corpora(&cfg, None, None).unwrap_err();
assert!(err.to_string().contains("no corpora"), "{err}");
}
#[test]
fn select_corpora_returns_all_when_no_filters_set() {
let cfg = Config {
corpora: vec![CorpusConfig {
name: "alpha".into(),
paths: vec!["/a".into()],
..Default::default()
}],
..Default::default()
};
let out = select_corpora(&cfg, None, None).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "alpha");
}
#[test]
fn ad_hoc_corpus_rejects_empty_file() {
let dir = tempfile::tempdir().unwrap();
let list = dir.path().join("empty.txt");
fs::write(&list, "\n \n").unwrap();
let err = ad_hoc_corpus(&list).unwrap_err();
assert!(err.to_string().contains("empty"), "{err}");
assert!(
crate::app::input_error::is_input_error(&err),
"empty paths-from must mark as InputError so MCP routes it to -32602: {err}"
);
}
#[test]
fn select_corpora_resolves_repository_derived_wiki_by_name() {
let cfg = Config {
repositories: vec![RepositoryConfig {
name: "tern".into(),
path: "/repos/tern".into(),
..Default::default()
}],
..Default::default()
};
let out = select_corpora(&cfg, Some("repo:tern:wiki"), None).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "repo:tern:wiki");
assert_eq!(
out[0].paths,
vec!["/repos/tern/.hallouminate/wiki".to_string()]
);
}
#[test]
fn select_corpora_includes_repository_derived_corpora_when_no_filters_set() {
let cfg = Config {
repositories: vec![RepositoryConfig {
name: "tern".into(),
path: "/repos/tern".into(),
..Default::default()
}],
..Default::default()
};
let out = select_corpora(&cfg, None, None).unwrap();
let names: Vec<&str> = out.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["repo:tern:wiki"]);
}
#[tokio::test]
async fn run_index_paths_from_returns_input_error_before_dialing_daemon() {
let dir = tempfile::tempdir().unwrap();
let list = dir.path().join("paths.txt");
fs::write(&list, "/tmp/a\n").unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(&cfg_path, "").unwrap();
let err = run_index(IndexArgs {
paths_from: Some(list),
config: Some(cfg_path),
..Default::default()
})
.await
.expect_err("paths_from must surface a clear error");
let msg = err.to_string();
assert!(
msg.contains("--paths-from") && msg.contains("not supported"),
"got: {msg}",
);
assert!(
crate::app::input_error::is_input_error(&err),
"must be an InputError so MCP routes it to -32602: {err}",
);
}
}