use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigTrigger {
FileChanged,
Periodic,
Sighup,
}
pub struct ConfigWatch {
path: Option<PathBuf>,
last_modified: Option<SystemTime>,
poll_timer: Option<tokio::time::Interval>,
periodic_timer: Option<tokio::time::Interval>,
#[cfg(unix)]
sighup: Option<tokio::signal::unix::Signal>,
}
impl ConfigWatch {
#[must_use]
pub fn new(path: Option<PathBuf>, poll_interval: Duration) -> Self {
let poll_timer = path.as_ref().map(|_| tokio::time::interval(poll_interval));
Self {
path,
last_modified: None,
poll_timer,
periodic_timer: None,
#[cfg(unix)]
sighup: None,
}
}
pub async fn prime(&mut self) {
if let Some(ref path) = self.path {
self.last_modified = file_mtime(path).await;
}
}
#[must_use]
pub fn with_periodic(mut self, interval: Duration) -> Self {
self.periodic_timer = (interval > Duration::ZERO).then(|| tokio::time::interval(interval));
self
}
#[must_use]
pub fn with_sighup(mut self, enabled: bool) -> Self {
#[cfg(unix)]
{
self.sighup = enabled.then(|| {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
.expect("failed to register SIGHUP handler")
});
}
#[cfg(not(unix))]
let _ = enabled;
self
}
#[must_use]
pub fn is_armed(&self) -> bool {
#[cfg(unix)]
let sighup = self.sighup.is_some();
#[cfg(not(unix))]
let sighup = false;
self.poll_timer.is_some() || self.periodic_timer.is_some() || sighup
}
pub async fn next_trigger(&mut self) -> ConfigTrigger {
loop {
match self.select_trigger().await {
ConfigTrigger::FileChanged => {
if let Some(ref path) = self.path {
let current = file_mtime(path).await;
let changed = match (&self.last_modified, ¤t) {
(Some(last), Some(now)) => now > last,
(None, Some(_)) => true,
_ => false,
};
if changed {
self.last_modified = current;
return ConfigTrigger::FileChanged;
}
}
}
other => return other,
}
}
}
async fn select_trigger(&mut self) -> ConfigTrigger {
#[cfg(unix)]
{
tokio::select! {
() = tick(self.poll_timer.as_mut()) => ConfigTrigger::FileChanged,
() = tick(self.periodic_timer.as_mut()) => ConfigTrigger::Periodic,
() = async {
match self.sighup.as_mut() {
Some(sig) => { sig.recv().await; },
None => std::future::pending::<()>().await,
}
} => ConfigTrigger::Sighup,
}
}
#[cfg(not(unix))]
{
tokio::select! {
() = tick(self.poll_timer.as_mut()) => ConfigTrigger::FileChanged,
() = tick(self.periodic_timer.as_mut()) => ConfigTrigger::Periodic,
}
}
}
}
async fn tick(timer: Option<&mut tokio::time::Interval>) {
match timer {
Some(t) => {
t.tick().await;
}
None => std::future::pending::<()>().await,
}
}
pub(crate) async fn file_mtime(path: &Path) -> Option<SystemTime> {
tokio::fs::metadata(path)
.await
.ok()
.and_then(|m| m.modified().ok())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[tokio::test]
async fn unarmed_watch_reports_no_triggers() {
let watch = ConfigWatch::new(None, Duration::from_millis(1));
assert!(!watch.is_armed());
}
#[tokio::test]
async fn periodic_fires_without_a_file() {
let mut watch = ConfigWatch::new(None, Duration::from_millis(1))
.with_periodic(Duration::from_millis(1));
assert!(watch.is_armed());
assert_eq!(watch.next_trigger().await, ConfigTrigger::Periodic);
}
#[tokio::test]
async fn file_change_fires_once_per_change() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("config.yaml");
std::fs::write(&path, "value: 1").expect("write");
let mut watch = ConfigWatch::new(Some(path.clone()), Duration::from_millis(10));
watch.prime().await;
tokio::time::sleep(Duration::from_millis(20)).await;
let mut f = std::fs::File::create(&path).expect("recreate");
f.write_all(b"value: 2").expect("write");
drop(f);
let trigger = tokio::time::timeout(Duration::from_secs(5), watch.next_trigger())
.await
.expect("a changed file must trigger");
assert_eq!(trigger, ConfigTrigger::FileChanged);
assert!(
tokio::time::timeout(Duration::from_millis(120), watch.next_trigger())
.await
.is_err(),
"an unchanged mtime must not resolve next_trigger"
);
}
#[tokio::test]
async fn file_mtime_reads_existing_and_missing_paths() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("test.txt");
std::fs::write(&path, "content").expect("write");
assert!(file_mtime(&path).await.is_some());
assert!(file_mtime(&dir.path().join("absent.txt")).await.is_none());
}
#[tokio::test]
async fn missing_file_never_triggers() {
let dir = TempDir::new().expect("tempdir");
let mut watch = ConfigWatch::new(
Some(dir.path().join("absent.yaml")),
Duration::from_millis(5),
);
assert!(
tokio::time::timeout(Duration::from_millis(60), watch.next_trigger())
.await
.is_err()
);
}
}