use crate::{IndexReport, Semlith, canonical, lock, walk_allowing};
use anyhow::{Context, Result};
use notify::event::{AccessKind, AccessMode};
use notify::{EventKind, 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),
Failed {
path: &'a Path,
why: String,
},
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,
Held {
catch_up: true,
waiting: &|| false,
defer: &|_| false,
},
progress,
|_| Ok(Vec::new()),
)
}
pub struct Held<'a> {
pub catch_up: bool,
pub waiting: &'a dyn Fn() -> bool,
pub defer: &'a dyn Fn(&[PathBuf]) -> bool,
}
pub fn run_held(
store: &mut Semlith,
roots: &[PathBuf],
debounce: Duration,
stop: &AtomicBool,
held: Held<'_>,
mut progress: impl FnMut(Progress),
mut pump: impl FnMut(&mut Semlith) -> Result<Vec<PathBuf>>,
) -> 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: notify::Result<notify::Event>| {
if let Ok(event) = &res
&& !is_change(&event.kind)
{
return;
}
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 waiting = held.waiting;
let step_aside = || {
if waiting() || stop.load(Ordering::Relaxed) {
crate::Flow::Yield
} else {
crate::Flow::Run
}
};
let catch_up = if held.catch_up {
store.index_walk_under(&roots, &step_aside, |path, _| {
progress(Progress::File(path))
})?
} else {
IndexReport::default()
};
let (files, chunks, _) = store.stats()?;
let mut behind = catch_up.remaining > 0;
progress(Progress::Ready {
catch_up,
files,
chunks,
});
while !stop.load(Ordering::Relaxed) {
let stopped = match pump(store) {
Ok(stopped) => stopped,
Err(e) => {
progress(Progress::Error(e.to_string()));
Vec::new()
}
};
if !stopped.is_empty() {
let stopped: Vec<PathBuf> = stopped.iter().map(|r| canonical(r)).collect();
let mut waiting_events: BTreeSet<PathBuf> = BTreeSet::new();
while let Ok(event) = rx.try_recv() {
match event {
Ok(event) => collect(&mut waiting_events, event.paths),
Err(e) => progress(Progress::Error(e.to_string())),
}
}
waiting_events.retain(|path| !stopped.iter().any(|root| path.starts_with(root)));
let paths = admissible(&roots, waiting_events, store.gitignore);
if !paths.is_empty() && !(held.defer)(&paths) {
let started = Instant::now();
let report =
store.index_changed(paths, |path, p| progress(file_progress(path, &p)))?;
if report.indexed > 0 || report.removed > 0 {
progress(Progress::Batch(report, started.elapsed()));
}
}
continue;
}
if behind && !waiting() {
let started = Instant::now();
let more = store.index_walk_under(&roots, &step_aside, |path, p| {
progress(file_progress(path, &p))
})?;
behind = more.remaining > 0;
if more.indexed > 0 || more.removed > 0 {
progress(Progress::Batch(more, started.elapsed()));
}
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, store.gitignore);
if paths.is_empty() || (held.defer)(&paths) {
continue;
}
let started = Instant::now();
let report = store.index_changed(paths, |path, p| progress(file_progress(path, &p)))?;
if report.indexed > 0 || report.removed > 0 {
progress(Progress::Batch(report, started.elapsed()));
}
}
Ok(())
}
fn file_progress<'a>(path: &'a Path, p: &crate::IndexProgress) -> Progress<'a> {
match p.outcome {
crate::FileOutcome::Failed => Progress::Failed {
path,
why: p.why.clone().unwrap_or_else(|| "failed".to_string()),
},
_ => Progress::File(path),
}
}
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 is_change(kind: &EventKind) -> bool {
match kind {
EventKind::Access(access) => *access == AccessKind::Close(AccessMode::Write),
_ => true,
}
}
fn collect(batch: &mut BTreeSet<PathBuf>, paths: Vec<PathBuf>) {
for path in paths {
batch.insert(resolve(&path));
}
}
fn admissible(roots: &[PathBuf], batch: BTreeSet<PathBuf>, gitignore: bool) -> 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_allowing(roots, &[], gitignore)
.files
.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_read_is_not_a_change() {
use notify::event::{CreateKind, ModifyKind};
assert!(!is_change(&EventKind::Access(AccessKind::Open(
AccessMode::Any
))));
assert!(!is_change(&EventKind::Access(AccessKind::Close(
AccessMode::Read
))));
assert!(is_change(&EventKind::Access(AccessKind::Close(
AccessMode::Write
))));
assert!(is_change(&EventKind::Modify(ModifyKind::Any)));
assert!(is_change(&EventKind::Create(CreateKind::File)));
}
#[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, true),
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, true);
assert_eq!(admitted, vec![canonical(&dir.path().join("real.md"))]);
}
}