1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub enum ChangeType {
17 Config,
19 Content(PathBuf),
21 Template(PathBuf),
23 Css,
25 Asset(PathBuf),
27}
28
29#[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 pub fn has_template_changes(&self) -> bool {
52 !self.template_files.is_empty()
53 }
54
55 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 self.content_files.insert(path);
66 }
67 ChangeType::Template(path) => {
68 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 self.asset_files.insert(path);
76 }
77 }
78 }
79
80 fn optimize(&mut self) {
81 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
92pub 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 let (tx, rx) = mpsc::channel();
118
119 let mut debouncer = new_debouncer(Duration::from_millis(300), tx)
121 .context("Failed to create file watcher")?;
122
123 let watcher = debouncer.watcher();
125
126 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 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 pub fn wait_for_changes(&self) -> Result<ChangeSet> {
157 let mut changes = ChangeSet::default();
158 trace!("Waiting for file changes...");
159
160 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 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 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 if path.starts_with(&self.output_dir) {
213 trace!("Skipping output directory path: {:?}", path);
214 return None;
215 }
216
217 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 if path == self.config_path {
228 return Some(ChangeType::Config);
229 }
230
231 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 if path.starts_with(&self.project_dir) {
242 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
243
244 if ext == "css" {
246 return Some(ChangeType::Css);
247 }
248
249 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 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 pub fn project_dir(&self) -> &Path {
286 &self.project_dir
287 }
288}
289
290pub 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}