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.display(),
74 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(&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 let plan = self.planner.plan()?;
128 Ok(Some(plan))
129 }
130 _ => Ok(None),
131 }
132 }
133
134 fn should_process_file(&self, path: &Path) -> bool {
136 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 if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
147 return false;
148 }
149
150 true
151 }
152
153 #[allow(dead_code)]
155 fn find_affected_templates(&self, _changed: &[PathBuf]) -> Vec<String> {
156 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 Vec::new()
167 }
168 }
169 }
170
171 pub fn regenerate_template(&self, template: &str) -> Result<()> {
173 log::info!("Regenerating template: {}", template);
176 Ok(())
177 }
178
179 pub fn resolver(&self) -> &ConventionResolver {
181 &self.resolver
182 }
183
184 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 assert!(watcher.should_process_file(&PathBuf::from("domain/test.yaml")));
217 assert!(watcher.should_process_file(&PathBuf::from("templates/test.tera")));
218
219 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 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 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 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 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 assert_eq!(affected.len(), 0);
271 }
272}