//! el-plugin-host — Plugin lifecycle management for el-ide. //! //! Manages installation, removal, and querying of IDE plugins. //! First-party plugins are pre-registered; a plugin registry server //! is required for third-party installation (not yet live). use std::collections::HashMap; use serde::{Deserialize, Serialize}; use thiserror::Error; // ── Types ───────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum PluginHook { OnSave, OnBuild, OnBuildComplete, CustomPanel, CustomTool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Plugin { pub name: String, pub version: String, pub description: String, pub installed: bool, pub enabled: bool, pub hooks: Vec, /// Whether this is a first-party Neuron Technologies plugin. pub first_party: bool, } #[derive(Debug, Error)] pub enum PluginError { #[error("plugin '{0}' not found")] NotFound(String), #[error("plugin '{0}' is already installed")] AlreadyInstalled(String), #[error("plugin registry is not available (registry URL not configured)")] RegistryUnavailable, #[error("plugin '{0}' cannot be removed: it is a required built-in")] BuiltIn(String), } // ── PluginHost ──────────────────────────────────────────────────────────────── pub struct PluginHost { plugins: HashMap, } impl PluginHost { /// Create a PluginHost pre-populated with first-party plugins. pub fn new() -> Self { let mut host = Self { plugins: HashMap::new() }; host.register_first_party_plugins(); host } // ── Public API ──────────────────────────────────────────────────────────── pub fn list(&self) -> Vec { let mut plugins: Vec = self.plugins.values().cloned().collect(); plugins.sort_by(|a, b| a.name.cmp(&b.name)); plugins } /// Install a plugin by name. /// /// For first-party plugins, this marks them as installed. /// For third-party, registry access is required (not yet implemented). pub fn install(&mut self, name: &str) -> Result { if let Some(plugin) = self.plugins.get_mut(name) { if plugin.installed { return Err(PluginError::AlreadyInstalled(name.to_string())); } plugin.installed = true; plugin.enabled = true; Ok(plugin.clone()) } else { Err(PluginError::RegistryUnavailable) } } /// Remove a plugin. pub fn remove(&mut self, name: &str) -> Result<(), PluginError> { match self.plugins.get_mut(name) { None => Err(PluginError::NotFound(name.to_string())), Some(p) => { if p.name == "el-theme-dark" { return Err(PluginError::BuiltIn(name.to_string())); } p.installed = false; p.enabled = false; Ok(()) } } } pub fn enable(&mut self, name: &str) -> Result<(), PluginError> { self.plugins .get_mut(name) .ok_or_else(|| PluginError::NotFound(name.to_string())) .map(|p| { p.enabled = true; }) } pub fn disable(&mut self, name: &str) -> Result<(), PluginError> { self.plugins .get_mut(name) .ok_or_else(|| PluginError::NotFound(name.to_string())) .map(|p| { p.enabled = false; }) } pub fn get(&self, name: &str) -> Option<&Plugin> { self.plugins.get(name) } // ── First-party plugins ─────────────────────────────────────────────────── fn register_first_party_plugins(&mut self) { let plugins = vec![ Plugin { name: "el-theme-dark".into(), version: "1.0.0".into(), description: "Dark theme — the default Engram IDE theme.".into(), installed: true, enabled: true, hooks: vec![], first_party: true, }, Plugin { name: "el-theme-light".into(), version: "1.0.0".into(), description: "Light theme for high-ambient-light environments.".into(), installed: false, enabled: false, hooks: vec![], first_party: true, }, Plugin { name: "el-fmt".into(), version: "0.1.0".into(), description: "Code formatter — auto-formats .el files on save.".into(), installed: true, enabled: true, hooks: vec![PluginHook::OnSave], first_party: true, }, Plugin { name: "el-doc".into(), version: "0.1.0".into(), description: "Documentation generator — produces HTML docs from type definitions.".into(), installed: false, enabled: false, hooks: vec![PluginHook::CustomTool], first_party: true, }, Plugin { name: "el-test".into(), version: "0.1.0".into(), description: "Test runner with inline results displayed in the editor gutter.".into(), installed: false, enabled: false, hooks: vec![PluginHook::OnBuildComplete, PluginHook::CustomPanel], first_party: true, }, ]; for plugin in plugins { self.plugins.insert(plugin.name.clone(), plugin); } } } impl Default for PluginHost { fn default() -> Self { Self::new() } } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn test_list_returns_all_first_party() { let host = PluginHost::new(); let plugins = host.list(); assert_eq!(plugins.len(), 5); } #[test] fn test_dark_theme_installed_by_default() { let host = PluginHost::new(); let plugin = host.get("el-theme-dark").unwrap(); assert!(plugin.installed); assert!(plugin.enabled); } #[test] fn test_install_uninstalled_plugin() { let mut host = PluginHost::new(); let result = host.install("el-doc"); assert!(result.is_ok()); let plugin = host.get("el-doc").unwrap(); assert!(plugin.installed); assert!(plugin.enabled); } #[test] fn test_install_already_installed_errors() { let mut host = PluginHost::new(); let result = host.install("el-theme-dark"); assert!(matches!(result, Err(PluginError::AlreadyInstalled(_)))); } #[test] fn test_remove_plugin() { let mut host = PluginHost::new(); host.install("el-fmt").ok(); // already installed let result = host.remove("el-fmt"); assert!(result.is_ok()); let plugin = host.get("el-fmt").unwrap(); assert!(!plugin.installed); } #[test] fn test_remove_builtin_errors() { let mut host = PluginHost::new(); let result = host.remove("el-theme-dark"); assert!(matches!(result, Err(PluginError::BuiltIn(_)))); } #[test] fn test_remove_nonexistent_errors() { let mut host = PluginHost::new(); let result = host.remove("el-nonexistent"); assert!(matches!(result, Err(PluginError::NotFound(_)))); } #[test] fn test_enable_disable() { let mut host = PluginHost::new(); host.disable("el-theme-dark").unwrap(); assert!(!host.get("el-theme-dark").unwrap().enabled); host.enable("el-theme-dark").unwrap(); assert!(host.get("el-theme-dark").unwrap().enabled); } }