use std::path::PathBuf;
use std::sync::Arc;
use parking_lot::RwLock;
use crate::InklogError;
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct HotReloadValues {
#[serde(default = "default_level")]
pub level: String,
#[serde(default)]
pub file_max_size: Option<String>,
#[serde(default)]
pub file_rotation_time: Option<String>,
#[serde(default)]
pub file_keep_files: Option<u32>,
#[serde(default)]
pub rate_limit: Option<u64>,
}
fn default_level() -> String {
"info".to_string()
}
impl HotReloadValues {
pub fn from_config(config: &crate::InklogConfig) -> Self {
Self {
level: config.global.level.clone(),
file_max_size: config.file_sink.as_ref().map(|f| f.max_size.clone()),
file_rotation_time: config.file_sink.as_ref().map(|f| f.rotation_time.clone()),
file_keep_files: config.file_sink.as_ref().map(|f| f.keep_files),
rate_limit: config.performance.rate_limit,
}
}
}
pub fn load_config_via_confers(path: &std::path::Path) -> Result<crate::InklogConfig, InklogError> {
confers::ConfigBuilder::<crate::InklogConfig>::new()
.allow_absolute_paths()
.file(path)
.build()
.map_err(|e| {
InklogError::ConfigError(format!(
"confers failed to load config '{}': {e}",
path.display()
))
})
}
pub struct ConfersConfigWatcher {
current: Arc<RwLock<HotReloadValues>>,
shutdown: Arc<std::sync::atomic::AtomicBool>,
task: tokio::task::JoinHandle<()>,
}
impl ConfersConfigWatcher {
pub async fn spawn<F>(
path: PathBuf,
debounce_ms: u64,
on_reload: F,
) -> Result<Self, InklogError>
where
F: Fn(&HotReloadValues) + Send + Sync + 'static,
{
if !path.exists() {
return Err(InklogError::ConfigError(format!(
"config file '{}' does not exist",
path.display()
)));
}
let initial = load_config_via_confers(&path)?;
let initial_values = HotReloadValues::from_config(&initial);
let current = Arc::new(RwLock::new(initial_values.clone()));
{
let current = current.clone();
on_reload(¤t.read().clone());
let _ = current;
}
let current_for_task = current.clone();
let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
let shutdown_for_task = shutdown.clone();
let callback = Arc::new(on_reload);
let mut watcher = confers::watcher::FsWatcher::new(&path, debounce_ms)
.await
.map_err(|e| {
InklogError::ConfigError(format!(
"confers FsWatcher failed to start for '{}': {e}",
path.display()
))
})?;
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
let task = tokio::spawn(async move {
loop {
if shutdown_for_task.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
match tokio::time::timeout(std::time::Duration::from_millis(200), watcher.recv())
.await
{
Ok(Some(_changed_path)) => {
let values = match load_config_via_confers(&path) {
Ok(cfg) => HotReloadValues::from_config(&cfg),
Err(e) => {
tracing::warn!(
error = %e,
path = %path.display(),
"config reload failed; keeping previous configuration"
);
continue;
}
};
let changed = {
let mut guard = current_for_task.write();
let changed = *guard != values;
if changed {
*guard = values.clone();
}
changed
};
if changed {
tracing::info!(path = %path.display(), "configuration hot-reloaded");
callback(&values);
}
}
Ok(None) => return, Err(_timeout) => continue,
}
}
});
Ok(Self {
current,
shutdown,
task,
})
}
pub fn current(&self) -> HotReloadValues {
self.current.read().clone()
}
pub fn stop(&self) {
self.shutdown
.store(true, std::sync::atomic::Ordering::Relaxed);
self.task.abort();
}
}
impl Drop for ConfersConfigWatcher {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn write_config(path: &std::path::Path, level: &str) {
std::fs::write(
path,
format!(
"[global]\nlevel = \"{level}\"\nformat = \"{{timestamp}} [{{level}}] {{message}}\"\n\n[file_sink]\nenabled = true\npath = \"logs/app.log\"\nmax_size = \"50MB\"\nkeep_files = 7\n"
),
)
.unwrap();
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_config_via_confers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("inklog_config.toml");
write_config(&path, "warn");
let config = load_config_via_confers(&path).unwrap();
assert_eq!(config.global.level, "warn");
assert_eq!(config.file_sink.as_ref().unwrap().max_size, "50MB");
let values = HotReloadValues::from_config(&config);
assert_eq!(values.level, "warn");
assert_eq!(values.file_keep_files, Some(7));
}
#[tokio::test]
#[serial_test::serial]
async fn test_watcher_hot_reloads_level() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("inklog_config.toml");
write_config(&path, "info");
let applied = Arc::new(AtomicUsize::new(0));
let applied_for_cb = applied.clone();
let watcher = ConfersConfigWatcher::spawn(path.clone(), 50, move |values| {
assert!(!values.level.is_empty());
applied_for_cb.fetch_add(1, Ordering::SeqCst);
})
.await
.unwrap();
assert_eq!(watcher.current().level, "info");
write_config(&path, "debug");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while watcher.current().level != "debug" && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert_eq!(
watcher.current().level,
"debug",
"level change must hot-reload"
);
watcher.stop();
assert!(
applied.load(Ordering::SeqCst) >= 2,
"callback must fire for initial load and the reload"
);
}
#[tokio::test]
#[serial_test::serial]
async fn test_watcher_keeps_old_config_on_invalid_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("inklog_config.toml");
write_config(&path, "info");
let mut watcher = ConfersConfigWatcher::spawn(path.clone(), 50, |_| {})
.await
.unwrap();
std::fs::write(&path, "this is not [ valid toml {{{{").unwrap();
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
assert_eq!(
watcher.current().level,
"info",
"invalid TOML must keep old config"
);
write_config(&path, "error");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while watcher.current().level != "error" && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert_eq!(
watcher.current().level,
"error",
"watcher survives invalid config"
);
watcher.stop();
}
#[tokio::test]
#[serial_test::serial]
async fn test_watcher_rejects_structural_change_to_hot_subset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("inklog_config.toml");
write_config(&path, "info");
let reloads = Arc::new(AtomicUsize::new(0));
let reloads_cb = reloads.clone();
let mut watcher = ConfersConfigWatcher::spawn(path.clone(), 50, move |_| {
reloads_cb.fetch_add(1, Ordering::SeqCst);
})
.await
.unwrap();
let initial = reloads.load(Ordering::SeqCst);
std::fs::write(
&path,
"[global]\nlevel = \"info\"\n\n[file_sink]\nenabled = true\npath = \"logs/other.log\"\nmax_size = \"50MB\"\nkeep_files = 7\n",
)
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
assert_eq!(
reloads.load(Ordering::SeqCst),
initial,
"structural-only change must not trigger hot reload"
);
watcher.stop();
}
#[tokio::test]
#[serial_test::serial]
async fn test_watcher_missing_file_fails_fast() {
let err = match ConfersConfigWatcher::spawn(
std::path::PathBuf::from("/nonexistent/inklog/config.toml"),
50,
|_| {},
)
.await
{
Err(e) => e,
Ok(_) => panic!("missing config file must fail fast"),
};
assert!(err.to_string().contains("does not exist"));
}
}