1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::Arc;
16use std::time::{Duration, SystemTime};
17
18pub const FS_WATCH_RETRY_DELAY_MS: u64 = 5_000;
21
22pub const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000;
25
26fn mtime(path: &Path) -> Option<SystemTime> {
27 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
28}
29
30fn snapshot(paths: &[PathBuf]) -> HashMap<PathBuf, Option<SystemTime>> {
31 paths.iter().map(|p| (p.clone(), mtime(p))).collect()
32}
33
34pub struct WatchHandle {
37 stop: Arc<AtomicBool>,
38 join: Option<tokio::task::JoinHandle<()>>,
39}
40
41impl WatchHandle {
42 pub fn close(mut self) {
44 self.stop.store(true, Ordering::SeqCst);
45 if let Some(join) = self.join.take() {
46 join.abort();
47 }
48 }
49
50 pub fn request_stop(&self) {
52 self.stop.store(true, Ordering::SeqCst);
53 }
54}
55
56impl Drop for WatchHandle {
57 fn drop(&mut self) {
58 self.stop.store(true, Ordering::SeqCst);
59 }
60}
61
62pub fn watch_paths<F>(paths: Vec<PathBuf>, poll_interval: Duration, on_change: F) -> WatchHandle
69where
70 F: Fn() + Send + Sync + 'static,
71{
72 let stop = Arc::new(AtomicBool::new(false));
73 let stop_task = stop.clone();
74 let join = tokio::spawn(async move {
75 let mut prev = snapshot(&paths);
76 loop {
77 tokio::time::sleep(poll_interval).await;
78 if stop_task.load(Ordering::SeqCst) {
79 break;
80 }
81 let current = match std::panic::catch_unwind(|| snapshot(&paths)) {
82 Ok(snap) => snap,
83 Err(_) => {
84 tokio::time::sleep(Duration::from_millis(FS_WATCH_RETRY_DELAY_MS)).await;
87 continue;
88 }
89 };
90 if current != prev {
91 prev = current;
92 on_change();
93 }
94 }
95 });
96 WatchHandle {
97 stop,
98 join: Some(join),
99 }
100}
101
102pub fn watch_path<F>(path: PathBuf, on_change: F) -> WatchHandle
104where
105 F: Fn() + Send + Sync + 'static,
106{
107 watch_paths(
108 vec![path],
109 Duration::from_millis(DEFAULT_POLL_INTERVAL_MS),
110 on_change,
111 )
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use std::sync::atomic::AtomicUsize;
118
119 #[test]
120 fn detects_content_change() {
121 let dir = tempfile::tempdir().unwrap();
122 let file = dir.path().join("theme.json");
123 std::fs::write(&file, "a").unwrap();
124
125 let hits = Arc::new(AtomicUsize::new(0));
126 let hits_task = hits.clone();
127 let rt = tokio::runtime::Builder::new_current_thread()
128 .enable_time()
129 .build()
130 .unwrap();
131 rt.block_on(async {
132 let handle = watch_paths(vec![file.clone()], Duration::from_millis(20), move || {
133 hits_task.fetch_add(1, Ordering::SeqCst);
134 });
135 tokio::time::sleep(Duration::from_millis(60)).await;
136 std::fs::write(&file, "bb").unwrap();
137 tokio::time::sleep(Duration::from_millis(120)).await;
139 handle.close();
140 });
141 assert!(
142 hits.load(Ordering::SeqCst) >= 1,
143 "expected at least one change"
144 );
145 }
146
147 #[test]
148 fn close_stops_watch() {
149 let dir = tempfile::tempdir().unwrap();
150 let file = dir.path().join("f");
151 std::fs::write(&file, "x").unwrap();
152 let rt = tokio::runtime::Builder::new_current_thread()
153 .enable_time()
154 .build()
155 .unwrap();
156 rt.block_on(async {
157 let handle = watch_path(file, || {});
158 handle.close();
159 });
160 }
161}