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.display(),
74                            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(&self, event: Event) -> Result<Option<GenerationPlan>> {
115        use notify::EventKind;
116
117        match event.kind {
118            EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
119                let has_changed_files = event.paths.iter().any(|p| self.should_process_file(p));
120
121                if !has_changed_files {
122                    return Ok(None);
123                }
124
125                // For now, regenerate all templates when any file changes
126                // A more sophisticated implementation could track dependencies
127                let plan = self.planner.plan()?;
128                Ok(Some(plan))
129            }
130            _ => Ok(None),
131        }
132    }
133
134    /// Check if a file should be processed
135    fn should_process_file(&self, path: &Path) -> bool {
136        // Ignore hidden files and directories
137        if path
138            .file_name()
139            .and_then(|n| n.to_str())
140            .is_some_and(|n| n.starts_with('.'))
141        {
142            return false;
143        }
144
145        // Ignore temporary files
146        if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
147            return false;
148        }
149
150        true
151    }
152
153    /// Find templates affected by file changes
154    #[allow(dead_code)]
155    fn find_affected_templates(&self, _changed: &[PathBuf]) -> Vec<String> {
156        // For now, return all templates
157        // A more sophisticated implementation would analyze dependencies
158        match self.resolver.discover() {
159            Ok(conventions) => conventions.templates.keys().cloned().collect(),
160            Err(e) => {
161                log::warn!(
162                    "Failed to discover conventions for affected templates: {}",
163                    e
164                );
165                // Return empty list on error - safer than continuing with stale data
166                Vec::new()
167            }
168        }
169    }
170
171    /// Regenerate a specific template
172    pub fn regenerate_template(&self, template: &str) -> Result<()> {
173        // In a real implementation, this would call the template engine
174        // For now, we just log it
175        log::info!("Regenerating template: {}", template);
176        Ok(())
177    }
178
179    /// Get the resolver
180    pub fn resolver(&self) -> &ConventionResolver {
181        &self.resolver
182    }
183
184    /// Get the planner
185    pub fn planner(&self) -> &GenerationPlanner {
186        &self.planner
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::fs;
194    use tempfile::TempDir;
195
196    #[test]
197    fn test_watcher_creation() {
198        let temp_dir = TempDir::new().unwrap();
199        let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf());
200        assert!(watcher.is_ok());
201    }
202
203    #[test]
204    fn test_watcher_with_debounce() {
205        let temp_dir = TempDir::new().unwrap();
206        let watcher = ProjectWatcher::with_debounce(temp_dir.path().to_path_buf(), 500);
207        assert!(watcher.is_ok());
208    }
209
210    #[test]
211    fn test_should_process_file() {
212        let temp_dir = TempDir::new().unwrap();
213        let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
214
215        // Should process
216        assert!(watcher.should_process_file(&PathBuf::from("domain/test.yaml")));
217        assert!(watcher.should_process_file(&PathBuf::from("templates/test.tera")));
218
219        // Should not process hidden and temp files
220        assert!(!watcher.should_process_file(&PathBuf::from(".hidden")));
221        assert!(!watcher.should_process_file(&PathBuf::from("test.tmp")));
222    }
223
224    #[test]
225    fn test_watch_and_stop() {
226        let temp_dir = TempDir::new().unwrap();
227
228        // Create watched directories
229        fs::create_dir_all(temp_dir.path().join("domain")).unwrap();
230        fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
231
232        let mut watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
233        assert!(watcher.watch().is_ok());
234        assert!(watcher.stop().is_ok());
235    }
236
237    #[test]
238    fn test_process_events() {
239        let temp_dir = TempDir::new().unwrap();
240
241        // Create watched directories
242        fs::create_dir_all(temp_dir.path().join("domain")).unwrap();
243
244        let mut watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
245        watcher.watch().unwrap();
246
247        // Process events (should be empty initially)
248        let plans = watcher.process_events().unwrap();
249        assert_eq!(plans.len(), 0);
250    }
251
252    #[test]
253    fn test_regenerate_template() {
254        let temp_dir = TempDir::new().unwrap();
255        let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
256
257        // Should not fail
258        assert!(watcher.regenerate_template("test_template").is_ok());
259    }
260
261    #[test]
262    fn test_find_affected_templates() {
263        let temp_dir = TempDir::new().unwrap();
264        let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
265
266        let changed = vec![temp_dir.path().join("domain/test.yaml")];
267        let affected = watcher.find_affected_templates(&changed);
268
269        // Should return empty since no conventions are registered
270        assert_eq!(affected.len(), 0);
271    }
272}