use crate::config::{ConfigLoader, FlowControlConfig};
use crate::error::LimiteronError;
use std::sync::Arc;
use tokio::sync::RwLock;
pub async fn load_config_from_file(path: &str) -> Result<FlowControlConfig, LimiteronError> {
let path = path.to_string();
tokio::task::spawn_blocking(move || ConfigLoader::load_from_file(&path))
.await
.map_err(|e| LimiteronError::ConfigError(format!("config load task failed: {}", e)))?
}
pub type GovernorConfigHandle = Arc<RwLock<FlowControlConfig>>;
#[cfg(feature = "config-confers-reload")]
#[async_trait::async_trait]
pub trait ConfigApplier: Send + Sync {
async fn apply(&self, config: FlowControlConfig) -> Result<(), LimiteronError>;
}
#[cfg(feature = "config-confers-reload")]
pub async fn watch_and_reload_with_applier(
path: &str,
applier: Arc<dyn ConfigApplier>,
) -> Result<tokio_util::sync::CancellationToken, LimiteronError> {
let path_buf = std::path::PathBuf::from(path);
let mut watcher = confers::FsWatcher::new(&path_buf, 500)
.await
.map_err(|e| LimiteronError::ConfigError(format!("failed to create watcher: {}", e)))?;
let cancel_token = tokio_util::sync::CancellationToken::new();
let cancel_clone = cancel_token.clone();
let file_path = path.to_string();
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel_clone.cancelled() => {
log::info!("confers watcher: cancelled, stopping");
break;
}
event = watcher.recv() => {
match event {
Some(_changed_path) => {
log::info!("confers watcher: config file changed, reloading");
match load_config_from_file(&file_path).await {
Ok(new_config) => {
if new_config.rules.is_empty() {
log::warn!(
"confers reload: new config has empty rules, rolling back"
);
continue;
}
match applier.apply(new_config).await {
Ok(()) => {
log::info!("confers reload: config applied successfully");
}
Err(e) => {
log::error!(
"confers reload: apply failed, keeping old config: {e}"
);
}
}
}
Err(e) => {
log::error!(
"confers reload: failed to parse config, rolling back: {}",
e
);
}
}
}
None => {
log::error!("confers watcher: watcher stopped unexpectedly; hot-reload inactive");
break;
}
}
}
}
}
});
Ok(cancel_token)
}
#[cfg(feature = "config-confers-reload")]
pub async fn watch_and_reload(
path: &str,
config_handle: GovernorConfigHandle,
) -> Result<tokio_util::sync::CancellationToken, LimiteronError> {
let path_buf = std::path::PathBuf::from(path);
let mut watcher = confers::FsWatcher::new(&path_buf, 500)
.await
.map_err(|e| LimiteronError::ConfigError(format!("failed to create watcher: {}", e)))?;
let cancel_token = tokio_util::sync::CancellationToken::new();
let cancel_clone = cancel_token.clone();
let file_path = path.to_string();
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel_clone.cancelled() => {
log::info!("confers watcher: cancelled, stopping");
break;
}
event = watcher.recv() => {
match event {
Some(_changed_path) => {
log::info!("confers watcher: config file changed, reloading");
match load_config_from_file(&file_path).await {
Ok(new_config) => {
if new_config.rules.is_empty() {
log::warn!(
"confers reload: new config has empty rules, rolling back"
);
continue;
}
let mut guard = config_handle.write().await;
*guard = new_config;
log::info!("confers reload: config updated successfully");
}
Err(e) => {
log::error!(
"confers reload: failed to parse config, rolling back: {}",
e
);
}
}
}
None => {
log::warn!("confers watcher: watcher stopped unexpectedly");
break;
}
}
}
}
}
});
Ok(cancel_token)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_load_config_from_file_not_found() {
let result = load_config_from_file("/nonexistent/path.toml").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_load_config_from_file_json_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test_config.json");
let config_json = r#"{
"version": "1.0.0",
"global": {
"storage": "memory",
"cache": "memory",
"metrics": "prometheus"
},
"rules": []
}"#;
std::fs::write(&path, config_json).unwrap();
let result = load_config_from_file(path.to_str().unwrap()).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().version, "1.0.0");
}
#[tokio::test]
async fn test_load_config_from_file_yaml_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test_config.yaml");
let config_yaml = r#"
version: "2.0.0"
global:
storage: memory
cache: memory
metrics: none
rules: []
"#;
std::fs::write(&path, config_yaml).unwrap();
let result = load_config_from_file(path.to_str().unwrap()).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().version, "2.0.0");
}
#[tokio::test]
async fn test_load_config_from_file_toml_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test_config.toml");
let config_toml = r#"
version = "3.0.0"
rules = []
[global]
storage = "memory"
cache = "memory"
metrics = "none"
"#;
std::fs::write(&path, config_toml).unwrap();
let result = load_config_from_file(path.to_str().unwrap()).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().version, "3.0.0");
}
#[tokio::test]
async fn test_load_config_from_file_unsupported_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config");
std::fs::write(&path, b"version = \"1.0.0\"\n").unwrap();
let result = load_config_from_file(path.to_str().unwrap()).await;
assert!(result.is_err());
match result {
Err(LimiteronError::ConfigError(msg)) => {
assert!(
msg.contains("Unsupported"),
"must contain Unsupported: {msg}"
)
}
other => panic!("expected ConfigError, got {other:?}"),
}
}
}