ggen_cli_lib/conventions/
watcher.rs1use 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
14pub struct ProjectWatcher {
16 debouncer: Debouncer<notify::RecommendedWatcher, FileIdMap>,
17 receiver: Receiver<DebounceEventResult>,
18 resolver: ConventionResolver,
19 planner: GenerationPlanner,
20 #[allow(dead_code)]
22 debounce_ms: u64,
23}
24
25impl ProjectWatcher {
26 pub fn new(project_root: PathBuf) -> Result<Self> {
28 Self::with_debounce(project_root, 300)
29 }
30
31 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 pub fn watch(&mut self) -> Result<()> {
61 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 pub fn stop(self) -> Result<()> {
85 drop(self.debouncer);
86 Ok(())
87 }
88
89 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 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 let plan = self.planner.plan()?;
132 Ok(Some(plan))
133 }
134 _ => Ok(None),
135 }
136 }
137
138 fn should_process_file(&self, path: &Path) -> bool {
140 if path.to_string_lossy().contains("generated/") {
142 return false;
143 }
144
145 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 if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
156 return false;
157 }
158
159 true
160 }
161
162 #[allow(dead_code)]
164 fn find_affected_templates(&self, _changed: &[PathBuf]) -> Vec<String> {
165 let conventions = self.resolver.discover().unwrap_or_else(|_| {
168 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 pub fn regenerate_template(&self, template: &str) -> Result<()> {
185 log::info!("Regenerating template: {}", template);
188 Ok(())
189 }
190
191 pub fn resolver(&self) -> &ConventionResolver {
193 &self.resolver
194 }
195
196 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 assert!(watcher.should_process_file(&PathBuf::from("domain/test.yaml")));
229 assert!(watcher.should_process_file(&PathBuf::from("templates/test.tera")));
230
231 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 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 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 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 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 assert_eq!(affected.len(), 0);
284 }
285}