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