#![cfg(feature = "hot-reload")]
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use notify::{EventKind, RecursiveMode};
use notify_debouncer_full::{new_debouncer, DebouncedEvent};
use serde::de::DeserializeOwned;
use crate::config::Config;
use crate::error::ConfigError;
const DEBOUNCE_MS: u64 = 250;
#[must_use = "dropping `WatchHandle` immediately stops the file watcher"]
pub struct WatchHandle {
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl std::fmt::Debug for WatchHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WatchHandle")
.field("stopped", &self.stop.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
impl Drop for WatchHandle {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(h) = self.thread.take() {
let _ = h.join();
}
}
}
impl<C> Config<C>
where
C: DeserializeOwned + Send + Sync + 'static,
{
pub fn watch_files(&self) -> Result<WatchHandle, ConfigError> {
if self.sources.files.is_empty() {
return Err(ConfigError::Watch("no user files registered".into()));
}
let mut targets = self
.sources
.files
.iter()
.map(|p| WatchedFile::new(p))
.collect::<Result<Vec<_>, _>>()?;
let (tx, rx) = mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(DEBOUNCE_MS), None, tx)
.map_err(|e| ConfigError::Watch(format!("debouncer: {e}")))?;
let mut watched_dirs = BTreeSet::new();
for dir in targets.iter().flat_map(WatchedFile::dirs) {
if watched_dirs.insert(dir.clone()) {
debouncer
.watch(&dir, RecursiveMode::NonRecursive)
.map_err(|e| ConfigError::Watch(format!("watch {}: {e}", dir.display())))?;
}
}
let this = self.clone();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = Arc::clone(&stop);
let thread = std::thread::Builder::new()
.name("rtb-config-watcher".into())
.spawn(move || {
while !stop_thread.load(Ordering::Relaxed) {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(Ok(events)) => {
if !should_reload(&events, &mut targets) {
continue;
}
for dir in targets.iter().flat_map(WatchedFile::dirs) {
if watched_dirs.insert(dir.clone()) {
let _ = debouncer.watch(&dir, RecursiveMode::NonRecursive);
}
}
let _ = this.reload();
}
Ok(Err(_notify_errs)) => { }
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
})
.map_err(|e| ConfigError::Watch(format!("spawn watcher thread: {e}")))?;
Ok(WatchHandle { stop, thread: Some(thread) })
}
}
struct WatchedFile {
path: PathBuf,
resolved: Option<PathBuf>,
}
impl WatchedFile {
fn new(registered: &Path) -> Result<Self, ConfigError> {
let name = registered.file_name().ok_or_else(|| {
ConfigError::Watch(format!("watch {}: not a file path", registered.display()))
})?;
let dir = match registered.parent() {
Some(d) if !d.as_os_str().is_empty() => d,
_ => Path::new("."),
};
let dir = dir
.canonicalize()
.map_err(|e| ConfigError::Watch(format!("watch {}: {e}", dir.display())))?;
let path = dir.join(name);
let resolved = path.canonicalize().ok();
Ok(Self { path, resolved })
}
fn dirs(&self) -> impl Iterator<Item = PathBuf> + '_ {
[Some(&self.path), self.resolved.as_ref()]
.into_iter()
.flatten()
.filter_map(|p| p.parent().map(Path::to_path_buf))
}
fn is_named_by(&self, event_path: &Path) -> bool {
event_path == self.path || self.resolved.as_deref() == Some(event_path)
}
fn refresh_resolved(&mut self) -> bool {
let now = self.path.canonicalize().ok();
let moved = now != self.resolved;
self.resolved = now;
moved
}
}
fn should_reload(events: &[DebouncedEvent], targets: &mut [WatchedFile]) -> bool {
let changes: Vec<&DebouncedEvent> =
events.iter().filter(|e| !matches!(e.kind, EventKind::Access(_))).collect();
if changes.is_empty() {
return false;
}
let named = changes
.iter()
.flat_map(|e| e.paths.iter())
.any(|p| targets.iter().any(|t| t.is_named_by(p)));
let mut moved = false;
for t in targets.iter_mut() {
moved |= t.refresh_resolved();
}
named || moved
}