ggen_cli_lib/conventions/
watcher.rs1use 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
14pub 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 pub fn new(project_root: PathBuf) -> Result<Self> {
26 Self::with_debounce(project_root, 300)
27 }
28
29 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 pub fn watch(&mut self) -> Result<()> {
57 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 pub fn stop(self) -> Result<()> {
79 drop(self.debouncer);
80 Ok(())
81 }
82
83 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 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 let plan = self.planner.plan()?;
126 Ok(Some(plan))
127 }
128 _ => Ok(None),
129 }
130 }
131
132 fn should_process_file(&self, path: &Path) -> bool {
134 if path.to_string_lossy().contains("generated/") {
136 return false;
137 }
138
139 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 if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
150 return false;
151 }
152
153 true
154 }
155
156 fn find_affected_templates(&self, _changed: &[PathBuf]) -> Vec<String> {
158 let conventions = self.resolver.discover().unwrap_or_else(|_| {
161 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 pub fn regenerate_template(&self, template: &str) -> Result<()> {
178 println!("Regenerating template: {}", template);
181 Ok(())
182 }
183
184 pub fn resolver(&self) -> &ConventionResolver {
186 &self.resolver
187 }
188
189 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 assert!(watcher.should_process_file(&PathBuf::from("domain/test.yaml")));
222 assert!(watcher.should_process_file(&PathBuf::from("templates/test.tera")));
223
224 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 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 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 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 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 assert_eq!(affected.len(), 0);
277 }
278}