oo-ide 0.0.3

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Extension configuration loaded from extension.yaml files.
//!
//! Extension configuration determines which extensions are loaded and their
//! status (disabled, enabled, or auto).
//!
//! Configuration is loaded from two optional files (merge order):
//! - Global: `~/.config/oo/extension.yaml`
//! - Project: `.oo/extension.yaml`
//!
//! If an extension is not listed in either file, it is treated as disabled.

use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::prelude::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ExtensionStatus {
    /// Extension is disabled (not listed in config).
    Disabled,
    /// Extension is explicitly enabled, always active.
    Enabled,
    /// Extension should call detect_project() to determine relevance.
    #[default]
    Auto,
}

impl ExtensionStatus {
    #[allow(dead_code)]
    pub fn is_enabled(&self) -> bool {
        matches!(self, Self::Enabled | Self::Auto)
    }
}

#[derive(Debug, Default)]
pub struct ExtensionConfig {
    extensions: HashMap<String, ExtensionStatus>,
}

impl ExtensionConfig {
    pub fn get(&self, name: &str) -> ExtensionStatus {
        // If a wildcard entry is present (used by tests to allow all extensions),
        // honor it first. Otherwise default to disabled unless explicitly
        // enabled in global or project config.
        if let Some(w) = self.extensions.get("*") {
            return *w;
        }
        self.extensions
            .get(name)
            .copied()
            .unwrap_or(ExtensionStatus::Disabled)
    }

    #[allow(dead_code)]
    pub fn allow_all() -> Self {
        let mut m = HashMap::new();
        // Use the wildcard key to indicate all extensions should be treated as
        // Auto (i.e., loaded and left to detect_project()).
        m.insert("*".to_string(), ExtensionStatus::Auto);
        Self { extensions: m }
    }

    /// Persist an extension status into the project-local `.oo/config.yaml` under the 'extensions' key.
    /// For compatibility also update legacy `.oo/extension.yaml`.
    pub fn set_project_status(name: &str, status: ExtensionStatus) -> Result<()> {
        use std::fs;
        use std::path::Path;
        use serde_yaml::{Value as YamlValue, Mapping as YamlMapping};

        let project_config_path = Path::new(".oo").join("config.yaml");

        // Load existing config or start with empty mapping
        let mut root = if project_config_path.exists() {
            let data = fs::read_to_string(&project_config_path)?;
            serde_yaml::from_str::<YamlValue>(&data).unwrap_or(YamlValue::Mapping(YamlMapping::new()))
        } else {
            YamlValue::Mapping(YamlMapping::new())
        };

        if let YamlValue::Mapping(ref mut map) = root {
            let key = YamlValue::String("extensions".to_string());
            match map.get_mut(&key) {
                Some(YamlValue::Mapping(ext_map)) => {
                    if let ExtensionStatus::Disabled = status {
                        ext_map.remove(YamlValue::String(name.to_string()));
                    } else {
                        ext_map.insert(
                            YamlValue::String(name.to_string()),
                            YamlValue::String(status_to_str(status).to_string()),
                        );
                    }
                    if ext_map.is_empty() {
                        map.remove(&key);
                    }
                }
                Some(_) => {
                    let mut new_map = YamlMapping::new();
                    if let ExtensionStatus::Disabled = status {} else {
                        new_map.insert(
                            YamlValue::String(name.to_string()),
                            YamlValue::String(status_to_str(status).to_string()),
                        );
                        map.insert(key, YamlValue::Mapping(new_map));
                    }
                }
                None => {
                    if let ExtensionStatus::Disabled = status {} else {
                        let mut new_map = YamlMapping::new();
                        new_map.insert(
                            YamlValue::String(name.to_string()),
                            YamlValue::String(status_to_str(status).to_string()),
                        );
                        map.insert(key, YamlValue::Mapping(new_map));
                    }
                }
            }
        }

        if let Some(parent) = project_config_path.parent() {
            fs::create_dir_all(parent)?;
        }
        let yaml = serde_yaml::to_string(&root)?;
        fs::write(&project_config_path, yaml)?;

        // Also update legacy .oo/extension.yaml for compatibility
        let legacy_path = Path::new(".oo").join("extension.yaml");
        let mut map: HashMap<String, String> = if legacy_path.exists() {
            let data = fs::read_to_string(&legacy_path)?;
            serde_saphyr::from_str(&data).unwrap_or_default()
        } else {
            HashMap::new()
        };
        if let ExtensionStatus::Disabled = status {
            map.remove(name);
        } else {
            map.insert(name.to_string(), status_to_str(status).to_string());
        }
        if map.is_empty() {
            if legacy_path.exists() {
                fs::remove_file(&legacy_path)?;
            }
        } else {
            if let Some(parent) = legacy_path.parent() {
                fs::create_dir_all(parent)?;
            }
            let yaml = serde_saphyr::to_string(&map)?;
            fs::write(&legacy_path, yaml)?;
        }

        Ok(())
    }

    /// Persist an extension status into the global `~/.config/oo/config.yaml` under the 'extensions' key.
    /// Also update legacy `~/.config/oo/extension.yaml` for compatibility.
    pub fn set_global_status(name: &str, status: ExtensionStatus) -> Result<()> {
        use std::fs;
        use serde_yaml::{Value as YamlValue, Mapping as YamlMapping};

        let global_config_dir = directories::ProjectDirs::from("com", "cyloncore", "oo")
            .ok_or_else(|| anyhow!("Failed to retrieve project dir."))?
            .config_dir()
            .to_path_buf();
        let global_config_path = global_config_dir.join("config.yaml");

        // Load existing config or start with empty mapping
        let mut root = if global_config_path.exists() {
            let data = fs::read_to_string(&global_config_path)?;
            serde_yaml::from_str::<YamlValue>(&data).unwrap_or(YamlValue::Mapping(YamlMapping::new()))
        } else {
            YamlValue::Mapping(YamlMapping::new())
        };

        if let YamlValue::Mapping(ref mut map) = root {
            let key = YamlValue::String("extensions".to_string());
            match map.get_mut(&key) {
                Some(YamlValue::Mapping(ext_map)) => {
                    if let ExtensionStatus::Disabled = status {
                        ext_map.remove(YamlValue::String(name.to_string()));
                    } else {
                        ext_map.insert(
                            YamlValue::String(name.to_string()),
                            YamlValue::String(status_to_str(status).to_string()),
                        );
                    }
                    if ext_map.is_empty() {
                        map.remove(&key);
                    }
                }
                Some(_) => {
                    let mut new_map = YamlMapping::new();
                    if let ExtensionStatus::Disabled = status {} else {
                        new_map.insert(
                            YamlValue::String(name.to_string()),
                            YamlValue::String(status_to_str(status).to_string()),
                        );
                        map.insert(key, YamlValue::Mapping(new_map));
                    }
                }
                None => {
                    if let ExtensionStatus::Disabled = status {} else {
                        let mut new_map = YamlMapping::new();
                        new_map.insert(
                            YamlValue::String(name.to_string()),
                            YamlValue::String(status_to_str(status).to_string()),
                        );
                        map.insert(key, YamlValue::Mapping(new_map));
                    }
                }
            }
        }

        if let Some(parent) = global_config_path.parent() {
            fs::create_dir_all(parent)?;
        }
        let yaml = serde_yaml::to_string(&root)?;
        fs::write(&global_config_path, yaml)?;

        // Also update legacy global extension.yaml for compatibility
        let legacy_global = global_config_dir.join("extension.yaml");
        let mut map: HashMap<String, String> = if legacy_global.exists() {
            let data = fs::read_to_string(&legacy_global)?;
            serde_saphyr::from_str(&data).unwrap_or_default()
        } else {
            HashMap::new()
        };
        if let ExtensionStatus::Disabled = status {
            map.remove(name);
        } else {
            map.insert(name.to_string(), status_to_str(status).to_string());
        }
        if map.is_empty() {
            if legacy_global.exists() {
                fs::remove_file(&legacy_global)?;
            }
        } else {
            if let Some(parent) = legacy_global.parent() {
                fs::create_dir_all(parent)?;
            }
            let yaml = serde_saphyr::to_string(&map)?;
            fs::write(&legacy_global, yaml)?;
        }

        Ok(())
    }
}

fn status_to_str(status: ExtensionStatus) -> &'static str {
    match status {
        ExtensionStatus::Enabled => "enabled",
        ExtensionStatus::Auto => "auto",
        ExtensionStatus::Disabled => "disabled",
    }
}

pub fn load() -> Result<ExtensionConfig> {
    let global_config_dir = directories::ProjectDirs::from("com", "cyloncore", "oo")
        .ok_or_else(|| anyhow!("Failed to retrieve project dir."))?
        .config_dir()
        .to_path_buf();

    let global_config_path = global_config_dir.join("config.yaml");
    let project_config_path = Path::new(".oo").join("config.yaml");

    let mut config = ExtensionConfig::default();

    // Merge 'extensions' mapping from a serde_yaml::Value into ExtensionConfig,
    // skipping non-status reserved keys.
    fn merge_from_value(config: &mut ExtensionConfig, value: &serde_yaml::Value) -> Result<()> {
        if let serde_yaml::Value::Mapping(mapping) = value {
            for (k, v) in mapping.iter() {
                if let serde_yaml::Value::String(key) = k {
                    // Skip common non-status keys under the `extensions` section.
                    if key == "extensions_dir" || key == "auto_load" {
                        continue;
                    }
                    let status = match v {
                        serde_yaml::Value::String(s) => match s.as_str() {
                            "enabled" => ExtensionStatus::Enabled,
                            "disabled" => ExtensionStatus::Disabled,
                            "auto" => ExtensionStatus::Auto,
                            other => {
                                log::warn!("Unknown extension status '{}', treating as disabled", other);
                                ExtensionStatus::Disabled
                            }
                        },
                        serde_yaml::Value::Null => ExtensionStatus::Disabled,
                        _ => {
                            log::warn!("Invalid extension status for '{}', treating as disabled", key);
                            ExtensionStatus::Disabled
                        }
                    };
                    config.extensions.insert(key.clone(), status);
                }
            }
        }
        Ok(())
    }

    // Merge defaults from packaged default_settings.yaml (if an 'extensions' mapping exists)
    if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(include_str!("../../data/default_settings.yaml"))
        && let serde_yaml::Value::Mapping(map) = value
            && let Some(ext_val) = map.get(serde_yaml::Value::String("extensions".to_string())) {
                merge_from_value(&mut config, ext_val)?;
            }

    // Merge from global config (config.yaml) 'extensions' mapping, if present
    if global_config_path.exists() {
        let data = std::fs::read_to_string(&global_config_path)?;
        if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&data)
            && let serde_yaml::Value::Mapping(map) = value
                && let Some(ext_val) = map.get(serde_yaml::Value::String("extensions".to_string())) {
                    merge_from_value(&mut config, ext_val)?;
                }
    }

    // Merge from project config (.oo/config.yaml) 'extensions' mapping, if present
    if project_config_path.exists() {
        let data = std::fs::read_to_string(&project_config_path)?;
        if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&data)
            && let serde_yaml::Value::Mapping(map) = value
                && let Some(ext_val) = map.get(serde_yaml::Value::String("extensions".to_string())) {
                    merge_from_value(&mut config, ext_val)?;
                }
    }

    // Backwards-compat: also load legacy extension.yaml files if present
    let legacy_global = global_config_dir.join("extension.yaml");
    if legacy_global.exists() {
        let data = std::fs::read_to_string(&legacy_global)?;
        merge_yaml(&mut config, &data)?;
        log::debug!("Loaded legacy global extension config from {:?}", legacy_global);
    }

    let legacy_project = Path::new(".oo").join("extension.yaml");
    if legacy_project.exists() {
        let data = std::fs::read_to_string(&legacy_project)?;
        merge_yaml(&mut config, &data)?;
        log::debug!("Loaded legacy project extension config from {:?}", legacy_project);
    }

    Ok(config)
}

fn merge_yaml(config: &mut ExtensionConfig, data: &str) -> Result<()> {
    use saphyr::LoadableYamlNode;
    let nodes = saphyr::Yaml::load_from_str(data)?;
    if let Some(node) = nodes.first() {
        merge_node(config, "", node)?;
    }
    Ok(())
}

fn merge_node(config: &mut ExtensionConfig, prefix: &str, node: &saphyr::Yaml) -> Result<()> {
    use saphyr::{Scalar, Yaml};
    match node {
        Yaml::Mapping(mapping) => {
            for (k, v) in mapping.iter() {
                let key = k.as_str().ok_or_else(|| anyhow!("Expected string key"))?;
                let full_key = if prefix.is_empty() {
                    key.to_string()
                } else {
                    format!("{}.{}", prefix, key)
                };
                merge_node(config, &full_key, v)?;
            }
            Ok(())
        }
        Yaml::Value(value) => {
            let status = match value {
                Scalar::String(s) => {
                    let s_str: &str = s;
                    match s_str {
                        "enabled" => ExtensionStatus::Enabled,
                        "disabled" => ExtensionStatus::Disabled,
                        "auto" => ExtensionStatus::Auto,
                        other => {
                            log::warn!("Unknown extension status '{}', treating as disabled", other);
                            ExtensionStatus::Disabled
                        }
                    }
                }
                Scalar::Null => ExtensionStatus::Disabled,
                _ => {
                    log::warn!("Invalid extension status type, treating as disabled");
                    ExtensionStatus::Disabled
                }
            };
            config.extensions.insert(prefix.to_string(), status);
            Ok(())
        }
        _ => Ok(()),
    }
}