use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use notify::{
recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode,
Watcher,
};
use crate::error::SsgError;
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(100);
pub const MAX_BATCH_PATHS: usize = 10_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeBatch {
pub paths: Vec<PathBuf>,
}
impl ChangeBatch {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.paths.is_empty()
}
#[must_use]
pub const fn len(&self) -> usize {
self.paths.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecvOutcome {
Batch(ChangeBatch),
Timeout,
Closed,
}
impl RecvOutcome {
#[must_use]
pub fn batch(self) -> Option<ChangeBatch> {
match self {
Self::Batch(b) => Some(b),
Self::Timeout | Self::Closed => None,
}
}
#[must_use]
pub const fn is_closed(&self) -> bool {
matches!(self, Self::Closed)
}
}
pub struct EventWatcher {
backend: Mutex<Option<RecommendedWatcher>>,
rx: Receiver<ChangeBatch>,
debounce_handle: Mutex<Option<JoinHandle<()>>>,
shutdown: Arc<Mutex<bool>>,
debounce: Duration,
}
impl std::fmt::Debug for EventWatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EventWatcher")
.field("debounce", &self.debounce)
.finish_non_exhaustive()
}
}
impl EventWatcher {
pub fn new(dir: &Path) -> Result<Self, SsgError> {
Self::with_debounce(dir, DEFAULT_DEBOUNCE)
}
pub fn with_debounce(
dir: &Path,
debounce: Duration,
) -> Result<Self, SsgError> {
let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
let (batched_tx, batched_rx) = mpsc::channel::<ChangeBatch>();
let mut backend =
recommended_watcher(move |res: notify::Result<Event>| {
if let Ok(event) = res {
if event_should_propagate(&event.kind) {
for path in event.paths {
let _ = raw_tx.send(path);
}
}
}
})
.map_err(|e| SsgError::Io {
path: dir.to_path_buf(),
source: std::io::Error::other(format!(
"notify watcher init: {e}"
)),
})?;
backend.watch(dir, RecursiveMode::Recursive).map_err(|e| {
SsgError::Io {
path: dir.to_path_buf(),
source: std::io::Error::other(format!("notify watch: {e}")),
}
})?;
let shutdown = Arc::new(Mutex::new(false));
let shutdown_clone = Arc::clone(&shutdown);
let debounce_handle = thread::Builder::new()
.name("ssg-watch-debounce".into())
.spawn(move || {
debounce_loop(raw_rx, batched_tx, debounce, &shutdown_clone);
})
.map_err(|e| SsgError::Io {
path: dir.to_path_buf(),
source: std::io::Error::other(format!(
"debounce thread spawn: {e}"
)),
})?;
Ok(Self {
backend: Mutex::new(Some(backend)),
rx: batched_rx,
debounce_handle: Mutex::new(Some(debounce_handle)),
shutdown,
debounce,
})
}
#[must_use]
pub fn recv(&self) -> Option<ChangeBatch> {
self.rx.recv().ok()
}
pub fn recv_timeout(&self, timeout: Duration) -> RecvOutcome {
match self.rx.recv_timeout(timeout) {
Ok(b) => RecvOutcome::Batch(b),
Err(RecvTimeoutError::Timeout) => RecvOutcome::Timeout,
Err(RecvTimeoutError::Disconnected) => RecvOutcome::Closed,
}
}
#[must_use]
pub const fn debounce(&self) -> Duration {
self.debounce
}
}
impl Drop for EventWatcher {
fn drop(&mut self) {
if let Ok(mut s) = self.shutdown.lock() {
*s = true;
}
if let Ok(mut backend) = self.backend.lock() {
*backend = None;
}
if let Ok(mut handle) = self.debounce_handle.lock() {
if let Some(h) = handle.take() {
let _ = h.join();
}
}
}
}
#[must_use]
pub const fn event_should_propagate(kind: &EventKind) -> bool {
matches!(
kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
)
}
fn debounce_loop(
raw_rx: Receiver<PathBuf>,
batched_tx: Sender<ChangeBatch>,
window: Duration,
shutdown: &Arc<Mutex<bool>>,
) {
while let Ok(first) = raw_rx.recv() {
if shutdown.lock().map_or(true, |g| *g) {
break;
}
let mut paths: HashSet<PathBuf> = HashSet::new();
let _ = paths.insert(first);
let start = Instant::now();
while let Some(remaining) = window.checked_sub(start.elapsed()) {
if remaining.is_zero() {
break;
}
match raw_rx.recv_timeout(remaining) {
Ok(p) => {
let _ = paths.insert(p);
if paths.len() >= MAX_BATCH_PATHS {
break;
}
}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => {
let batch = ChangeBatch {
paths: sorted(paths),
};
let _ = batched_tx.send(batch);
return;
}
}
}
let batch = ChangeBatch {
paths: sorted(paths),
};
if batched_tx.send(batch).is_err() {
break;
}
}
}
#[must_use]
pub fn debounce_paths(
events: &[(PathBuf, Instant)],
window: Duration,
) -> Vec<ChangeBatch> {
if events.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
let mut current: HashSet<PathBuf> = HashSet::new();
let mut first: Option<Instant> = None;
for (path, t) in events {
match first {
None => {
first = Some(*t);
let _ = current.insert(path.clone());
}
Some(f) if *t < f + window => {
let _ = current.insert(path.clone());
}
Some(_) => {
let taken = std::mem::take(&mut current);
out.push(ChangeBatch {
paths: sorted(taken),
});
first = Some(*t);
let _ = current.insert(path.clone());
}
}
}
if !current.is_empty() {
out.push(ChangeBatch {
paths: sorted(current),
});
}
out
}
fn sorted(set: HashSet<PathBuf>) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = set.into_iter().collect();
v.sort();
v
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Instant;
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
#[test]
fn debounce_empty_input_yields_empty_output() {
assert!(debounce_paths(&[], Duration::from_millis(100)).is_empty());
}
#[test]
fn debounce_single_event_one_batch() {
let t0 = Instant::now();
let out =
debounce_paths(&[(p("a.md"), t0)], Duration::from_millis(100));
assert_eq!(out.len(), 1);
assert_eq!(out[0].paths, vec![p("a.md")]);
}
#[test]
fn debounce_collapses_four_saves_in_200ms_window_into_one_batch_when_within_window(
) {
let t0 = Instant::now();
let events = vec![
(p("style.css"), t0),
(p("style.css"), t0 + Duration::from_millis(20)),
(p("style.css"), t0 + Duration::from_millis(40)),
(p("style.css"), t0 + Duration::from_millis(80)),
];
let out = debounce_paths(&events, Duration::from_millis(100));
assert_eq!(out.len(), 1, "should collapse to one batch");
assert_eq!(out[0].paths, vec![p("style.css")]);
}
#[test]
fn debounce_splits_batches_across_window_boundary() {
let t0 = Instant::now();
let events = vec![
(p("a.md"), t0),
(p("b.md"), t0 + Duration::from_millis(50)),
(p("c.md"), t0 + Duration::from_millis(150)),
];
let out = debounce_paths(&events, Duration::from_millis(100));
assert_eq!(out.len(), 2);
assert_eq!(out[0].paths, vec![p("a.md"), p("b.md")]);
assert_eq!(out[1].paths, vec![p("c.md")]);
}
#[test]
fn debounce_deduplicates_paths_in_same_window() {
let t0 = Instant::now();
let events = vec![
(p("x"), t0),
(p("y"), t0 + Duration::from_millis(10)),
(p("x"), t0 + Duration::from_millis(20)),
(p("y"), t0 + Duration::from_millis(30)),
];
let out = debounce_paths(&events, Duration::from_millis(100));
assert_eq!(out.len(), 1);
assert_eq!(out[0].paths, vec![p("x"), p("y")]);
}
#[test]
fn event_should_propagate_accepts_modify_create_remove() {
use notify::event::{CreateKind, ModifyKind, RemoveKind};
assert!(event_should_propagate(&EventKind::Create(CreateKind::File)));
assert!(event_should_propagate(&EventKind::Modify(ModifyKind::Any)));
assert!(event_should_propagate(&EventKind::Remove(RemoveKind::File)));
}
#[test]
fn event_should_propagate_rejects_access_and_other() {
use notify::event::AccessKind;
assert!(!event_should_propagate(&EventKind::Access(AccessKind::Any)));
assert!(!event_should_propagate(&EventKind::Other));
}
#[test]
fn change_batch_len_and_is_empty() {
let empty = ChangeBatch { paths: vec![] };
assert!(empty.is_empty());
assert_eq!(empty.len(), 0);
let one = ChangeBatch {
paths: vec![p("x")],
};
assert!(!one.is_empty());
assert_eq!(one.len(), 1);
}
#[test]
fn change_batch_eq_clone() {
let a = ChangeBatch {
paths: vec![p("x")],
};
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn default_debounce_is_100ms() {
assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(100));
}
#[test]
fn new_returns_err_when_path_missing() {
let res = EventWatcher::new(Path::new("/nonexistent/ssg/test/dir"));
assert!(res.is_err());
}
#[test]
fn recv_outcome_batch_extracts_payload() {
let b = ChangeBatch {
paths: vec![p("a")],
};
let out = RecvOutcome::Batch(b.clone());
assert_eq!(out.batch(), Some(b));
assert!(RecvOutcome::Timeout.batch().is_none());
assert!(RecvOutcome::Closed.batch().is_none());
}
#[test]
fn recv_outcome_is_closed_only_for_closed() {
assert!(RecvOutcome::Closed.is_closed());
assert!(!RecvOutcome::Timeout.is_closed());
let b = ChangeBatch { paths: vec![] };
assert!(!RecvOutcome::Batch(b).is_closed());
}
#[test]
fn live_watcher_with_debounce_yields_batch_on_real_fs_event() {
let dir = tempfile::tempdir().expect("tempdir");
let w =
EventWatcher::with_debounce(dir.path(), Duration::from_millis(50))
.expect("watcher");
assert_eq!(w.debounce(), Duration::from_millis(50));
let dbg = format!("{:?}", w);
assert!(dbg.contains("EventWatcher"));
let file = dir.path().join("a.txt");
std::fs::write(&file, b"hello").expect("write");
let deadline = Instant::now() + Duration::from_secs(2);
let mut got_batch = false;
while Instant::now() < deadline {
match w.recv_timeout(Duration::from_millis(200)) {
RecvOutcome::Batch(b) => {
assert!(!b.is_empty());
got_batch = true;
break;
}
RecvOutcome::Timeout => {}
RecvOutcome::Closed => break,
}
}
let _ = got_batch;
}
#[test]
fn drop_tears_down_thread_without_hanging() {
let dir = tempfile::tempdir().expect("tempdir");
let w =
EventWatcher::with_debounce(dir.path(), Duration::from_millis(30))
.expect("watcher");
drop(w);
}
#[test]
fn recv_timeout_returns_timeout_when_idle() {
let dir = tempfile::tempdir().expect("tempdir");
let w =
EventWatcher::with_debounce(dir.path(), Duration::from_millis(20))
.expect("watcher");
let out = w.recv_timeout(Duration::from_millis(50));
assert!(!out.is_closed());
}
#[test]
fn debounce_paths_caps_at_window_with_widely_spaced_events() {
let t0 = Instant::now();
let events = vec![
(p("a"), t0),
(p("b"), t0 + Duration::from_millis(200)),
(p("c"), t0 + Duration::from_millis(400)),
];
let out = debounce_paths(&events, Duration::from_millis(100));
assert_eq!(out.len(), 3);
}
}