use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crossbeam_channel::{Receiver, Sender};
use notify::{RecursiveMode, Watcher};
use tracing::{debug, info, trace, warn};
#[derive(Debug, Clone)]
pub struct ConfigChange {
pub path: PathBuf,
pub kind: ChangeKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
Create,
Modify,
Remove,
}
const REARM_INTERVAL: Duration = Duration::from_secs(5);
pub struct ConfigWatcher {
dir: PathBuf,
subscribers: HashMap<PathBuf, Vec<Sender<ConfigChange>>>,
}
impl ConfigWatcher {
pub fn new(dir: PathBuf) -> Self {
Self {
dir,
subscribers: HashMap::new(),
}
}
pub fn subscribe(&mut self, basename: &str) -> Receiver<ConfigChange> {
let (tx, rx) = crossbeam_channel::unbounded();
self.subscribers
.entry(PathBuf::from(basename))
.or_default()
.push(tx);
rx
}
pub fn spawn(self) {
ensure_config_dir(&self.dir);
let (raw_tx, raw_rx) =
crossbeam_channel::unbounded::<Result<notify::Event, notify::Error>>();
let mut watcher: Option<notify::RecommendedWatcher> =
match notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
let _ = raw_tx.send(res);
}) {
Ok(w) => Some(w),
Err(e) => {
warn!(
error = %e,
"failed to create the filesystem watcher; config-file changes \
will not reload automatically",
);
None
}
};
let armed = match watcher.as_mut() {
Some(w) => match w.watch(&self.dir, RecursiveMode::NonRecursive) {
Ok(()) => {
info!(dir = %self.dir.display(), "config directory watch armed");
true
}
Err(e) => {
warn!(dir = %self.dir.display(), error = %e,
"initial config dir watch failed; will retry on the re-arm cadence");
false
}
},
None => false,
};
let _ = std::thread::Builder::new()
.name("config-watch".into())
.spawn(move || transport_loop(self.subscribers, self.dir, watcher, raw_rx, armed));
}
}
fn classify(kind: ¬ify::EventKind) -> Option<ChangeKind> {
match kind {
notify::EventKind::Create(_) => Some(ChangeKind::Create),
notify::EventKind::Modify(_) => Some(ChangeKind::Modify),
notify::EventKind::Remove(_) => Some(ChangeKind::Remove),
_ => None,
}
}
fn route(
subscribers: &HashMap<PathBuf, Vec<Sender<ConfigChange>>>,
event: ¬ify::Event,
) -> Vec<(PathBuf, ChangeKind)> {
let Some(kind) = classify(&event.kind) else {
return Vec::new();
};
let changed: Vec<&OsStr> = event.paths.iter().filter_map(|p| p.file_name()).collect();
subscribers
.keys()
.filter(|b| changed.iter().any(|name| *name == b.as_os_str()))
.cloned()
.map(|b| (b, kind))
.collect()
}
fn dir_was_removed(dir: &Path, event: ¬ify::Event) -> bool {
matches!(event.kind, notify::EventKind::Remove(_)) && event.paths.iter().any(|p| p == dir)
}
fn deliver(
subscribers: &HashMap<PathBuf, Vec<Sender<ConfigChange>>>,
dir: &Path,
basename: &Path,
kind: ChangeKind,
) {
if let Some(senders) = subscribers.get(basename) {
let change = ConfigChange {
path: dir.join(basename),
kind,
};
for tx in senders {
let _ = tx.try_send(change.clone());
}
}
}
fn ensure_config_dir(dir: &Path) {
match std::fs::create_dir_all(dir) {
Ok(()) => debug!(dir = %dir.display(), "config dir ready"),
Err(e) => warn!(
dir = %dir.display(),
error = %e,
"failed to create the config dir; config-file auto-reload may be unavailable",
),
}
}
fn is_overflow(event: ¬ify::Event) -> bool {
event.flag() == Some(notify::event::Flag::Rescan)
}
fn snapshot_content(dir: &Path, basename: &Path) -> Option<Vec<u8>> {
std::fs::read(dir.join(basename)).ok()
}
fn rescan_changes(
dir: &Path,
subscribers: &HashMap<PathBuf, Vec<Sender<ConfigChange>>>,
last_known: &mut HashMap<PathBuf, Option<Vec<u8>>>,
) -> Vec<(PathBuf, ChangeKind)> {
let mut out = Vec::new();
for basename in subscribers.keys() {
let current = snapshot_content(dir, basename);
let prev = last_known.entry(basename.clone()).or_insert(None);
let kind = match (¤t, &*prev) {
(Some(_), None) => Some(ChangeKind::Create),
(Some(cur), Some(prev)) if cur != prev => Some(ChangeKind::Modify),
(None, Some(_)) => Some(ChangeKind::Remove),
_ => None,
};
*prev = current;
if let Some(kind) = kind {
out.push((basename.clone(), kind));
}
}
out
}
fn note_routed_state(
dir: &Path,
last_known: &mut HashMap<PathBuf, Option<Vec<u8>>>,
routed: &[(PathBuf, ChangeKind)],
) {
for (basename, _) in routed {
let content = snapshot_content(dir, basename);
last_known.insert(basename.clone(), content);
}
}
fn transport_loop(
subscribers: HashMap<PathBuf, Vec<Sender<ConfigChange>>>,
dir: PathBuf,
mut watcher: Option<notify::RecommendedWatcher>,
raw_rx: crossbeam_channel::Receiver<Result<notify::Event, notify::Error>>,
mut armed: bool,
) {
let mut last_known: HashMap<PathBuf, Option<Vec<u8>>> = HashMap::new();
loop {
if !armed && let Some(w) = watcher.as_mut() {
match w.watch(&dir, RecursiveMode::NonRecursive) {
Ok(()) => {
armed = true;
info!(
dir = %dir.display(),
"config directory is now watchable; auto-reload armed",
);
}
Err(e) => {
tracing::debug!(
dir = %dir.display(),
error = %e,
"config directory still not watchable; will retry",
);
}
}
}
let raw = if armed {
match raw_rx.recv() {
Ok(raw) => raw,
Err(_) => {
info!("config watch raw channel closed; exiting");
break;
}
}
} else {
match raw_rx.recv_timeout(REARM_INTERVAL) {
Ok(raw) => raw,
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
continue;
}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
info!("config watch raw channel closed; exiting");
break;
}
}
};
match raw {
Ok(event) => {
if armed && dir_was_removed(&dir, &event) {
armed = false;
info!(
dir = %dir.display(),
"config directory removed; watch will re-arm on the next retry",
);
}
if is_overflow(&event) {
warn!(
dir = %dir.display(),
"watcher queue overflow detected; rescanning the config dir",
);
for (basename, kind) in rescan_changes(&dir, &subscribers, &mut last_known) {
debug!(basename = %basename.display(), ?kind,
"overflow rescan synthesized a change");
deliver(&subscribers, &dir, &basename, kind);
}
debug!(
dir = %dir.display(),
tracked = last_known.len(),
"overflow rescan complete; last-known state refreshed",
);
} else {
let routed = route(&subscribers, &event);
note_routed_state(&dir, &mut last_known, &routed);
for (basename, kind) in routed {
trace!(basename = %basename.display(), ?kind, "delivering config change");
deliver(&subscribers, &dir, &basename, kind);
}
}
}
Err(e) => {
warn!(error = %e, dir = %dir.display(),
"config watcher error; rescanning the config dir to replay any missed changes");
for (basename, kind) in rescan_changes(&dir, &subscribers, &mut last_known) {
debug!(basename = %basename.display(), ?kind,
"error-path rescan synthesized a change");
deliver(&subscribers, &dir, &basename, kind);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(path: &Path, kind: notify::EventKind) -> notify::Event {
notify::Event {
kind,
paths: vec![path.to_path_buf()],
attrs: notify::event::EventAttributes::default(),
}
}
#[test]
fn classify_strips_access_and_other_noise() {
assert_eq!(
classify(¬ify::EventKind::Access(notify::event::AccessKind::Read)),
None
);
assert_eq!(classify(¬ify::EventKind::Other), None);
assert_eq!(
classify(¬ify::EventKind::Create(notify::event::CreateKind::File)),
Some(ChangeKind::Create)
);
assert_eq!(
classify(¬ify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Any
))),
Some(ChangeKind::Modify)
);
assert_eq!(
classify(¬ify::EventKind::Remove(notify::event::RemoveKind::File)),
Some(ChangeKind::Remove)
);
}
#[test]
fn dir_was_removed_detects_only_the_watched_directory() {
let dir = Path::new("/cfg");
assert!(dir_was_removed(
dir,
&event(
dir,
notify::EventKind::Remove(notify::event::RemoveKind::Folder)
)
));
assert!(!dir_was_removed(
dir,
&event(
&dir.join("accounts.toml"),
notify::EventKind::Remove(notify::event::RemoveKind::File)
)
));
assert!(!dir_was_removed(
dir,
&event(
dir,
notify::EventKind::Modify(notify::event::ModifyKind::Name(
notify::event::RenameMode::To
))
)
));
}
#[test]
fn route_matches_by_basename_only() {
let overlay = PathBuf::from("models-overlay.toml");
let accounts = PathBuf::from("accounts.toml");
let mut subs: HashMap<PathBuf, Vec<Sender<ConfigChange>>> = HashMap::new();
let (tx, _rx) = crossbeam_channel::unbounded();
subs.insert(overlay.clone(), vec![tx]);
let (tx2, _rx2) = crossbeam_channel::unbounded();
subs.insert(accounts.clone(), vec![tx2]);
for kind in [
notify::EventKind::Create(notify::event::CreateKind::File),
notify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Any,
)),
notify::EventKind::Remove(notify::event::RemoveKind::File),
] {
let routed = route(&subs, &event(Path::new("/cfg/models-overlay.toml"), kind));
assert_eq!(
routed,
vec![(overlay.clone(), ChangeKind::from_kind(&kind))]
);
}
let routed = route(
&subs,
&event(
Path::new("/cfg/accounts.toml"),
notify::EventKind::Create(notify::event::CreateKind::File),
),
);
assert_eq!(routed, vec![(accounts.clone(), ChangeKind::Create)]);
let routed = route(
&subs,
&event(
Path::new("/cfg/config.toml"),
notify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Any,
)),
),
);
assert!(routed.is_empty());
let routed = route(
&subs,
&event(
Path::new("/cfg/models-overlay.toml"),
notify::EventKind::Access(notify::event::AccessKind::Read),
),
);
assert!(routed.is_empty());
}
#[test]
fn is_overflow_detects_only_the_rescan_flag() {
let overflow = event(Path::new("/cfg"), notify::EventKind::Other)
.set_flag(notify::event::Flag::Rescan); assert!(is_overflow(&overflow));
assert!(!is_overflow(&event(
Path::new("/cfg/accounts.toml"),
notify::EventKind::Create(notify::event::CreateKind::File)
)));
assert!(!is_overflow(&event(
Path::new("/cfg"),
notify::EventKind::Other
)));
}
#[test]
fn rescan_synthesizes_create_modify_remove_divergences() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("accounts.toml"), "a = 1\n").unwrap();
std::fs::write(d.join("models-overlay.toml"), "o = 1\n").unwrap();
let mut subs: HashMap<PathBuf, Vec<Sender<ConfigChange>>> = HashMap::new();
let (tx, _rx) = crossbeam_channel::unbounded();
subs.insert(PathBuf::from("accounts.toml"), vec![tx]);
let (tx2, _rx2) = crossbeam_channel::unbounded();
subs.insert(PathBuf::from("models-overlay.toml"), vec![tx2]);
let mut last_known: HashMap<PathBuf, Option<Vec<u8>>> = HashMap::new();
let changes = rescan_changes(d, &subs, &mut last_known);
let expected = vec![
(PathBuf::from("accounts.toml"), ChangeKind::Create),
(PathBuf::from("models-overlay.toml"), ChangeKind::Create),
];
assert_eq!(changes.len(), 2);
for pair in expected {
assert!(changes.contains(&pair), "missing replay of {pair:?}");
}
assert!(rescan_changes(d, &subs, &mut last_known).is_empty());
std::fs::write(d.join("accounts.toml"), "a = 2\n").unwrap();
assert_eq!(
rescan_changes(d, &subs, &mut last_known),
vec![(PathBuf::from("accounts.toml"), ChangeKind::Modify)]
);
std::fs::remove_file(d.join("accounts.toml")).unwrap();
assert_eq!(
rescan_changes(d, &subs, &mut last_known),
vec![(PathBuf::from("accounts.toml"), ChangeKind::Remove)]
);
assert!(rescan_changes(d, &subs, &mut last_known).is_empty());
std::fs::write(d.join("accounts.toml"), "a = 3\n").unwrap();
assert_eq!(
rescan_changes(d, &subs, &mut last_known),
vec![(PathBuf::from("accounts.toml"), ChangeKind::Create)]
);
}
#[test]
fn rescan_delivers_through_the_subscriber_channel() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("accounts.toml"), "a = 1\n").unwrap();
let mut subs: HashMap<PathBuf, Vec<Sender<ConfigChange>>> = HashMap::new();
let (tx, rx) = crossbeam_channel::unbounded();
subs.insert(PathBuf::from("accounts.toml"), vec![tx]);
let mut last_known: HashMap<PathBuf, Option<Vec<u8>>> = HashMap::new();
for (basename, kind) in rescan_changes(dir.path(), &subs, &mut last_known) {
deliver(&subs, dir.path(), &basename, kind);
}
let got = rx.try_recv().unwrap();
assert_eq!(got.kind, ChangeKind::Create);
assert_eq!(got.path, dir.path().join("accounts.toml"));
}
#[test]
fn note_routed_state_suppresses_replay_of_already_delivered_changes() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("accounts.toml"), "a = 1\n").unwrap();
let mut subs: HashMap<PathBuf, Vec<Sender<ConfigChange>>> = HashMap::new();
let (tx, _rx) = crossbeam_channel::unbounded();
subs.insert(PathBuf::from("accounts.toml"), vec![tx]);
let mut last_known: HashMap<PathBuf, Option<Vec<u8>>> = HashMap::new();
let routed = vec![(PathBuf::from("accounts.toml"), ChangeKind::Create)];
note_routed_state(d, &mut last_known, &routed);
assert!(rescan_changes(d, &subs, &mut last_known).is_empty());
std::fs::remove_file(d.join("accounts.toml")).unwrap();
assert_eq!(
rescan_changes(d, &subs, &mut last_known),
vec![(PathBuf::from("accounts.toml"), ChangeKind::Remove)]
);
}
impl ChangeKind {
fn from_kind(kind: ¬ify::EventKind) -> ChangeKind {
classify(kind).expect("event kind is classified")
}
}
}