use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ChangeKind {
Css,
Content,
Template,
Other,
}
#[must_use]
pub fn classify_change(path: &Path) -> ChangeKind {
match path.extension().and_then(|e| e.to_str()) {
Some("css") => ChangeKind::Css,
Some("md" | "markdown") => ChangeKind::Content,
Some("html" | "jinja" | "jinja2" | "j2") => ChangeKind::Template,
_ => ChangeKind::Other,
}
}
#[derive(Debug, Clone)]
pub struct WatchConfig {
directory: PathBuf,
poll_interval: Duration,
}
impl WatchConfig {
#[must_use]
pub const fn new(directory: PathBuf, poll_interval: Duration) -> Self {
Self {
directory,
poll_interval,
}
}
#[must_use]
pub fn directory(&self) -> &Path {
&self.directory
}
#[must_use]
pub const fn poll_interval(&self) -> Duration {
self.poll_interval
}
}
#[derive(Debug)]
pub struct FileWatcher {
config: WatchConfig,
snapshots: HashMap<PathBuf, SystemTime>,
}
impl FileWatcher {
pub fn new(config: WatchConfig) -> io::Result<Self> {
let snapshots = Self::scan_directory(&config.directory)?;
Ok(Self { config, snapshots })
}
#[must_use]
pub const fn config(&self) -> &WatchConfig {
&self.config
}
pub fn check_for_changes(&mut self) -> io::Result<Vec<PathBuf>> {
let current = Self::scan_directory(&self.config.directory)?;
let mut changed: Vec<PathBuf> = Vec::new();
for (path, mtime) in ¤t {
match self.snapshots.get(path) {
Some(old_mtime) if old_mtime == mtime => {}
_ => changed.push(path.clone()),
}
}
for path in self.snapshots.keys() {
if !current.contains_key(path) {
changed.push(path.clone());
}
}
self.snapshots = current;
Ok(changed)
}
#[must_use]
pub fn tracked_file_count(&self) -> usize {
self.snapshots.len()
}
fn scan_directory(dir: &Path) -> io::Result<HashMap<PathBuf, SystemTime>> {
let mut map = HashMap::new();
if dir.is_dir() {
Self::walk_dir(dir, &mut map)?;
}
Ok(map)
}
fn walk_dir(
dir: &Path,
out: &mut HashMap<PathBuf, SystemTime>,
) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = next_entry(entry)?;
let path = entry.path();
let ft = entry_file_type(&entry)?;
if ft.is_dir() {
Self::walk_dir(&path, out)?;
} else if ft.is_file() {
out.extend(snapshot_mtime(&path).map(|mtime| (path, mtime)));
}
}
Ok(())
}
}
#[cfg(all(test, feature = "test-fault-injection"))]
mod fault {
use std::cell::Cell;
thread_local! {
static ARMED: Cell<Option<&'static str>> = const { Cell::new(None) };
}
pub(super) fn arm(name: &'static str) -> ArmGuard {
ARMED.with(|a| a.set(Some(name)));
ArmGuard
}
pub(super) fn armed(name: &str) -> bool {
ARMED.with(|a| a.get() == Some(name))
}
#[derive(Debug)]
pub(super) struct ArmGuard;
impl Drop for ArmGuard {
fn drop(&mut self) {
ARMED.with(|a| a.set(None));
}
}
}
#[allow(clippy::missing_const_for_fn)]
fn next_entry(entry: io::Result<fs::DirEntry>) -> io::Result<fs::DirEntry> {
#[cfg(all(test, feature = "test-fault-injection"))]
if fault::armed("watch::dir-entry") {
return Err(io::Error::other("injected: watch::dir-entry"));
}
entry
}
fn entry_file_type(entry: &fs::DirEntry) -> io::Result<fs::FileType> {
#[cfg(all(test, feature = "test-fault-injection"))]
if fault::armed("watch::entry-file-type") {
return Err(io::Error::other("injected: watch::entry-file-type"));
}
entry.file_type()
}
fn snapshot_mtime(path: &Path) -> Option<SystemTime> {
fs::metadata(path).ok()?.modified().ok()
}
pub const MAX_WATCH_ITERATIONS: usize = 1_000_000;
pub fn watch_blocking<F>(watcher: &mut FileWatcher, callback: F)
where
F: FnMut(&[PathBuf]) -> bool,
{
watch_blocking_bounded(watcher, MAX_WATCH_ITERATIONS, callback);
}
fn watch_blocking_bounded<F>(
watcher: &mut FileWatcher,
max_iterations: usize,
mut callback: F,
) where
F: FnMut(&[PathBuf]) -> bool,
{
for _ in 0..max_iterations {
match watcher.check_for_changes() {
Ok(changes) if !changes.is_empty() => {
if !callback(&changes) {
return;
}
}
Ok(_) => {} Err(e) => {
eprintln!("watch error: {e}");
}
}
thread::sleep(watcher.config.poll_interval);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
use std::thread;
use std::time::Duration;
fn tmp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("ssg_watch_test_{name}_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create tmp dir");
dir
}
fn write_file(path: &Path, content: &str) {
let mut f = File::create(path).expect("create file");
f.write_all(content.as_bytes()).expect("write file");
}
#[test]
fn config_accessors() {
let dir = std::env::temp_dir().join("ssg_watch_fake");
let interval = Duration::from_millis(500);
let cfg = WatchConfig::new(dir.clone(), interval);
assert_eq!(cfg.directory(), dir.as_path());
assert_eq!(cfg.poll_interval(), interval);
}
#[test]
fn file_watcher_config_accessor_returns_stored_config() {
let dir = tmp_dir("watcher_config");
let interval = Duration::from_millis(250);
let cfg = WatchConfig::new(dir.clone(), interval);
let watcher = FileWatcher::new(cfg).expect("new watcher");
let returned = watcher.config();
assert_eq!(returned.directory(), dir.as_path());
assert_eq!(returned.poll_interval(), interval);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn new_watcher_snapshots_existing_files() {
let dir = tmp_dir("snapshot");
write_file(&dir.join("a.md"), "hello");
write_file(&dir.join("b.md"), "world");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let watcher = FileWatcher::new(cfg).expect("new watcher");
assert_eq!(watcher.tracked_file_count(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn no_changes_returns_empty() {
let dir = tmp_dir("nochange");
write_file(&dir.join("a.md"), "hello");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
let changes = watcher.check_for_changes().expect("check");
assert!(changes.is_empty(), "expected no changes");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detects_new_file() {
let dir = tmp_dir("newfile");
write_file(&dir.join("a.md"), "hello");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
write_file(&dir.join("b.md"), "new");
let changes = watcher.check_for_changes().expect("check");
assert!(
changes.contains(&dir.join("b.md")),
"expected new file in changes"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detects_modified_file() {
let dir = tmp_dir("modified");
write_file(&dir.join("a.md"), "v1");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
thread::sleep(Duration::from_millis(1100));
write_file(&dir.join("a.md"), "v2");
let changes = watcher.check_for_changes().expect("check");
assert!(
changes.contains(&dir.join("a.md")),
"expected modified file in changes"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detects_removed_file() {
let dir = tmp_dir("removed");
write_file(&dir.join("a.md"), "hello");
write_file(&dir.join("b.md"), "world");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
fs::remove_file(dir.join("b.md")).expect("remove file");
let changes = watcher.check_for_changes().expect("check");
assert!(
changes.contains(&dir.join("b.md")),
"expected removed file in changes"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn tracks_files_in_subdirectories() {
let dir = tmp_dir("subdirs");
let sub = dir.join("posts");
fs::create_dir_all(&sub).expect("create subdir");
write_file(&sub.join("first.md"), "post");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let watcher = FileWatcher::new(cfg).expect("new watcher");
assert_eq!(watcher.tracked_file_count(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn check_clears_changes_after_read() {
let dir = tmp_dir("clear");
write_file(&dir.join("a.md"), "v1");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
write_file(&dir.join("b.md"), "new");
let first = watcher.check_for_changes().expect("check");
assert!(!first.is_empty());
let second = watcher.check_for_changes().expect("check");
assert!(second.is_empty(), "changes should be cleared after read");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watch_blocking_stops_on_false() {
let dir = tmp_dir("blocking");
write_file(&dir.join("a.md"), "v1");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(10));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
thread::sleep(Duration::from_millis(1100));
write_file(&dir.join("a.md"), "v2");
let mut invoked = false;
watch_blocking(&mut watcher, |_changes| {
invoked = true;
false });
assert!(invoked, "callback should have been invoked");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watch_blocking_bounded_exhausts_iterations_without_early_return() {
let dir = tmp_dir("bounded_exhaust");
write_file(&dir.join("a.md"), "v1");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
let mut calls = 0;
watch_blocking_bounded(&mut watcher, 3, |_changes| {
calls += 1;
true });
assert_eq!(calls, 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watch_blocking_returns_after_callback_false_deterministic() {
let dir = tmp_dir("blocking_det");
write_file(&dir.join("a.md"), "v1");
write_file(&dir.join("b.md"), "v1");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
watcher.snapshots.clear();
let mut call_count = 0;
watch_blocking(&mut watcher, |changes| {
call_count += 1;
assert!(!changes.is_empty());
false });
assert_eq!(
call_count, 1,
"callback should have been called exactly once"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watch_blocking_no_changes_branch_executes() {
let dir = tmp_dir("no_changes_arm");
write_file(&dir.join("a.md"), "x");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
let changes = watcher.check_for_changes().expect("check");
assert!(changes.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn empty_directory_is_valid() {
let dir = tmp_dir("empty");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let watcher = FileWatcher::new(cfg).expect("new watcher");
assert_eq!(watcher.tracked_file_count(), 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn nonexistent_directory_errors() {
let dir = std::env::temp_dir().join("ssg_watch_test_nonexistent_99999");
let _ = fs::remove_dir_all(&dir);
let cfg = WatchConfig::new(dir, Duration::from_millis(50));
let watcher = FileWatcher::new(cfg);
assert!(watcher.is_ok());
assert_eq!(watcher.unwrap().tracked_file_count(), 0);
}
#[test]
fn watch_config_default_values() {
let dir = std::env::temp_dir().join("ssg_watch_defaults");
let poll = Duration::from_secs(2);
let debounce = Duration::from_millis(100);
let cfg = WatchConfig::new(dir.clone(), poll);
assert_eq!(cfg.poll_interval(), Duration::from_secs(2));
assert_eq!(cfg.directory(), dir.as_path());
assert_ne!(cfg.poll_interval(), debounce);
}
#[test]
fn file_watcher_empty_directory() {
let dir = tmp_dir("empty_watch");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
assert_eq!(watcher.tracked_file_count(), 0);
let changes = watcher.check_for_changes().expect("check");
assert!(changes.is_empty(), "empty dir should have no changes");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn file_watcher_detects_new_file() {
let dir = tmp_dir("detect_new");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
assert_eq!(watcher.tracked_file_count(), 0);
write_file(&dir.join("added.md"), "new content");
let changes = watcher.check_for_changes().expect("check");
assert_eq!(changes.len(), 1);
assert!(changes[0].ends_with("added.md"));
assert_eq!(watcher.tracked_file_count(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
#[cfg(unix)]
fn walk_dir_skips_entries_that_are_neither_file_nor_dir() {
let dir = tmp_dir("symlink_skip");
write_file(&dir.join("real.md"), "content");
std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md"))
.expect("create symlink");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let watcher = FileWatcher::new(cfg).expect("new watcher");
assert_eq!(watcher.tracked_file_count(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn scan_directory_nonexistent_returns_empty_map() {
let dir = PathBuf::from("/nonexistent_ssg_watch_test_dir");
let cfg = WatchConfig::new(dir, Duration::from_millis(50));
let watcher = FileWatcher::new(cfg).expect("should succeed");
assert_eq!(watcher.tracked_file_count(), 0);
}
#[test]
fn watch_config_clone() {
let dir = std::env::temp_dir().join("ssg_watch_clone");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(100));
let cloned = cfg;
assert_eq!(cloned.directory(), dir.as_path());
assert_eq!(cloned.poll_interval(), Duration::from_millis(100));
}
#[test]
fn file_watcher_debug_output() {
let dir = tmp_dir("debug_out");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let watcher = FileWatcher::new(cfg).expect("new watcher");
let debug = format!("{watcher:?}");
assert!(debug.contains("FileWatcher"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn file_watcher_nested_directory() {
let dir = tmp_dir("nested_watch");
let sub = dir.join("a/b/c");
fs::create_dir_all(&sub).expect("create nested dirs");
write_file(&sub.join("deep.md"), "deep content");
write_file(&dir.join("root.md"), "root content");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let watcher = FileWatcher::new(cfg).expect("new watcher");
assert_eq!(watcher.tracked_file_count(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_classify_css() {
assert_eq!(
classify_change(Path::new("styles/main.css")),
ChangeKind::Css
);
}
#[test]
fn test_classify_markdown() {
assert_eq!(
classify_change(Path::new("content/post.md")),
ChangeKind::Content
);
assert_eq!(
classify_change(Path::new("content/post.markdown")),
ChangeKind::Content
);
}
#[test]
fn test_classify_html() {
assert_eq!(
classify_change(Path::new("templates/base.html")),
ChangeKind::Template
);
assert_eq!(
classify_change(Path::new("templates/base.jinja")),
ChangeKind::Template
);
assert_eq!(
classify_change(Path::new("templates/base.jinja2")),
ChangeKind::Template
);
assert_eq!(
classify_change(Path::new("templates/base.j2")),
ChangeKind::Template
);
}
#[test]
fn test_classify_other() {
assert_eq!(
classify_change(Path::new("src/main.rs")),
ChangeKind::Other
);
assert_eq!(
classify_change(Path::new("config.toml")),
ChangeKind::Other
);
}
#[test]
fn test_classify_no_extension() {
assert_eq!(classify_change(Path::new("Makefile")), ChangeKind::Other);
}
#[test]
fn snapshot_mtime_returns_some_for_existing_file() {
let dir = tmp_dir("mtime_some");
let file = dir.join("a.md");
write_file(&file, "content");
assert!(snapshot_mtime(&file).is_some());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn snapshot_mtime_returns_none_for_missing_file() {
let missing = Path::new("/nonexistent_ssg_watch_mtime_test");
assert!(snapshot_mtime(missing).is_none());
}
#[test]
#[cfg(unix)]
fn new_watcher_errors_on_unreadable_subdirectory() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir("unreadable_new");
let locked = dir.join("locked");
fs::create_dir_all(&locked).expect("create locked dir");
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
.expect("chmod");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let res = FileWatcher::new(cfg);
let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
let _ = fs::remove_dir_all(&dir);
assert!(res.is_err(), "unreadable subdirectory must fail the scan");
}
#[test]
#[cfg(unix)]
fn check_for_changes_errors_when_directory_becomes_unreadable() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir("unreadable_check");
write_file(&dir.join("a.md"), "x");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o000))
.expect("chmod");
let res = watcher.check_for_changes();
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o755));
let _ = fs::remove_dir_all(&dir);
assert!(res.is_err(), "unreadable root must fail the rescan");
}
#[test]
fn watch_blocking_keeps_polling_while_callback_returns_true() {
let dir = tmp_dir("blocking_continue");
write_file(&dir.join("a.md"), "v1");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
watcher.snapshots.clear();
let mut calls = 0;
let plant = dir.join("b.md");
watch_blocking(&mut watcher, |_changes| {
calls += 1;
if calls == 1 {
let mut f = File::create(&plant).expect("create planted");
f.write_all(b"new").expect("write planted");
true } else {
false
}
});
assert_eq!(calls, 2, "callback should fire for the planted change");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn watch_blocking_idles_through_no_change_polls() {
let dir = tmp_dir("blocking_idle");
write_file(&dir.join("a.md"), "v1");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
watcher.snapshots.clear();
let planted = dir.join("late.md");
let mut calls = 0;
let mut writer: Option<thread::JoinHandle<()>> = None;
watch_blocking(&mut watcher, |_changes| {
calls += 1;
if calls == 1 {
let path = planted.clone();
writer = Some(thread::spawn(move || {
thread::sleep(Duration::from_millis(50));
let mut f = File::create(&path).expect("create late");
f.write_all(b"late").expect("write late");
}));
true } else {
false
}
});
assert_eq!(calls, 2);
if let Some(h) = writer {
h.join().expect("writer thread");
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
#[cfg(unix)]
fn watch_blocking_reports_scan_errors_and_recovers() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir("blocking_error");
write_file(&dir.join("a.md"), "v1");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
let mut watcher = FileWatcher::new(cfg).expect("new watcher");
watcher.snapshots.clear();
let mut calls = 0;
let mut helper: Option<thread::JoinHandle<()>> = None;
let root = dir.clone();
watch_blocking(&mut watcher, |_changes| {
calls += 1;
if calls == 1 {
let root = root.clone();
helper = Some(thread::spawn(move || {
fs::set_permissions(
&root,
fs::Permissions::from_mode(0o000),
)
.expect("lock dir");
thread::sleep(Duration::from_millis(50));
fs::set_permissions(
&root,
fs::Permissions::from_mode(0o755),
)
.expect("unlock dir");
let mut f = File::create(root.join("late.md"))
.expect("create late");
f.write_all(b"late").expect("write late");
}));
true
} else {
false
}
});
assert_eq!(calls, 2, "loop must survive scan errors and recover");
if let Some(h) = helper {
h.join().expect("helper thread");
}
let _ = fs::remove_dir_all(&dir);
}
#[cfg(feature = "test-fault-injection")]
mod fault_injection {
use super::*;
#[test]
fn walk_dir_surfaces_injected_entry_error() {
let dir = tmp_dir("fault_entry");
write_file(&dir.join("a.md"), "x");
let guard = fault::arm("watch::dir-entry");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
let err = FileWatcher::new(cfg)
.expect_err("injected entry error must propagate");
assert!(err.to_string().contains("watch::dir-entry"));
drop(guard);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn walk_dir_surfaces_injected_file_type_error() {
let dir = tmp_dir("fault_file_type");
write_file(&dir.join("a.md"), "x");
let guard = fault::arm("watch::entry-file-type");
let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
let err = FileWatcher::new(cfg)
.expect_err("injected file-type error must propagate");
assert!(err.to_string().contains("watch::entry-file-type"));
drop(guard);
let _ = fs::remove_dir_all(&dir);
}
}
}