use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
pub const FS_WATCH_RETRY_DELAY_MS: u64 = 5_000;
pub const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000;
fn mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
}
fn snapshot(paths: &[PathBuf]) -> HashMap<PathBuf, Option<SystemTime>> {
paths.iter().map(|p| (p.clone(), mtime(p))).collect()
}
pub struct WatchHandle {
stop: Arc<AtomicBool>,
join: Option<tokio::task::JoinHandle<()>>,
}
impl WatchHandle {
pub fn close(mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(join) = self.join.take() {
join.abort();
}
}
pub fn request_stop(&self) {
self.stop.store(true, Ordering::SeqCst);
}
}
impl Drop for WatchHandle {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
}
}
pub fn watch_paths<F>(paths: Vec<PathBuf>, poll_interval: Duration, on_change: F) -> WatchHandle
where
F: Fn() + Send + Sync + 'static,
{
let stop = Arc::new(AtomicBool::new(false));
let stop_task = stop.clone();
let join = tokio::spawn(async move {
let mut prev = snapshot(&paths);
loop {
tokio::time::sleep(poll_interval).await;
if stop_task.load(Ordering::SeqCst) {
break;
}
let current = match std::panic::catch_unwind(|| snapshot(&paths)) {
Ok(snap) => snap,
Err(_) => {
tokio::time::sleep(Duration::from_millis(FS_WATCH_RETRY_DELAY_MS)).await;
continue;
}
};
if current != prev {
prev = current;
on_change();
}
}
});
WatchHandle {
stop,
join: Some(join),
}
}
pub fn watch_path<F>(path: PathBuf, on_change: F) -> WatchHandle
where
F: Fn() + Send + Sync + 'static,
{
watch_paths(vec![path], Duration::from_millis(DEFAULT_POLL_INTERVAL_MS), on_change)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
#[test]
fn detects_content_change() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("theme.json");
std::fs::write(&file, "a").unwrap();
let hits = Arc::new(AtomicUsize::new(0));
let hits_task = hits.clone();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
rt.block_on(async {
let handle = watch_paths(
vec![file.clone()],
Duration::from_millis(20),
move || {
hits_task.fetch_add(1, Ordering::SeqCst);
},
);
tokio::time::sleep(Duration::from_millis(60)).await;
std::fs::write(&file, "bb").unwrap();
tokio::time::sleep(Duration::from_millis(120)).await;
handle.close();
});
assert!(hits.load(Ordering::SeqCst) >= 1, "expected at least one change");
}
#[test]
fn close_stops_watch() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f");
std::fs::write(&file, "x").unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
rt.block_on(async {
let handle = watch_path(file, || {});
handle.close();
});
}
}