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