use std::path::{Path, PathBuf};
use std::time::Duration;
use notify::RecursiveMode;
use notify_debouncer_full::{DebounceEventResult, new_debouncer};
use crate::domain::common::{CorpusConfig, canonicalize_or_passthrough, expand_tilde};
use crate::domain::corpus::sandbox::ensure_corpus_allows_file;
use super::dispatch::index_single_file;
use super::state::DaemonState;
struct WatchRoot {
watched: PathBuf,
canonical_watched: PathBuf,
corpus: CorpusConfig,
canonical_file_root: Option<PathBuf>,
mode: RecursiveMode,
}
pub struct WatcherHandle {
_task: tokio::task::JoinHandle<()>,
_debouncer: Box<dyn std::any::Any + Send>,
}
pub fn spawn_corpus_watcher(state: &DaemonState) -> Option<WatcherHandle> {
let cfg = state.baseline();
let debounce = Duration::from_millis(cfg.watch.debounce_ms);
let corpora = match cfg.effective_corpora() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
target: "hallouminate::daemon",
error = %e,
"watcher: could not enumerate baseline corpora; auto-reindex disabled",
);
return None;
}
};
let mut roots: Vec<WatchRoot> = Vec::new();
for corpus in &corpora {
for raw in &corpus.paths {
if let Some(root) = build_watch_root(corpus, raw) {
roots.push(root);
}
}
}
if roots.is_empty() {
return None;
}
let (tx, rx) = std::sync::mpsc::channel::<DebounceEventResult>();
let mut debouncer = match new_debouncer(debounce, None, move |res| {
let _ = tx.send(res);
}) {
Ok(d) => d,
Err(e) => {
tracing::warn!(
target: "hallouminate::daemon",
error = %e,
"watcher: failed to create debouncer; auto-reindex disabled",
);
return None;
}
};
for root in &roots {
if let Err(e) = debouncer.watch(&root.watched, root.mode) {
tracing::warn!(
target: "hallouminate::daemon",
root = %root.watched.display(),
error = %e,
"watcher: failed to watch root; that corpus will not auto-reindex",
);
}
}
let state = state.clone();
let shutdown = state.shutdown_token().clone();
let task = tokio::spawn(async move {
let rx = std::sync::Arc::new(std::sync::Mutex::new(rx));
loop {
let rx_recv = rx.clone();
let next = tokio::select! {
_ = shutdown.cancelled() => break,
got = tokio::task::spawn_blocking(move || {
rx_recv.lock().expect("watch rx mutex").recv()
}) => got,
};
let batch = match next {
Ok(Ok(res)) => res,
_ => break,
};
let events = match batch {
Ok(events) => events,
Err(errors) => {
for err in errors {
tracing::warn!(
target: "hallouminate::daemon",
error = %err,
"watcher: notify backend error",
);
}
continue;
}
};
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for event in events {
for path in &event.paths {
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if seen.insert(path.clone()) {
handle_changed_path(&state, &roots, path).await;
}
}
}
}
});
Some(WatcherHandle {
_task: task,
_debouncer: Box::new(debouncer),
})
}
fn build_watch_root(corpus: &CorpusConfig, raw: &str) -> Option<WatchRoot> {
let root = expand_tilde(raw);
if root.is_dir() {
let canonical_watched = canonicalize_or_passthrough(&root).into_path_buf();
Some(WatchRoot {
watched: root,
canonical_watched,
corpus: corpus.clone(),
canonical_file_root: None,
mode: RecursiveMode::Recursive,
})
} else if root.is_file() {
let parent = root.parent()?.to_path_buf();
let canonical_watched = canonicalize_or_passthrough(&parent).into_path_buf();
let canonical_file_root = Some(canonicalize_or_passthrough(&root).into_path_buf());
Some(WatchRoot {
watched: parent,
canonical_watched,
corpus: corpus.clone(),
canonical_file_root,
mode: RecursiveMode::NonRecursive,
})
} else {
None
}
}
async fn handle_changed_path(state: &DaemonState, roots: &[WatchRoot], path: &Path) {
let Some(owner) = owning_corpus(roots, path) else {
return;
};
let corpus = &owner.corpus;
let guard = match state.acquire_mutation_guard(&corpus.name).await {
Ok(g) => g,
Err(e) => {
tracing::warn!(target: "hallouminate::daemon", error = %e, "watcher: lock failed");
return;
}
};
let exists = path.is_file();
let store = state.store();
if exists {
let chunker = state.make_chunker();
let mut embedder = if state.embeddings_enabled() {
match state.embedder().await {
Ok(g) => Some(g),
Err(e) => {
tracing::warn!(target: "hallouminate::daemon", error = %e, "watcher: embedder unavailable; skipping reindex");
return;
}
}
} else {
None
};
let embedder_dyn: Option<&mut dyn crate::domain::embeddings::EmbedBatch> = embedder
.as_mut()
.map(|g| &mut **g as &mut dyn crate::domain::embeddings::EmbedBatch);
match index_single_file(&store, embedder_dyn, &chunker, corpus, path).await {
Ok(stats) => tracing::debug!(
target: "hallouminate::daemon",
corpus = %corpus.name,
path = %path.display(),
upserted = stats.files_upserted,
"watcher: reindexed changed file",
),
Err(e) => tracing::warn!(
target: "hallouminate::daemon",
path = %path.display(),
error = %e,
"watcher: reindex failed",
),
}
} else {
let file_ref = delete_file_ref(owner, path);
if let Some(file_ref_str) = file_ref.as_path().to_str()
&& let Err(e) = store.delete_file(&corpus.name, file_ref_str).await
{
tracing::warn!(
target: "hallouminate::daemon",
path = %path.display(),
error = %e,
"watcher: prune failed",
);
}
}
drop(guard);
}
fn owning_corpus<'r>(roots: &'r [WatchRoot], path: &Path) -> Option<&'r WatchRoot> {
let mut best: Option<(usize, &WatchRoot)> = None;
for root in roots {
if !path.starts_with(&root.canonical_watched) {
continue;
}
match &root.canonical_file_root {
Some(file) if file != path => continue,
None if ensure_corpus_allows_file(&root.corpus, path).is_err() => continue,
_ => {}
}
let depth = root.canonical_watched.components().count();
if best.as_ref().is_none_or(|(d, _)| depth > *d) {
best = Some((depth, root));
}
}
best.map(|(_, r)| r)
}
fn delete_file_ref(owner: &WatchRoot, path: &Path) -> crate::domain::common::FileRef {
match path.strip_prefix(&owner.canonical_watched) {
Ok(rel) => crate::domain::common::FileRef::new(owner.canonical_watched.join(rel)),
Err(_) => canonicalize_or_passthrough(path),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn corpus(name: &str, root: &str, globs: &[&str]) -> CorpusConfig {
CorpusConfig {
name: name.into(),
paths: vec![root.into()],
globs: globs.iter().map(|g| g.to_string()).collect(),
exclude: vec![],
global: false,
}
}
fn watch_root(watched: &str, corpus: CorpusConfig, file_root: Option<&str>) -> WatchRoot {
let mode = if file_root.is_some() {
RecursiveMode::NonRecursive
} else {
RecursiveMode::Recursive
};
WatchRoot {
watched: PathBuf::from(watched),
canonical_watched: PathBuf::from(watched),
corpus,
canonical_file_root: file_root.map(PathBuf::from),
mode,
}
}
fn name_of(owner: Option<&WatchRoot>) -> Option<String> {
owner.map(|r| r.corpus.name.clone())
}
#[test]
fn file_root_rejects_sibling_md_under_watched_parent() {
let cfg = corpus("claude-config", "/home/u/.claude/CLAUDE.md", &["**/*.md"]);
let roots = vec![watch_root(
"/home/u/.claude",
cfg.clone(),
Some("/home/u/.claude/CLAUDE.md"),
)];
assert_eq!(
name_of(owning_corpus(
&roots,
Path::new("/home/u/.claude/CLAUDE.md")
))
.as_deref(),
Some("claude-config"),
"the declared file must be a member"
);
assert!(
owning_corpus(&roots, Path::new("/home/u/.claude/RTK.md")).is_none(),
"a sibling .md must not be attributed to a file-path corpus"
);
}
#[test]
fn file_root_owns_declared_file_even_when_absent() {
let cfg = corpus("claude-config", "/home/u/.claude/CLAUDE.md", &["**/*.md"]);
let roots = vec![watch_root(
"/home/u/.claude",
cfg,
Some("/home/u/.claude/CLAUDE.md"),
)];
assert_eq!(
name_of(owning_corpus(
&roots,
Path::new("/home/u/.claude/CLAUDE.md")
))
.as_deref(),
Some("claude-config"),
"a deleted owned file must still resolve so its rows can be pruned"
);
}
#[test]
fn dir_root_accepts_glob_matched_descendant() {
let cfg = corpus("wiki", "/srv/wiki", &["**/*.md"]);
let roots = vec![watch_root("/srv/wiki", cfg, None)];
assert_eq!(
name_of(owning_corpus(
&roots,
Path::new("/srv/wiki/topics/spice.md")
))
.as_deref(),
Some("wiki"),
"a dir root must own any glob-matched descendant"
);
assert!(
owning_corpus(&roots, Path::new("/srv/wiki/notes.txt")).is_none(),
"a non-glob-matched file under a dir root is not owned"
);
}
#[test]
fn delete_file_ref_rebuilds_under_canonical_root() {
let owner = WatchRoot {
watched: PathBuf::from("/link/wiki"),
canonical_watched: PathBuf::from("/real/wiki"),
corpus: corpus("wiki", "/link/wiki", &["**/*.md"]),
canonical_file_root: None,
mode: RecursiveMode::Recursive,
};
let deleted = Path::new("/real/wiki/topics/spice.md");
assert_eq!(
delete_file_ref(&owner, deleted).as_path(),
Path::new("/real/wiki/topics/spice.md"),
"prune key must re-root the canonical tail under the canonical (resolved) root, \
matching the key the indexer wrote against the resolved ancestor"
);
}
#[test]
fn owning_corpus_matches_canonical_event_path_under_symlinked_root() {
let owner = WatchRoot {
watched: PathBuf::from("/link/wiki"),
canonical_watched: PathBuf::from("/real/wiki"),
corpus: corpus("wiki", "/link/wiki", &["**/*.md"]),
canonical_file_root: None,
mode: RecursiveMode::Recursive,
};
let roots = vec![owner];
assert_eq!(
name_of(owning_corpus(
&roots,
Path::new("/real/wiki/topics/spice.md")
))
.as_deref(),
Some("wiki"),
"a canonical event path under a symlinked root must resolve to its corpus"
);
}
#[test]
fn recursion_mode_is_recursive_for_dir_root_nonrecursive_for_file_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir_root = tmp.path().join("wiki");
std::fs::create_dir(&dir_root).expect("mkdir");
let file_root = tmp.path().join("CLAUDE.md");
std::fs::write(&file_root, "# config\n").expect("write file root");
let dir_corpus = corpus("wiki", dir_root.to_str().unwrap(), &["**/*.md"]);
let dir_wr =
build_watch_root(&dir_corpus, dir_root.to_str().unwrap()).expect("dir root must build");
assert_eq!(
dir_wr.mode,
RecursiveMode::Recursive,
"a directory corpus root must be watched recursively"
);
assert_eq!(dir_wr.watched, dir_root, "a dir root is watched at itself");
assert!(
dir_wr.canonical_file_root.is_none(),
"a dir root has no file-membership constraint"
);
let file_corpus = corpus("claude-config", file_root.to_str().unwrap(), &["**/*.md"]);
let file_wr = build_watch_root(&file_corpus, file_root.to_str().unwrap())
.expect("file root must build");
assert_eq!(
file_wr.mode,
RecursiveMode::NonRecursive,
"a file-path corpus root must be watched non-recursively at its parent"
);
assert_eq!(
file_wr.watched,
tmp.path(),
"a file-path root is watched at its parent dir"
);
assert!(
file_wr.canonical_file_root.is_some(),
"a file-path root pins the exact declared file for membership"
);
}
#[test]
fn build_watch_root_skips_absent_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let absent = tmp.path().join("not-there");
let cfg = corpus("ghost", absent.to_str().unwrap(), &["**/*.md"]);
assert!(
build_watch_root(&cfg, absent.to_str().unwrap()).is_none(),
"an absent root must not produce a WatchRoot"
);
}
#[test]
fn delete_file_ref_is_identity_for_plain_root() {
let owner = watch_root("/srv/wiki", corpus("wiki", "/srv/wiki", &["**/*.md"]), None);
assert_eq!(
delete_file_ref(&owner, Path::new("/srv/wiki/topics/spice.md")).as_path(),
Path::new("/srv/wiki/topics/spice.md"),
"a non-symlinked root must prune the path unchanged"
);
}
}