use serde::{de::DeserializeOwned, Serialize};
use std::path::PathBuf;
use thiserror::Error;
use tokio::fs;
use tokio::sync::RwLock;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Config file not found: {0}")]
NotFound(PathBuf),
#[error("Failed to parse config: {0}")]
ParseError(String),
#[error("Config validation failed: {0}")]
ValidationError(String),
#[error("Failed to save config: {0}")]
SaveError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
pub type ConfigResult<T> = Result<T, ConfigError>;
#[derive(Debug)]
pub struct ConfigManager {
config_dir: PathBuf,
cache: RwLock<serde_json::Value>,
}
impl ConfigManager {
pub fn new(config_dir: PathBuf) -> Self {
Self {
config_dir,
cache: RwLock::new(serde_json::Value::Null),
}
}
pub async fn load_config<T: DeserializeOwned>(&self, plugin_name: &str) -> ConfigResult<T> {
let config_path = self.get_config_path(plugin_name);
if !config_path.exists() {
return Err(ConfigError::NotFound(config_path));
}
let content = fs::read_to_string(&config_path).await?;
let config: serde_json::Value =
serde_json::from_str(&content).map_err(|e| ConfigError::ParseError(e.to_string()))?;
*self.cache.write().await = config.clone();
serde_json::from_value(config).map_err(|e| ConfigError::ParseError(e.to_string()))
}
pub async fn save_config<T: Serialize>(
&self,
plugin_name: &str,
config: &T,
) -> ConfigResult<()> {
let config_path = self.get_config_path(plugin_name);
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(config)
.map_err(|e| ConfigError::SaveError(e.to_string()))?;
fs::write(&config_path, content).await?;
*self.cache.write().await =
serde_json::to_value(config).map_err(|e| ConfigError::SaveError(e.to_string()))?;
Ok(())
}
pub async fn validate_config(
&self,
config: &serde_json::Value,
schema: &serde_json::Value,
) -> ConfigResult<()> {
if schema.get("type") == Some(&serde_json::json!("object")) && !config.is_object() {
return Err(ConfigError::ValidationError(
"Config must be an object".into(),
));
}
Ok(())
}
fn get_config_path(&self, plugin_name: &str) -> PathBuf {
self.config_dir.join(plugin_name).with_extension("json")
}
pub async fn has_config(&self, plugin_name: &str) -> bool {
self.get_config_path(plugin_name).exists()
}
pub async fn delete_config(&self, plugin_name: &str) -> ConfigResult<()> {
let config_path = self.get_config_path(plugin_name);
if config_path.exists() {
fs::remove_file(&config_path).await?;
*self.cache.write().await = serde_json::Value::Null;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct TestConfig {
name: String,
value: i32,
}
#[tokio::test]
async fn test_config_lifecycle() {
let temp_dir = TempDir::new().unwrap();
let config_manager = ConfigManager::new(temp_dir.path().to_path_buf());
assert!(!config_manager.has_config("test-plugin").await);
let config = TestConfig {
name: "test".to_string(),
value: 42,
};
config_manager
.save_config("test-plugin", &config)
.await
.unwrap();
assert!(config_manager.has_config("test-plugin").await);
let loaded: TestConfig = config_manager.load_config("test-plugin").await.unwrap();
assert_eq!(loaded, config);
config_manager.delete_config("test-plugin").await.unwrap();
assert!(!config_manager.has_config("test-plugin").await);
}
#[tokio::test]
async fn test_config_validation() {
let temp_dir = TempDir::new().unwrap();
let config_manager = ConfigManager::new(temp_dir.path().to_path_buf());
let valid_config = serde_json::json!({
"name": "test",
"value": 42
});
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "integer" }
}
});
assert!(config_manager
.validate_config(&valid_config, &schema)
.await
.is_ok());
let invalid_config = serde_json::json!(42);
assert!(matches!(
config_manager
.validate_config(&invalid_config, &schema)
.await,
Err(ConfigError::ValidationError(_))
));
}
#[tokio::test]
async fn test_config_errors() {
let temp_dir = TempDir::new().unwrap();
let config_manager = ConfigManager::new(temp_dir.path().to_path_buf());
let result: ConfigResult<TestConfig> = config_manager.load_config("nonexistent").await;
assert!(matches!(result, Err(ConfigError::NotFound(_))));
let config_path = config_manager.get_config_path("invalid");
fs::create_dir_all(config_path.parent().unwrap())
.await
.unwrap();
fs::write(&config_path, "invalid json").await.unwrap();
let result: ConfigResult<TestConfig> = config_manager.load_config("invalid").await;
assert!(matches!(result, Err(ConfigError::ParseError(_))));
}
}