Skip to main content

hd_watch/
watcher.rs

1use std::path::{Path, PathBuf};
2use std::sync::mpsc;
3use std::time::Duration;
4
5use notify::{Config, PollWatcher, RecursiveMode, Watcher};
6
7use crate::debounce::{ChangeKind, Debouncer, RawChange};
8use crate::filter::PathFilter;
9
10#[derive(Debug, thiserror::Error)]
11pub enum WatchError {
12    #[error("notify error: {0}")]
13    Notify(#[from] notify::Error),
14    #[error("watch root does not exist: {0}")]
15    RootNotFound(String),
16}
17
18/// Watches a directory tree for filesystem changes, filters them,
19/// debounces, and provides batched change events.
20pub struct FileWatcher {
21    _watcher: PollWatcher,
22    receiver: mpsc::Receiver<notify::Result<notify::Event>>,
23    root: PathBuf,
24    filter: PathFilter,
25    debouncer: Debouncer,
26}
27
28impl FileWatcher {
29    pub fn new(root: &Path, filter: PathFilter) -> Result<Self, WatchError> {
30        Self::with_poll_interval(root, filter, Duration::from_millis(200))
31    }
32
33    pub fn with_poll_interval(root: &Path, filter: PathFilter, interval: Duration) -> Result<Self, WatchError> {
34        if !root.exists() {
35            return Err(WatchError::RootNotFound(root.display().to_string()));
36        }
37
38        let (tx, rx) = mpsc::channel();
39        let config = Config::default()
40            .with_poll_interval(interval)
41            .with_compare_contents(true);
42        let mut watcher = PollWatcher::new(tx, config)?;
43        watcher.watch(root, RecursiveMode::Recursive)?;
44
45        Ok(FileWatcher {
46            _watcher: watcher,
47            receiver: rx,
48            root: root.to_path_buf(),
49            filter,
50            debouncer: Debouncer::new(),
51        })
52    }
53
54    /// Poll for pending filesystem changes. Non-blocking: drains the
55    /// receiver and returns all coalesced changes.
56    pub fn poll_changes(&mut self) -> Vec<RawChange> {
57        // Drain all pending events from the notify watcher
58        while let Ok(event_result) = self.receiver.try_recv() {
59            if let Ok(event) = event_result {
60                let kind = match event.kind {
61                    notify::EventKind::Create(_) => ChangeKind::Created,
62                    notify::EventKind::Modify(_) => ChangeKind::Modified,
63                    notify::EventKind::Remove(_) => ChangeKind::Deleted,
64                    _ => continue,
65                };
66
67                for path in event.paths {
68                    // Convert absolute path to relative
69                    if let Ok(relative) = path.strip_prefix(&self.root) {
70                        let rel_str = relative.to_string_lossy().to_string();
71                        if self.filter.is_included(&rel_str) {
72                            self.debouncer.push(RawChange {
73                                path: relative.to_path_buf(),
74                                kind: kind.clone(),
75                            });
76                        }
77                    }
78                }
79            }
80        }
81
82        self.debouncer.drain()
83    }
84
85    /// Get a reference to the watch root.
86    pub fn root(&self) -> &Path {
87        &self.root
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use tempfile::TempDir;
95    use std::fs;
96
97    fn test_watcher(root: &Path, filter: PathFilter) -> FileWatcher {
98        FileWatcher::with_poll_interval(root, filter, Duration::from_millis(100)).unwrap()
99    }
100
101    /// Helper: poll with retries to handle poll interval latency.
102    fn poll_with_retry(watcher: &mut FileWatcher, retries: u32) -> Vec<RawChange> {
103        for _ in 0..retries {
104            std::thread::sleep(Duration::from_millis(300));
105            let changes = watcher.poll_changes();
106            if !changes.is_empty() {
107                return changes;
108            }
109        }
110        vec![]
111    }
112
113    #[test]
114    fn detect_file_change() {
115        let dir = TempDir::new().unwrap();
116        let file_path = dir.path().join("test.rs");
117        fs::write(&file_path, b"original").unwrap();
118
119        let filter = PathFilter::new(vec![], vec![]);
120        let mut watcher = test_watcher(dir.path(), filter);
121
122        // Small delay to let the watcher do its initial scan
123        std::thread::sleep(Duration::from_millis(500));
124
125        // Modify file
126        fs::write(&file_path, b"modified").unwrap();
127
128        let changes = poll_with_retry(&mut watcher, 10);
129        assert!(!changes.is_empty(), "should detect the file change");
130    }
131
132    #[test]
133    fn filtered_files_ignored() {
134        let dir = TempDir::new().unwrap();
135        let log_path = dir.path().join("debug.log");
136        let rs_path = dir.path().join("main.rs");
137        fs::write(&log_path, b"log").unwrap();
138        fs::write(&rs_path, b"code").unwrap();
139
140        let filter = PathFilter::new(vec![], vec!["*.log".into()]);
141        let mut watcher = test_watcher(dir.path(), filter);
142
143        // Small delay to let the watcher do its initial scan
144        std::thread::sleep(Duration::from_millis(500));
145
146        fs::write(&log_path, b"more log").unwrap();
147        fs::write(&rs_path, b"more code").unwrap();
148
149        let changes = poll_with_retry(&mut watcher, 10);
150
151        // Only the .rs file change should come through
152        let paths: Vec<_> = changes.iter().map(|c| c.path.clone()).collect();
153        assert!(!paths.iter().any(|p| p.to_string_lossy().contains(".log")),
154            "log files should be filtered out");
155    }
156
157    #[test]
158    fn detect_new_file() {
159        let dir = TempDir::new().unwrap();
160        let filter = PathFilter::new(vec![], vec![]);
161        let mut watcher = test_watcher(dir.path(), filter);
162
163        // Small delay to let the watcher do its initial scan
164        std::thread::sleep(Duration::from_millis(500));
165
166        // Create a new file
167        let new_file = dir.path().join("new.rs");
168        fs::write(&new_file, b"new content").unwrap();
169
170        let changes = poll_with_retry(&mut watcher, 10);
171        assert!(!changes.is_empty(), "should detect new file creation");
172    }
173}