facett-core/src/action_bus.rs
//! **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);
}
}