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