Skip to main content

cc_audit/
watch.rs

1use crate::config::WatchConfig;
2use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
3use std::path::Path;
4use std::sync::mpsc::{Receiver, channel};
5use std::time::Duration;
6use tracing::warn;
7
8pub struct FileWatcher {
9    watcher: RecommendedWatcher,
10    receiver: Receiver<Result<notify::Event, notify::Error>>,
11    debounce_duration: Duration,
12}
13
14impl FileWatcher {
15    /// Creates a new FileWatcher with default configuration.
16    pub fn new() -> Result<Self, notify::Error> {
17        Self::with_config(&WatchConfig::default())
18    }
19
20    /// Creates a new FileWatcher with custom configuration.
21    pub fn with_config(config: &WatchConfig) -> Result<Self, notify::Error> {
22        let (tx, rx) = channel();
23
24        let watcher = RecommendedWatcher::new(
25            move |res| {
26                let _ = tx.send(res);
27            },
28            Config::default().with_poll_interval(Duration::from_millis(config.poll_interval_ms)),
29        )?;
30
31        Ok(Self {
32            watcher,
33            receiver: rx,
34            debounce_duration: Duration::from_millis(config.debounce_ms),
35        })
36    }
37
38    pub fn watch(&mut self, path: &Path) -> Result<(), notify::Error> {
39        self.watcher.watch(path, RecursiveMode::Recursive)
40    }
41
42    pub fn wait_for_change(&self) -> bool {
43        // Simple debounce: collect events for debounce_duration
44        let mut has_change = false;
45
46        loop {
47            match self.receiver.recv_timeout(if has_change {
48                self.debounce_duration
49            } else {
50                Duration::from_secs(60 * 60) // 1 hour timeout when waiting for first event
51            }) {
52                Ok(Ok(event)) => {
53                    // Only react to meaningful changes
54                    if matches!(
55                        event.kind,
56                        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
57                    ) {
58                        has_change = true;
59                    }
60                }
61                Ok(Err(e)) => {
62                    warn!(error = %e, "File watch error");
63                }
64                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
65                    // Debounce period complete or timeout
66                    if has_change {
67                        return true;
68                    }
69                    // Continue waiting if no change yet
70                }
71                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
72                    return false;
73                }
74            }
75        }
76    }
77}
78
79/// Note: The `Default` implementation for `FileWatcher` may panic if the underlying
80/// file watcher cannot be created. Prefer using `FileWatcher::new()` for production
81/// code to properly handle potential errors.
82impl Default for FileWatcher {
83    /// Creates a new `FileWatcher` with default settings.
84    ///
85    /// # Panics
86    ///
87    /// Panics if the file watcher cannot be created (e.g., due to OS limitations).
88    fn default() -> Self {
89        Self::new().expect("Failed to create file watcher")
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use std::fs;
97    use tempfile::TempDir;
98
99    #[test]
100    fn test_file_watcher_creation() {
101        let watcher = FileWatcher::new();
102        assert!(watcher.is_ok());
103    }
104
105    #[test]
106    fn test_file_watcher_with_config() {
107        let config = WatchConfig {
108            debounce_ms: 500,
109            poll_interval_ms: 1000,
110        };
111        let watcher = FileWatcher::with_config(&config);
112        assert!(watcher.is_ok());
113        let watcher = watcher.unwrap();
114        assert_eq!(watcher.debounce_duration, Duration::from_millis(500));
115    }
116
117    #[test]
118    fn test_file_watcher_with_custom_debounce() {
119        let config = WatchConfig {
120            debounce_ms: 100,
121            poll_interval_ms: 200,
122        };
123        let watcher = FileWatcher::with_config(&config).unwrap();
124        assert_eq!(watcher.debounce_duration, Duration::from_millis(100));
125    }
126
127    #[test]
128    fn test_watch_directory() {
129        let temp_dir = TempDir::new().unwrap();
130        let mut watcher = FileWatcher::new().unwrap();
131        let result = watcher.watch(temp_dir.path());
132        assert!(result.is_ok());
133    }
134
135    #[test]
136    fn test_watch_nonexistent_directory() {
137        let mut watcher = FileWatcher::new().unwrap();
138        let result = watcher.watch(Path::new("/nonexistent/path/12345"));
139        assert!(result.is_err());
140    }
141
142    #[test]
143    fn test_default_trait() {
144        // This will panic if it fails, which is expected behavior
145        let _watcher = FileWatcher::default();
146    }
147
148    #[test]
149    fn test_watch_file_change() {
150        use std::thread;
151        use std::time::Duration;
152
153        let temp_dir = TempDir::new().unwrap();
154        let test_file = temp_dir.path().join("test.md");
155        fs::write(&test_file, "initial content").unwrap();
156
157        let mut watcher = FileWatcher::new().unwrap();
158        watcher.watch(temp_dir.path()).unwrap();
159
160        // Spawn a thread to modify the file after a short delay
161        let test_file_clone = test_file.clone();
162        let handle = thread::spawn(move || {
163            thread::sleep(Duration::from_millis(100));
164            fs::write(&test_file_clone, "modified content").unwrap();
165        });
166
167        // Wait for change (with timeout via recv_timeout)
168        // We use a separate thread to avoid blocking forever
169        let (tx, _rx) = channel();
170        let watcher_receiver = watcher.receiver;
171        thread::spawn(move || {
172            let result = watcher_receiver.recv_timeout(Duration::from_secs(2));
173            let _ = tx.send(result.is_ok());
174        });
175
176        handle.join().unwrap();
177
178        // Give the watcher some time to process
179        thread::sleep(Duration::from_millis(500));
180    }
181
182    #[test]
183    fn test_wait_for_change_with_create_event() {
184        use std::thread;
185        use std::time::Duration;
186
187        let temp_dir = TempDir::new().unwrap();
188        let mut watcher = FileWatcher::new().unwrap();
189        watcher.watch(temp_dir.path()).unwrap();
190
191        // Spawn a thread to create a new file
192        let test_file = temp_dir.path().join("new_file.txt");
193        thread::spawn(move || {
194            thread::sleep(Duration::from_millis(100));
195            fs::write(&test_file, "new content").unwrap();
196        });
197
198        // wait_for_change should return true on file creation
199        let result = watcher.wait_for_change();
200        assert!(result);
201    }
202
203    #[test]
204    fn test_wait_for_change_with_remove_event() {
205        use std::thread;
206        use std::time::Duration;
207
208        let temp_dir = TempDir::new().unwrap();
209        let test_file = temp_dir.path().join("to_remove.txt");
210        fs::write(&test_file, "content").unwrap();
211
212        let mut watcher = FileWatcher::new().unwrap();
213        watcher.watch(temp_dir.path()).unwrap();
214
215        // Spawn a thread to remove the file
216        let test_file_clone = test_file.clone();
217        thread::spawn(move || {
218            thread::sleep(Duration::from_millis(100));
219            fs::remove_file(&test_file_clone).unwrap();
220        });
221
222        // wait_for_change should return true on file removal
223        let result = watcher.wait_for_change();
224        assert!(result);
225    }
226
227    #[test]
228    fn test_wait_for_change_disconnected() {
229        use std::thread;
230        use std::time::Duration;
231
232        // Create a watcher but manually drop the sender to simulate disconnection
233        let (tx, rx) = channel::<Result<notify::Event, notify::Error>>();
234
235        // Create a minimal watcher struct with the receiver
236        // We need to simulate the disconnection scenario
237        let watcher_handle = thread::spawn(move || {
238            // This tests the Disconnected branch
239            // Drop the sender to disconnect
240            drop(tx);
241        });
242
243        // Small delay to ensure sender is dropped
244        thread::sleep(Duration::from_millis(50));
245
246        // Now try to receive - should get disconnected error
247        let result = rx.recv_timeout(Duration::from_millis(100));
248        assert!(result.is_err());
249
250        watcher_handle.join().unwrap();
251    }
252
253    #[test]
254    fn test_debounce_duration() {
255        let watcher = FileWatcher::new().unwrap();
256        assert_eq!(watcher.debounce_duration, Duration::from_millis(300));
257    }
258
259    #[test]
260    fn test_wait_for_change_with_modify_event() {
261        use std::thread;
262        use std::time::Duration;
263
264        let temp_dir = TempDir::new().unwrap();
265        let test_file = temp_dir.path().join("existing.txt");
266        fs::write(&test_file, "initial").unwrap();
267
268        let mut watcher = FileWatcher::new().unwrap();
269        watcher.watch(temp_dir.path()).unwrap();
270
271        // Spawn a thread to modify the file
272        let test_file_clone = test_file.clone();
273        thread::spawn(move || {
274            thread::sleep(Duration::from_millis(100));
275            fs::write(&test_file_clone, "modified").unwrap();
276        });
277
278        // wait_for_change should return true on file modification
279        let result = watcher.wait_for_change();
280        assert!(result);
281    }
282
283    #[test]
284    fn test_receiver_fields() {
285        // Test that FileWatcher fields are properly initialized
286        let watcher = FileWatcher::new().unwrap();
287        assert_eq!(watcher.debounce_duration, Duration::from_millis(300));
288        // receiver is private but we can test via wait behavior
289    }
290}