use crate::config::Config;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::SystemTime;
use tokio::sync::RwLock;
const MAX_HISTORY: usize = 5;
fn history_path(config_path: &std::path::Path) -> PathBuf {
let mut p = config_path.to_path_buf();
p.set_extension("toml.history");
p
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PersistedHistory {
pub versions: Vec<PersistedVersion>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PersistedVersion {
pub version: u64,
pub loaded_at: String,
pub config_json: String,
}
impl PersistedHistory {
pub fn load(config_path: &std::path::Path) -> Option<Self> {
let path = history_path(config_path);
let raw = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&raw).ok()
}
fn save(&self, config_path: &std::path::Path) {
let path = history_path(config_path);
if let Ok(json) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&path, json);
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigVersion {
pub config: Config,
pub loaded_at: SystemTime,
pub version: u64,
}
pub struct ConfigWatcher {
pub path: PathBuf,
pub health: AtomicBool,
pub last_error: RwLock<Option<String>>,
history: RwLock<Vec<ConfigVersion>>,
version_counter: AtomicU64,
last_mtime: RwLock<Option<SystemTime>>,
}
impl ConfigWatcher {
pub fn new(path: PathBuf) -> Arc<Self> {
Arc::new(Self {
path,
health: AtomicBool::new(true),
last_error: RwLock::new(None),
history: RwLock::new(Vec::with_capacity(MAX_HISTORY)),
version_counter: AtomicU64::new(0),
last_mtime: RwLock::new(None),
})
}
pub async fn init(&self) -> Result<Config, anyhow::Error> {
let cfg = Config::load(Some(&self.path))?;
self.record_mtime(&self.path).await;
self.store_version(cfg.clone()).await;
Ok(cfg)
}
pub async fn poll(&self) -> Option<Config> {
let current_mtime = match self.file_mtime().await {
Some(t) => t,
None => return None, };
let last = *self.last_mtime.read().await;
if Some(current_mtime) == last {
return None; }
match Config::load(Some(&self.path)) {
Ok(new_cfg) => {
*self.last_mtime.write().await = Some(current_mtime);
self.health.store(true, Ordering::Release);
*self.last_error.write().await = None;
self.store_version(new_cfg.clone()).await;
tracing::info!(path = %self.path.display(), "config auto-reloaded");
Some(new_cfg)
}
Err(e) => {
self.health.store(false, Ordering::Release);
*self.last_error.write().await = Some(e.to_string());
tracing::error!(path = %self.path.display(), error = %e, "config reload failed — keeping old config");
*self.last_mtime.write().await = Some(current_mtime); None
}
}
}
async fn store_version(&self, config: Config) {
let mut history = self.history.write().await;
let version = self.version_counter.fetch_add(1, Ordering::Relaxed) + 1;
history.push(ConfigVersion {
config: config.clone(),
loaded_at: SystemTime::now(),
version,
});
if history.len() > MAX_HISTORY {
history.remove(0);
}
let now = chrono::Utc::now().to_rfc3339();
if let Ok(json) = serde_json::to_string(&config) {
let mut persisted = PersistedHistory::load(&self.path).unwrap_or(PersistedHistory {
versions: Vec::new(),
});
persisted.versions.push(PersistedVersion {
version,
loaded_at: now,
config_json: json,
});
if persisted.versions.len() > MAX_HISTORY {
persisted.versions.remove(0);
}
persisted.save(&self.path);
}
}
pub async fn current_version(&self) -> u64 {
self.version_counter.load(Ordering::Relaxed)
}
pub async fn rollback(&self, version: u64) -> Option<Config> {
let history = self.history.read().await;
history
.iter()
.find(|v| v.version == version)
.map(|v| v.config.clone())
}
pub async fn version_history(&self) -> Vec<ConfigVersion> {
self.history.read().await.clone()
}
async fn record_mtime(&self, path: &std::path::Path) {
*self.last_mtime.write().await = file_mtime(path).await;
}
async fn file_mtime(&self) -> Option<SystemTime> {
file_mtime(&self.path).await
}
pub fn is_healthy(&self) -> bool {
self.health.load(Ordering::Acquire)
}
}
async fn file_mtime(path: &std::path::Path) -> Option<SystemTime> {
tokio::fs::metadata(path).await.ok()?.modified().ok()
}
pub async fn spawn_watcher(watcher: Arc<ConfigWatcher>, state: Arc<crate::AppState>) {
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if let Some(new_cfg) = watcher.poll().await {
*state.config.write().unwrap() = new_cfg;
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_config_path() -> PathBuf {
let dir = std::env::temp_dir();
dir.join(format!("portail-test-{}.toml", uuid::Uuid::new_v4()))
}
#[tokio::test]
async fn poll_detects_change() {
let path = temp_config_path();
let toml = r#"listen = "127.0.0.1:9999""#;
std::fs::write(&path, toml).unwrap();
let watcher = ConfigWatcher::new(path.clone());
let cfg = watcher.init().await.unwrap();
assert_eq!(cfg.listen, "127.0.0.1:9999");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
assert!(watcher.poll().await.is_none());
let toml2 = r#"listen = "0.0.0.0:8080""#;
std::fs::write(&path, toml2).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let updated = watcher.poll().await;
assert!(updated.is_some(), "expected poll to detect change");
assert_eq!(updated.unwrap().listen, "0.0.0.0:8080");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn poll_ignores_invalid_config() {
let path = temp_config_path();
std::fs::write(&path, "listen = \"127.0.0.1:9999\"").unwrap();
let watcher = ConfigWatcher::new(path.clone());
let _ = watcher.init().await.unwrap();
assert!(watcher.is_healthy());
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
std::fs::write(&path, "{{{ not toml }}}").unwrap();
let result = watcher.poll().await;
assert!(result.is_none());
assert!(!watcher.is_healthy());
assert!(watcher.last_error.read().await.is_some());
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn version_history_rolls() {
let path = temp_config_path();
for i in 0..7 {
let toml = format!(r#"listen = "0.0.0.0:{}""#, 8000 + i);
std::fs::write(&path, &toml).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let watcher = ConfigWatcher::new(path.clone());
if i == 0 {
let _ = watcher.init().await.unwrap();
}
if i > 0 {
let _ = watcher.poll().await;
}
}
let _ = std::fs::remove_file(&path);
let _watcher = ConfigWatcher::new(path.clone());
let history_path = history_path(&path);
assert!(history_path.exists());
}
#[tokio::test]
async fn rollback_returns_correct_version() {
let path = temp_config_path();
std::fs::write(&path, "listen = \"0.0.0.0:8001\"").unwrap();
let watcher = ConfigWatcher::new(path.clone());
let _ = watcher.init().await.unwrap();
assert_eq!(watcher.current_version().await, 1);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
std::fs::write(&path, "listen = \"0.0.0.0:8002\"").unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let _ = watcher.poll().await;
assert_eq!(watcher.current_version().await, 2);
let rolled = watcher.rollback(1).await;
assert!(rolled.is_some());
assert_eq!(rolled.unwrap().listen, "0.0.0.0:8001");
assert!(watcher.rollback(999).await.is_none());
let _ = std::fs::remove_file(&path);
}
}