use std::{
path::PathBuf,
sync::{Arc, OnceLock},
time::Duration,
};
use eyre::{Result, WrapErr};
use log::{debug, error, info, warn};
use notify::{
Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher,
event::{EventKind, ModifyKind},
};
use tokio::sync::watch;
use super::Settings;
static SETTINGS_WATCHER: OnceLock<Result<SettingsWatcher, String>> = OnceLock::new();
pub fn global_settings_watcher() -> Result<&'static SettingsWatcher> {
let result = SETTINGS_WATCHER.get_or_init(|| SettingsWatcher::new().map_err(|e| e.to_string()));
match result {
Ok(watcher) => Ok(watcher),
Err(e) => Err(eyre::eyre!("{}", e)),
}
}
pub struct SettingsWatcher {
rx: watch::Receiver<Arc<Settings>>,
_watcher: RecommendedWatcher,
}
impl SettingsWatcher {
pub fn new() -> Result<Self> {
let initial_settings = Arc::new(Settings::new()?);
let (tx, rx) = watch::channel(initial_settings);
let config_path = Self::config_path();
info!("starting config file watcher: {:?}", config_path);
let watcher = Self::create_watcher(tx, config_path)?;
Ok(Self {
rx,
_watcher: watcher,
})
}
pub fn subscribe(&self) -> watch::Receiver<Arc<Settings>> {
self.rx.clone()
}
pub fn current(&self) -> Arc<Settings> {
self.rx.borrow().clone()
}
fn config_path() -> PathBuf {
let config_dir = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
PathBuf::from(p)
} else {
atuin_common::utils::config_dir()
};
config_dir.join("config.toml")
}
fn create_watcher(
tx: watch::Sender<Arc<Settings>>,
config_path: PathBuf,
) -> Result<RecommendedWatcher> {
let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<()>();
let config_path_clone = config_path.clone();
std::thread::spawn(move || {
Self::debounce_loop(debounce_rx, tx, config_path_clone);
});
let config_path_for_watcher = config_path.clone();
let canonical_config_path = config_path_for_watcher
.canonicalize()
.unwrap_or_else(|_| config_path_for_watcher.clone());
let mut watcher = RecommendedWatcher::new(
move |res: Result<notify::Event, notify::Error>| {
match res {
Ok(event) => {
if event.paths.is_empty() {
warn!(
"config watcher: event has no paths, triggering reload to be safe"
);
let _ = debounce_tx.send(());
return;
}
let is_config_file = event.paths.iter().any(|path| {
let canonical_event_path =
path.canonicalize().unwrap_or_else(|_| path.clone());
canonical_event_path == canonical_config_path
|| path.file_name() == config_path_for_watcher.file_name()
});
if !is_config_file {
return;
}
if matches!(
event.kind,
EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any)
| EventKind::Create(_)
) {
debug!("config file event detected: {:?}", event);
let _ = debounce_tx.send(());
}
}
Err(e) => {
error!("file watcher error: {}", e);
}
}
},
NotifyConfig::default(),
)
.wrap_err("failed to create file watcher")?;
let watch_path = config_path.parent().unwrap_or(&config_path);
if !watch_path.exists() {
warn!(
"config directory does not exist, creating it: {:?}",
watch_path
);
std::fs::create_dir_all(watch_path)
.wrap_err_with(|| format!("failed to create config directory: {:?}", watch_path))?;
}
watcher
.watch(watch_path, RecursiveMode::NonRecursive)
.wrap_err_with(|| format!("failed to watch config directory: {:?}", watch_path))?;
info!("config file watcher initialized for: {:?}", watch_path);
Ok(watcher)
}
fn debounce_loop(
rx: std::sync::mpsc::Receiver<()>,
tx: watch::Sender<Arc<Settings>>,
config_path: PathBuf,
) {
const DEBOUNCE_DURATION: Duration = Duration::from_millis(500);
loop {
if rx.recv().is_err() {
debug!("config watcher debounce loop exiting");
return;
}
while rx.recv_timeout(DEBOUNCE_DURATION).is_ok() {
}
if !config_path.exists() {
debug!(
"config file does not exist, skipping reload: {:?}",
config_path
);
continue;
}
info!("config file changed, reloading settings: {:?}", config_path);
match Settings::new() {
Ok(settings) => {
if tx.send(Arc::new(settings)).is_err() {
debug!("all settings subscribers dropped, exiting");
return;
}
info!("settings reloaded successfully");
}
Err(e) => {
warn!("failed to reload settings: {}", e);
}
}
}
}
}