rs_web/
watch.rs

1//! File watcher for incremental rebuilds
2
3use anyhow::{Context, Result};
4use log::{debug, trace, warn};
5use notify::{RecommendedWatcher, RecursiveMode};
6use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::mpsc::{self, Receiver};
10use std::time::{Duration, Instant};
11
12use crate::config::Config;
13
14/// Types of changes that trigger different rebuild strategies
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub enum ChangeType {
17    /// Config file changed - requires full rebuild
18    Config,
19    /// Content file changed - requires full rebuild (Lua controls content)
20    Content(PathBuf),
21    /// Template file changed - re-render all posts
22    Template,
23    /// CSS/style file changed - only rebuild CSS
24    Css,
25    /// Static file changed (non-image) - copy that file
26    StaticFile(PathBuf),
27    /// Image file changed - optimize and copy that image
28    Image(PathBuf),
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    fn add(&mut self, change: ChangeType) {
55        match change {
56            ChangeType::Config => self.full_rebuild = true,
57            ChangeType::Content(path) => {
58                // Any content change triggers full rebuild
59                self.content_files.insert(path);
60            }
61            ChangeType::Template => self.reload_templates = true,
62            ChangeType::Css => self.rebuild_css = true,
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    fn optimize(&mut self) {
73        // If full rebuild, clear incremental changes
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    }
83}
84
85/// File watcher for incremental builds
86pub struct FileWatcher {
87    project_dir: PathBuf,
88    output_dir: PathBuf,
89    config_path: PathBuf,
90    templates_dir: PathBuf,
91    styles_dir: PathBuf,
92    static_dir: PathBuf,
93    rx: Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
94    _watcher: notify_debouncer_mini::Debouncer<RecommendedWatcher>,
95}
96
97impl FileWatcher {
98    pub fn new(project_dir: &Path, config: &Config, output_dir: &Path) -> Result<Self> {
99        let project_dir = project_dir
100            .canonicalize()
101            .unwrap_or_else(|_| project_dir.to_path_buf());
102        let output_dir = output_dir
103            .canonicalize()
104            .unwrap_or_else(|_| output_dir.to_path_buf());
105        let config_path = project_dir.join("config.lua");
106        let templates_dir = project_dir.join(&config.paths.templates);
107        let styles_dir = project_dir.join(&config.paths.styles);
108        let static_dir = project_dir.join(&config.paths.static_files);
109
110        let templates_dir = templates_dir.canonicalize().unwrap_or(templates_dir);
111        let styles_dir = styles_dir.canonicalize().unwrap_or(styles_dir);
112        let static_dir = static_dir.canonicalize().unwrap_or(static_dir);
113        let config_path = config_path.canonicalize().unwrap_or(config_path);
114
115        // Create channel for events
116        let (tx, rx) = mpsc::channel();
117
118        // Create debounced watcher (300ms debounce)
119        let mut debouncer = new_debouncer(Duration::from_millis(300), tx)
120            .context("Failed to create file watcher")?;
121
122        // Watch all relevant directories
123        let watcher = debouncer.watcher();
124
125        // Watch config file
126        if config_path.exists() {
127            trace!("Watching config: {:?}", config_path);
128            watcher
129                .watch(&config_path, RecursiveMode::NonRecursive)
130                .with_context(|| format!("Failed to watch config: {:?}", config_path))?;
131        }
132
133        // Watch project directory for content changes (Lua decides what's content)
134        trace!("Watching project: {:?}", project_dir);
135        watcher
136            .watch(&project_dir, RecursiveMode::Recursive)
137            .with_context(|| format!("Failed to watch project: {:?}", project_dir))?;
138
139        debug!("File watcher initialized");
140        println!("Watching for changes...");
141        println!("  Project:   {:?}", project_dir);
142        println!("  Templates: {:?}", templates_dir);
143        println!("  Styles:    {:?}", styles_dir);
144        println!("  Static:    {:?}", static_dir);
145
146        Ok(Self {
147            project_dir,
148            output_dir,
149            config_path,
150            templates_dir,
151            styles_dir,
152            static_dir,
153            rx,
154            _watcher: debouncer,
155        })
156    }
157
158    /// Wait for changes and return aggregated change set
159    pub fn wait_for_changes(&self) -> Result<ChangeSet> {
160        let mut changes = ChangeSet::default();
161        trace!("Waiting for file changes...");
162
163        // Block until we receive events
164        match self.rx.recv() {
165            Ok(Ok(events)) => {
166                trace!("Received {} file events", events.len());
167                for event in events {
168                    if event.kind == DebouncedEventKind::Any
169                        && let Some(change) = self.classify_change(&event.path)
170                    {
171                        trace!("Classified change: {:?} -> {:?}", event.path, change);
172                        changes.add(change);
173                    }
174                }
175            }
176            Ok(Err(e)) => {
177                warn!("Watch error: {:?}", e);
178            }
179            Err(e) => {
180                return Err(anyhow::anyhow!("Watch channel closed: {:?}", e));
181            }
182        }
183
184        // Drain any additional pending events (with short timeout)
185        let drain_start = Instant::now();
186        while drain_start.elapsed() < Duration::from_millis(50) {
187            match self.rx.try_recv() {
188                Ok(Ok(events)) => {
189                    for event in events {
190                        if event.kind == DebouncedEventKind::Any
191                            && let Some(change) = self.classify_change(&event.path)
192                        {
193                            changes.add(change);
194                        }
195                    }
196                }
197                Ok(Err(e)) => {
198                    warn!("Watch error: {:?}", e);
199                }
200                Err(mpsc::TryRecvError::Empty) => break,
201                Err(mpsc::TryRecvError::Disconnected) => break,
202            }
203        }
204
205        changes.optimize();
206        Ok(changes)
207    }
208
209    /// Classify a file path into a change type
210    fn classify_change(&self, path: &Path) -> Option<ChangeType> {
211        let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
212        let path = path.as_path();
213
214        // Skip events from the output directory (prevents feedback loop)
215        if path.starts_with(&self.output_dir) {
216            trace!("Skipping output directory path: {:?}", path);
217            return None;
218        }
219
220        // Skip hidden files and directories
221        if path
222            .components()
223            .any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
224        {
225            trace!("Skipping hidden path: {:?}", path);
226            return None;
227        }
228
229        // Config file
230        if path == self.config_path {
231            return Some(ChangeType::Config);
232        }
233
234        // Styles directory
235        if path.starts_with(&self.styles_dir) {
236            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
237            if ext == "css" {
238                return Some(ChangeType::Css);
239            }
240            return None;
241        }
242
243        // Templates directory
244        if path.starts_with(&self.templates_dir) {
245            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
246            if ext == "html" || ext == "htm" {
247                return Some(ChangeType::Template);
248            }
249            return None;
250        }
251
252        // Static directory
253        if path.starts_with(&self.static_dir) {
254            let ext = path
255                .extension()
256                .and_then(|e| e.to_str())
257                .unwrap_or("")
258                .to_lowercase();
259
260            if let Ok(rel) = path.strip_prefix(&self.static_dir) {
261                if matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp" | "gif") {
262                    return Some(ChangeType::Image(rel.to_path_buf()));
263                }
264                return Some(ChangeType::StaticFile(rel.to_path_buf()));
265            }
266            return None;
267        }
268
269        // Any other file in project directory is content (triggers full rebuild)
270        if path.starts_with(&self.project_dir) {
271            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
272            if (ext == "md" || ext == "html" || ext == "htm" || ext == "json" || ext == "lua")
273                && let Ok(rel) = path.strip_prefix(&self.project_dir)
274            {
275                return Some(ChangeType::Content(rel.to_path_buf()));
276            }
277        }
278
279        None
280    }
281
282    /// Get the project directory
283    pub fn project_dir(&self) -> &Path {
284        &self.project_dir
285    }
286}
287
288/// Format a change set for display
289pub fn format_changes(changes: &ChangeSet) -> String {
290    let mut parts = Vec::new();
291
292    if changes.full_rebuild {
293        return "config changed (full rebuild)".to_string();
294    }
295
296    if changes.reload_templates {
297        parts.push("templates".to_string());
298    }
299
300    if changes.rebuild_css {
301        parts.push("styles".to_string());
302    }
303
304    if changes.rebuild_home {
305        parts.push("home".to_string());
306    }
307
308    if !changes.content_files.is_empty() {
309        parts.push(format!("{} content files", changes.content_files.len()));
310    }
311
312    if !changes.static_files.is_empty() {
313        parts.push(format!("{} static files", changes.static_files.len()));
314    }
315
316    if !changes.image_files.is_empty() {
317        parts.push(format!("{} images", changes.image_files.len()));
318    }
319
320    if parts.is_empty() {
321        return "no actionable changes".to_string();
322    }
323
324    parts.join(", ")
325}