arcature 2026.2.0

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! Typed events and the event dispatcher (A11).
//!
//! Events are typed application data: `#[derive(Event)] pub struct
//! UserRegistered { pub user_id: Uuid }`. The application dispatches an
//! event explicitly via `Dispatcher::dispatch(&event).await` — there is
//! no hidden dispatch, no automatic wiring (PROGRAM.md: "Do not silently
//! queue listeners" / "event dispatch" is forbidden hidden behavior in
//! macros).
//!
//! # Listener semantics
//!
//! - **Execution order**: Listeners run sequentially in registration order.
//! - **Failure handling**: A listener failure is logged and does NOT stop
//!   other listeners. All registered listeners always run. The `dispatch`
//!   method returns `Ok(())` if all listeners succeeded, or the first
//!   listener error if any listener failed.
//! - **Sync vs async**: Listeners are async. `dispatch` awaits each
//!   listener in turn — no `tokio::spawn`, no concurrent execution.
//!
//! # Registration
//!
//! Registration is explicit (PROGRAM.md: "Registration is explicit through
//! module metadata. No runtime discovery."). The application calls
//! `Dispatcher::register` at startup:
//!
//! ```ignore
//! let mut dispatcher = Dispatcher::new();
//! let mailer = resources.mail().unwrap().clone();
//! dispatcher.register(move |event: UserRegistered| {
//!     let mailer = mailer.clone();
//!     async move { send_welcome(event, mailer).await }
//! });
//! ```
//!
//! The `module!` macro's `listeners:` section is metadata for `arc check`
//! inspection — it does NOT register listeners at runtime. The application
//! registers listeners explicitly.
//!
//! # Type erasure
//!
//! The dispatcher type-erases listeners behind `serde_json::Value` — the
//! event is serialized once at dispatch time, and each listener
//! deserializes it back. This is the established pattern in the codebase
//! (the `arcature-jobs` registry does the same). It avoids `TypeId`/`Any`
//! (AGENTS.md §17) and is safe for in-process dispatch (events are small
//! data structs; the serialization overhead is negligible).
//!
//! # Test fake/spy
//!
//! `Dispatcher::recording()` creates a dispatcher that records dispatched
//! event names. Tests use it to assert `dispatcher.was_dispatched
//! ("UserRegistered")` without registering real listeners.

use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};

use crate::dx::DxComponent;

/// A boxed future returned by type-erased listeners.
type BoxFuture = Pin<Box<dyn Future<Output = Result<(), DispatchError>> + Send>>;

/// A type-erased listener: takes a serialized event and returns a future.
type ErasedListener = Arc<dyn Fn(serde_json::Value) -> BoxFuture + Send + Sync>;

/// The marker trait for typed Arcature events (A11).
///
/// An event is a plain data struct that carries information about something
/// that happened in the application. The `#[derive(Event)]` macro generates
/// `impl DxComponent` (with the static `NAME`) and `impl Event` (empty — it
/// is a marker).
///
/// # Example
///
/// ```ignore
/// #[derive(Event)]
/// pub struct UserRegistered {
///     pub user_id: Uuid,
/// }
/// ```
///
/// The `Event` trait extends `DxComponent` so the event has a static `NAME`
/// used for dispatch lookup and `arc check` inspection.
pub trait Event: DxComponent + Send + Sync + 'static {}

/// A typed error from event dispatch (A11).
///
/// No raw `String` errors (AGENTS.md §18). Each variant is a failure that
/// can actually happen — no "future-proof" variants.
#[derive(Debug)]
pub enum DispatchError {
    /// The event could not be serialized for type-erased dispatch. The
    /// string is the serde error message (not the event payload — no
    /// information disclosure).
    Serialize(String),
    /// The event payload could not be deserialized by a listener. The
    /// string is a generic message — the serde error is NOT included
    /// because it may echo the payload (information disclosure, matching
    /// the `arcature-jobs` registry's `HandlerError::Malformed` design).
    Deserialize,
    /// A listener returned an error. The string is the listener's error
    /// message (the listener decides what to expose — Arcature does not
    /// leak internal details).
    Listener(String),
}

impl std::fmt::Display for DispatchError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Serialize(msg) => write!(f, "event serialization failed: {msg}"),
            Self::Deserialize => write!(f, "event payload did not deserialize by listener"),
            Self::Listener(msg) => write!(f, "listener error: {msg}"),
        }
    }
}

impl std::error::Error for DispatchError {}

/// The typed event dispatcher (A11).
///
/// Holds type-erased listeners keyed by event type name. The application
/// registers listeners at startup and calls `dispatch` explicitly in
/// controller/handler code. There is no hidden dispatch — the caller
/// decides when and where to dispatch.
///
/// # Listener semantics
///
/// - Listeners run sequentially in registration order.
/// - A listener failure is logged and does NOT stop other listeners.
/// - `dispatch` returns `Ok(())` if all listeners succeeded, or the first
///   listener error if any listener failed (but all listeners still ran).
///
/// # Clone
///
/// `Dispatcher` is `Clone` — it holds an `Arc` internally. Clone is cheap
/// and safe for sharing across tasks (the listener map is behind an
/// `Arc`, not a `Mutex` — the map is frozen after registration).
///
/// # Test spy
///
/// `Dispatcher::recording()` creates a dispatcher that records dispatched
/// event names. Tests use `was_dispatched(name)` to assert events were
/// dispatched without registering real listeners.
#[derive(Clone)]
pub struct Dispatcher {
    /// The listener map, keyed by event type name. Frozen after
    /// registration (no mutation after startup).
    listeners: Arc<HashMap<String, Vec<ErasedListener>>>,
    /// For testing: records dispatched event names. `None` in production.
    record: Option<Arc<Mutex<Vec<String>>>>,
}

impl Dispatcher {
    /// Create a new empty dispatcher (no listeners, no recording).
    #[must_use]
    pub fn new() -> Self {
        Self {
            listeners: Arc::new(HashMap::new()),
            record: None,
        }
    }

    /// Create a recording dispatcher for tests. Records dispatched event
    /// names so tests can assert `was_dispatched("UserRegistered")`.
    #[must_use]
    pub fn recording() -> Self {
        Self {
            listeners: Arc::new(HashMap::new()),
            record: Some(Arc::new(Mutex::new(Vec::new()))),
        }
    }

    /// Register a listener for an event type.
    ///
    /// The listener is `Fn(E) -> Fut` where `Fut: Future<Output =
    /// Result<(), DispatchError>> + Send`. The event type `E` must
    /// implement `Event + Serialize + DeserializeOwned`.
    ///
    /// Multiple listeners can be registered for the same event type. They
    /// run in registration order.
    #[allow(clippy::needless_pass_by_value)]
    pub fn register<E, F, Fut>(self, handler: F) -> Self
    where
        E: Event + serde::Serialize + serde::de::DeserializeOwned,
        F: Fn(E) -> Fut + Send + Sync + 'static,
        Fut: Future<Output = Result<(), DispatchError>> + Send + 'static,
    {
        let handler = Arc::new(handler);
        let erased: ErasedListener = Arc::new(move |event: serde_json::Value| {
            let handler = handler.clone();
            Box::pin(async move {
                let event: E =
                    serde_json::from_value(event).map_err(|_| DispatchError::Deserialize)?;
                handler(event).await
            })
        });

        let mut map = (*self.listeners).clone();
        map.entry(E::NAME.to_string()).or_default().push(erased);
        Self {
            listeners: Arc::new(map),
            record: self.record,
        }
    }

    /// Dispatch an event to all registered listeners.
    ///
    /// Listeners run sequentially in registration order. A listener failure
    /// is logged and does NOT stop other listeners. Returns `Ok(())` if
    /// all listeners succeeded, or the first listener error if any
    /// listener failed (but all listeners still ran).
    ///
    /// If no listeners are registered for the event type, this is a no-op
    /// (returns `Ok(())`).
    pub async fn dispatch<E>(&self, event: &E) -> Result<(), DispatchError>
    where
        E: Event + serde::Serialize,
    {
        // Record the event name if in recording mode.
        if let Some(record) = &self.record
            && let Ok(mut guard) = record.lock()
        {
            guard.push(E::NAME.to_string());
        }

        let listeners = self.listeners.get(E::NAME);
        if listeners.is_none_or(|l| l.is_empty()) {
            return Ok(());
        }

        let value =
            serde_json::to_value(event).map_err(|e| DispatchError::Serialize(e.to_string()))?;

        let listeners = listeners.expect("checked non-empty above");
        let mut first_error: Option<DispatchError> = None;

        for listener in listeners {
            match listener(value.clone()).await {
                Ok(()) => {}
                Err(e) => {
                    eprintln!("event listener error for {}: {e}", E::NAME);
                    if first_error.is_none() {
                        first_error = Some(e);
                    }
                }
            }
        }

        match first_error {
            Some(e) => Err(e),
            None => Ok(()),
        }
    }

    /// Returns `true` if an event with the given type name was dispatched
    /// (recording mode only). In production mode, always returns `false`.
    #[must_use]
    pub fn was_dispatched(&self, name: &str) -> bool {
        self.record
            .as_ref()
            .map(|r| {
                r.lock()
                    .map(|guard| guard.iter().any(|n| n == name))
                    .unwrap_or(false)
            })
            .unwrap_or(false)
    }

    /// Returns the names of all dispatched events (recording mode only).
    /// In production mode, returns an empty vec.
    #[must_use]
    pub fn dispatched_events(&self) -> Vec<String> {
        self.record
            .as_ref()
            .and_then(|r| r.lock().ok())
            .map(|guard| guard.clone())
            .unwrap_or_default()
    }

    /// Returns the number of listeners registered for an event type.
    #[must_use]
    pub fn listener_count(&self, event_name: &str) -> usize {
        self.listeners.get(event_name).map(|l| l.len()).unwrap_or(0)
    }
}

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

impl std::fmt::Debug for Dispatcher {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Dispatcher")
            .field("event_types", &self.listeners.len())
            .field("is_recording", &self.record.is_some())
            .finish_non_exhaustive()
    }
}