oo-ide 0.0.3

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! `ExtensionManager` — loads, tracks, and dispatches to WASM extensions.

use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};

use tokio::sync::mpsc::UnboundedSender;
use wasmtime::Engine;

use crate::commands::{CommandRegistry, command};
use crate::operation::Operation;
use crate::schema::SchemaRegistry;

use super::activation::ActivationState;
use super::config::{ExtensionConfig, ExtensionStatus};
use super::loader::{load_cyix, CyixSchema};
use super::manifest::ExtensionManifest;
use super::runtime::ExtensionInstance;

// ---------------------------------------------------------------------------
// LoadedExtension
// ---------------------------------------------------------------------------

struct LoadedExtension {
    manifest: ExtensionManifest,
    instance: ExtensionInstance,
    state: ActivationState,
    schemas: Vec<CyixSchema>,
    schemas_registered: bool,
    log_matchers: Vec<crate::log_matcher::CompiledMatcher>,
}

impl LoadedExtension {
    fn is_active(&self) -> bool {
        self.state.is_active()
    }
}

// ---------------------------------------------------------------------------
// ExtensionManager
// ---------------------------------------------------------------------------

pub(crate) struct ExtensionManager {
    extensions: Vec<LoadedExtension>,
    #[allow(dead_code)]
    _engine: Engine,
    schema_registry: Arc<RwLock<SchemaRegistry>>,
    matcher_registry: std::sync::Arc<crate::log_matcher::MatcherRegistry>,
}

impl ExtensionManager {
    /// Create a manager with no extensions loaded (used when extensions are
    /// disabled or the extensions directory is empty/absent).
    pub(crate) fn empty() -> Self {
        Self {
            extensions: Vec::new(),
            _engine: Engine::default(),
            schema_registry: SchemaRegistry::shared(),
            matcher_registry: std::sync::Arc::new(crate::log_matcher::MatcherRegistry::new()),
        }
    }

    /// Scan `ext_dir` for `.cyix` files, load each one, and apply the extension
    /// config to determine which are loaded and their activation state.
    pub(crate) fn load_all(
        ext_dir: &Path,
        op_tx: UnboundedSender<Vec<Operation>>,
        project_languages: Vec<String>,
        config: &ExtensionConfig,
    ) -> (Self, Vec<(String, String)>) {
        let engine = Engine::default();
        let mut extensions = Vec::new();
        let mut extension_defaults = Vec::new();
        let schema_registry = SchemaRegistry::shared();

        let entries = match std::fs::read_dir(ext_dir) {
            Ok(e) => e,
            Err(e) => {
                log::debug!("extensions dir {:?} not readable: {}", ext_dir, e);
                return (
                    Self {
                        extensions,
                        _engine: engine,
                        schema_registry: schema_registry.clone(),
                        matcher_registry: std::sync::Arc::new(crate::log_matcher::MatcherRegistry::new()),
                    },
                    extension_defaults,
                );
            }
        };

        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("cyix") {
                continue;
            }

            match load_cyix(&path) {
                Ok(pkg) => {
                    let ext_status = config.get(&pkg.manifest.name);
                    if ext_status == ExtensionStatus::Disabled {
                        log::debug!("Extension {:?} is disabled, skipping load", pkg.manifest.name);
                        continue;
                    }

                    let ext_config_yaml = pkg.default_config.clone();
                    let ext_config = parse_config(ext_config_yaml.as_deref());
                    match ExtensionInstance::new(&engine, &pkg.wasm_bytes, op_tx.clone(), ext_config, project_languages.clone()) {
                        Ok(instance) => {
                            let state = match ext_status {
                                ExtensionStatus::Enabled => ActivationState::Global,
                                ExtensionStatus::Auto => ActivationState::Auto,
                                ExtensionStatus::Disabled => continue,
                            };
                            log::info!(
                                "loaded extension {:?} v{} ({:?})",
                                pkg.manifest.name,
                                pkg.manifest.version,
                                state
                            );
                            if let Some(yaml) = ext_config_yaml {
                                extension_defaults.push((pkg.manifest.name.clone(), yaml));
                            }
                            extensions.push(LoadedExtension {
                                manifest: pkg.manifest,
                                instance,
                                state,
                                schemas: pkg.schemas,
                                schemas_registered: false,
                                log_matchers: pkg.log_matchers,
                            });
                            // If the extension is active at load time, register its packaged schemas now.
                            if extensions.last().map(|e| e.state.is_active()).unwrap_or(false) {
                                let idx = extensions.len() - 1;
                                let mut reg = schema_registry.write().unwrap();
                                for s in &extensions[idx].schemas {
                                    let id = s.id.clone();
                                    let name = s.name.clone().unwrap_or_else(|| id.clone());
                                    reg.register_from_extension(id, name, s.patterns.clone(), s.schema_json.clone());
                                }
                                extensions[idx].schemas_registered = true;
                            }
                        }
                        Err(e) => {
                            log::error!("failed to instantiate {:?}: {}", path, e);
                        }
                    }
                }
                Err(e) => {
                    log::error!("failed to load {:?}: {}", path, e);
                }
            };
        }

        // Build initial manager and populate matcher registry atomically from
        // all active extensions' packaged matchers.
        let manager = Self {
            extensions,
            _engine: engine,
            schema_registry,
            matcher_registry: std::sync::Arc::new(crate::log_matcher::MatcherRegistry::new()),
        };

        // Collect matchers from active extensions and register into the
        // manager's registry.  Failure indicates duplicate matcher IDs or
        // other validation problems; log and continue with an empty registry.
        // Include Auto-state extensions here — detect_project() will unregister
        // irrelevant ones shortly after; this ensures matchers are ready before
        // detect_project() runs.
        let combined: Vec<crate::log_matcher::CompiledMatcher> = manager
            .extensions
            .iter()
            .filter(|e| e.is_active() || e.state == ActivationState::Auto)
            .flat_map(|e| e.log_matchers.iter().cloned())
            .collect();
        if let Err(e) = manager.matcher_registry.replace_all(combined) {
            log::warn!("matcher_registry: failed to populate at load_all: {}", e);
        }

        (manager, extension_defaults)
    }

    /// Call `detect_project()` on all `Auto`-state extensions and update
    /// their state accordingly.  Returns any operations generated (none currently).
    pub(crate) fn detect_project(&mut self) {
        // For Auto extensions that become irrelevant, unregister their matchers.
        let mut to_unregister: Vec<crate::log_matcher::MatcherId> = Vec::new();
        for ext in &mut self.extensions {
            if ext.state != ActivationState::Auto {
                continue;
            }
            let relevant = ext.instance.detect_project();
            log::debug!("detect_project for {:?}: {}", ext.manifest.name, relevant);
            if !relevant {
                ext.state = ActivationState::Disabled;
                for m in &ext.log_matchers {
                    to_unregister.push(m.id.clone());
                }
            } else {
                // Promote to Project-active so is_active() returns true.
                ext.state = ActivationState::Project;
                // Register schemas if not already done at load time.
                if !ext.schemas_registered {
                    let mut reg = self.schema_registry.write().unwrap();
                    for s in &ext.schemas {
                        let id = s.id.clone();
                        let name = s.name.clone().unwrap_or_else(|| id.clone());
                        reg.register_from_extension(id, name, s.patterns.clone(), s.schema_json.clone());
                    }
                    ext.schemas_registered = true;
                }
            }
        }
        if !to_unregister.is_empty() {
            let removed = self.matcher_registry.unregister_by_ids(&to_unregister);
            log::debug!("matcher_registry: unregistered {} matchers after detect_project", removed);
        }
    }

    /// Dispatch a named event to all active extensions that subscribe to it.
    /// Returns the collected operations from all extension calls.
    pub(crate) fn dispatch_event(
        &mut self,
        event_name: &str,
        payload: &HashMap<String, String>,
    ) -> Vec<Operation> {
        let payload_yaml = map_to_yaml(payload);
        for ext in &mut self.extensions {
            if !ext.is_active() {
                continue;
            }
            if !ext.manifest.events.iter().any(|e| e == event_name) {
                continue;
            }
            if let Err(e) = ext.instance.call_export("oo_event", &payload_yaml) {
                log::warn!(
                    "error dispatching event {:?} to {:?}: {}",
                    event_name,
                    ext.manifest.name,
                    e
                );
            }
        }
        // Operations are sent via op_tx inside ExtensionInstance; nothing to return here.
        vec![]
    }

    /// Invoke a command's WASM function.
    #[allow(dead_code)]
    pub(crate) fn call_command(&mut self, ext_name: &str, fn_name: &str) {
        let export = format!("__oo_cmd_{}", fn_name);
        for ext in &mut self.extensions {
            if ext.manifest.name != ext_name || !ext.is_active() {
                continue;
            }
            if let Err(e) = ext.instance.call_export(&export, "") {
                log::warn!("error calling command {:?}: {}", export, e);
            }
        }
    }

    /// Register all active extension commands with the IDE command registry.
    ///
    /// Each command handler sends an `Operation::RunCommand` that is resolved
    /// back here via `call_command` in app.rs.
    pub(crate) fn register_commands(&self, registry: &mut CommandRegistry) {
        for ext in &self.extensions {
            if !ext.is_active() {
                continue;
            }
            for cmd in &ext.manifest.commands {
                let ext_name = ext.manifest.name.clone();
                let fn_name = cmd.function.clone();
                let id_str = cmd.id.clone();
                let title = cmd.title.clone();
                let description = cmd.description.clone();
                let context = cmd.activation_context.clone();

                // Build a CommandId from the "group.name" string.
                let Ok(id) = id_str.parse::<crate::commands::CommandId>() else {
                    log::warn!(
                        "extension {:?} has invalid command id {:?}",
                        ext_name,
                        id_str
                    );
                    continue;
                };

                let mut builder = command(id.clone())
                    .title(title.clone())
                    .description(description.clone());

                if !context.is_empty() {
                    builder = builder.context(context.clone());
                }

                let op_id = id.clone();
                let handler_ext = ext_name.clone();
                let handler_fn = fn_name.clone();
                // Handlers are pure; dispatch happens in apply_operation via RunCommand.
                // We encode the extension target in args (operation carries it).
                let _ = (handler_ext, handler_fn); // resolved in app.rs RunCommand arm

                registry.register(builder.handler(move |_ctx, _| {
                    vec![Operation::RunCommand {
                        id: op_id.clone(),
                        args: Default::default(),
                    }]
                }));
            }
        }
    }

    /// Return a cloned Arc handle to the internal MatcherRegistry. This allows
    /// consumers (e.g., AppState::new) to obtain the registry without exposing
    /// the field directly.
    pub(crate) fn matcher_registry_arc(&self) -> std::sync::Arc<crate::log_matcher::MatcherRegistry> {
        std::sync::Arc::clone(&self.matcher_registry)
    }

    /// Returns `(name, version, state)` tuples for all loaded extensions.
    #[cfg(test)]
    pub(crate) fn states(&self) -> Vec<(String, String, ActivationState)> {
        self.extensions
            .iter()
            .map(|e| {
                (
                    e.manifest.name.clone(),
                    e.manifest.version.clone(),
                    e.state.clone(),
                )
            })
            .collect()
    }
    pub(crate) fn set_state(&mut self, name: &str, state: ActivationState) {
        let mut changed_idx: Option<(usize, bool)> = None;
        for (i, ext) in self.extensions.iter_mut().enumerate() {
            if ext.manifest.name == name {
                let was_active = ext.state.is_active();
                ext.state = state;
                let now_active = ext.state.is_active();
                if !was_active && now_active && !ext.schemas_registered {
                    let mut reg = self.schema_registry.write().unwrap();
                    for s in &ext.schemas {
                        let id = s.id.clone();
                        let name = s.name.clone().unwrap_or_else(|| id.clone());
                        reg.register_from_extension(id, name, s.patterns.clone(), s.schema_json.clone());
                    }
                    ext.schemas_registered = true;
                }
                if was_active != now_active {
                    changed_idx = Some((i, was_active));
                }
                break;
            }
        }

        if let Some((idx, was_active)) = changed_idx {
            let ext = &self.extensions[idx];
            if !was_active && ext.state.is_active() {
                if let Err(e) = self.matcher_registry.register(ext.log_matchers.clone()) {
                    log::warn!("matcher_registry: failed to register matchers for {}: {}", name, e);
                }
            } else if was_active && !ext.state.is_active() {
                let ids: Vec<crate::log_matcher::MatcherId> = ext
                    .log_matchers
                    .iter()
                    .map(|m| m.id.clone())
                    .collect();
                let removed = self.matcher_registry.unregister_by_ids(&ids);
                log::debug!("matcher_registry: unregistered {} matchers for {}", removed, name);
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn parse_config(yaml: Option<&str>) -> HashMap<String, String> {
    let Some(text) = yaml else {
        return HashMap::new();
    };
    serde_saphyr::from_str(text).unwrap_or_default()
}

fn map_to_yaml(map: &HashMap<String, String>) -> String {
    serde_saphyr::to_string(map).unwrap_or_default()
}