ggen_cli_lib/conventions/
watcher.rs

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