1use std::path::Path;
12use std::sync::Arc;
13
14use anyhow::{Context, Result};
15use notify_debouncer_mini::notify::{RecommendedWatcher, RecursiveMode};
16use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
17use tracing::{info, warn};
18
19use crate::config::DevConfig;
20use crate::worker_pool::WorkerPool;
21
22pub type WatchGuard = Debouncer<RecommendedWatcher>;
25
26pub fn spawn(dev: &DevConfig, pool: Arc<WorkerPool>) -> Result<WatchGuard> {
32 let handle = tokio::runtime::Handle::current();
33 let extensions: Vec<String> = dev
34 .watch_extensions
35 .iter()
36 .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
37 .collect();
38
39 let mut debouncer = new_debouncer(dev.debounce, move |res: DebounceEventResult| match res {
40 Ok(events) => {
41 let changed = events
42 .iter()
43 .any(|e| matches_extension(&e.path, &extensions));
44 if changed {
45 let pool = pool.clone();
46 handle.spawn(async move { pool.trigger_reload().await });
47 }
48 },
49 Err(error) => {
50 warn!(%error, "file watcher error");
51 },
52 })
53 .context("failed to create file watcher")?;
54
55 let mut watched = 0usize;
56 for path in &dev.watch_paths {
57 let p = Path::new(path);
58 if !p.exists() {
59 warn!(path, "watch path does not exist, skipping");
60 continue;
61 }
62 debouncer
63 .watcher()
64 .watch(p, RecursiveMode::Recursive)
65 .with_context(|| format!("failed to watch {path}"))?;
66 watched += 1;
67 }
68
69 if watched == 0 {
70 warn!("hot reload enabled but no watch paths exist; watcher is idle");
71 } else {
72 info!(
73 paths = ?dev.watch_paths,
74 extensions = ?dev.watch_extensions,
75 debounce_ms = u64::try_from(dev.debounce.as_millis()).unwrap_or(u64::MAX),
76 "hot reload watcher started"
77 );
78 }
79
80 Ok(debouncer)
81}
82
83fn matches_extension(path: &Path, extensions: &[String]) -> bool {
85 path.extension()
86 .and_then(|e| e.to_str())
87 .map(str::to_ascii_lowercase)
88 .is_some_and(|ext| extensions.contains(&ext))
89}