use notify_debouncer_mini::{DebouncedEventKind, Debouncer, new_debouncer};
use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
pub struct Watch {
changes: Receiver<()>,
_debouncer: Option<Debouncer<notify::RecommendedWatcher>>,
}
impl Watch {
pub fn changes(&self) -> &Receiver<()> {
&self.changes
}
#[cfg(all(test, feature = "tui-backend"))]
pub fn detached() -> (std::sync::mpsc::Sender<()>, Self) {
let (tx, changes) = mpsc::channel();
(
tx,
Self {
changes,
_debouncer: None,
},
)
}
}
pub fn watch_file(path: &Path) -> Result<Watch, Box<dyn std::error::Error>> {
let (tx, rx) = mpsc::channel();
let path = path.canonicalize()?;
let watch_path = path.clone();
let mut debouncer = new_debouncer(
Duration::from_millis(300),
move |res: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| {
if let Ok(events) = res {
for event in &events {
if event.kind == DebouncedEventKind::Any && event.path == path {
let _ = tx.send(());
return;
}
}
}
},
)?;
let parent = watch_path.parent().unwrap_or(&watch_path);
debouncer
.watcher()
.watch(parent, notify::RecursiveMode::NonRecursive)?;
Ok(Watch {
changes: rx,
_debouncer: Some(debouncer),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::mpsc::RecvTimeoutError;
use std::time::Instant;
const PATIENCE: Duration = Duration::from_secs(10);
fn drain(rx: &Receiver<()>) {
let until = Instant::now() + Duration::from_secs(2);
while Instant::now() < until && rx.recv_timeout(Duration::from_millis(400)).is_ok() {}
}
#[test]
fn an_edit_to_the_watched_file_is_reported() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("doc.md");
std::fs::write(&file, "# one\n").unwrap();
let watch = watch_file(&file).expect("the file exists, so watching it must work");
let rx = watch.changes();
drain(rx);
std::fs::write(&file, "# two\n").unwrap();
assert_eq!(
rx.recv_timeout(PATIENCE),
Ok(()),
"editing the watched file should have produced a signal"
);
}
#[test]
fn watching_survives_an_atomic_replacement() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("doc.md");
std::fs::write(&file, "# one\n").unwrap();
let watch = watch_file(&file).unwrap();
let rx = watch.changes();
drain(rx);
let tmp = dir.path().join("doc.md.new");
let mut handle = std::fs::File::create(&tmp).unwrap();
handle.write_all(b"# replaced\n").unwrap();
handle.sync_all().unwrap();
drop(handle);
std::fs::rename(&tmp, &file).unwrap();
assert_eq!(
rx.recv_timeout(PATIENCE),
Ok(()),
"replacing the file through a rename should have produced a signal"
);
drain(rx);
std::fs::write(&file, "# edited after the rename\n").unwrap();
assert_eq!(
rx.recv_timeout(PATIENCE),
Ok(()),
"the watch should have survived the replacement"
);
}
#[test]
fn a_neighbour_in_the_same_directory_is_ignored() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("doc.md");
std::fs::write(&file, "# one\n").unwrap();
let watch = watch_file(&file).unwrap();
let rx = watch.changes();
drain(rx);
std::fs::write(dir.path().join("other.md"), "# unrelated\n").unwrap();
std::fs::write(dir.path().join("notes.txt"), "unrelated\n").unwrap();
assert_eq!(
rx.recv_timeout(Duration::from_secs(2)),
Err(RecvTimeoutError::Timeout),
"a change to a neighbouring file should not have woken the viewer"
);
std::fs::write(&file, "# two\n").unwrap();
assert_eq!(
rx.recv_timeout(PATIENCE),
Ok(()),
"the watcher must still be alive after ignoring the neighbours"
);
}
#[test]
fn a_watch_can_be_replaced_by_another() {
let dir = tempfile::tempdir().unwrap();
let first_file = dir.path().join("first.md");
let second_file = dir.path().join("second.md");
std::fs::write(&first_file, "# one\n").unwrap();
std::fs::write(&second_file, "# one\n").unwrap();
let first = watch_file(&first_file).unwrap();
drain(first.changes());
let second = watch_file(&second_file).unwrap();
drop(first);
drain(second.changes());
std::fs::write(&second_file, "# two\n").unwrap();
assert_eq!(
second.changes().recv_timeout(PATIENCE),
Ok(()),
"dropping the previous watch must not have disturbed the new one"
);
}
#[test]
fn watching_a_missing_file_is_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(
watch_file(&dir.path().join("no-such-file.md")).is_err(),
"there is nothing to canonicalise, so this cannot succeed quietly"
);
}
}