rs_web/
watch.rs

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/// Types of changes that trigger different rebuild strategies
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum ChangeType {
15    /// Config file changed - requires full rebuild
16    Config,
17    /// Content file changed (markdown or HTML in content dir)
18    Content(PathBuf),
19    /// Template file changed - re-render all posts
20    Template,
21    /// CSS/style file changed - only rebuild CSS
22    Css,
23    /// Static file changed (non-image) - copy that file
24    StaticFile(PathBuf),
25    /// Image file changed - optimize and copy that image
26    Image(PathBuf),
27    /// Home page changed
28    Home,
29}
30
31/// Aggregated changes from a batch of file events
32#[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    /// Optimize the change set - if full rebuild is needed, clear everything else
73    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 templates changed, we need to re-render all content anyway
83        if self.reload_templates {
84            self.content_files.clear();
85            self.rebuild_home = true;
86        }
87    }
88}
89
90/// File watcher for incremental builds
91pub 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        // Canonicalize project_dir to ensure consistent path matching
107        let project_dir = project_dir
108            .canonicalize()
109            .unwrap_or_else(|_| project_dir.to_path_buf());
110
111        // Canonicalize output_dir to skip it during watch
112        let output_dir = output_dir
113            .canonicalize()
114            .unwrap_or_else(|_| output_dir.to_path_buf());
115
116        // Resolve all watched paths (canonicalize for consistent matching)
117        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        // Canonicalize watched directories if they exist
125        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        // Create channel for events
133        let (tx, rx) = mpsc::channel();
134
135        // Create debounced watcher (300ms debounce)
136        let mut debouncer = new_debouncer(Duration::from_millis(300), tx)
137            .context("Failed to create file watcher")?;
138
139        // Watch all relevant directories
140        let watcher = debouncer.watcher();
141
142        // Watch config file
143        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        // Watch content directory
151        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        // Watch templates directory
159        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        // Watch styles directory
167        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        // Watch static directory
175        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    /// Wait for changes and return aggregated change set
204    pub fn wait_for_changes(&self) -> Result<ChangeSet> {
205        let mut changes = ChangeSet::default();
206        trace!("Waiting for file changes...");
207
208        // Block until we receive events
209        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        // Drain any additional pending events (with short timeout)
230        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    /// Classify a file path into a change type
255    fn classify_change(&self, path: &Path) -> Option<ChangeType> {
256        // Canonicalize the event path for consistent comparison
257        let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
258        let path = path.as_path();
259
260        // Skip events from the output directory (prevents feedback loop)
261        if path.starts_with(&self.output_dir) {
262            trace!("Skipping output directory path: {:?}", path);
263            return None;
264        }
265
266        // Skip hidden files and directories
267        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        // Config file
276        if path == self.config_path {
277            return Some(ChangeType::Config);
278        }
279
280        // Home page
281        if path == self.home_path {
282            return Some(ChangeType::Home);
283        }
284
285        // Check specific directories FIRST (before content, which may be a parent)
286
287        // Styles directory
288        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        // Templates directory
297        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        // Static directory
306        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        // Content directory (check last as it may be a parent of other dirs)
323        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    /// Get the project directory
337    pub fn project_dir(&self) -> &Path {
338        &self.project_dir
339    }
340}
341
342/// Format a change set for display
343pub 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}