Skip to main content

barbacane_lib/
dev.rs

1//! Dev server file watcher.
2//!
3//! Watches spec files, manifest, and local plugin WASM files for changes,
4//! bridging `notify` events to a tokio channel with debouncing.
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
11use tokio::sync::mpsc;
12
13/// File watcher for the dev server.
14///
15/// Watches a set of paths (files and directories) and produces debounced
16/// change notifications via an async channel.
17pub struct DevWatcher {
18    watcher: RecommendedWatcher,
19    rx: mpsc::UnboundedReceiver<PathBuf>,
20    watched: HashSet<PathBuf>,
21}
22
23impl DevWatcher {
24    /// Create a new watcher for the given paths.
25    ///
26    /// Files are watched non-recursively; directories are watched recursively
27    /// (so new spec files in the specs folder are picked up automatically).
28    pub fn new(paths: &[PathBuf]) -> Result<Self, String> {
29        let (tx, rx) = mpsc::unbounded_channel();
30
31        let watcher = RecommendedWatcher::new(
32            move |res: Result<notify::Event, notify::Error>| {
33                if let Ok(event) = res {
34                    for path in event.paths {
35                        let _ = tx.send(path);
36                    }
37                }
38            },
39            Config::default(),
40        )
41        .map_err(|e| format!("failed to create file watcher: {e}"))?;
42
43        let mut dev_watcher = Self {
44            watcher,
45            rx,
46            watched: HashSet::new(),
47        };
48
49        for path in paths {
50            dev_watcher.watch(path)?;
51        }
52
53        Ok(dev_watcher)
54    }
55
56    /// Watch a single path (file or directory).
57    fn watch(&mut self, path: &Path) -> Result<(), String> {
58        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
59
60        if self.watched.contains(&canonical) {
61            return Ok(());
62        }
63
64        let mode = if canonical.is_dir() {
65            RecursiveMode::Recursive
66        } else {
67            RecursiveMode::NonRecursive
68        };
69
70        self.watcher
71            .watch(&canonical, mode)
72            .map_err(|e| format!("failed to watch {}: {e}", canonical.display()))?;
73
74        self.watched.insert(canonical);
75        Ok(())
76    }
77
78    /// Wait for the next batch of file changes, debounced.
79    ///
80    /// Blocks until at least one change arrives, then collects any additional
81    /// changes within the debounce window. Returns deduplicated changed paths.
82    pub async fn next_change(&mut self, debounce: Duration) -> Vec<PathBuf> {
83        // Wait for first event.
84        let first = match self.rx.recv().await {
85            Some(p) => p,
86            None => return vec![],
87        };
88
89        let mut changed = vec![first];
90        let deadline = tokio::time::Instant::now() + debounce;
91
92        // Drain additional events within debounce window.
93        loop {
94            tokio::select! {
95                _ = tokio::time::sleep_until(deadline) => break,
96                path = self.rx.recv() => {
97                    match path {
98                        Some(p) => {
99                            if !changed.contains(&p) {
100                                changed.push(p);
101                            }
102                        }
103                        None => break,
104                    }
105                }
106            }
107        }
108
109        changed
110    }
111
112    /// Replace the set of watched paths.
113    ///
114    /// Unwatches paths no longer in the set and watches new ones.
115    pub fn update_watches(&mut self, new_paths: &[PathBuf]) -> Result<(), String> {
116        let new_set: HashSet<PathBuf> = new_paths
117            .iter()
118            .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
119            .collect();
120
121        // Unwatch removed paths.
122        let to_remove: Vec<PathBuf> = self.watched.difference(&new_set).cloned().collect();
123
124        for path in &to_remove {
125            let _ = self.watcher.unwatch(path);
126            self.watched.remove(path);
127        }
128
129        // Watch new paths.
130        for path in &new_set {
131            if !self.watched.contains(path) {
132                self.watch(path)?;
133            }
134        }
135
136        Ok(())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn watcher_creation_with_valid_paths() {
146        let temp = tempfile::tempdir().unwrap();
147        let file = temp.path().join("test.yaml");
148        std::fs::write(&file, "test").unwrap();
149
150        let watcher = DevWatcher::new(std::slice::from_ref(&file));
151        assert!(watcher.is_ok());
152    }
153
154    #[test]
155    fn watcher_creation_with_directory() {
156        let temp = tempfile::tempdir().unwrap();
157        let watcher = DevWatcher::new(&[temp.path().to_path_buf()]);
158        assert!(watcher.is_ok());
159    }
160
161    #[tokio::test]
162    async fn watcher_detects_file_change() {
163        let temp = tempfile::tempdir().unwrap();
164        let file = temp.path().join("test.yaml");
165        std::fs::write(&file, "v1").unwrap();
166
167        let mut watcher = DevWatcher::new(std::slice::from_ref(&file)).unwrap();
168
169        // Write a change after a small delay.
170        let file_clone = file.clone();
171        tokio::spawn(async move {
172            tokio::time::sleep(Duration::from_millis(50)).await;
173            std::fs::write(&file_clone, "v2").unwrap();
174        });
175
176        let changed = tokio::time::timeout(
177            Duration::from_secs(5),
178            watcher.next_change(Duration::from_millis(200)),
179        )
180        .await
181        .expect("timed out waiting for change");
182
183        assert!(!changed.is_empty());
184    }
185
186    #[tokio::test]
187    async fn watcher_detects_new_file_in_directory() {
188        let temp = tempfile::tempdir().unwrap();
189
190        let mut watcher = DevWatcher::new(&[temp.path().to_path_buf()]).unwrap();
191
192        // Create a new file after a small delay.
193        let dir = temp.path().to_path_buf();
194        tokio::spawn(async move {
195            tokio::time::sleep(Duration::from_millis(50)).await;
196            std::fs::write(dir.join("new.yaml"), "content").unwrap();
197        });
198
199        let changed = tokio::time::timeout(
200            Duration::from_secs(5),
201            watcher.next_change(Duration::from_millis(200)),
202        )
203        .await
204        .expect("timed out waiting for change");
205
206        assert!(!changed.is_empty());
207    }
208}