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)]
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 = entry?;
let path = entry.path();
let ft = entry.file_type()?;
if ft.is_dir() {
Self::walk_dir(&path, out)?;
} else if ft.is_file() {
if let Ok(meta) = fs::metadata(&path) {
if let Ok(mtime) = meta.modified() {
let _ = out.insert(path, mtime);
}
}
}
}
Ok(())
}
}
pub const MAX_WATCH_ITERATIONS: usize = 1_000_000;
pub fn watch_blocking<F>(watcher: &mut FileWatcher, mut callback: F)
where
F: FnMut(&[PathBuf]) -> bool,
{
for _ in 0..MAX_WATCH_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)]
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_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]
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);
}
}