use std::path::PathBuf;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::SystemTime;
use crate::config::Config;
struct Cached {
mtime: Option<SystemTime>,
config: Arc<Config>,
}
pub struct ConfigReloader {
path: Option<PathBuf>,
cache: Mutex<Cached>,
}
impl ConfigReloader {
pub fn new(path: PathBuf, initial: Config) -> Self {
let mtime = file_mtime(&path);
Self {
path: Some(path),
cache: Mutex::new(Cached {
mtime,
config: Arc::new(initial),
}),
}
}
pub fn fixed(config: Config) -> Self {
Self {
path: None,
cache: Mutex::new(Cached {
mtime: None,
config: Arc::new(config),
}),
}
}
pub fn current(&self) -> Arc<Config> {
let Some(path) = &self.path else {
return self
.cache
.lock()
.unwrap_or_else(PoisonError::into_inner)
.config
.clone();
};
let mtime = file_mtime(path);
let mut cached = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
if mtime == cached.mtime {
return cached.config.clone();
}
let displayed = path.display();
match Config::load_from_path_public(path) {
Ok(config) => {
let config = Arc::new(config);
cached.mtime = mtime;
cached.config = config.clone();
tracing::info!(path = %displayed, "reloaded config after an on-disk change");
config
}
Err(e) => {
tracing::warn!(
path = %displayed,
error = %e,
"config changed on disk but failed to reload; keeping the last good config"
);
cached.config.clone()
}
}
}
}
fn file_mtime(path: &std::path::Path) -> Option<SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn write(path: &std::path::Path, body: &str) {
std::fs::write(path, body).unwrap();
}
fn bump_mtime(path: &std::path::Path) {
let later = SystemTime::now() + Duration::from_secs(5);
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
f.set_modified(later).unwrap();
}
fn config_with_grant(agent: &str, path: &str) -> String {
let mut c = Config::default();
c.agent_read_paths.insert(
agent.to_string(),
crate::config::ReadPathGrants {
allow: vec![path.to_string()],
},
);
toml::to_string(&c).unwrap()
}
fn empty_config() -> String {
toml::to_string(&Config::default()).unwrap()
}
#[test]
fn an_unchanged_file_returns_the_cached_config_without_reloading() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write(&path, &empty_config());
let reloader = ConfigReloader::new(path.clone(), Config::default());
let a = reloader.current();
let b = reloader.current();
assert!(Arc::ptr_eq(&a, &b));
}
#[test]
fn an_edited_file_is_reloaded_on_the_next_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write(&path, &empty_config());
let reloader = ConfigReloader::new(path.clone(), Config::default());
assert!(
reloader
.current()
.read_path_grants_for_agent("cto")
.is_empty()
);
write(&path, &config_with_grant("cto", "~/.leviath/runs"));
bump_mtime(&path);
let reloaded = reloader.current();
assert_eq!(
reloaded.read_path_grants_for_agent("cto"),
vec!["~/.leviath/runs".to_string()],
"the new grant must be visible without a restart"
);
}
#[test]
fn a_config_that_appears_after_boot_is_picked_up() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let reloader = ConfigReloader::new(path.clone(), Config::default());
assert!(
reloader
.current()
.read_path_grants_for_agent("cto")
.is_empty()
);
write(&path, &config_with_grant("cto", "~/docs"));
let reloaded = reloader.current();
assert_eq!(
reloaded.read_path_grants_for_agent("cto"),
vec!["~/docs".to_string()]
);
}
#[test]
fn a_broken_edit_keeps_the_last_good_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write(&path, &config_with_grant("cto", "~/good"));
let reloader =
ConfigReloader::new(path.clone(), Config::load_from_path_public(&path).unwrap());
assert_eq!(
reloader.current().read_path_grants_for_agent("cto"),
vec!["~/good".to_string()]
);
write(&path, "this is not valid : : toml");
bump_mtime(&path);
let after = reloader.current();
assert_eq!(
after.read_path_grants_for_agent("cto"),
vec!["~/good".to_string()],
"a broken file must not break the spawn - keep the last good config"
);
}
#[test]
fn a_good_save_after_a_broken_one_recovers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write(&path, &config_with_grant("cto", "~/good"));
let reloader =
ConfigReloader::new(path.clone(), Config::load_from_path_public(&path).unwrap());
let _ = reloader.current();
write(&path, "broken : :");
bump_mtime(&path);
let _ = reloader.current();
write(&path, &config_with_grant("cto", "~/fixed"));
bump_mtime(&path);
assert_eq!(
reloader.current().read_path_grants_for_agent("cto"),
vec!["~/fixed".to_string()]
);
}
}