use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use arc_swap::ArcSwap;
use notify::{RecommendedWatcher, RecursiveMode, Watcher as _};
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::builder::ConfigBuilder;
use crate::error::Error;
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(500);
const RETRY_ATTEMPTS: usize = 3;
const RETRY_INTERVAL: Duration = Duration::from_millis(50);
type ChangeCallback<T> = Arc<dyn Fn(Arc<T>) + Send + Sync>;
type ErrorCallback = Arc<dyn Fn(&Error) + Send + Sync>;
pub struct ConfigWatcher<T> {
shared: Arc<Shared<T>>,
watcher: Option<RecommendedWatcher>,
thread: Option<JoinHandle<()>>,
}
struct Shared<T> {
current: ArcSwap<T>,
on_change: Mutex<Vec<ChangeCallback<T>>>,
on_error: Mutex<Vec<ErrorCallback>>,
}
impl ConfigBuilder {
pub fn watch<T>(self) -> Result<ConfigWatcher<T>, Error>
where
T: DeserializeOwned + Send + Sync + 'static,
{
let initial_value = self.build_value()?;
let initial: T =
serde_json::from_value(initial_value.clone()).map_err(Error::Deserialize)?;
let shared = Arc::new(Shared {
current: ArcSwap::from_pointee(initial),
on_change: Mutex::new(Vec::new()),
on_error: Mutex::new(Vec::new()),
});
let (sender, receiver) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = notify::recommended_watcher(move |event| {
let _ = sender.send(event);
})?;
let mut file_names: HashSet<OsString> = HashSet::new();
let mut dirs: HashMap<PathBuf, (PathBuf, bool)> = HashMap::new();
for (path, required) in self.file_paths() {
if let Some(name) = path.file_name() {
file_names.insert(name.to_os_string());
}
let dir = parent_dir(&path);
let key = dir.canonicalize().unwrap_or_else(|_| dir.clone());
let entry = dirs.entry(key).or_insert_with(|| (dir, false));
entry.1 |= required;
}
for (dir, required) in dirs.into_values() {
if let Err(err) = watcher.watch(&dir, RecursiveMode::NonRecursive) {
if required {
return Err(Error::Watch(err));
}
}
}
let thread = {
let shared = Arc::clone(&shared);
std::thread::spawn(move || {
reload_loop(&receiver, &self, &shared, initial_value, &file_names);
})
};
Ok(ConfigWatcher {
shared,
watcher: Some(watcher),
thread: Some(thread),
})
}
}
impl<T> ConfigWatcher<T> {
#[must_use]
pub fn get(&self) -> Arc<T> {
self.shared.current.load_full()
}
pub fn on_change(&self, callback: impl Fn(Arc<T>) + Send + Sync + 'static) {
push_callback(&self.shared.on_change, Arc::new(callback));
}
pub fn on_error(&self, callback: impl Fn(&Error) + Send + Sync + 'static) {
push_callback(&self.shared.on_error, Arc::new(callback));
}
}
impl<T> Drop for ConfigWatcher<T> {
fn drop(&mut self) {
drop(self.watcher.take());
if let Some(handle) = self.thread.take() {
let _ = handle.join();
}
}
}
impl<T> Shared<T> {
fn fire_change(&self, config: &Arc<T>) {
for callback in lock_snapshot(&self.on_change) {
callback(Arc::clone(config));
}
}
fn fire_error(&self, error: &Error) {
for callback in lock_snapshot(&self.on_error) {
callback(error);
}
}
}
fn reload_loop<T>(
receiver: &mpsc::Receiver<notify::Result<notify::Event>>,
builder: &ConfigBuilder,
shared: &Shared<T>,
mut last_value: Value,
file_names: &HashSet<OsString>,
) where
T: DeserializeOwned,
{
while let Ok(event) = receiver.recv() {
if !is_relevant(&event, file_names) {
continue;
}
loop {
match receiver.recv_timeout(DEBOUNCE_WINDOW) {
Ok(_) => {}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => return,
}
}
reload(builder, shared, &mut last_value);
}
}
fn reload<T>(builder: &ConfigBuilder, shared: &Shared<T>, last_value: &mut Value)
where
T: DeserializeOwned,
{
let mut result = builder.build_value();
for _ in 0..RETRY_ATTEMPTS {
if !matches!(result, Err(Error::NotFound { .. })) {
break;
}
std::thread::sleep(RETRY_INTERVAL);
result = builder.build_value();
}
let value = match result {
Ok(value) => value,
Err(err) => return shared.fire_error(&err),
};
if value == *last_value {
return;
}
match serde_json::from_value::<T>(value.clone()) {
Ok(config) => {
*last_value = value;
let snapshot = Arc::new(config);
shared.current.store(Arc::clone(&snapshot));
shared.fire_change(&snapshot);
}
Err(err) => shared.fire_error(&Error::Deserialize(err)),
}
}
fn is_relevant(event: ¬ify::Result<notify::Event>, file_names: &HashSet<OsString>) -> bool {
match event {
Ok(event) => event.paths.iter().any(|path| {
path.file_name()
.is_some_and(|name| file_names.contains(name))
}),
Err(_) => true,
}
}
fn parent_dir(path: &Path) -> PathBuf {
match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
_ => PathBuf::from("."),
}
}
fn push_callback<C>(mutex: &Mutex<Vec<C>>, callback: C) {
match mutex.lock() {
Ok(mut guard) => guard.push(callback),
Err(poisoned) => poisoned.into_inner().push(callback),
}
}
fn lock_snapshot<C: Clone>(mutex: &Mutex<Vec<C>>) -> Vec<C> {
match mutex.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parent_dir_fallbacks_to_current() {
assert_eq!(parent_dir(Path::new("a/b/c.toml")), PathBuf::from("a/b"));
assert_eq!(parent_dir(Path::new("c.toml")), PathBuf::from("."));
}
#[test]
fn event_relevance_by_file_name() {
let names: HashSet<OsString> = [OsString::from("app.toml")].into();
let event =
notify::Event::new(notify::EventKind::Any).add_path(PathBuf::from("/tmp/app.toml"));
assert!(is_relevant(&Ok(event), &names));
let other =
notify::Event::new(notify::EventKind::Any).add_path(PathBuf::from("/tmp/other.toml"));
assert!(!is_relevant(&Ok(other), &names));
}
#[test]
fn config_watcher_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ConfigWatcher<String>>();
}
}