exomonad-core 0.1.0

ExoMonad core: effect system, WASM hosting, MCP server, built-in handlers, shared types
Documentation
//! Extensible effects system with protobuf binary encoding.
//!
//! This module provides the infrastructure for effect handlers that use
//! protobuf binary encoding for the WASM boundary. Effect types are defined
//! in `.proto` files and generated by prost.
//!
//! # Architecture
//!
//! ```text
//! WASM Guest (Haskell)
//!//!     │ EffectEnvelope { effect_type, payload: protobuf bytes }
//!//! yield_effect host function
//!//!     │ EffectEnvelope::decode → routes by namespace prefix
//!//! EffectRegistry
//!     ├── "git.*"       → GitHandler (builtin)
//!     ├── "github.*"    → GitHubHandler (builtin)
//!     └── "egregore.*"  → EgregoreHandler (user-provided)
//! ```
//!
//! # Usage
//!
//! External consumers implement [`EffectHandler`] for their domain:
//!
//! ```rust,ignore
//! use crate::effects::{EffectHandler, EffectError, EffectResult};
//! use async_trait::async_trait;
//!
//! struct EgregoreHandler { /* ... */ }
//!
//! #[async_trait]
//! impl EffectHandler for EgregoreHandler {
//!     fn namespace(&self) -> &str { "egregore" }
//!
//!     async fn handle(&self, effect_type: &str, payload: &[u8]) -> EffectResult<Vec<u8>> {
//!         // Decode proto request, handle, encode proto response
//!         todo!()
//!     }
//! }
//! ```

pub mod error;
pub mod host_fn;

pub use error::EffectError;

use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;

/// Result type for effect handlers.
pub type EffectResult<T> = Result<T, EffectError>;

// Generated typed effect traits (GitEffects, GitHubEffects, etc.)
// and dispatch helpers (dispatch_git_effect, dispatch_github_effect, etc.)
include!(concat!(env!("OUT_DIR"), "/effect_traits.rs"));

/// Trait for effect handlers - implemented per namespace.
///
/// Effect handlers are registered with the [`EffectRegistry`] and dispatched
/// based on the effect type prefix (namespace).
///
/// Payloads are protobuf-encoded bytes. Each handler decodes the request
/// using `prost::Message::decode` and encodes the response with `encode_to_vec`.
#[async_trait]
pub trait EffectHandler: Send + Sync {
    /// Namespace prefix this handler owns (e.g., "egregore", "git").
    fn namespace(&self) -> &str;

    /// Handle an effect request.
    ///
    /// # Arguments
    ///
    /// * `effect_type` - Full effect type including namespace (e.g., "git.get_branch")
    /// * `payload` - Protobuf-encoded request bytes
    ///
    /// # Returns
    ///
    /// Protobuf-encoded response bytes, or an error.
    async fn handle(&self, effect_type: &str, payload: &[u8]) -> EffectResult<Vec<u8>>;
}

/// Registry for effect handlers.
///
/// Maps namespace prefixes to handlers and dispatches effect requests.
pub struct EffectRegistry {
    handlers: HashMap<String, Arc<dyn EffectHandler>>,
}

impl EffectRegistry {
    /// Create a new empty effect registry.
    pub fn new() -> Self {
        Self {
            handlers: HashMap::new(),
        }
    }

    /// Register an effect handler for its namespace.
    ///
    /// # Panics
    ///
    /// Panics if a handler is already registered for this namespace.
    pub fn register(&mut self, handler: Arc<dyn EffectHandler>) {
        let namespace = handler.namespace().to_string();
        if self.handlers.contains_key(&namespace) {
            panic!(
                "Effect handler already registered for namespace: {}",
                namespace
            );
        }
        tracing::info!(namespace = %namespace, "Registered effect handler");
        self.handlers.insert(namespace, handler);
    }

    /// Register a handler, taking ownership and wrapping it in an Arc.
    pub fn register_owned(&mut self, handler: impl EffectHandler + 'static) {
        self.register(Arc::new(handler));
    }

    /// Dispatch an effect to the appropriate handler.
    ///
    /// Routes based on namespace prefix extracted from the effect type.
    pub async fn dispatch(&self, effect_type: &str, payload: &[u8]) -> EffectResult<Vec<u8>> {
        let namespace = effect_type.split('.').next().ok_or_else(|| {
            EffectError::invalid_input("Effect type must contain namespace prefix")
        })?;

        let handler = self
            .handlers
            .get(namespace)
            .ok_or_else(|| EffectError::not_found(format!("handler/{}", namespace)))?;

        tracing::debug!(
            effect_type = %effect_type,
            namespace = %namespace,
            payload_bytes = payload.len(),
            "Dispatching effect"
        );

        handler.handle(effect_type, payload).await
    }

    /// Check if a handler is registered for a namespace.
    pub fn has_handler(&self, namespace: &str) -> bool {
        self.handlers.contains_key(namespace)
    }

    /// Get the list of registered namespaces.
    pub fn namespaces(&self) -> Vec<&str> {
        self.handlers.keys().map(|s| s.as_str()).collect()
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;

    struct TestHandler {
        ns: String,
    }

    impl TestHandler {
        fn new(ns: &str) -> Self {
            Self { ns: ns.to_string() }
        }
    }

    #[async_trait]
    impl EffectHandler for TestHandler {
        fn namespace(&self) -> &str {
            &self.ns
        }

        async fn handle(&self, _effect_type: &str, payload: &[u8]) -> EffectResult<Vec<u8>> {
            // Echo payload back
            Ok(payload.to_vec())
        }
    }

    #[tokio::test]
    async fn test_registry_dispatch() {
        let mut registry = EffectRegistry::new();
        registry.register_owned(TestHandler::new("test"));

        let payload = b"hello";
        let result = registry.dispatch("test.do_thing", payload).await.unwrap();

        assert_eq!(result, payload);
    }

    #[tokio::test]
    async fn test_registry_not_found() {
        let registry = EffectRegistry::new();

        let result = registry.dispatch("unknown.effect", &[]).await;

        assert!(result.is_err());
        if let Err(EffectError::NotFound { resource }) = result {
            assert!(resource.contains("unknown"));
        } else {
            panic!("Expected NotFound error");
        }
    }

    #[test]
    fn test_registry_namespaces() {
        let mut registry = EffectRegistry::new();
        registry.register_owned(TestHandler::new("alpha"));
        registry.register_owned(TestHandler::new("beta"));

        let mut namespaces = registry.namespaces();
        namespaces.sort();
        assert_eq!(namespaces, vec!["alpha", "beta"]);
    }

    #[test]
    #[should_panic(expected = "already registered")]
    fn test_duplicate_registration_panics() {
        let mut registry = EffectRegistry::new();
        registry.register_owned(TestHandler::new("test"));
        registry.register_owned(TestHandler::new("test"));
    }
}