1use anyhow::{Context, Result};
2use log::{debug, trace, warn};
3use notify::{RecommendedWatcher, RecursiveMode};
4use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7use std::sync::mpsc::{self, Receiver};
8use std::time::{Duration, Instant};
9
10use crate::config::Config;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum ChangeType {
15 Config,
17 Content(PathBuf),
19 Template,
21 Css,
23 StaticFile(PathBuf),
25 Image(PathBuf),
27 Home,
29}
30
31#[derive(Debug, Default)]
33pub struct ChangeSet {
34 pub full_rebuild: bool,
35 pub rebuild_css: bool,
36 pub reload_templates: bool,
37 pub rebuild_home: bool,
38 pub content_files: HashSet<PathBuf>,
39 pub static_files: HashSet<PathBuf>,
40 pub image_files: HashSet<PathBuf>,
41}
42
43impl ChangeSet {
44 pub fn is_empty(&self) -> bool {
45 !self.full_rebuild
46 && !self.rebuild_css
47 && !self.reload_templates
48 && !self.rebuild_home
49 && self.content_files.is_empty()
50 && self.static_files.is_empty()
51 && self.image_files.is_empty()
52 }
53
54 pub fn add(&mut self, change: ChangeType) {
55 match change {
56 ChangeType::Config => self.full_rebuild = true,
57 ChangeType::Css => self.rebuild_css = true,
58 ChangeType::Template => self.reload_templates = true,
59 ChangeType::Home => self.rebuild_home = true,
60 ChangeType::Content(path) => {
61 self.content_files.insert(path);
62 }
63 ChangeType::StaticFile(path) => {
64 self.static_files.insert(path);
65 }
66 ChangeType::Image(path) => {
67 self.image_files.insert(path);
68 }
69 }
70 }
71
72 pub fn optimize(&mut self) {
74 if self.full_rebuild {
75 self.rebuild_css = false;
76 self.reload_templates = false;
77 self.rebuild_home = false;
78 self.content_files.clear();
79 self.static_files.clear();
80 self.image_files.clear();
81 }
82 if self.reload_templates {
84 self.content_files.clear();
85 self.rebuild_home = true;
86 }
87 }
88}
89
90pub struct FileWatcher {
92 project_dir: PathBuf,
93 output_dir: PathBuf,
94 config_path: PathBuf,
95 content_dir: PathBuf,
96 templates_dir: PathBuf,
97 styles_dir: PathBuf,
98 static_dir: PathBuf,
99 home_path: PathBuf,
100 rx: Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
101 _watcher: notify_debouncer_mini::Debouncer<RecommendedWatcher>,
102}
103
104impl FileWatcher {
105 pub fn new(project_dir: &Path, config: &Config, output_dir: &Path) -> Result<Self> {
106 let project_dir = project_dir
108 .canonicalize()
109 .unwrap_or_else(|_| project_dir.to_path_buf());
110
111 let output_dir = output_dir
113 .canonicalize()
114 .unwrap_or_else(|_| output_dir.to_path_buf());
115
116 let config_path = project_dir.join("config.toml");
118 let content_dir = project_dir.join(&config.paths.content);
119 let templates_dir = project_dir.join(&config.paths.templates);
120 let styles_dir = project_dir.join(&config.paths.styles);
121 let static_dir = project_dir.join(&config.paths.static_files);
122 let home_path = content_dir.join(&config.paths.home);
123
124 let content_dir = content_dir.canonicalize().unwrap_or(content_dir);
126 let templates_dir = templates_dir.canonicalize().unwrap_or(templates_dir);
127 let styles_dir = styles_dir.canonicalize().unwrap_or(styles_dir);
128 let static_dir = static_dir.canonicalize().unwrap_or(static_dir);
129 let home_path = home_path.canonicalize().unwrap_or(home_path);
130 let config_path = config_path.canonicalize().unwrap_or(config_path);
131
132 let (tx, rx) = mpsc::channel();
134
135 let mut debouncer = new_debouncer(Duration::from_millis(300), tx)
137 .context("Failed to create file watcher")?;
138
139 let watcher = debouncer.watcher();
141
142 if config_path.exists() {
144 trace!("Watching config: {:?}", config_path);
145 watcher
146 .watch(&config_path, RecursiveMode::NonRecursive)
147 .with_context(|| format!("Failed to watch config: {:?}", config_path))?;
148 }
149
150 if content_dir.exists() {
152 trace!("Watching content: {:?}", content_dir);
153 watcher
154 .watch(&content_dir, RecursiveMode::Recursive)
155 .with_context(|| format!("Failed to watch content: {:?}", content_dir))?;
156 }
157
158 if templates_dir.exists() {
160 trace!("Watching templates: {:?}", templates_dir);
161 watcher
162 .watch(&templates_dir, RecursiveMode::Recursive)
163 .with_context(|| format!("Failed to watch templates: {:?}", templates_dir))?;
164 }
165
166 if styles_dir.exists() {
168 trace!("Watching styles: {:?}", styles_dir);
169 watcher
170 .watch(&styles_dir, RecursiveMode::Recursive)
171 .with_context(|| format!("Failed to watch styles: {:?}", styles_dir))?;
172 }
173
174 if static_dir.exists() {
176 trace!("Watching static: {:?}", static_dir);
177 watcher
178 .watch(&static_dir, RecursiveMode::Recursive)
179 .with_context(|| format!("Failed to watch static: {:?}", static_dir))?;
180 }
181
182 debug!("File watcher initialized");
183 println!("Watching for changes...");
184 println!(" Content: {:?}", content_dir);
185 println!(" Templates: {:?}", templates_dir);
186 println!(" Styles: {:?}", styles_dir);
187 println!(" Static: {:?}", static_dir);
188
189 Ok(Self {
190 project_dir,
191 output_dir,
192 config_path,
193 content_dir,
194 templates_dir,
195 styles_dir,
196 static_dir,
197 home_path,
198 rx,
199 _watcher: debouncer,
200 })
201 }
202
203 pub fn wait_for_changes(&self) -> Result<ChangeSet> {
205 let mut changes = ChangeSet::default();
206 trace!("Waiting for file changes...");
207
208 match self.rx.recv() {
210 Ok(Ok(events)) => {
211 trace!("Received {} file events", events.len());
212 for event in events {
213 if event.kind == DebouncedEventKind::Any
214 && let Some(change) = self.classify_change(&event.path)
215 {
216 trace!("Classified change: {:?} -> {:?}", event.path, change);
217 changes.add(change);
218 }
219 }
220 }
221 Ok(Err(e)) => {
222 warn!("Watch error: {:?}", e);
223 }
224 Err(e) => {
225 return Err(anyhow::anyhow!("Watch channel closed: {:?}", e));
226 }
227 }
228
229 let drain_start = Instant::now();
231 while drain_start.elapsed() < Duration::from_millis(50) {
232 match self.rx.try_recv() {
233 Ok(Ok(events)) => {
234 for event in events {
235 if event.kind == DebouncedEventKind::Any
236 && let Some(change) = self.classify_change(&event.path)
237 {
238 changes.add(change);
239 }
240 }
241 }
242 Ok(Err(e)) => {
243 warn!("Watch error: {:?}", e);
244 }
245 Err(mpsc::TryRecvError::Empty) => break,
246 Err(mpsc::TryRecvError::Disconnected) => break,
247 }
248 }
249
250 changes.optimize();
251 Ok(changes)
252 }
253
254 fn classify_change(&self, path: &Path) -> Option<ChangeType> {
256 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
258 let path = path.as_path();
259
260 if path.starts_with(&self.output_dir) {
262 trace!("Skipping output directory path: {:?}", path);
263 return None;
264 }
265
266 if path
268 .components()
269 .any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
270 {
271 trace!("Skipping hidden path: {:?}", path);
272 return None;
273 }
274
275 if path == self.config_path {
277 return Some(ChangeType::Config);
278 }
279
280 if path == self.home_path {
282 return Some(ChangeType::Home);
283 }
284
285 if path.starts_with(&self.styles_dir) {
289 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
290 if ext == "css" {
291 return Some(ChangeType::Css);
292 }
293 return None;
294 }
295
296 if path.starts_with(&self.templates_dir) {
298 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
299 if ext == "html" || ext == "htm" {
300 return Some(ChangeType::Template);
301 }
302 return None;
303 }
304
305 if path.starts_with(&self.static_dir) {
307 let ext = path
308 .extension()
309 .and_then(|e| e.to_str())
310 .unwrap_or("")
311 .to_lowercase();
312
313 if let Ok(rel) = path.strip_prefix(&self.static_dir) {
314 if matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp" | "gif") {
315 return Some(ChangeType::Image(rel.to_path_buf()));
316 }
317 return Some(ChangeType::StaticFile(rel.to_path_buf()));
318 }
319 return None;
320 }
321
322 if path.starts_with(&self.content_dir) {
324 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
325 if (ext == "md" || ext == "html" || ext == "htm")
326 && let Ok(rel) = path.strip_prefix(&self.content_dir)
327 {
328 return Some(ChangeType::Content(rel.to_path_buf()));
329 }
330 return None;
331 }
332
333 None
334 }
335
336 pub fn project_dir(&self) -> &Path {
338 &self.project_dir
339 }
340}
341
342pub fn format_changes(changes: &ChangeSet) -> String {
344 let mut parts = Vec::new();
345
346 if changes.full_rebuild {
347 return "config changed (full rebuild)".to_string();
348 }
349
350 if changes.reload_templates {
351 parts.push("templates".to_string());
352 }
353
354 if changes.rebuild_css {
355 parts.push("css".to_string());
356 }
357
358 if changes.rebuild_home {
359 parts.push("home".to_string());
360 }
361
362 if !changes.content_files.is_empty() {
363 let count = changes.content_files.len();
364 if count == 1 {
365 let path = changes.content_files.iter().next().unwrap();
366 parts.push(format!("content: {}", path.display()));
367 } else {
368 parts.push(format!("{} content files", count));
369 }
370 }
371
372 if !changes.static_files.is_empty() {
373 parts.push(format!("{} static files", changes.static_files.len()));
374 }
375
376 if !changes.image_files.is_empty() {
377 parts.push(format!("{} images", changes.image_files.len()));
378 }
379
380 if parts.is_empty() {
381 "no relevant changes".to_string()
382 } else {
383 parts.join(", ")
384 }
385}