ggen_cli_lib/conventions/
watcher.rs1use 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
13pub struct ProjectWatcher {
15 debouncer: Debouncer<notify::RecommendedWatcher, FileIdMap>,
16 receiver: Receiver<DebounceEventResult>,
17 resolver: ConventionResolver,
18 planner: GenerationPlanner,
19 #[allow(dead_code)]
21 debounce_ms: u64,
22}
23
24impl ProjectWatcher {
25 pub fn new(project_root: PathBuf) -> Result<Self> {
27 Self::with_debounce(project_root, 300)
28 }
29
30 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 pub fn watch(&mut self) -> Result<()> {
60 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, e
74 ))
75 })?;
76 }
77 }
78
79 Ok(())
80 }
81
82 pub fn stop(self) -> Result<()> {
84 drop(self.debouncer);
85 Ok(())
86 }
87
88 pub fn process_events(&mut self) -> Result<Vec<GenerationPlan>> {
90 let mut plans = Vec::new();
91
92 while let Ok(result) = self.receiver.try_recv() {
93 match result {
94 Ok(events) => {
95 for event in events {
96 if let Some(plan) = self.handle_change(event.event)? {
97 plans.push(plan);
98 }
99 }
100 }
101 Err(errors) => {
102 for error in errors {
103 log::error!("Watch error: {:?}", error);
104 }
105 }
106 }
107 }
108
109 Ok(plans)
110 }
111
112 fn handle_change(&mut self, event: Event) -> Result<Option<GenerationPlan>> {
114 use notify::EventKind;
115
116 match event.kind {
117 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
118 let changed_files: Vec<PathBuf> = event
119 .paths
120 .into_iter()
121 .filter(|p| self.should_process_file(p))
122 .collect();
123
124 if changed_files.is_empty() {
125 return Ok(None);
126 }
127
128 let plan = self.planner.plan()?;
131 Ok(Some(plan))
132 }
133 _ => Ok(None),
134 }
135 }
136
137 fn should_process_file(&self, path: &Path) -> bool {
139 if path
141 .file_name()
142 .and_then(|n| n.to_str())
143 .is_some_and(|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 #[allow(dead_code)]
158 fn find_affected_templates(&self, _changed: &[PathBuf]) -> Vec<String> {
159 match self.resolver.discover() {
162 Ok(conventions) => conventions.templates.keys().cloned().collect(),
163 Err(e) => {
164 log::warn!(
165 "Failed to discover conventions for affected templates: {}",
166 e
167 );
168 Vec::new()
170 }
171 }
172 }
173
174 pub fn regenerate_template(&self, template: &str) -> Result<()> {
176 log::info!("Regenerating template: {}", template);
179 Ok(())
180 }
181
182 pub fn resolver(&self) -> &ConventionResolver {
184 &self.resolver
185 }
186
187 pub fn planner(&self) -> &GenerationPlanner {
189 &self.planner
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::fs;
197 use tempfile::TempDir;
198
199 #[test]
200 fn test_watcher_creation() {
201 let temp_dir = TempDir::new().unwrap();
202 let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf());
203 assert!(watcher.is_ok());
204 }
205
206 #[test]
207 fn test_watcher_with_debounce() {
208 let temp_dir = TempDir::new().unwrap();
209 let watcher = ProjectWatcher::with_debounce(temp_dir.path().to_path_buf(), 500);
210 assert!(watcher.is_ok());
211 }
212
213 #[test]
214 fn test_should_process_file() {
215 let temp_dir = TempDir::new().unwrap();
216 let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
217
218 assert!(watcher.should_process_file(&PathBuf::from("domain/test.yaml")));
220 assert!(watcher.should_process_file(&PathBuf::from("templates/test.tera")));
221
222 assert!(!watcher.should_process_file(&PathBuf::from(".hidden")));
224 assert!(!watcher.should_process_file(&PathBuf::from("test.tmp")));
225 }
226
227 #[test]
228 fn test_watch_and_stop() {
229 let temp_dir = TempDir::new().unwrap();
230
231 fs::create_dir_all(temp_dir.path().join("domain")).unwrap();
233 fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
234
235 let mut watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
236 assert!(watcher.watch().is_ok());
237 assert!(watcher.stop().is_ok());
238 }
239
240 #[test]
241 fn test_process_events() {
242 let temp_dir = TempDir::new().unwrap();
243
244 fs::create_dir_all(temp_dir.path().join("domain")).unwrap();
246
247 let mut watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
248 watcher.watch().unwrap();
249
250 let plans = watcher.process_events().unwrap();
252 assert_eq!(plans.len(), 0);
253 }
254
255 #[test]
256 fn test_regenerate_template() {
257 let temp_dir = TempDir::new().unwrap();
258 let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
259
260 assert!(watcher.regenerate_template("test_template").is_ok());
262 }
263
264 #[test]
265 fn test_find_affected_templates() {
266 let temp_dir = TempDir::new().unwrap();
267 let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
268
269 let changed = vec![temp_dir.path().join("domain/test.yaml")];
270 let affected = watcher.find_affected_templates(&changed);
271
272 assert_eq!(affected.len(), 0);
274 }
275}