use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
use notify::{RecommendedWatcher, RecursiveMode, Watcher as _};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Change {
Payload,
SiblingAppeared,
SiblingWentAway,
}
#[must_use]
pub fn classify(payload: &str, paths: &[&Path], kind: EventKind) -> Vec<Change> {
paths
.iter()
.map(|p| {
let is_payload = p.file_name().is_some_and(|n| n == payload);
match (is_payload, kind) {
(true, _) => Change::Payload,
(false, EventKind::Gone) => Change::SiblingWentAway,
(false, _) => Change::SiblingAppeared,
}
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
Touched,
Gone,
}
impl EventKind {
fn of(kind: notify::EventKind) -> Option<Self> {
use notify::event::{AccessKind, AccessMode, ModifyKind, RenameMode};
match kind {
notify::EventKind::Remove(_)
| notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)) => Some(Self::Gone),
notify::EventKind::Access(AccessKind::Close(AccessMode::Write)) => Some(Self::Touched),
notify::EventKind::Access(_) => None,
_ => Some(Self::Touched),
}
}
}
pub struct Watch {
watcher: Option<RecommendedWatcher>,
changes: Receiver<Change>,
#[cfg(windows)]
stopped: Receiver<notify::windows::MetaEvent>,
}
#[cfg(windows)]
const STOP_WAIT: Duration = Duration::from_secs(5);
impl Watch {
pub fn on(dir: &Path, payload: &str) -> notify::Result<Self> {
let (tx, changes) = mpsc::channel();
let payload = payload.to_string();
let handler = move |event: notify::Result<notify::Event>| {
let Ok(event) = event else {
return;
};
let Some(kind) = EventKind::of(event.kind) else {
return;
};
let paths: Vec<&Path> = event.paths.iter().map(AsRef::as_ref).collect();
for change in classify(&payload, &paths, kind) {
if tx.send(change).is_err() {
return;
}
}
};
#[cfg(windows)]
let (mut watcher, stopped) = {
let (meta_tx, stopped) = mpsc::channel();
let handler: std::sync::Arc<std::sync::Mutex<dyn notify::EventHandler>> =
std::sync::Arc::new(std::sync::Mutex::new(handler));
let watcher = notify::windows::ReadDirectoryChangesWatcher::create(handler, meta_tx)?;
(watcher, stopped)
};
#[cfg(not(windows))]
let mut watcher = notify::recommended_watcher(handler)?;
watcher.watch(dir, RecursiveMode::NonRecursive)?;
Ok(Self {
watcher: Some(watcher),
changes,
#[cfg(windows)]
stopped,
})
}
pub fn drain(&self) -> impl Iterator<Item = Change> + '_ {
self.changes.try_iter()
}
#[must_use]
pub fn next_change(&self, within: Duration) -> Option<Change> {
self.changes.recv_timeout(within).ok()
}
}
pub fn siblings_present(dir: &Path, payload: &str) -> std::io::Result<bool> {
for entry in std::fs::read_dir(dir)? {
if entry?.file_name() != *payload {
return Ok(true);
}
}
Ok(false)
}
impl Drop for Watch {
fn drop(&mut self) {
drop(self.watcher.take());
#[cfg(windows)]
{
let deadline = std::time::Instant::now() + STOP_WAIT;
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
break;
}
match self.stopped.recv_timeout(left) {
Ok(notify::windows::MetaEvent::SingleWatchComplete) | Err(_) => break,
Ok(_) => {}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::{classify, siblings_present, Change, EventKind, Watch};
use std::path::{Path, PathBuf};
use std::time::Duration;
fn at(names: &[&str]) -> Vec<PathBuf> {
names
.iter()
.map(|n| Path::new("/s/payload").join(n))
.collect()
}
fn refs(paths: &[PathBuf]) -> Vec<&Path> {
paths.iter().map(AsRef::as_ref).collect()
}
#[test]
fn writing_the_payload_is_the_write_back_trigger() {
let p = at(&["report.pdf"]);
assert_eq!(
classify("report.pdf", &refs(&p), EventKind::Touched),
[Change::Payload]
);
}
#[test]
fn anything_else_appearing_is_the_application_working() {
for name in [
"~$report.docx",
".~lock.report.pdf#",
"report.pdf.tmp",
"4919",
] {
let p = at(&[name]);
assert_eq!(
classify("report.pdf", &refs(&p), EventKind::Touched),
[Change::SiblingAppeared],
"{name}"
);
}
}
#[test]
fn a_sibling_going_away_is_the_application_finishing() {
let p = at(&["~$report.docx"]);
assert_eq!(
classify("report.pdf", &refs(&p), EventKind::Gone),
[Change::SiblingWentAway]
);
}
#[test]
fn the_payload_going_away_is_still_the_payload() {
let p = at(&["report.pdf"]);
assert_eq!(
classify("report.pdf", &refs(&p), EventKind::Gone),
[Change::Payload]
);
}
#[test]
fn a_rename_naming_both_paths_reports_both() {
let p = at(&["report.pdf.tmp", "report.pdf"]);
assert_eq!(
classify("report.pdf", &refs(&p), EventKind::Touched),
[Change::SiblingAppeared, Change::Payload]
);
}
#[test]
fn a_payload_named_like_a_lock_file_is_still_the_payload() {
let p = at(&["~$report.docx"]);
assert_eq!(
classify("~$report.docx", &refs(&p), EventKind::Touched),
[Change::Payload]
);
}
#[test]
fn siblings_are_asked_of_the_directory_rather_than_remembered() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("report.pdf"), b"x").unwrap();
assert!(!siblings_present(tmp.path(), "report.pdf").unwrap());
std::fs::write(tmp.path().join("~$report.pdf"), b"").unwrap();
assert!(siblings_present(tmp.path(), "report.pdf").unwrap());
std::fs::remove_file(tmp.path().join("~$report.pdf")).unwrap();
assert!(!siblings_present(tmp.path(), "report.pdf").unwrap());
}
#[test]
fn reading_the_payload_is_not_a_change_to_it() {
let tmp = tempfile::tempdir().unwrap();
let payload = tmp.path().join("report.pdf");
std::fs::write(&payload, b"first").unwrap();
let watch = Watch::on(tmp.path(), "report.pdf").unwrap();
let cap = std::time::Instant::now() + Duration::from_secs(5);
while watch.next_change(Duration::from_millis(400)).is_some()
&& std::time::Instant::now() < cap
{}
let _ = std::fs::read(&payload).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let mut seen = Vec::new();
while std::time::Instant::now() < deadline {
if let Some(c) = watch.next_change(Duration::from_millis(100)) {
seen.push(c);
}
}
assert!(
!seen.contains(&Change::Payload),
"reading the payload was reported as a change: {seen:?}"
);
}
#[test]
fn a_real_atomic_save_reaches_the_watch() {
let tmp = tempfile::tempdir().unwrap();
let payload = tmp.path().join("report.pdf");
std::fs::write(&payload, b"first").unwrap();
let watch = Watch::on(tmp.path(), "report.pdf").unwrap();
let scratch = tmp.path().join("report.pdf.tmp");
std::fs::write(&scratch, b"second").unwrap();
std::fs::rename(&scratch, &payload).unwrap();
let mut seen = Vec::new();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while std::time::Instant::now() < deadline && !seen.contains(&Change::Payload) {
if let Some(c) = watch.next_change(Duration::from_millis(250)) {
seen.push(c);
}
}
assert!(
seen.contains(&Change::Payload),
"the save never arrived: {seen:?}"
);
assert_eq!(std::fs::read(&payload).unwrap(), b"second");
}
}