use anyhow::{Context, Result};
use notify::{Config as NotifyConfig, Event, PollWatcher, RecursiveMode, Watcher};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, channel};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct ConfigReloadEvent {
pub path: PathBuf,
}
pub struct ConfigWatcher {
_watcher: Box<dyn Watcher + Send>,
event_receiver: Receiver<ConfigReloadEvent>,
}
impl std::fmt::Debug for ConfigWatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfigWatcher").finish_non_exhaustive()
}
}
fn make_event_handler(
filename: std::ffi::OsString,
canonical_path: PathBuf,
debounce_delay: Duration,
tx: std::sync::mpsc::Sender<ConfigReloadEvent>,
last_event_time: Arc<Mutex<Option<Instant>>>,
) -> impl Fn(std::result::Result<Event, notify::Error>) + Send + 'static {
move |result: std::result::Result<Event, notify::Error>| {
if let Ok(event) = result {
if !matches!(
event.kind,
notify::EventKind::Modify(_) | notify::EventKind::Create(_)
) {
return;
}
let matches_config: bool = event
.paths
.iter()
.any(|p: &PathBuf| p.file_name().map(|f| f == filename).unwrap_or(false));
if !matches_config {
return;
}
let should_send: bool = {
let now: Instant = Instant::now();
let mut last: parking_lot::MutexGuard<'_, Option<Instant>> = last_event_time.lock();
if let Some(last_time) = *last {
if now.duration_since(last_time) < debounce_delay {
log::trace!("Debouncing config reload event");
false
} else {
*last = Some(now);
true
}
} else {
*last = Some(now);
true
}
};
if should_send {
let reload_event = ConfigReloadEvent {
path: canonical_path.clone(),
};
log::info!("Config file changed: {}", reload_event.path.display());
if let Err(e) = tx.send(reload_event) {
log::error!("Failed to send config reload event: {}", e);
}
}
}
}
}
impl ConfigWatcher {
pub fn new(config_path: &Path, debounce_delay_ms: u64) -> Result<Self> {
if !config_path.exists() {
anyhow::bail!("Config file not found: {}", config_path.display());
}
let canonical: PathBuf = config_path
.canonicalize()
.unwrap_or_else(|_| config_path.to_path_buf());
let filename: std::ffi::OsString = canonical
.file_name()
.context("Config path has no filename")?
.to_os_string();
let parent_dir: PathBuf = canonical
.parent()
.context("Config path has no parent directory")?
.to_path_buf();
let (tx, rx) = channel::<ConfigReloadEvent>();
let debounce_delay: Duration = Duration::from_millis(debounce_delay_ms);
let last_event_time: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let mut watcher: Box<dyn Watcher + Send> = Self::create_watcher(
filename,
canonical.clone(),
debounce_delay,
tx,
last_event_time,
)?;
watcher
.watch(&parent_dir, RecursiveMode::NonRecursive)
.with_context(|| {
format!("Failed to watch config directory: {}", parent_dir.display())
})?;
log::info!("Config hot reload: watching {}", canonical.display());
Ok(Self {
_watcher: watcher,
event_receiver: rx,
})
}
fn create_watcher(
filename: std::ffi::OsString,
canonical_path: PathBuf,
debounce_delay: Duration,
tx: std::sync::mpsc::Sender<ConfigReloadEvent>,
last_event_time: Arc<Mutex<Option<Instant>>>,
) -> Result<Box<dyn Watcher + Send>> {
let filename2 = filename.clone();
let canonical_path2 = canonical_path.clone();
let debounce_delay2 = debounce_delay;
let tx2 = tx.clone();
let last_event_time2 = Arc::clone(&last_event_time);
let handler = make_event_handler(
filename,
canonical_path,
debounce_delay,
tx,
last_event_time,
);
match notify::recommended_watcher(handler) {
Ok(w) => {
log::debug!("Config watcher: using native (RecommendedWatcher) backend");
Ok(Box::new(w))
}
Err(e) => {
log::warn!(
"Config watcher: native backend unavailable ({}); falling back to PollWatcher",
e
);
let fallback_handler = make_event_handler(
filename2,
canonical_path2,
debounce_delay2,
tx2,
last_event_time2,
);
let poll_watcher = PollWatcher::new(
fallback_handler,
NotifyConfig::default().with_poll_interval(Duration::from_millis(500)),
)
.context("Failed to create fallback PollWatcher")?;
Ok(Box::new(poll_watcher))
}
}
}
pub fn try_recv(&self) -> Option<ConfigReloadEvent> {
self.event_receiver.try_recv().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_watcher_creation_with_existing_file() {
let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
let config_path: PathBuf = temp_dir.path().join("config.yaml");
fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
let result = ConfigWatcher::new(&config_path, 100);
assert!(
result.is_ok(),
"ConfigWatcher should succeed with existing file"
);
}
#[test]
fn test_watcher_creation_with_nonexistent_file() {
let path = PathBuf::from("/tmp/nonexistent_config_watcher_test/config.yaml");
let result = ConfigWatcher::new(&path, 100);
assert!(
result.is_err(),
"ConfigWatcher should fail with nonexistent file"
);
}
#[test]
fn test_no_initial_events() {
let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
let config_path: PathBuf = temp_dir.path().join("config.yaml");
fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
let watcher: ConfigWatcher =
ConfigWatcher::new(&config_path, 100).expect("Failed to create watcher");
assert!(
watcher.try_recv().is_none(),
"No events should be pending after creation"
);
}
#[test]
fn test_file_change_detection() {
let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
let config_path: PathBuf = temp_dir.path().join("config.yaml");
fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
let watcher: ConfigWatcher =
ConfigWatcher::new(&config_path, 50).expect("Failed to create watcher");
std::thread::sleep(Duration::from_millis(100));
fs::write(&config_path, "font_size: 14.0\n").expect("Failed to write config");
std::thread::sleep(Duration::from_millis(700));
if let Some(event) = watcher.try_recv() {
assert!(
event.path.ends_with("config.yaml"),
"Event path should end with config.yaml"
);
}
}
#[test]
fn test_debug_impl() {
let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
let config_path: PathBuf = temp_dir.path().join("config.yaml");
fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
let watcher: ConfigWatcher =
ConfigWatcher::new(&config_path, 100).expect("Failed to create watcher");
let debug_str: String = format!("{:?}", watcher);
assert!(
debug_str.contains("ConfigWatcher"),
"Debug output should contain struct name"
);
}
}