use crate::{IndexReport, Semlith, canonical, lock, walk};
use anyhow::{Context, Result};
use notify::{RecursiveMode, Watcher};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::{Duration, Instant};
pub static STOP: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
pub fn stop_on_signal() {
extern "C" fn handler(sig: libc::c_int) {
STOP.store(true, Ordering::SeqCst);
unsafe { libc::signal(sig, libc::SIG_DFL) };
}
unsafe {
libc::signal(libc::SIGINT, handler as *const () as libc::sighandler_t);
libc::signal(libc::SIGTERM, handler as *const () as libc::sighandler_t);
}
}
#[cfg(not(unix))]
pub fn stop_on_signal() {}
pub const DEBOUNCE: Duration = Duration::from_millis(500);
const MAX_BATCH_WAIT: Duration = Duration::from_secs(5);
const IDLE_TICK: Duration = Duration::from_millis(250);
pub enum Progress<'a> {
Ready {
catch_up: IndexReport,
files: i64,
chunks: i64,
},
File(&'a Path),
Batch(IndexReport, Duration),
Error(String),
}
pub fn run(
store: &mut Semlith,
roots: &[PathBuf],
debounce: Duration,
stop: &AtomicBool,
progress: impl FnMut(Progress),
) -> Result<()> {
let _lock = lock::StoreLock::acquire(store.dir())?;
run_held(
store,
roots,
debounce,
stop,
&|| false,
progress,
|_| Ok(()),
)
}
pub fn run_held(
store: &mut Semlith,
roots: &[PathBuf],
debounce: Duration,
stop: &AtomicBool,
waiting: &dyn Fn() -> bool,
mut progress: impl FnMut(Progress),
mut pump: impl FnMut(&mut Semlith) -> Result<()>,
) -> Result<()> {
let roots: Vec<PathBuf> = roots.iter().map(|r| canonical(r)).collect();
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})
.context("starting the filesystem watcher")?;
for root in &roots {
watcher
.watch(root, RecursiveMode::Recursive)
.with_context(|| format!("watching {}", root.display()))?;
}
let step_aside = || {
if waiting() || stop.load(Ordering::Relaxed) {
crate::Flow::Yield
} else {
crate::Flow::Run
}
};
let catch_up = store.index_walk_under(&roots, &step_aside, |path, _| {
progress(Progress::File(path))
})?;
let (files, chunks, _) = store.stats()?;
let mut behind = catch_up.remaining > 0;
progress(Progress::Ready {
catch_up,
files,
chunks,
});
while !stop.load(Ordering::Relaxed) {
if let Err(e) = pump(store) {
progress(Progress::Error(e.to_string()));
}
if behind && !waiting() {
let more = store.index_walk_under(&roots, &step_aside, |path, _| {
progress(Progress::File(path))
})?;
behind = more.remaining > 0;
if more.indexed > 0 || more.removed > 0 {
progress(Progress::Batch(more, Duration::ZERO));
}
continue;
}
let first = match rx.recv_timeout(IDLE_TICK) {
Ok(Ok(event)) => event,
Ok(Err(e)) => {
progress(Progress::Error(e.to_string()));
continue;
}
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break,
};
let mut batch: BTreeSet<PathBuf> = BTreeSet::new();
collect(&mut batch, first.paths);
drain(&rx, &mut batch, debounce, &mut progress);
let paths = admissible(&roots, batch);
if paths.is_empty() {
continue;
}
let started = Instant::now();
let report = store.index_changed(paths, |path, _| progress(Progress::File(path)))?;
if report.indexed > 0 || report.removed > 0 {
progress(Progress::Batch(report, started.elapsed()));
}
}
Ok(())
}
fn drain(
rx: &mpsc::Receiver<notify::Result<notify::Event>>,
batch: &mut BTreeSet<PathBuf>,
debounce: Duration,
progress: &mut impl FnMut(Progress),
) {
let deadline = Instant::now() + MAX_BATCH_WAIT;
loop {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return;
}
match rx.recv_timeout(debounce.min(left)) {
Ok(Ok(event)) => collect(batch, event.paths),
Ok(Err(e)) => progress(Progress::Error(e.to_string())),
Err(RecvTimeoutError::Timeout) => return,
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
fn collect(batch: &mut BTreeSet<PathBuf>, paths: Vec<PathBuf>) {
for path in paths {
batch.insert(resolve(&path));
}
}
fn admissible(roots: &[PathBuf], batch: BTreeSet<PathBuf>) -> Vec<PathBuf> {
let gone: Vec<PathBuf> = batch.iter().filter(|p| !p.exists()).cloned().collect();
if batch.len() == gone.len() {
return gone;
}
let visible: BTreeSet<PathBuf> = walk(roots).into_iter().collect();
batch
.into_iter()
.filter(|p| visible.contains(p) || gone.contains(p))
.collect()
}
fn resolve(path: &Path) -> PathBuf {
if path.exists() {
return canonical(path);
}
match (path.parent(), path.file_name()) {
(Some(dir), Some(name)) if dir.exists() => canonical(dir).join(name),
_ => path.to_path_buf(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_file_still_resolves_under_its_canonical_parent() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("gone.md");
std::fs::write(&real, "x").unwrap();
let while_present = resolve(&real);
std::fs::remove_file(&real).unwrap();
assert_eq!(
resolve(&real),
while_present,
"the same file resolves to two different keys depending on whether it exists"
);
}
#[test]
fn a_batch_of_deletions_needs_no_walk() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("never-existed.md");
let batch: BTreeSet<PathBuf> = [missing.clone()].into_iter().collect();
assert_eq!(
admissible(&[dir.path().to_path_buf()], batch),
vec![missing]
);
}
#[test]
fn the_store_directory_is_not_admissible() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join(".semlith");
std::fs::create_dir(&store).unwrap();
let index = store.join("index.tv");
std::fs::write(&index, "not really an index").unwrap();
std::fs::write(dir.path().join("real.md"), "corpus").unwrap();
let batch: BTreeSet<PathBuf> = [canonical(&index), canonical(&dir.path().join("real.md"))]
.into_iter()
.collect();
let admitted = admissible(&[dir.path().to_path_buf()], batch);
assert_eq!(admitted, vec![canonical(&dir.path().join("real.md"))]);
}
}