#![allow(dead_code)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileEvent {
Created(PathBuf),
Modified(PathBuf),
Deleted(PathBuf),
Renamed(PathBuf, PathBuf),
}
impl FileEvent {
#[must_use]
pub fn is_modification(&self) -> bool {
matches!(self, FileEvent::Modified(_))
}
#[must_use]
pub fn path(&self) -> &Path {
match self {
FileEvent::Created(p)
| FileEvent::Modified(p)
| FileEvent::Deleted(p)
| FileEvent::Renamed(p, _) => p.as_path(),
}
}
}
#[derive(Debug, Clone)]
pub struct WatchConfig {
pub extensions: Vec<String>,
pub watch_create: bool,
pub watch_modify: bool,
pub watch_delete: bool,
pub debounce: Duration,
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
extensions: Vec::new(),
watch_create: true,
watch_modify: true,
watch_delete: true,
debounce: Duration::from_millis(50),
}
}
}
impl WatchConfig {
#[must_use]
pub fn should_watch(&self, path: &Path) -> bool {
if self.extensions.is_empty() {
return true;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
self.extensions.iter().any(|e| e == ext)
} else {
false
}
}
}
#[derive(Debug, Clone)]
struct WatchEntry {
last_modified: Option<SystemTime>,
last_len: u64,
}
#[derive(Debug)]
pub struct FileWatcher {
config: WatchConfig,
watched: HashMap<PathBuf, WatchEntry>,
pending_events: Vec<FileEvent>,
}
impl FileWatcher {
#[must_use]
pub fn new(config: WatchConfig) -> Self {
Self {
config,
watched: HashMap::new(),
pending_events: Vec::new(),
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(WatchConfig::default())
}
pub fn add_path(&mut self, path: impl Into<PathBuf>) {
let path = path.into();
if !self.config.should_watch(&path) {
return;
}
let entry = if let Ok(meta) = std::fs::metadata(&path) {
WatchEntry {
last_modified: meta.modified().ok(),
last_len: meta.len(),
}
} else {
WatchEntry {
last_modified: None,
last_len: 0,
}
};
self.watched.insert(path, entry);
}
pub fn check_events(&mut self) {
let paths: Vec<PathBuf> = self.watched.keys().cloned().collect();
for path in paths {
if let Ok(meta) = std::fs::metadata(&path) {
let new_modified = meta.modified().ok();
let new_len = meta.len();
let entry = self
.watched
.get_mut(&path)
.expect("invariant: path came from watched map");
let changed = entry.last_modified != new_modified || entry.last_len != new_len;
if changed && self.config.watch_modify {
self.pending_events.push(FileEvent::Modified(path.clone()));
}
let entry = self
.watched
.get_mut(&path)
.expect("invariant: path came from watched map");
entry.last_modified = new_modified;
entry.last_len = new_len;
} else {
let entry = self
.watched
.get(&path)
.expect("invariant: path came from watched map");
if entry.last_modified.is_some() && self.config.watch_delete {
self.pending_events.push(FileEvent::Deleted(path.clone()));
}
let entry = self
.watched
.get_mut(&path)
.expect("invariant: path came from watched map");
entry.last_modified = None;
entry.last_len = 0;
}
}
}
#[must_use]
pub fn event_count(&self) -> usize {
self.pending_events.len()
}
pub fn drain_events(&mut self) -> Vec<FileEvent> {
std::mem::take(&mut self.pending_events)
}
#[must_use]
pub fn watched_count(&self) -> usize {
self.watched.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[test]
fn test_file_event_is_modification_true() {
let ev = FileEvent::Modified(PathBuf::from("foo.rs"));
assert!(ev.is_modification());
}
#[test]
fn test_file_event_is_modification_false_created() {
let ev = FileEvent::Created(PathBuf::from("foo.rs"));
assert!(!ev.is_modification());
}
#[test]
fn test_file_event_is_modification_false_deleted() {
let ev = FileEvent::Deleted(PathBuf::from("foo.rs"));
assert!(!ev.is_modification());
}
#[test]
fn test_file_event_is_modification_false_renamed() {
let ev = FileEvent::Renamed(PathBuf::from("a.rs"), PathBuf::from("b.rs"));
assert!(!ev.is_modification());
}
#[test]
fn test_file_event_path() {
let p = PathBuf::from("video.mp4");
let ev = FileEvent::Modified(p.clone());
assert_eq!(ev.path(), p.as_path());
}
#[test]
fn test_watch_config_should_watch_all_extensions() {
let cfg = WatchConfig {
extensions: vec![],
..Default::default()
};
assert!(cfg.should_watch(Path::new("any_file.xyz")));
}
#[test]
fn test_watch_config_should_watch_matching_ext() {
let cfg = WatchConfig {
extensions: vec!["rs".to_string(), "toml".to_string()],
..Default::default()
};
assert!(cfg.should_watch(Path::new("lib.rs")));
assert!(cfg.should_watch(Path::new("Cargo.toml")));
}
#[test]
fn test_watch_config_should_not_watch_non_matching_ext() {
let cfg = WatchConfig {
extensions: vec!["rs".to_string()],
..Default::default()
};
assert!(!cfg.should_watch(Path::new("video.mp4")));
}
#[test]
fn test_watch_config_should_not_watch_no_ext() {
let cfg = WatchConfig {
extensions: vec!["rs".to_string()],
..Default::default()
};
assert!(!cfg.should_watch(Path::new("Makefile")));
}
#[test]
fn test_add_path_nonexistent_is_tracked() {
let mut watcher = FileWatcher::with_defaults();
watcher.add_path("/nonexistent/path/file.rs");
assert_eq!(watcher.watched_count(), 1);
}
#[test]
fn test_add_path_filtered_by_extension() {
let cfg = WatchConfig {
extensions: vec!["rs".to_string()],
..Default::default()
};
let mut watcher = FileWatcher::new(cfg);
watcher.add_path("/tmp/video.mp4");
assert_eq!(watcher.watched_count(), 0);
}
#[test]
fn test_event_count_initially_zero() {
let watcher = FileWatcher::with_defaults();
assert_eq!(watcher.event_count(), 0);
}
#[test]
fn test_check_events_no_watched_paths() {
let mut watcher = FileWatcher::with_defaults();
watcher.check_events();
assert_eq!(watcher.event_count(), 0);
}
#[test]
fn test_modification_detected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.rs");
{
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(b"hello").unwrap();
}
let mut watcher = FileWatcher::with_defaults();
watcher.add_path(&path);
{
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(b" world").unwrap();
}
watcher.check_events();
assert!(watcher.event_count() >= 1);
let events = watcher.drain_events();
assert!(events.iter().any(|e| e.is_modification()));
}
#[test]
fn test_drain_events_clears_queue() {
let mut watcher = FileWatcher::with_defaults();
watcher
.pending_events
.push(FileEvent::Created(PathBuf::from("x.rs")));
assert_eq!(watcher.event_count(), 1);
let drained = watcher.drain_events();
assert_eq!(drained.len(), 1);
assert_eq!(watcher.event_count(), 0);
}
}