1use std::collections::HashMap;
41use std::fs;
42use std::io;
43use std::path::{Path, PathBuf};
44use std::thread;
45use std::time::{Duration, SystemTime};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum ChangeKind {
59 Css,
61 Content,
63 Template,
65 Other,
67}
68
69#[must_use]
71pub fn classify_change(path: &Path) -> ChangeKind {
72 match path.extension().and_then(|e| e.to_str()) {
73 Some("css") => ChangeKind::Css,
74 Some("md" | "markdown") => ChangeKind::Content,
75 Some("html" | "jinja" | "jinja2" | "j2") => ChangeKind::Template,
76 _ => ChangeKind::Other,
77 }
78}
79
80#[derive(Debug, Clone)]
86pub struct WatchConfig {
87 directory: PathBuf,
89 poll_interval: Duration,
91}
92
93impl WatchConfig {
94 #[must_use]
101 pub const fn new(directory: PathBuf, poll_interval: Duration) -> Self {
102 Self {
103 directory,
104 poll_interval,
105 }
106 }
107
108 #[must_use]
110 pub fn directory(&self) -> &Path {
111 &self.directory
112 }
113
114 #[must_use]
116 pub const fn poll_interval(&self) -> Duration {
117 self.poll_interval
118 }
119}
120
121#[derive(Debug)]
131pub struct FileWatcher {
132 config: WatchConfig,
134 snapshots: HashMap<PathBuf, SystemTime>,
136}
137
138impl FileWatcher {
139 pub fn new(config: WatchConfig) -> io::Result<Self> {
144 let snapshots = Self::scan_directory(&config.directory)?;
145 Ok(Self { config, snapshots })
146 }
147
148 #[must_use]
150 pub const fn config(&self) -> &WatchConfig {
151 &self.config
152 }
153
154 pub fn check_for_changes(&mut self) -> io::Result<Vec<PathBuf>> {
163 let current = Self::scan_directory(&self.config.directory)?;
164 let mut changed: Vec<PathBuf> = Vec::new();
165
166 for (path, mtime) in ¤t {
168 match self.snapshots.get(path) {
169 Some(old_mtime) if old_mtime == mtime => {}
170 _ => changed.push(path.clone()),
171 }
172 }
173
174 for path in self.snapshots.keys() {
176 if !current.contains_key(path) {
177 changed.push(path.clone());
178 }
179 }
180
181 self.snapshots = current;
182 Ok(changed)
183 }
184
185 #[must_use]
187 pub fn tracked_file_count(&self) -> usize {
188 self.snapshots.len()
189 }
190
191 fn scan_directory(dir: &Path) -> io::Result<HashMap<PathBuf, SystemTime>> {
196 let mut map = HashMap::new();
197 if dir.is_dir() {
198 Self::walk_dir(dir, &mut map)?;
199 }
200 Ok(map)
201 }
202
203 fn walk_dir(
205 dir: &Path,
206 out: &mut HashMap<PathBuf, SystemTime>,
207 ) -> io::Result<()> {
208 for entry in fs::read_dir(dir)? {
209 let entry = entry?;
210 let path = entry.path();
211 let ft = entry.file_type()?;
212
213 if ft.is_dir() {
214 Self::walk_dir(&path, out)?;
215 } else if ft.is_file() {
216 if let Ok(meta) = fs::metadata(&path) {
217 if let Ok(mtime) = meta.modified() {
218 let _ = out.insert(path, mtime);
219 }
220 }
221 }
222 }
223 Ok(())
224 }
225}
226
227pub const MAX_WATCH_ITERATIONS: usize = 1_000_000;
263
264pub fn watch_blocking<F>(watcher: &mut FileWatcher, mut callback: F)
270where
271 F: FnMut(&[PathBuf]) -> bool,
272{
273 for _ in 0..MAX_WATCH_ITERATIONS {
274 match watcher.check_for_changes() {
275 Ok(changes) if !changes.is_empty() => {
276 if !callback(&changes) {
277 return;
278 }
279 }
280 Ok(_) => {} Err(e) => {
282 eprintln!("watch error: {e}");
283 }
284 }
285 thread::sleep(watcher.config.poll_interval);
286 }
287}
288
289#[cfg(test)]
294#[allow(clippy::unwrap_used, clippy::expect_used)]
295mod tests {
296 use super::*;
297 use std::fs::{self, File};
298 use std::io::Write;
299 use std::thread;
300 use std::time::Duration;
301
302 fn tmp_dir(name: &str) -> PathBuf {
304 let dir = std::env::temp_dir()
305 .join(format!("ssg_watch_test_{name}_{}", std::process::id()));
306 let _ = fs::remove_dir_all(&dir);
307 fs::create_dir_all(&dir).expect("create tmp dir");
308 dir
309 }
310
311 fn write_file(path: &Path, content: &str) {
313 let mut f = File::create(path).expect("create file");
314 f.write_all(content.as_bytes()).expect("write file");
315 }
316
317 #[test]
320 fn config_accessors() {
321 let dir = std::env::temp_dir().join("ssg_watch_fake");
322 let interval = Duration::from_millis(500);
323 let cfg = WatchConfig::new(dir.clone(), interval);
324 assert_eq!(cfg.directory(), dir.as_path());
325 assert_eq!(cfg.poll_interval(), interval);
326 }
327
328 #[test]
329 fn file_watcher_config_accessor_returns_stored_config() {
330 let dir = tmp_dir("watcher_config");
332 let interval = Duration::from_millis(250);
333 let cfg = WatchConfig::new(dir.clone(), interval);
334 let watcher = FileWatcher::new(cfg).expect("new watcher");
335 let returned = watcher.config();
336 assert_eq!(returned.directory(), dir.as_path());
337 assert_eq!(returned.poll_interval(), interval);
338 let _ = fs::remove_dir_all(&dir);
339 }
340
341 #[test]
342 fn new_watcher_snapshots_existing_files() {
343 let dir = tmp_dir("snapshot");
344 write_file(&dir.join("a.md"), "hello");
345 write_file(&dir.join("b.md"), "world");
346
347 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
348 let watcher = FileWatcher::new(cfg).expect("new watcher");
349
350 assert_eq!(watcher.tracked_file_count(), 2);
351 let _ = fs::remove_dir_all(&dir);
352 }
353
354 #[test]
355 fn no_changes_returns_empty() {
356 let dir = tmp_dir("nochange");
357 write_file(&dir.join("a.md"), "hello");
358
359 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
360 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
361
362 let changes = watcher.check_for_changes().expect("check");
363 assert!(changes.is_empty(), "expected no changes");
364 let _ = fs::remove_dir_all(&dir);
365 }
366
367 #[test]
368 fn detects_new_file() {
369 let dir = tmp_dir("newfile");
370 write_file(&dir.join("a.md"), "hello");
371
372 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
373 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
374
375 write_file(&dir.join("b.md"), "new");
377
378 let changes = watcher.check_for_changes().expect("check");
379 assert!(
380 changes.contains(&dir.join("b.md")),
381 "expected new file in changes"
382 );
383 let _ = fs::remove_dir_all(&dir);
384 }
385
386 #[test]
387 fn detects_modified_file() {
388 let dir = tmp_dir("modified");
389 write_file(&dir.join("a.md"), "v1");
390
391 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
392 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
393
394 thread::sleep(Duration::from_millis(1100));
396 write_file(&dir.join("a.md"), "v2");
397
398 let changes = watcher.check_for_changes().expect("check");
399 assert!(
400 changes.contains(&dir.join("a.md")),
401 "expected modified file in changes"
402 );
403 let _ = fs::remove_dir_all(&dir);
404 }
405
406 #[test]
407 fn detects_removed_file() {
408 let dir = tmp_dir("removed");
409 write_file(&dir.join("a.md"), "hello");
410 write_file(&dir.join("b.md"), "world");
411
412 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
413 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
414
415 fs::remove_file(dir.join("b.md")).expect("remove file");
416
417 let changes = watcher.check_for_changes().expect("check");
418 assert!(
419 changes.contains(&dir.join("b.md")),
420 "expected removed file in changes"
421 );
422 let _ = fs::remove_dir_all(&dir);
423 }
424
425 #[test]
426 fn tracks_files_in_subdirectories() {
427 let dir = tmp_dir("subdirs");
428 let sub = dir.join("posts");
429 fs::create_dir_all(&sub).expect("create subdir");
430 write_file(&sub.join("first.md"), "post");
431
432 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
433 let watcher = FileWatcher::new(cfg).expect("new watcher");
434
435 assert_eq!(watcher.tracked_file_count(), 1);
436 let _ = fs::remove_dir_all(&dir);
437 }
438
439 #[test]
440 fn check_clears_changes_after_read() {
441 let dir = tmp_dir("clear");
442 write_file(&dir.join("a.md"), "v1");
443
444 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
445 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
446
447 write_file(&dir.join("b.md"), "new");
449 let first = watcher.check_for_changes().expect("check");
450 assert!(!first.is_empty());
451
452 let second = watcher.check_for_changes().expect("check");
453 assert!(second.is_empty(), "changes should be cleared after read");
454 let _ = fs::remove_dir_all(&dir);
455 }
456
457 #[test]
458 fn watch_blocking_stops_on_false() {
459 let dir = tmp_dir("blocking");
460 write_file(&dir.join("a.md"), "v1");
461
462 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(10));
463 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
464
465 thread::sleep(Duration::from_millis(1100));
467 write_file(&dir.join("a.md"), "v2");
468
469 let mut invoked = false;
470 watch_blocking(&mut watcher, |_changes| {
471 invoked = true;
472 false });
474
475 assert!(invoked, "callback should have been invoked");
476 let _ = fs::remove_dir_all(&dir);
477 }
478
479 #[test]
480 fn watch_blocking_returns_after_callback_false_deterministic() {
481 let dir = tmp_dir("blocking_det");
487 write_file(&dir.join("a.md"), "v1");
488 write_file(&dir.join("b.md"), "v1");
489
490 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
491 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
492
493 watcher.snapshots.clear();
496
497 let mut call_count = 0;
498 watch_blocking(&mut watcher, |changes| {
499 call_count += 1;
500 assert!(!changes.is_empty());
501 false });
503
504 assert_eq!(
505 call_count, 1,
506 "callback should have been called exactly once"
507 );
508 let _ = fs::remove_dir_all(&dir);
509 }
510
511 #[test]
512 fn watch_blocking_no_changes_branch_executes() {
513 let dir = tmp_dir("no_changes_arm");
534 write_file(&dir.join("a.md"), "x");
535 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(1));
536 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
537 let changes = watcher.check_for_changes().expect("check");
541 assert!(changes.is_empty());
542 let _ = fs::remove_dir_all(&dir);
543 }
544
545 #[test]
546 fn empty_directory_is_valid() {
547 let dir = tmp_dir("empty");
548
549 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
550 let watcher = FileWatcher::new(cfg).expect("new watcher");
551
552 assert_eq!(watcher.tracked_file_count(), 0);
553 let _ = fs::remove_dir_all(&dir);
554 }
555
556 #[test]
557 fn nonexistent_directory_errors() {
558 let dir = std::env::temp_dir().join("ssg_watch_test_nonexistent_99999");
559 let _ = fs::remove_dir_all(&dir);
560
561 let cfg = WatchConfig::new(dir, Duration::from_millis(50));
562 let watcher = FileWatcher::new(cfg);
565 assert!(watcher.is_ok());
566 assert_eq!(watcher.unwrap().tracked_file_count(), 0);
567 }
568
569 #[test]
570 fn watch_config_default_values() {
571 let dir = std::env::temp_dir().join("ssg_watch_defaults");
573 let poll = Duration::from_secs(2);
574 let debounce = Duration::from_millis(100);
575
576 let cfg = WatchConfig::new(dir.clone(), poll);
578
579 assert_eq!(cfg.poll_interval(), Duration::from_secs(2));
581 assert_eq!(cfg.directory(), dir.as_path());
582 assert_ne!(cfg.poll_interval(), debounce);
584 }
585
586 #[test]
587 fn file_watcher_empty_directory() {
588 let dir = tmp_dir("empty_watch");
590
591 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
593 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
594
595 assert_eq!(watcher.tracked_file_count(), 0);
597 let changes = watcher.check_for_changes().expect("check");
598 assert!(changes.is_empty(), "empty dir should have no changes");
599 let _ = fs::remove_dir_all(&dir);
600 }
601
602 #[test]
603 fn file_watcher_detects_new_file() {
604 let dir = tmp_dir("detect_new");
606 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
607 let mut watcher = FileWatcher::new(cfg).expect("new watcher");
608 assert_eq!(watcher.tracked_file_count(), 0);
609
610 write_file(&dir.join("added.md"), "new content");
612 let changes = watcher.check_for_changes().expect("check");
613
614 assert_eq!(changes.len(), 1);
616 assert!(changes[0].ends_with("added.md"));
617 assert_eq!(watcher.tracked_file_count(), 1);
618 let _ = fs::remove_dir_all(&dir);
619 }
620
621 #[test]
622 fn scan_directory_nonexistent_returns_empty_map() {
623 let dir = PathBuf::from("/nonexistent_ssg_watch_test_dir");
626 let cfg = WatchConfig::new(dir, Duration::from_millis(50));
627 let watcher = FileWatcher::new(cfg).expect("should succeed");
628 assert_eq!(watcher.tracked_file_count(), 0);
629 }
630
631 #[test]
632 fn watch_config_clone() {
633 let dir = std::env::temp_dir().join("ssg_watch_clone");
634 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(100));
635 let cloned = cfg;
636 assert_eq!(cloned.directory(), dir.as_path());
637 assert_eq!(cloned.poll_interval(), Duration::from_millis(100));
638 }
639
640 #[test]
641 fn file_watcher_debug_output() {
642 let dir = tmp_dir("debug_out");
643 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
644 let watcher = FileWatcher::new(cfg).expect("new watcher");
645 let debug = format!("{watcher:?}");
646 assert!(debug.contains("FileWatcher"));
647 let _ = fs::remove_dir_all(&dir);
648 }
649
650 #[test]
651 fn file_watcher_nested_directory() {
652 let dir = tmp_dir("nested_watch");
654 let sub = dir.join("a/b/c");
655 fs::create_dir_all(&sub).expect("create nested dirs");
656 write_file(&sub.join("deep.md"), "deep content");
657 write_file(&dir.join("root.md"), "root content");
658
659 let cfg = WatchConfig::new(dir.clone(), Duration::from_millis(50));
661 let watcher = FileWatcher::new(cfg).expect("new watcher");
662
663 assert_eq!(watcher.tracked_file_count(), 2);
665 let _ = fs::remove_dir_all(&dir);
666 }
667
668 #[test]
669 fn test_classify_css() {
670 assert_eq!(
671 classify_change(Path::new("styles/main.css")),
672 ChangeKind::Css
673 );
674 }
675
676 #[test]
677 fn test_classify_markdown() {
678 assert_eq!(
679 classify_change(Path::new("content/post.md")),
680 ChangeKind::Content
681 );
682 assert_eq!(
683 classify_change(Path::new("content/post.markdown")),
684 ChangeKind::Content
685 );
686 }
687
688 #[test]
689 fn test_classify_html() {
690 assert_eq!(
691 classify_change(Path::new("templates/base.html")),
692 ChangeKind::Template
693 );
694 assert_eq!(
695 classify_change(Path::new("templates/base.jinja")),
696 ChangeKind::Template
697 );
698 assert_eq!(
699 classify_change(Path::new("templates/base.jinja2")),
700 ChangeKind::Template
701 );
702 assert_eq!(
703 classify_change(Path::new("templates/base.j2")),
704 ChangeKind::Template
705 );
706 }
707
708 #[test]
709 fn test_classify_other() {
710 assert_eq!(
711 classify_change(Path::new("src/main.rs")),
712 ChangeKind::Other
713 );
714 assert_eq!(
715 classify_change(Path::new("config.toml")),
716 ChangeKind::Other
717 );
718 }
719
720 #[test]
721 fn test_classify_no_extension() {
722 assert_eq!(classify_change(Path::new("Makefile")), ChangeKind::Other);
723 }
724}