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