webpage_quality_analyzer 1.0.2

High-performance webpage quality analyzer with 115 comprehensive metrics - Rust library with WASM, C++, and Python bindings
Documentation
use crate::config::config_models::ProfileConfig;
use crate::config::enhanced_models::EnhancedScoringProfile;
use crate::config::profiles::ProfileLoader;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;

/// Supported configuration file formats
#[derive(Debug, Clone, Copy)]
pub enum ConfigFormat {
    Yaml,
    Toml,
    Json,
}

/// Manages loading and accessing configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigManager {
    pub config: ProfileConfig,
    #[serde(skip)]
    pub profile_loader: ProfileLoader,
}

impl Default for ConfigManager {
    fn default() -> Self {
        Self::new()
    }
}

impl ConfigManager {
    /// Creates a new ConfigManager with default settings.
    pub fn new() -> Self {
        let profile_loader = ProfileLoader::default(); // Load defaults automatically
        let profiles = Self::extract_profiles_from_loader(&profile_loader);

        Self {
            config: ProfileConfig {
                active_profile: Some("content_article".to_string()),
                presets: profiles,
                output: Default::default(),
                bands: Default::default(),
                plugins: Default::default(),
            },
            profile_loader,
        }
    }

    /// Extract profiles from ProfileLoader into HashMap for backward compatibility
    fn extract_profiles_from_loader(
        loader: &ProfileLoader,
    ) -> std::collections::HashMap<String, EnhancedScoringProfile> {
        let mut profiles = std::collections::HashMap::new();
        for name in loader.list_profiles() {
            if let Some(profile) = loader.get_profile(&name) {
                profiles.insert(name, profile.clone());
            }
        }
        profiles
    }

    /// Loads configuration from a specified file path.
    pub fn from_file(path: &Path) -> Result<Self, String> {
        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
        let format = match extension {
            "yaml" | "yml" => ConfigFormat::Yaml,
            "toml" => ConfigFormat::Toml,
            "json" => ConfigFormat::Json,
            _ => return Err("Unsupported config file format".to_string()),
        };
        let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
        Self::from_str(&content, format)
    }

    /// Loads configuration from a string (internal use only).
    pub(crate) fn from_str(content: &str, format: ConfigFormat) -> Result<Self, String> {
        let config: ProfileConfig = match format {
            ConfigFormat::Yaml => serde_yaml::from_str(content).map_err(|e| e.to_string())?,
            ConfigFormat::Toml => toml::from_str(content).map_err(|e| e.to_string())?,
            ConfigFormat::Json => serde_json::from_str(content).map_err(|e| e.to_string())?,
        };

        // Validate and normalize all profiles
        // EnhancedScoringProfile validates itself at creation time

        Ok(Self {
            config,
            profile_loader: ProfileLoader::default(),
        })
    }

    /// Retrieves a specific scoring profile by name.
    pub fn get_profile(&self, name: &str) -> Option<&EnhancedScoringProfile> {
        self.config.presets.get(name)
    }

    /// Gets the active profile with optional override
    pub fn get_active_profile_with_override(
        &self,
        profile_override: Option<String>,
    ) -> Result<&EnhancedScoringProfile, String> {
        let profile_name = profile_override.as_deref().unwrap_or(
            self.config
                .active_profile
                .as_deref()
                .unwrap_or("content_article"),
        );
        self.get_profile(profile_name)
            .ok_or_else(|| format!("Profile '{}' not found.", profile_name))
    }

    /// Gets the active profile without override (for the new API)
    pub fn get_active_profile(&self) -> Result<&EnhancedScoringProfile, String> {
        let profile_name = self
            .config
            .active_profile
            .as_deref()
            .unwrap_or("content_article");
        self.get_profile(profile_name)
            .ok_or_else(|| format!("Profile '{}' not found.", profile_name))
    }

    /// Sets the active profile by name
    pub fn set_active_profile(&mut self, name: &str) -> Result<(), String> {
        if self.get_profile(name).is_some() {
            self.config.active_profile = Some(name.to_string());
            Ok(())
        } else {
            Err(format!("Profile '{}' not found.", name))
        }
    }

    /// Creates a ConfigManager from a ProfileConfig
    pub fn from_profile_config(config: ProfileConfig) -> Result<Self, String> {
        // Validate that the active profile exists
        let active_name = config
            .active_profile
            .as_deref()
            .unwrap_or("content_article");
        if !config.presets.contains_key(active_name) {
            return Err(format!(
                "Active profile '{}' not found in presets",
                active_name
            ));
        }

        Ok(Self {
            config,
            profile_loader: ProfileLoader::default(),
        })
    }

    /// Creates a sample configuration file (for CLI/examples only).
    #[cfg(feature = "cli")]
    pub fn create_sample_config(path: &Path, format: ConfigFormat) -> Result<(), String> {
        let loader = ProfileLoader::default();
        let profiles = Self::extract_profiles_from_loader(&loader);

        let config = ProfileConfig {
            active_profile: Some("content_article".to_string()),
            presets: profiles,
            output: Default::default(),
            bands: Default::default(),
            plugins: Default::default(),
        };

        let content = match format {
            ConfigFormat::Yaml => serde_yaml::to_string(&config).map_err(|e| e.to_string())?,
            ConfigFormat::Toml => toml::to_string_pretty(&config).map_err(|e| e.to_string())?,
            ConfigFormat::Json => {
                serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?
            }
        };

        fs::write(path, content).map_err(|e| e.to_string())
    }

    /// Add a custom profile
    pub fn add_profile(
        &mut self,
        name: String,
        profile: EnhancedScoringProfile,
    ) -> Result<(), String> {
        self.profile_loader
            .add_profile(name.clone(), profile.clone())?;
        self.config.presets.insert(name, profile);
        Ok(())
    }

    /// Get a profile from the loader (more up-to-date than config.presets)
    pub fn get_profile_from_loader(&self, name: &str) -> Option<&EnhancedScoringProfile> {
        self.profile_loader.get_profile(name)
    }

    /// Gets the list of available profile names
    pub fn get_available_profiles(&self) -> Vec<String> {
        self.config.presets.keys().cloned().collect()
    }

    /// Gets the name of the currently active profile
    pub fn get_active_profile_name(&self) -> Result<String, String> {
        self.config
            .active_profile
            .clone()
            .ok_or_else(|| "No active profile set".to_string())
    }

    /// Create a profile builder (facade for ProfileLoader's builder)
    pub fn profile_builder() -> crate::config::profiles::ProfileBuilder {
        use crate::config::profiles::ProfileLoader;
        ProfileLoader::create_profile_builder()
    }

    // ==================== Phase 1: Runtime Profile Modification ====================

    /// Set a runtime-modified profile without persisting to disk
    ///
    /// This method allows runtime modifications to profiles (from ProfileModifier)
    /// to be applied to the ConfigManager without saving to configuration files.
    /// The modified profile replaces the current active profile for the session.
    ///
    /// # Arguments
    /// * `profile` - The modified EnhancedScoringProfile to use
    ///
    /// # Returns
    /// Ok(()) if successful, or an error message if validation fails
    pub fn set_runtime_profile(
        &mut self,
        profile: EnhancedScoringProfile,
    ) -> Result<(), crate::AnalyzeError> {
        // Validate profile structure (basic sanity checks)
        if profile.metadata.name.is_empty() {
            return Err(crate::AnalyzeError::InvalidProfile(
                "Profile name cannot be empty".to_string(),
            ));
        }

        // Validate category weights sum to 1.0 (or close to it)
        let weight_sum: f32 = profile.category_weights.values().sum();
        if (weight_sum - 1.0).abs() > 0.01 {
            return Err(crate::AnalyzeError::InvalidProfile(format!(
                "Category weights must sum to 1.0, got {}",
                weight_sum
            )));
        }

        let profile_name = profile.metadata.name.clone();

        // Add to both profile_loader and config.presets
        self.profile_loader
            .add_profile(profile_name.clone(), profile.clone())
            .map_err(|e| {
                crate::AnalyzeError::ConfigError(format!("Failed to add runtime profile: {}", e))
            })?;

        self.config.presets.insert(profile_name.clone(), profile);

        // Set as active profile
        self.config.active_profile = Some(profile_name);

        Ok(())
    }
}