use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
#[cfg(target_os = "linux")]
use notify::event::CreateKind;
use notify::{Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
use thiserror::Error;
use tracing::{debug, info, warn};
use crate::config::Config;
use crate::scanner::{CollectObserver, FileResult, ScanError, ScanReport};
use crate::store::Store;
#[derive(Debug, Error)]
pub enum WatchError {
#[error("notify error: {0}")]
Notify(#[from] notify::Error),
#[error("scan error: {0}")]
Scan(#[from] ScanError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
pub type BatchCallback = Box<dyn FnMut(WatchBatch<'_>) + Send>;
pub struct WatchBatch<'a> {
pub kind: BatchKind,
pub report: &'a ScanReport,
pub results: &'a [FileResult],
}
#[derive(Debug, Clone, Copy)]
pub enum BatchKind {
InitialScan,
Incremental {
paths: usize,
},
}
const DEBOUNCE_QUEUE_CAPACITY: usize = 64;
#[derive(Default)]
struct PendingPaths(Mutex<BTreeSet<PathBuf>>);
impl PendingPaths {
fn coalesce(&self, events: &[DebouncedEvent]) {
let mut set = self.0.lock().expect("pending paths poisoned");
for ev in events {
if !is_relevant(&ev.event.kind) {
continue;
}
set.extend(ev.event.paths.iter().cloned());
}
}
fn take(&self) -> Vec<PathBuf> {
let mut set = self.0.lock().expect("pending paths poisoned");
std::mem::take(&mut *set).into_iter().collect()
}
}
fn dispatch_debounced(
tx: &std::sync::mpsc::SyncSender<DebounceEventResult>,
pending: &PendingPaths,
res: DebounceEventResult,
) {
match tx.try_send(res) {
Ok(()) => {}
Err(std::sync::mpsc::TrySendError::Full(Ok(events))) => {
debug!(
n = events.len(),
"debounce queue full; coalescing batch into the pending set"
);
pending.coalesce(&events);
}
Err(std::sync::mpsc::TrySendError::Full(Err(errors))) => {
for e in errors {
warn!(error = %e, "watch error (debounce queue full)");
}
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {}
}
}
pub fn watch_paths(
root: &Path,
config: &Config,
shutdown: tokio::sync::oneshot::Receiver<()>,
on_change: impl FnMut(Vec<PathBuf>, BatchKind),
) -> Result<(), WatchError> {
watch_paths_reloading(root, config, || None, shutdown, on_change)
}
pub fn watch_paths_reloading(
root: &Path,
config: &Config,
mut reload: impl FnMut() -> Option<Arc<Config>>,
mut shutdown: tokio::sync::oneshot::Receiver<()>,
mut on_change: impl FnMut(Vec<PathBuf>, BatchKind),
) -> Result<(), WatchError> {
let (tx, rx) = std::sync::mpsc::sync_channel::<DebounceEventResult>(DEBOUNCE_QUEUE_CAPACITY);
let pending = Arc::new(PendingPaths::default());
let pending_producer = Arc::clone(&pending);
let debounce = Duration::from_millis(config.watch.debounce_ms);
let mut debouncer = new_debouncer_opt::<_, RecommendedWatcher, NoCache>(
debounce,
None,
move |res| dispatch_debounced(&tx, &pending_producer, res),
NoCache::new(),
NotifyConfig::default(),
)?;
let mut filter = crate::scanner_filter::IndexFilter::new(root, config)?;
#[cfg(target_os = "linux")]
{
let filters = filter.filters();
debouncer.watch(root, RecursiveMode::NonRecursive)?;
let walker = crate::scanner_filter::ignore_walk_builder(
root,
config.scan.respect_gitignore,
config.scan.follow_symlinks,
)
.build();
for dent in walker {
let Ok(entry) = dent else {
continue;
};
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
if entry.path() == root {
continue; }
let Ok(rel) = entry.path().strip_prefix(root) else {
continue;
};
if !filters.allows_dir(rel.to_string_lossy().as_ref()) {
continue;
}
if let Err(e) = debouncer.watch(entry.path(), RecursiveMode::NonRecursive) {
warn!(path = %entry.path().display(), error = %e, "inotify watch registration failed; continuing");
}
}
}
#[cfg(not(target_os = "linux"))]
{
debouncer.watch(root, RecursiveMode::Recursive)?;
}
loop {
if !matches!(
shutdown.try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
) {
info!("shutdown requested; exiting watcher");
return Ok(());
}
let mut queued: Vec<Vec<DebouncedEvent>> = Vec::new();
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(Ok(events)) => queued.push(events),
Ok(Err(errors)) => {
for e in errors {
warn!(error = %e, "watch error");
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
info!("debouncer channel closed; exiting watcher");
return Ok(());
}
}
for _ in 1..DEBOUNCE_QUEUE_CAPACITY {
match rx.try_recv() {
Ok(Ok(events)) => queued.push(events),
Ok(Err(errors)) => {
for e in errors {
warn!(error = %e, "watch error");
}
}
Err(_) => break,
}
}
let mut candidates: Vec<PathBuf> = Vec::new();
for ev in queued.into_iter().flatten() {
if !is_relevant(&ev.event.kind) {
continue;
}
#[cfg(target_os = "linux")]
if matches!(&ev.event.kind, EventKind::Create(CreateKind::Folder)) {
for p in &ev.event.paths {
if p.is_dir()
&& let Err(e) = debouncer.watch(p, RecursiveMode::NonRecursive)
{
warn!(path = %p.display(), error = %e, "inotify re-arm failed");
}
}
}
candidates.extend(ev.event.paths.iter().cloned());
}
let coalesced = pending.take();
#[cfg(target_os = "linux")]
for p in &coalesced {
if p.is_dir()
&& let Err(e) = debouncer.watch(p, RecursiveMode::NonRecursive)
{
warn!(path = %p.display(), error = %e, "inotify re-arm failed");
}
}
candidates.extend(coalesced);
if candidates.is_empty() {
continue;
}
if let Some(fresh) = reload() {
match crate::scanner_filter::IndexFilter::new(root, &fresh) {
Ok(rebuilt) => {
info!("config changed; watcher filter rebuilt");
filter = rebuilt;
}
Err(error) => warn!(%error, "config changed but its filter does not build; keeping the previous one"),
}
}
filter.clear_cache();
candidates.sort();
candidates.dedup();
let touched: Vec<PathBuf> = candidates
.into_iter()
.filter(|p| keep_event_path(&filter, root, p))
.collect();
if touched.is_empty() {
continue;
}
debug!(n = touched.len(), "debounced batch");
let n = touched.len();
on_change(touched, BatchKind::Incremental { paths: n });
}
}
pub fn watch(
root: &Path,
store: Arc<Mutex<Store>>,
config: Arc<Config>,
shutdown: tokio::sync::oneshot::Receiver<()>,
mut on_batch: BatchCallback,
) -> Result<(), WatchError> {
info!(root = %root.display(), "initial scan");
{
let mut guard = store.lock().expect("store poisoned");
let mut observer = CollectObserver::new();
let report = crate::scanner::scan_with_observer(
root,
&mut guard,
&config,
crate::scanner::ScanSource::WorkingTree,
crate::scanner::EmbedMode::Inline,
&crate::scanner::ScanCancel::new(),
&mut observer,
)?;
on_batch(WatchBatch {
kind: BatchKind::InitialScan,
report: &report,
results: observer.results(),
});
}
info!("initial scan complete; entering watch mode");
let current = Arc::new(Mutex::new(Arc::clone(&config)));
let reload_current = Arc::clone(¤t);
let mut reloader = config_reloader(root);
watch_paths_reloading(
root,
&config,
move || {
let fresh = reloader()?;
*reload_current.lock().expect("config poisoned") = Arc::clone(&fresh);
Some(fresh)
},
shutdown,
|touched, kind| {
let config = Arc::clone(¤t.lock().expect("config poisoned"));
let mut guard = store.lock().expect("store poisoned");
let mut observer = CollectObserver::new();
match crate::scanner::scan_paths_with_observer(
root,
&mut guard,
&config,
&touched,
crate::scanner::EmbedMode::Inline,
&crate::scanner::ScanCancel::new(),
&mut observer,
) {
Ok(report) => {
on_batch(WatchBatch {
kind,
report: &report,
results: observer.results(),
});
}
Err(e) => warn!(error = %e, "scan_paths failed"),
}
},
)
}
pub fn config_reloader(root: &Path) -> impl FnMut() -> Option<Arc<Config>> + use<> {
let root = root.to_path_buf();
let mut stamp = crate::config::daemon::ConfigStamp::of(&root);
move || {
let now = crate::config::daemon::ConfigStamp::of(&root);
if now == stamp {
return None;
}
stamp = now;
match crate::config::load_with_overrides(&root, None, None) {
Ok(loaded) => Some(Arc::new(loaded.config)),
Err(error) => {
warn!(%error, "config changed but does not load; keeping the previous config");
None
}
}
}
}
fn is_relevant(kind: &EventKind) -> bool {
matches!(kind, EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_))
}
fn keep_event_path(filter: &crate::scanner_filter::IndexFilter, root: &Path, p: &Path) -> bool {
let Ok(rel) = p.strip_prefix(root) else {
return false;
};
if rel.components().any(|c| c.as_os_str() == crate::config::BASEMIND_DIR) {
return false;
}
let rel_cow = rel.to_string_lossy();
let rel_normalized;
let rel: &str = if rel_cow.contains('\\') {
rel_normalized = rel_cow.replace('\\', "/");
&rel_normalized
} else {
&rel_cow
};
if rel.is_empty() {
return false;
}
if p.exists() {
filter.is_indexable(p)
} else {
filter.allows_glob(rel)
}
}
#[cfg(test)]
#[path = "watcher_tests.rs"]
mod tests;