1use 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub enum ChangeType {
35 Config,
37 Content(PathBuf),
39 Template(PathBuf),
41 Css,
43 Asset(PathBuf),
45}
46
47#[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 pub fn has_template_changes(&self) -> bool {
70 !self.template_files.is_empty()
71 }
72
73 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 self.content_files.insert(path);
84 }
85 ChangeType::Template(path) => {
86 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 self.asset_files.insert(path);
94 }
95 }
96 }
97
98 fn optimize(&mut self) {
99 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
110pub 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 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 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 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 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 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 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 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 if path.starts_with(&self.output_dir) {
234 trace!("Skipping output directory path: {:?}", path);
235 return None;
236 }
237
238 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 if path == self.config_path {
249 return Some(ChangeType::Config);
250 }
251
252 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 if path.starts_with(&self.project_dir) {
263 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
264
265 if ext == "css" {
267 return Some(ChangeType::Css);
268 }
269
270 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 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 pub fn project_dir(&self) -> &Path {
307 &self.project_dir
308 }
309}
310
311pub 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}