ggen_cli_lib/conventions/
watcher.rs

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