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 StaticFile(PathBuf),
27 Image(PathBuf),
29}
30
31#[derive(Debug, Default)]
33pub struct ChangeSet {
34 pub full_rebuild: bool,
35 pub rebuild_css: bool,
36 pub rebuild_home: bool,
37 pub content_files: HashSet<PathBuf>,
38 pub template_files: HashSet<PathBuf>,
39 pub static_files: HashSet<PathBuf>,
40 pub image_files: HashSet<PathBuf>,
41}
42
43impl ChangeSet {
44 pub fn is_empty(&self) -> bool {
45 !self.full_rebuild
46 && !self.rebuild_css
47 && !self.rebuild_home
48 && self.content_files.is_empty()
49 && self.template_files.is_empty()
50 && self.static_files.is_empty()
51 && self.image_files.is_empty()
52 }
53
54 pub fn has_template_changes(&self) -> bool {
56 !self.template_files.is_empty()
57 }
58
59 fn add(&mut self, change: ChangeType) {
60 match change {
61 ChangeType::Config => self.full_rebuild = true,
62 ChangeType::Content(path) => {
63 let canonical = path.canonicalize().unwrap_or(path);
64 self.content_files.insert(canonical);
65 }
66 ChangeType::Template(path) => {
67 let canonical = path.canonicalize().unwrap_or(path);
68 self.template_files.insert(canonical);
69 }
70 ChangeType::Css => self.rebuild_css = true,
71 ChangeType::StaticFile(path) => {
72 let canonical = path.canonicalize().unwrap_or(path);
73 self.static_files.insert(canonical);
74 }
75 ChangeType::Image(path) => {
76 let canonical = path.canonicalize().unwrap_or(path);
77 self.image_files.insert(canonical);
78 }
79 }
80 }
81
82 fn optimize(&mut self) {
83 if self.full_rebuild {
85 self.rebuild_css = false;
86 self.rebuild_home = false;
87 self.content_files.clear();
88 self.template_files.clear();
89 self.static_files.clear();
90 self.image_files.clear();
91 }
92 }
93}
94
95pub struct FileWatcher {
97 project_dir: PathBuf,
98 output_dir: PathBuf,
99 config_path: PathBuf,
100 templates_dir: PathBuf,
101 styles_dir: PathBuf,
102 static_dir: PathBuf,
103 rx: Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
104 _watcher: notify_debouncer_mini::Debouncer<RecommendedWatcher>,
105}
106
107impl FileWatcher {
108 pub fn new(project_dir: &Path, config: &Config, output_dir: &Path) -> Result<Self> {
109 let project_dir = project_dir
110 .canonicalize()
111 .unwrap_or_else(|_| project_dir.to_path_buf());
112 let output_dir = output_dir
113 .canonicalize()
114 .unwrap_or_else(|_| output_dir.to_path_buf());
115 let config_path = project_dir.join("config.lua");
116 let templates_dir = project_dir.join(&config.paths.templates);
117 let styles_dir = project_dir.join(&config.paths.styles);
118 let static_dir = project_dir.join(&config.paths.static_files);
119
120 let templates_dir = templates_dir.canonicalize().unwrap_or(templates_dir);
121 let styles_dir = styles_dir.canonicalize().unwrap_or(styles_dir);
122 let static_dir = static_dir.canonicalize().unwrap_or(static_dir);
123 let config_path = config_path.canonicalize().unwrap_or(config_path);
124
125 let (tx, rx) = mpsc::channel();
127
128 let mut debouncer = new_debouncer(Duration::from_millis(300), tx)
130 .context("Failed to create file watcher")?;
131
132 let watcher = debouncer.watcher();
134
135 if config_path.exists() {
137 trace!("Watching config: {:?}", config_path);
138 watcher
139 .watch(&config_path, RecursiveMode::NonRecursive)
140 .with_context(|| format!("Failed to watch config: {:?}", config_path))?;
141 }
142
143 trace!("Watching project: {:?}", project_dir);
145 watcher
146 .watch(&project_dir, RecursiveMode::Recursive)
147 .with_context(|| format!("Failed to watch project: {:?}", project_dir))?;
148
149 debug!("File watcher initialized");
150 println!("Watching for changes...");
151 println!(" Project: {:?}", project_dir);
152 println!(" Templates: {:?}", templates_dir);
153 println!(" Styles: {:?}", styles_dir);
154 println!(" Static: {:?}", static_dir);
155
156 Ok(Self {
157 project_dir,
158 output_dir,
159 config_path,
160 templates_dir,
161 styles_dir,
162 static_dir,
163 rx,
164 _watcher: debouncer,
165 })
166 }
167
168 pub fn wait_for_changes(&self) -> Result<ChangeSet> {
170 let mut changes = ChangeSet::default();
171 trace!("Waiting for file changes...");
172
173 match self.rx.recv() {
175 Ok(Ok(events)) => {
176 trace!("Received {} file events", events.len());
177 for event in events {
178 if event.kind == DebouncedEventKind::Any
179 && let Some(change) = self.classify_change(&event.path)
180 {
181 trace!("Classified change: {:?} -> {:?}", event.path, change);
182 changes.add(change);
183 }
184 }
185 }
186 Ok(Err(e)) => {
187 warn!("Watch error: {:?}", e);
188 }
189 Err(e) => {
190 return Err(anyhow::anyhow!("Watch channel closed: {:?}", e));
191 }
192 }
193
194 let drain_start = Instant::now();
196 while drain_start.elapsed() < Duration::from_millis(50) {
197 match self.rx.try_recv() {
198 Ok(Ok(events)) => {
199 for event in events {
200 if event.kind == DebouncedEventKind::Any
201 && let Some(change) = self.classify_change(&event.path)
202 {
203 changes.add(change);
204 }
205 }
206 }
207 Ok(Err(e)) => {
208 warn!("Watch error: {:?}", e);
209 }
210 Err(mpsc::TryRecvError::Empty) => break,
211 Err(mpsc::TryRecvError::Disconnected) => break,
212 }
213 }
214
215 changes.optimize();
216 Ok(changes)
217 }
218
219 fn classify_change(&self, path: &Path) -> Option<ChangeType> {
221 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
222 let path = path.as_path();
223
224 if path.starts_with(&self.output_dir) {
226 trace!("Skipping output directory path: {:?}", path);
227 return None;
228 }
229
230 if path
232 .components()
233 .any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
234 {
235 trace!("Skipping hidden path: {:?}", path);
236 return None;
237 }
238
239 if path == self.config_path {
241 return Some(ChangeType::Config);
242 }
243
244 if path.starts_with(&self.styles_dir) {
246 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
247 if ext == "css" {
248 return Some(ChangeType::Css);
249 }
250 return None;
251 }
252
253 if path.starts_with(&self.templates_dir) {
255 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
256 if ext == "html" || ext == "htm" {
257 return Some(ChangeType::Template(path.to_path_buf()));
258 }
259 return None;
260 }
261
262 if path.starts_with(&self.static_dir) {
264 let ext = path
265 .extension()
266 .and_then(|e| e.to_str())
267 .unwrap_or("")
268 .to_lowercase();
269
270 if let Ok(rel) = path.strip_prefix(&self.static_dir) {
271 if matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp" | "gif") {
272 return Some(ChangeType::Image(rel.to_path_buf()));
273 }
274 return Some(ChangeType::StaticFile(rel.to_path_buf()));
275 }
276 return None;
277 }
278
279 if path.starts_with(&self.project_dir) {
281 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
282 if (ext == "md" || ext == "html" || ext == "htm" || ext == "json" || ext == "lua")
283 && let Ok(rel) = path.strip_prefix(&self.project_dir)
284 {
285 return Some(ChangeType::Content(rel.to_path_buf()));
286 }
287 }
288
289 None
290 }
291
292 pub fn project_dir(&self) -> &Path {
294 &self.project_dir
295 }
296}
297
298pub fn format_changes(changes: &ChangeSet) -> String {
300 let mut parts = Vec::new();
301
302 if changes.full_rebuild {
303 return "config changed (full rebuild)".to_string();
304 }
305
306 if !changes.template_files.is_empty() {
307 parts.push(format!("{} templates", changes.template_files.len()));
308 }
309
310 if changes.rebuild_css {
311 parts.push("styles".to_string());
312 }
313
314 if changes.rebuild_home {
315 parts.push("home".to_string());
316 }
317
318 if !changes.content_files.is_empty() {
319 parts.push(format!("{} content files", changes.content_files.len()));
320 }
321
322 if !changes.static_files.is_empty() {
323 parts.push(format!("{} static files", changes.static_files.len()));
324 }
325
326 if !changes.image_files.is_empty() {
327 parts.push(format!("{} images", changes.image_files.len()));
328 }
329
330 if parts.is_empty() {
331 return "no actionable changes".to_string();
332 }
333
334 parts.join(", ")
335}