Skip to main content

ggen_cli_lib/conventions/
watcher.rs

1//! File watcher for automatic regeneration on RDF changes
2
3use ggen_core::utils::error::Result;
4use notify::{Event, RecursiveMode, Watcher};
5use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, FileIdMap};
6use std::path::{Path, PathBuf};
7use std::sync::mpsc::{channel, Receiver};
8use std::time::Duration;
9
10use super::planner::{GenerationPlan, GenerationPlanner};
11use super::resolver::ConventionResolver;
12
13/// Watches project files and triggers regeneration on changes
14pub struct ProjectWatcher {
15    debouncer: Debouncer<notify::RecommendedWatcher, FileIdMap>,
16    receiver: Receiver<DebounceEventResult>,
17    resolver: ConventionResolver,
18    planner: GenerationPlanner,
19    /// Debounce delay in milliseconds
20    #[allow(dead_code)]
21    debounce_ms: u64,
22}
23
24impl ProjectWatcher {
25    /// Create a new project watcher with default 300ms debounce
26    pub fn new(project_root: PathBuf) -> Result<Self> {
27        Self::with_debounce(project_root, 300)
28    }
29
30    /// Create a new project watcher with custom debounce duration
31    fn with_debounce(project_root: PathBuf, debounce_ms: u64) -> Result<Self> {
32        let (tx, rx) = channel();
33
34        let debouncer = new_debouncer(
35            Duration::from_millis(debounce_ms),
36            None,
37            move |result: DebounceEventResult| {
38                let _ = tx.send(result);
39            },
40        )
41        .map_err(|e| {
42            ggen_core::utils::error::Error::new(&format!("Failed to create file watcher: {}", e))
43        })?;
44
45        let resolver = ConventionResolver::new(project_root.clone());
46        let conventions = resolver.discover()?;
47        let planner = GenerationPlanner::new(conventions.clone());
48
49        Ok(Self {
50            debouncer,
51            receiver: rx,
52            resolver,
53            planner,
54            debounce_ms,
55        })
56    }
57
58    /// Start watching the project directories
59    pub fn watch(&mut self) -> Result<()> {
60        // Get conventions to determine watch directories
61        let conventions = self.resolver.discover()?;
62
63        let watched_dirs = vec![&conventions.rdf_dir, &conventions.templates_dir];
64
65        for dir in watched_dirs {
66            if dir.exists() {
67                self.debouncer
68                    .watcher()
69                    .watch(dir, RecursiveMode::Recursive)
70                    .map_err(|e| {
71                        ggen_core::utils::error::Error::new(&format!(
72                            "Failed to watch directory {:?}: {}",
73                            dir, e
74                        ))
75                    })?;
76            }
77        }
78
79        Ok(())
80    }
81
82    /// Stop watching (drops the watcher)
83    pub fn stop(self) -> Result<()> {
84        drop(self.debouncer);
85        Ok(())
86    }
87
88    /// Process pending file system events
89    pub fn process_events(&mut self) -> Result<Vec<GenerationPlan>> {
90        let mut plans = Vec::new();
91
92        while let Ok(result) = self.receiver.try_recv() {
93            match result {
94                Ok(events) => {
95                    for event in events {
96                        if let Some(plan) = self.handle_change(event.event)? {
97                            plans.push(plan);
98                        }
99                    }
100                }
101                Err(errors) => {
102                    for error in errors {
103                        log::error!("Watch error: {:?}", error);
104                    }
105                }
106            }
107        }
108
109        Ok(plans)
110    }
111
112    /// Handle a file system change event
113    fn handle_change(&mut self, event: Event) -> Result<Option<GenerationPlan>> {
114        use notify::EventKind;
115
116        match event.kind {
117            EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
118                let changed_files: Vec<PathBuf> = event
119                    .paths
120                    .into_iter()
121                    .filter(|p| self.should_process_file(p))
122                    .collect();
123
124                if changed_files.is_empty() {
125                    return Ok(None);
126                }
127
128                // For now, regenerate all templates when any file changes
129                // A more sophisticated implementation could track dependencies
130                let plan = self.planner.plan()?;
131                Ok(Some(plan))
132            }
133            _ => Ok(None),
134        }
135    }
136
137    /// Check if a file should be processed
138    fn should_process_file(&self, path: &Path) -> bool {
139        // Ignore hidden files and directories
140        if path
141            .file_name()
142            .and_then(|n| n.to_str())
143            .is_some_and(|n| n.starts_with('.'))
144        {
145            return false;
146        }
147
148        // Ignore temporary files
149        if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
150            return false;
151        }
152
153        true
154    }
155
156    /// Find templates affected by file changes
157    #[allow(dead_code)]
158    fn find_affected_templates(&self, _changed: &[PathBuf]) -> Vec<String> {
159        // For now, return all templates
160        // A more sophisticated implementation would analyze dependencies
161        match self.resolver.discover() {
162            Ok(conventions) => conventions.templates.keys().cloned().collect(),
163            Err(e) => {
164                log::warn!(
165                    "Failed to discover conventions for affected templates: {}",
166                    e
167                );
168                // Return empty list on error - safer than continuing with stale data
169                Vec::new()
170            }
171        }
172    }
173
174    /// Regenerate a specific template
175    pub fn regenerate_template(&self, template: &str) -> Result<()> {
176        // In a real implementation, this would call the template engine
177        // For now, we just log it
178        log::info!("Regenerating template: {}", template);
179        Ok(())
180    }
181
182    /// Get the resolver
183    pub fn resolver(&self) -> &ConventionResolver {
184        &self.resolver
185    }
186
187    /// Get the planner
188    pub fn planner(&self) -> &GenerationPlanner {
189        &self.planner
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::fs;
197    use tempfile::TempDir;
198
199    #[test]
200    fn test_watcher_creation() {
201        let temp_dir = TempDir::new().unwrap();
202        let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf());
203        assert!(watcher.is_ok());
204    }
205
206    #[test]
207    fn test_watcher_with_debounce() {
208        let temp_dir = TempDir::new().unwrap();
209        let watcher = ProjectWatcher::with_debounce(temp_dir.path().to_path_buf(), 500);
210        assert!(watcher.is_ok());
211    }
212
213    #[test]
214    fn test_should_process_file() {
215        let temp_dir = TempDir::new().unwrap();
216        let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
217
218        // Should process
219        assert!(watcher.should_process_file(&PathBuf::from("domain/test.yaml")));
220        assert!(watcher.should_process_file(&PathBuf::from("templates/test.tera")));
221
222        // Should not process hidden and temp files
223        assert!(!watcher.should_process_file(&PathBuf::from(".hidden")));
224        assert!(!watcher.should_process_file(&PathBuf::from("test.tmp")));
225    }
226
227    #[test]
228    fn test_watch_and_stop() {
229        let temp_dir = TempDir::new().unwrap();
230
231        // Create watched directories
232        fs::create_dir_all(temp_dir.path().join("domain")).unwrap();
233        fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
234
235        let mut watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
236        assert!(watcher.watch().is_ok());
237        assert!(watcher.stop().is_ok());
238    }
239
240    #[test]
241    fn test_process_events() {
242        let temp_dir = TempDir::new().unwrap();
243
244        // Create watched directories
245        fs::create_dir_all(temp_dir.path().join("domain")).unwrap();
246
247        let mut watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
248        watcher.watch().unwrap();
249
250        // Process events (should be empty initially)
251        let plans = watcher.process_events().unwrap();
252        assert_eq!(plans.len(), 0);
253    }
254
255    #[test]
256    fn test_regenerate_template() {
257        let temp_dir = TempDir::new().unwrap();
258        let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
259
260        // Should not fail
261        assert!(watcher.regenerate_template("test_template").is_ok());
262    }
263
264    #[test]
265    fn test_find_affected_templates() {
266        let temp_dir = TempDir::new().unwrap();
267        let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
268
269        let changed = vec![temp_dir.path().join("domain/test.yaml")];
270        let affected = watcher.find_affected_templates(&changed);
271
272        // Should return empty since no conventions are registered
273        assert_eq!(affected.len(), 0);
274    }
275}