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}
26
27#[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 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 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
77pub 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 let (tx, rx) = mpsc::channel();
103
104 let mut debouncer = new_debouncer(Duration::from_millis(300), tx)
106 .context("Failed to create file watcher")?;
107
108 let watcher = debouncer.watcher();
110
111 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 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 pub fn wait_for_changes(&self) -> Result<ChangeSet> {
142 let mut changes = ChangeSet::default();
143 trace!("Waiting for file changes...");
144
145 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 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 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 if path.starts_with(&self.output_dir) {
198 trace!("Skipping output directory path: {:?}", path);
199 return None;
200 }
201
202 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 if path == self.config_path {
213 return Some(ChangeType::Config);
214 }
215
216 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 if path.starts_with(&self.project_dir) {
227 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
228
229 if ext == "css" {
231 return Some(ChangeType::Css);
232 }
233
234 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 pub fn project_dir(&self) -> &Path {
247 &self.project_dir
248 }
249}
250
251pub 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}