vanguard-plugin 0.1.1

Plugin system for the Vanguard version manager
Documentation
use serde::{de::DeserializeOwned, Serialize};
use std::path::PathBuf;
use thiserror::Error;
use tokio::fs;
use tokio::sync::RwLock;

/// Errors that can occur during plugin configuration operations
#[derive(Error, Debug)]
pub enum ConfigError {
    /// Configuration file not found
    #[error("Config file not found: {0}")]
    NotFound(PathBuf),

    /// Failed to parse configuration
    #[error("Failed to parse config: {0}")]
    ParseError(String),

    /// Failed to validate configuration
    #[error("Config validation failed: {0}")]
    ValidationError(String),

    /// Failed to save configuration
    #[error("Failed to save config: {0}")]
    SaveError(String),

    /// IO error occurred
    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),
}

/// Result type for configuration operations
pub type ConfigResult<T> = Result<T, ConfigError>;

/// Manages plugin configuration storage and validation
#[derive(Debug)]
pub struct ConfigManager {
    /// Base directory for plugin configs
    config_dir: PathBuf,
    /// In-memory config cache
    cache: RwLock<serde_json::Value>,
}

impl ConfigManager {
    /// Create a new config manager
    pub fn new(config_dir: PathBuf) -> Self {
        Self {
            config_dir,
            cache: RwLock::new(serde_json::Value::Null),
        }
    }

    /// Load configuration for a plugin
    pub async fn load_config<T: DeserializeOwned>(&self, plugin_name: &str) -> ConfigResult<T> {
        let config_path = self.get_config_path(plugin_name);

        // Check if config file exists
        if !config_path.exists() {
            return Err(ConfigError::NotFound(config_path));
        }

        // Read and parse config file
        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()))?;

        // Update cache
        *self.cache.write().await = config.clone();

        // Convert to requested type
        serde_json::from_value(config).map_err(|e| ConfigError::ParseError(e.to_string()))
    }

    /// Save configuration for a plugin
    pub async fn save_config<T: Serialize>(
        &self,
        plugin_name: &str,
        config: &T,
    ) -> ConfigResult<()> {
        let config_path = self.get_config_path(plugin_name);

        // Create parent directories if they don't exist
        if let Some(parent) = config_path.parent() {
            fs::create_dir_all(parent).await?;
        }

        // Serialize config
        let content = serde_json::to_string_pretty(config)
            .map_err(|e| ConfigError::SaveError(e.to_string()))?;

        // Write to file
        fs::write(&config_path, content).await?;

        // Update cache
        *self.cache.write().await =
            serde_json::to_value(config).map_err(|e| ConfigError::SaveError(e.to_string()))?;

        Ok(())
    }

    /// Validate configuration against a JSON schema
    pub async fn validate_config(
        &self,
        config: &serde_json::Value,
        schema: &serde_json::Value,
    ) -> ConfigResult<()> {
        // TODO: Implement JSON Schema validation
        // For now just check that config is an object if schema requires it
        if schema.get("type") == Some(&serde_json::json!("object")) && !config.is_object() {
            return Err(ConfigError::ValidationError(
                "Config must be an object".into(),
            ));
        }
        Ok(())
    }

    /// Get the path to a plugin's config file
    fn get_config_path(&self, plugin_name: &str) -> PathBuf {
        self.config_dir.join(plugin_name).with_extension("json")
    }

    /// Check if configuration exists for a plugin
    pub async fn has_config(&self, plugin_name: &str) -> bool {
        self.get_config_path(plugin_name).exists()
    }

    /// Delete configuration for a plugin
    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());

        // Initially no config exists
        assert!(!config_manager.has_config("test-plugin").await);

        // Save new config
        let config = TestConfig {
            name: "test".to_string(),
            value: 42,
        };
        config_manager
            .save_config("test-plugin", &config)
            .await
            .unwrap();

        // Config should now exist
        assert!(config_manager.has_config("test-plugin").await);

        // Load and verify config
        let loaded: TestConfig = config_manager.load_config("test-plugin").await.unwrap();
        assert_eq!(loaded, config);

        // Delete 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());

        // Valid object config
        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());

        // Invalid non-object config
        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());

        // Test loading non-existent config
        let result: ConfigResult<TestConfig> = config_manager.load_config("nonexistent").await;
        assert!(matches!(result, Err(ConfigError::NotFound(_))));

        // Test loading invalid JSON
        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(_))));
    }
}