Skip to main content

serializer/
watch.rs

1//! File Watcher for .sr and dx config files
2//!
3//! Monitors filesystem for changes to:
4//! - Root `dx` config file (no extension)
5//! - All `.sr` rule definition files
6//!
7//! Provides hot-reload capability for development mode.
8//!
9//! ## Output Directory
10//!
11//! When .sr files change, outputs are generated to `.dx/serializer/`:
12//! - `{name}.human` - Human-readable format for editor display
13//! - `{name}.machine` - Binary format for runtime loading
14
15use std::path::PathBuf;
16
17#[cfg(feature = "watch")]
18use notify::{
19    Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher,
20};
21#[cfg(feature = "watch")]
22use std::path::Path;
23#[cfg(feature = "watch")]
24use std::sync::mpsc::{Receiver, Sender, channel};
25#[cfg(feature = "watch")]
26use std::time::Duration;
27
28/// File change event
29#[derive(Debug, Clone)]
30pub enum FileChange {
31    /// Root dx config file changed
32    ConfigChanged(PathBuf),
33    /// A .sr rule file changed
34    RuleFileChanged(PathBuf),
35    /// A .sr rule file was created
36    RuleFileCreated(PathBuf),
37    /// A .sr rule file was deleted
38    RuleFileDeleted(PathBuf),
39}
40/// File watcher for dx config and .sr files
41#[cfg(feature = "watch")]
42pub struct DxWatcher {
43    watcher: RecommendedWatcher,
44    receiver: Receiver<FileChange>,
45    watched_paths: Vec<PathBuf>,
46}
47
48#[cfg(feature = "watch")]
49impl DxWatcher {
50    /// Create a new file watcher
51    ///
52    /// # Arguments
53    /// * `debounce_ms` - Debounce delay in milliseconds (prevents duplicate events)
54    ///
55    /// # Example
56    /// ```no_run
57    /// use serializer::watch::DxWatcher;
58    ///
59    /// let mut watcher = DxWatcher::new(250).unwrap();
60    /// watcher.watch_directory(".").unwrap();
61    ///
62    /// for change in watcher.changes() {
63    ///     println!("File changed: {:?}", change);
64    /// }
65    /// ```
66    pub fn new(debounce_ms: u64) -> Result<Self, notify::Error> {
67        let (tx, rx) = channel();
68
69        let watcher = Self::create_watcher(tx, debounce_ms)?;
70
71        Ok(Self {
72            watcher,
73            receiver: rx,
74            watched_paths: Vec::new(),
75        })
76    }
77
78    /// Create the underlying notify watcher
79    fn create_watcher(
80        tx: Sender<FileChange>,
81        debounce_ms: u64,
82    ) -> Result<RecommendedWatcher, notify::Error> {
83        let mut config = Config::default();
84        config = config.with_poll_interval(Duration::from_millis(debounce_ms));
85
86        RecommendedWatcher::new(
87            move |res: Result<Event, notify::Error>| {
88                if let Ok(event) = res {
89                    if let Some(change) = Self::process_event(event) {
90                        let _ = tx.send(change);
91                    }
92                }
93            },
94            config,
95        )
96    }
97
98    /// Process a notify event into a FileChange
99    fn process_event(event: Event) -> Option<FileChange> {
100        let path = event.paths.first()?;
101
102        // Check if it's a dx config file
103        if path.file_name()?.to_str()? == "dx" {
104            return Some(match event.kind {
105                EventKind::Create(_) | EventKind::Modify(_) => {
106                    FileChange::ConfigChanged(path.clone())
107                }
108                _ => return None,
109            });
110        }
111
112        // Check if it's a .sr file
113        if path.extension()?.to_str()? == "sr" {
114            return Some(match event.kind {
115                EventKind::Create(_) => FileChange::RuleFileCreated(path.clone()),
116                EventKind::Modify(_) => FileChange::RuleFileChanged(path.clone()),
117                EventKind::Remove(_) => FileChange::RuleFileDeleted(path.clone()),
118                _ => return None,
119            });
120        }
121
122        None
123    }
124
125    /// Watch a directory for dx and .sr files
126    ///
127    /// This will recursively watch the directory and all subdirectories.
128    pub fn watch_directory<P: AsRef<Path>>(&mut self, path: P) -> Result<(), notify::Error> {
129        let path = path.as_ref();
130        self.watcher.watch(path, RecursiveMode::Recursive)?;
131        self.watched_paths.push(path.to_path_buf());
132        Ok(())
133    }
134
135    /// Watch a specific file
136    pub fn watch_file<P: AsRef<Path>>(&mut self, path: P) -> Result<(), notify::Error> {
137        let path = path.as_ref();
138        self.watcher.watch(path, RecursiveMode::NonRecursive)?;
139        self.watched_paths.push(path.to_path_buf());
140        Ok(())
141    }
142
143    /// Stop watching a path
144    pub fn unwatch<P: AsRef<Path>>(&mut self, path: P) -> Result<(), notify::Error> {
145        let path = path.as_ref();
146        self.watcher.unwatch(path)?;
147        self.watched_paths.retain(|p| p != path);
148        Ok(())
149    }
150
151    /// Get an iterator over file changes
152    ///
153    /// This will block until a change occurs. Use `try_recv()` for non-blocking.
154    pub fn changes(&self) -> impl Iterator<Item = FileChange> + '_ {
155        std::iter::from_fn(move || self.receiver.recv().ok())
156    }
157
158    /// Try to receive a change without blocking
159    pub fn try_recv(&self) -> Option<FileChange> {
160        self.receiver.try_recv().ok()
161    }
162
163    /// Get all currently watched paths
164    pub fn watched_paths(&self) -> &[PathBuf] {
165        &self.watched_paths
166    }
167}
168
169/// Helper function to find all .sr files in a directory
170#[cfg(feature = "watch")]
171pub fn find_dxs_files<P: AsRef<Path>>(dir: P) -> Result<Vec<PathBuf>, std::io::Error> {
172    use std::fs;
173
174    let mut dxs_files = Vec::new();
175
176    for entry in fs::read_dir(dir)? {
177        let entry = entry?;
178        let path = entry.path();
179
180        if path.is_file() {
181            if let Some(ext) = path.extension() {
182                if ext == "sr" {
183                    dxs_files.push(path);
184                }
185            }
186        } else if path.is_dir() {
187            // Recursively search subdirectories
188            dxs_files.extend(find_dxs_files(&path)?);
189        }
190    }
191
192    Ok(dxs_files)
193}
194
195/// Helper function to find the root dx config file
196#[cfg(feature = "watch")]
197pub fn find_dx_config<P: AsRef<Path>>(start_dir: P) -> Option<PathBuf> {
198    let mut current = start_dir.as_ref().to_path_buf();
199
200    loop {
201        let config_path = current.join("dx");
202        if config_path.exists() && config_path.is_file() {
203            return Some(config_path);
204        }
205
206        // Move up one directory
207        if !current.pop() {
208            break;
209        }
210    }
211
212    None
213}
214
215/// Stub implementations when watch feature is not enabled
216#[cfg(not(feature = "watch"))]
217pub struct DxWatcher;
218
219#[cfg(not(feature = "watch"))]
220impl DxWatcher {
221    /// Return an error explaining that watcher support is behind the `watch` feature.
222    pub fn new(_debounce_ms: u64) -> Result<Self, &'static str> {
223        Err("Watch feature not enabled. Enable with --features watch")
224    }
225}
226
227#[cfg(test)]
228#[cfg(feature = "watch")]
229mod tests {
230    use super::*;
231    use std::fs;
232    use tempfile::tempdir;
233
234    #[test]
235    fn test_find_dxs_files() {
236        let temp = tempdir().unwrap();
237        let rules_dir = temp.path().join("rules");
238        fs::create_dir(&rules_dir).unwrap();
239
240        // Create some .sr files
241        fs::write(rules_dir.join("js-rules.sr"), "# JS rules").unwrap();
242        fs::write(rules_dir.join("py-rules.sr"), "# Python rules").unwrap();
243        fs::write(rules_dir.join("other.txt"), "not a rule file").unwrap();
244
245        let dxs_files = find_dxs_files(&rules_dir).unwrap();
246        assert_eq!(dxs_files.len(), 2);
247
248        let names: Vec<_> = dxs_files
249            .iter()
250            .filter_map(|p| p.file_name()?.to_str())
251            .collect();
252        assert!(names.contains(&"js-rules.sr"));
253        assert!(names.contains(&"py-rules.sr"));
254    }
255
256    #[test]
257    fn test_find_dx_config() {
258        let temp = tempdir().unwrap();
259        let config_path = temp.path().join("dx");
260        fs::write(&config_path, "# dx config").unwrap();
261
262        let found = find_dx_config(temp.path()).unwrap();
263        assert_eq!(found, config_path);
264    }
265
266    #[test]
267    fn test_find_dx_config_in_parent() {
268        let temp = tempdir().unwrap();
269        let config_path = temp.path().join("dx");
270        fs::write(&config_path, "# dx config").unwrap();
271
272        // Create subdirectory
273        let sub_dir = temp.path().join("src");
274        fs::create_dir(&sub_dir).unwrap();
275
276        // Should find config in parent
277        let found = find_dx_config(&sub_dir).unwrap();
278        assert_eq!(found, config_path);
279    }
280
281    #[test]
282    fn test_watcher_creation() {
283        let watcher = DxWatcher::new(250);
284        assert!(watcher.is_ok());
285    }
286}