facett-core 0.1.11

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Shared action bus** (BUS-1) — the *one* typed, observable outbox facets use
//! to hand side work to the host: a kanban [`dragdrop`](crate::dragdrop) move, a
//! [`time_axis`](crate::time_axis) scrub that should scroll linked facets, a
//! toolbar command. It is the common carrier the two other primitives' `Effect`s
//! flow through on their way to the host.
//!
//! Engine-agnostic (no egui, no rendering) and deterministic: every dispatched
//! [`BusAction`] gets a monotonic `seq`, so a headless test asserts the exact
//! order of what a facet emitted (FC-7). Following the Elm idiom ([`crate::elm`]),
//! it is a serializable `Model` mutated only through [`update`](ActionBus::update)
//! with a [`BusMsg`], and the host **drains** the queue each frame — the actions
//! themselves *are* the side work as data (FC-8). A primitive used by facets/hosts;
//! it does not `impl Facet`.

use std::collections::VecDeque;

use serde::{Deserialize, Serialize};

/// One action a facet handed to the host. `source` is the emitting facet's title
/// (or stable id), `kind` a stable verb (`"move"`, `"scrub"`, `"open"`, …), and
/// `payload` the arbitrary detail. `seq` is the monotonic dispatch order.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BusAction {
    pub seq: u64,
    pub source: String,
    pub kind: String,
    pub payload: serde_json::Value,
}

/// Every input the bus accepts (FC-2) — the only legal way to mutate an
/// [`ActionBus`].
#[derive(Clone, Debug, PartialEq)]
pub enum BusMsg {
    /// Enqueue an action from `source` of `kind` carrying `payload`.
    Dispatch { source: String, kind: String, payload: serde_json::Value },
    /// Drop every queued action without delivering it.
    Clear,
}

/// The shared action outbox (BUS-1). A FIFO of [`BusAction`]s plus the monotonic
/// counter that stamps each dispatch.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ActionBus {
    queue: VecDeque<BusAction>,
    /// Next `seq` to assign — only ever increases, even across drains.
    next_seq: u64,
}

impl ActionBus {
    pub fn new() -> Self {
        Self::default()
    }

    /// Enqueue an action, returning its assigned `seq`. The ergonomic sibling of
    /// `update(BusMsg::Dispatch { .. })`.
    pub fn dispatch(
        &mut self,
        source: impl Into<String>,
        kind: impl Into<String>,
        payload: serde_json::Value,
    ) -> u64 {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.queue.push_back(BusAction { seq, source: source.into(), kind: kind.into(), payload });
        seq
    }

    /// Number of undelivered actions.
    pub fn len(&self) -> usize {
        self.queue.len()
    }

    /// Whether the outbox is empty.
    pub fn is_empty(&self) -> bool {
        self.queue.is_empty()
    }

    /// Look at the next action without removing it.
    pub fn peek(&self) -> Option<&BusAction> {
        self.queue.front()
    }

    /// **Drain** every queued action in dispatch order — how the host reads what
    /// the facets emitted this frame. The `seq` counter is *not* reset, so ids
    /// stay unique for the whole session.
    pub fn drain(&mut self) -> Vec<BusAction> {
        self.queue.drain(..).collect()
    }

    /// **The single mutation path** (FC-2). Apply one [`BusMsg`]; returns the
    /// assigned `seq` for a `Dispatch` (so a caller can correlate), `None` for
    /// `Clear`.
    pub fn update(&mut self, msg: BusMsg) -> Option<u64> {
        match msg {
            BusMsg::Dispatch { source, kind, payload } => Some(self.dispatch(source, kind, payload)),
            BusMsg::Clear => {
                self.queue.clear();
                None
            }
        }
    }

    /// Observable state as JSON (the `state_json` discipline): the pending queue
    /// and the next seq, so a headless test asserts what's outstanding.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "pending": self.queue.iter().collect::<Vec<_>>(),
            "len": self.queue.len(),
            "next_seq": self.next_seq,
        })
    }
}

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

    #[test]
    fn dispatch_stamps_monotonic_seqs_and_preserves_order() {
        let mut bus = ActionBus::new();
        let a = bus.dispatch("kanban", "move", serde_json::json!({ "card": "SUPP-1" }));
        let b = bus.dispatch("gantt", "scrub", serde_json::json!({ "t": 12.0 }));
        assert_eq!((a, b), (0, 1));
        assert_eq!(bus.len(), 2);
        let drained = bus.drain();
        assert_eq!(drained.iter().map(|x| x.seq).collect::<Vec<_>>(), vec![0, 1], "FIFO order");
        assert_eq!(drained[0].kind, "move");
        assert!(bus.is_empty(), "drain empties the queue");
    }

    #[test]
    fn seq_keeps_climbing_across_drains() {
        let mut bus = ActionBus::new();
        bus.dispatch("a", "x", serde_json::Value::Null);
        bus.drain();
        let seq = bus.dispatch("b", "y", serde_json::Value::Null);
        assert_eq!(seq, 1, "seq is not reset by a drain — ids stay unique");
    }

    #[test]
    fn update_dispatch_and_clear() {
        let mut bus = ActionBus::new();
        let seq = bus.update(BusMsg::Dispatch {
            source: "toast".into(),
            kind: "open".into(),
            payload: serde_json::json!({ "id": 7 }),
        });
        assert_eq!(seq, Some(0));
        assert_eq!(bus.peek().unwrap().kind, "open");
        assert_eq!(bus.update(BusMsg::Clear), None);
        assert!(bus.is_empty(), "clear drops everything");
    }

    #[test]
    fn state_json_and_serde_round_trip() {
        let mut bus = ActionBus::new();
        bus.dispatch("kanban", "move", serde_json::json!({ "to": "done" }));
        let s = bus.state_json();
        assert_eq!(s["len"], 1);
        assert_eq!(s["pending"][0]["kind"], "move");
        let json = serde_json::to_string(&bus).unwrap();
        let back: ActionBus = serde_json::from_str(&json).unwrap();
        assert_eq!(bus, back);
    }
}