Skip to main content

mini_static/
watcher.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4use std::time::{Duration, SystemTime};
5
6use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
7
8use crate::reload::ChangeType;
9
10/// A change event broadcast when a watched file is added, modified, or removed.
11#[derive(Clone, Debug)]
12pub struct ChangeEvent {
13    /// The path to the file that changed, relative to the watched root.
14    pub path: PathBuf,
15    /// The type of change (CSS, script, HTML, or other).
16    pub change_type: ChangeType,
17}
18
19/// Broadcasts file change events to multiple subscribers.
20///
21/// A single broadcaster can have many subscribers (e.g., multiple browser clients
22/// connected via SSE). When a file changes, all active subscribers are notified.
23/// If a subscriber's channel is full or closed, that subscriber is removed.
24#[derive(Clone)]
25pub struct Broadcaster {
26    senders: Arc<Mutex<Vec<UnboundedSender<ChangeEvent>>>>,
27}
28
29impl Broadcaster {
30    /// Create a new broadcaster with no subscribers.
31    pub fn new() -> Self {
32        Broadcaster {
33            senders: Arc::new(Mutex::new(Vec::new())),
34        }
35    }
36
37    /// Broadcast a change event to all active subscribers.
38    ///
39    /// Removes any subscribers whose channels are closed or full.
40    pub fn broadcast(&self, event: ChangeEvent) {
41        let mut senders = self.senders.lock().unwrap();
42        senders.retain(|sender| sender.send(event.clone()).is_ok());
43    }
44
45    /// Subscribe to change events.
46    ///
47    /// Returns a receiver that will yield each broadcasted change event.
48    pub fn subscribe(&self) -> UnboundedReceiver<ChangeEvent> {
49        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
50        self.senders.lock().unwrap().push(tx);
51        rx
52    }
53
54    /// Get the current number of active subscribers.
55    #[cfg(test)]
56    pub fn subscriber_count(&self) -> usize {
57        self.senders.lock().unwrap().len()
58    }
59}
60
61impl Default for Broadcaster {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67/// Start watching a directory for file changes.
68///
69/// Spawns a background task that periodically polls the directory tree for
70/// modifications using mtime. When changes are detected, broadcasts them to all
71/// active subscribers via the given `broadcaster`.
72///
73/// The poll interval is bounded to prevent busy-waiting (per architecture principle A2).
74/// Polls every 500ms — a balance between responsiveness and system load.
75///
76/// # Panics
77///
78/// Panics if the async task cannot be spawned (e.g., no runtime available).
79pub fn start_watching(dir: Arc<PathBuf>, broadcaster: Broadcaster) {
80    tokio::spawn(async move {
81        let mut mtimes: HashMap<PathBuf, SystemTime> = HashMap::new();
82        let mut first_pass = true;
83        let mut interval = tokio::time::interval(Duration::from_millis(500));
84        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
85
86        loop {
87            interval.tick().await;
88
89            let entries = walk_dir(dir.as_path()).await.unwrap_or_default();
90            let mut current = HashMap::new();
91
92            for path in entries {
93                if let Ok(meta) = tokio::fs::metadata(&path).await {
94                    if let Ok(mtime) = meta.modified() {
95                        current.insert(path.clone(), mtime);
96
97                        if !first_pass {
98                            let is_new = !mtimes.contains_key(&path);
99                            let changed = mtimes.get(&path).is_none_or(|old| *old != mtime);
100                            if is_new || changed {
101                                let change_type = ChangeType::from_path(&path);
102                                broadcaster.broadcast(ChangeEvent { path, change_type });
103                            }
104                        }
105                    }
106                }
107            }
108
109            mtimes = current;
110            first_pass = false;
111        }
112    });
113}
114
115/// Recursively walk a directory tree and return all file paths.
116async fn walk_dir(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
117    let mut files = Vec::new();
118    let mut dirs = vec![dir.to_path_buf()];
119
120    while let Some(dir) = dirs.pop() {
121        let mut rd = tokio::fs::read_dir(&dir).await?;
122        while let Some(entry) = rd.next_entry().await? {
123            let path = entry.path();
124            if entry.file_type().await?.is_dir() {
125                dirs.push(path);
126            } else {
127                files.push(path);
128            }
129        }
130    }
131
132    Ok(files)
133}
134
135#[cfg(test)]
136#[path = "../tests/unit/watcher.rs"]
137mod tests;