Skip to main content

rs_web/
watch.rs

1//! File watcher for incremental rebuilds
2
3use anyhow::{Context, Result};
4use log::{debug, trace, warn};
5use notify::event::{AccessKind, EventKind, ModifyKind};
6use notify::{RecommendedWatcher, RecursiveMode};
7use notify_debouncer_full::{DebouncedEvent, RecommendedCache, new_debouncer};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::sync::mpsc::{self, Receiver};
11use std::time::{Duration, Instant};
12
13fn is_mutation_event(kind: &EventKind) -> bool {
14    match kind {
15        EventKind::Create(_) | EventKind::Remove(_) => true,
16        EventKind::Modify(ModifyKind::Data(_))
17        | EventKind::Modify(ModifyKind::Name(_))
18        | EventKind::Modify(ModifyKind::Any) => true,
19        // Metadata-only / Other / Access(*) are intentionally ignored.
20        EventKind::Modify(ModifyKind::Metadata(_)) | EventKind::Modify(ModifyKind::Other) => false,
21        EventKind::Access(AccessKind::Any)
22        | EventKind::Access(AccessKind::Read)
23        | EventKind::Access(AccessKind::Open(_))
24        | EventKind::Access(AccessKind::Close(_))
25        | EventKind::Access(AccessKind::Other) => false,
26        EventKind::Any | EventKind::Other => false,
27    }
28}
29
30use crate::config::Config;
31
32/// Types of changes that trigger different rebuild strategies
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub enum ChangeType {
35    /// Config file changed - requires full rebuild
36    Config,
37    /// Content file changed - requires data()/pages() rebuild
38    Content(PathBuf),
39    /// Template file changed - re-render affected pages
40    Template(PathBuf),
41    /// CSS/style file changed - triggers before_build hook (CSS hot reload)
42    Css,
43    /// Static asset changed (JS/images/fonts) - triggers before_build hook
44    Asset(PathBuf),
45}
46
47/// Aggregated changes from a batch of file events
48#[derive(Debug, Default)]
49pub struct ChangeSet {
50    pub full_rebuild: bool,
51    pub rebuild_css: bool,
52    pub rebuild_home: bool,
53    pub content_files: HashSet<PathBuf>,
54    pub asset_files: HashSet<PathBuf>,
55    pub template_files: HashSet<PathBuf>,
56}
57
58impl ChangeSet {
59    pub fn is_empty(&self) -> bool {
60        !self.full_rebuild
61            && !self.rebuild_css
62            && !self.rebuild_home
63            && self.content_files.is_empty()
64            && self.asset_files.is_empty()
65            && self.template_files.is_empty()
66    }
67
68    /// Check if any templates changed
69    pub fn has_template_changes(&self) -> bool {
70        !self.template_files.is_empty()
71    }
72
73    /// Check if any assets changed
74    pub fn has_asset_changes(&self) -> bool {
75        !self.asset_files.is_empty()
76    }
77
78    fn add(&mut self, change: ChangeType) {
79        match change {
80            ChangeType::Config => self.full_rebuild = true,
81            ChangeType::Content(path) => {
82                // Path is already relative (project prefix stripped in classify_change)
83                self.content_files.insert(path);
84            }
85            ChangeType::Template(path) => {
86                // Template paths are absolute (from templates_dir)
87                let canonical = path.canonicalize().unwrap_or(path);
88                self.template_files.insert(canonical);
89            }
90            ChangeType::Css => self.rebuild_css = true,
91            ChangeType::Asset(path) => {
92                // Path is already relative (project prefix stripped in classify_change)
93                self.asset_files.insert(path);
94            }
95        }
96    }
97
98    fn optimize(&mut self) {
99        // If full rebuild, clear incremental changes
100        if self.full_rebuild {
101            self.rebuild_css = false;
102            self.rebuild_home = false;
103            self.content_files.clear();
104            self.asset_files.clear();
105            self.template_files.clear();
106        }
107    }
108}
109
110/// File watcher for incremental builds
111pub struct FileWatcher {
112    project_dir: PathBuf,
113    output_dir: PathBuf,
114    config_path: PathBuf,
115    templates_dir: PathBuf,
116    rx: Receiver<Result<Vec<DebouncedEvent>, Vec<notify::Error>>>,
117    _watcher: notify_debouncer_full::Debouncer<RecommendedWatcher, RecommendedCache>,
118}
119
120impl FileWatcher {
121    pub fn new(project_dir: &Path, config: &Config, output_dir: &Path) -> Result<Self> {
122        let project_dir = project_dir
123            .canonicalize()
124            .unwrap_or_else(|_| project_dir.to_path_buf());
125        let output_dir = output_dir
126            .canonicalize()
127            .unwrap_or_else(|_| output_dir.to_path_buf());
128        let config_path = project_dir.join("config.lua");
129        let templates_dir = project_dir.join(&config.paths.templates);
130
131        let templates_dir = templates_dir.canonicalize().unwrap_or(templates_dir);
132        let config_path = config_path.canonicalize().unwrap_or(config_path);
133
134        // Create channel for events
135        let (tx, rx) = mpsc::channel();
136
137        let mut debouncer = new_debouncer(Duration::from_millis(300), None, tx)
138            .context("Failed to create file watcher")?;
139
140        // Watch config file
141        if config_path.exists() {
142            trace!("Watching config: {:?}", config_path);
143            debouncer
144                .watch(&config_path, RecursiveMode::NonRecursive)
145                .with_context(|| format!("Failed to watch config: {:?}", config_path))?;
146        }
147
148        // Watch project directory for content changes (Lua decides what's content)
149        trace!("Watching project: {:?}", project_dir);
150        debouncer
151            .watch(&project_dir, RecursiveMode::Recursive)
152            .with_context(|| format!("Failed to watch project: {:?}", project_dir))?;
153
154        debug!("File watcher initialized");
155        rs_print!("Watching for changes...");
156        rs_print!("  Project:   {:?}", project_dir);
157        rs_print!("  Templates: {:?}", templates_dir);
158
159        Ok(Self {
160            project_dir,
161            output_dir,
162            config_path,
163            templates_dir,
164            rx,
165            _watcher: debouncer,
166        })
167    }
168
169    /// Wait for changes and return aggregated change set
170    pub fn wait_for_changes(&self) -> Result<ChangeSet> {
171        let mut changes = ChangeSet::default();
172        trace!("Waiting for file changes...");
173
174        let process_events = |events: Vec<DebouncedEvent>, changes: &mut ChangeSet| {
175            for event in events {
176                if !is_mutation_event(&event.event.kind) {
177                    trace!(
178                        "Ignoring non-mutation event: {:?} {:?}",
179                        event.event.kind, event.event.paths
180                    );
181                    continue;
182                }
183                for path in &event.event.paths {
184                    if let Some(change) = self.classify_change(path) {
185                        trace!("Classified change: {:?} -> {:?}", path, change);
186                        changes.add(change);
187                    }
188                }
189            }
190        };
191
192        // Block until we receive events
193        match self.rx.recv() {
194            Ok(Ok(events)) => {
195                trace!("Received {} file events", events.len());
196                process_events(events, &mut changes);
197            }
198            Ok(Err(errs)) => {
199                for e in errs {
200                    warn!("Watch error: {:?}", e);
201                }
202            }
203            Err(e) => {
204                return Err(anyhow::anyhow!("Watch channel closed: {:?}", e));
205            }
206        }
207
208        // Drain any additional pending events (with short timeout)
209        let drain_start = Instant::now();
210        while drain_start.elapsed() < Duration::from_millis(50) {
211            match self.rx.try_recv() {
212                Ok(Ok(events)) => process_events(events, &mut changes),
213                Ok(Err(errs)) => {
214                    for e in errs {
215                        warn!("Watch error: {:?}", e);
216                    }
217                }
218                Err(mpsc::TryRecvError::Empty) => break,
219                Err(mpsc::TryRecvError::Disconnected) => break,
220            }
221        }
222
223        changes.optimize();
224        Ok(changes)
225    }
226
227    /// Classify a file path into a change type
228    fn classify_change(&self, path: &Path) -> Option<ChangeType> {
229        let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
230        let path = path.as_path();
231
232        // Skip events from the output directory (prevents feedback loop)
233        if path.starts_with(&self.output_dir) {
234            trace!("Skipping output directory path: {:?}", path);
235            return None;
236        }
237
238        // Skip hidden files and directories
239        if path
240            .components()
241            .any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
242        {
243            trace!("Skipping hidden path: {:?}", path);
244            return None;
245        }
246
247        // Config file
248        if path == self.config_path {
249            return Some(ChangeType::Config);
250        }
251
252        // Templates directory
253        if path.starts_with(&self.templates_dir) {
254            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
255            if ext == "html" || ext == "htm" {
256                return Some(ChangeType::Template(path.to_path_buf()));
257            }
258            return None;
259        }
260
261        // Any file in project directory
262        if path.starts_with(&self.project_dir) {
263            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
264
265            // CSS files trigger before_build hook (CSS hot reload)
266            if ext == "css" {
267                return Some(ChangeType::Css);
268            }
269
270            // Static assets trigger before_build hook
271            // Includes: JS/TS, images, fonts, etc.
272            if matches!(
273                ext,
274                "js" | "ts"
275                    | "mjs"
276                    | "mts"
277                    | "png"
278                    | "jpg"
279                    | "jpeg"
280                    | "gif"
281                    | "webp"
282                    | "svg"
283                    | "ico"
284                    | "woff"
285                    | "woff2"
286                    | "ttf"
287                    | "otf"
288                    | "eot"
289            ) && let Ok(rel) = path.strip_prefix(&self.project_dir)
290            {
291                return Some(ChangeType::Asset(rel.to_path_buf()));
292            }
293
294            // Content files trigger rebuild (markdown, data files, Lua scripts)
295            if matches!(ext, "md" | "html" | "htm" | "json" | "lua")
296                && let Ok(rel) = path.strip_prefix(&self.project_dir)
297            {
298                return Some(ChangeType::Content(rel.to_path_buf()));
299            }
300        }
301
302        None
303    }
304
305    /// Get the project directory
306    pub fn project_dir(&self) -> &Path {
307        &self.project_dir
308    }
309}
310
311/// Format a change set for display
312pub fn format_changes(changes: &ChangeSet) -> String {
313    let mut parts = Vec::new();
314
315    if changes.full_rebuild {
316        return "config changed (full rebuild)".to_string();
317    }
318
319    if !changes.template_files.is_empty() {
320        parts.push(format!("{} templates", changes.template_files.len()));
321    }
322
323    if changes.rebuild_css {
324        parts.push("styles".to_string());
325    }
326
327    if !changes.asset_files.is_empty() {
328        parts.push(format!("{} assets", changes.asset_files.len()));
329    }
330
331    if changes.rebuild_home {
332        parts.push("home".to_string());
333    }
334
335    if !changes.content_files.is_empty() {
336        parts.push(format!("{} content files", changes.content_files.len()));
337    }
338
339    if parts.is_empty() {
340        return "no actionable changes".to_string();
341    }
342
343    parts.join(", ")
344}