Skip to main content

facett_core/
action_bus.rs

1//! **Shared action bus** (BUS-1) — the *one* typed, observable outbox facets use
2//! to hand side work to the host: a kanban [`dragdrop`](crate::dragdrop) move, a
3//! [`time_axis`](crate::time_axis) scrub that should scroll linked facets, a
4//! toolbar command. It is the common carrier the two other primitives' `Effect`s
5//! flow through on their way to the host.
6//!
7//! Engine-agnostic (no egui, no rendering) and deterministic: every dispatched
8//! [`BusAction`] gets a monotonic `seq`, so a headless test asserts the exact
9//! order of what a facet emitted (FC-7). Following the Elm idiom ([`crate::elm`]),
10//! it is a serializable `Model` mutated only through [`update`](ActionBus::update)
11//! with a [`BusMsg`], and the host **drains** the queue each frame — the actions
12//! themselves *are* the side work as data (FC-8). A primitive used by facets/hosts;
13//! it does not `impl Facet`.
14
15use std::collections::VecDeque;
16
17use serde::{Deserialize, Serialize};
18
19/// One action a facet handed to the host. `source` is the emitting facet's title
20/// (or stable id), `kind` a stable verb (`"move"`, `"scrub"`, `"open"`, …), and
21/// `payload` the arbitrary detail. `seq` is the monotonic dispatch order.
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23pub struct BusAction {
24    pub seq: u64,
25    pub source: String,
26    pub kind: String,
27    pub payload: serde_json::Value,
28}
29
30/// Every input the bus accepts (FC-2) — the only legal way to mutate an
31/// [`ActionBus`].
32#[derive(Clone, Debug, PartialEq)]
33pub enum BusMsg {
34    /// Enqueue an action from `source` of `kind` carrying `payload`.
35    Dispatch { source: String, kind: String, payload: serde_json::Value },
36    /// Drop every queued action without delivering it.
37    Clear,
38}
39
40/// The shared action outbox (BUS-1). A FIFO of [`BusAction`]s plus the monotonic
41/// counter that stamps each dispatch.
42#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
43pub struct ActionBus {
44    queue: VecDeque<BusAction>,
45    /// Next `seq` to assign — only ever increases, even across drains.
46    next_seq: u64,
47}
48
49impl ActionBus {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Enqueue an action, returning its assigned `seq`. The ergonomic sibling of
55    /// `update(BusMsg::Dispatch { .. })`.
56    pub fn dispatch(
57        &mut self,
58        source: impl Into<String>,
59        kind: impl Into<String>,
60        payload: serde_json::Value,
61    ) -> u64 {
62        let seq = self.next_seq;
63        self.next_seq += 1;
64        self.queue.push_back(BusAction { seq, source: source.into(), kind: kind.into(), payload });
65        seq
66    }
67
68    /// Number of undelivered actions.
69    pub fn len(&self) -> usize {
70        self.queue.len()
71    }
72
73    /// Whether the outbox is empty.
74    pub fn is_empty(&self) -> bool {
75        self.queue.is_empty()
76    }
77
78    /// Look at the next action without removing it.
79    pub fn peek(&self) -> Option<&BusAction> {
80        self.queue.front()
81    }
82
83    /// **Drain** every queued action in dispatch order — how the host reads what
84    /// the facets emitted this frame. The `seq` counter is *not* reset, so ids
85    /// stay unique for the whole session.
86    pub fn drain(&mut self) -> Vec<BusAction> {
87        self.queue.drain(..).collect()
88    }
89
90    /// **The single mutation path** (FC-2). Apply one [`BusMsg`]; returns the
91    /// assigned `seq` for a `Dispatch` (so a caller can correlate), `None` for
92    /// `Clear`.
93    pub fn update(&mut self, msg: BusMsg) -> Option<u64> {
94        match msg {
95            BusMsg::Dispatch { source, kind, payload } => Some(self.dispatch(source, kind, payload)),
96            BusMsg::Clear => {
97                self.queue.clear();
98                None
99            }
100        }
101    }
102
103    /// Observable state as JSON (the `state_json` discipline): the pending queue
104    /// and the next seq, so a headless test asserts what's outstanding.
105    pub fn state_json(&self) -> serde_json::Value {
106        serde_json::json!({
107            "pending": self.queue.iter().collect::<Vec<_>>(),
108            "len": self.queue.len(),
109            "next_seq": self.next_seq,
110        })
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn dispatch_stamps_monotonic_seqs_and_preserves_order() {
120        let mut bus = ActionBus::new();
121        let a = bus.dispatch("kanban", "move", serde_json::json!({ "card": "SUPP-1" }));
122        let b = bus.dispatch("gantt", "scrub", serde_json::json!({ "t": 12.0 }));
123        assert_eq!((a, b), (0, 1));
124        assert_eq!(bus.len(), 2);
125        let drained = bus.drain();
126        assert_eq!(drained.iter().map(|x| x.seq).collect::<Vec<_>>(), vec![0, 1], "FIFO order");
127        assert_eq!(drained[0].kind, "move");
128        assert!(bus.is_empty(), "drain empties the queue");
129    }
130
131    #[test]
132    fn seq_keeps_climbing_across_drains() {
133        let mut bus = ActionBus::new();
134        bus.dispatch("a", "x", serde_json::Value::Null);
135        bus.drain();
136        let seq = bus.dispatch("b", "y", serde_json::Value::Null);
137        assert_eq!(seq, 1, "seq is not reset by a drain — ids stay unique");
138    }
139
140    #[test]
141    fn update_dispatch_and_clear() {
142        let mut bus = ActionBus::new();
143        let seq = bus.update(BusMsg::Dispatch {
144            source: "toast".into(),
145            kind: "open".into(),
146            payload: serde_json::json!({ "id": 7 }),
147        });
148        assert_eq!(seq, Some(0));
149        assert_eq!(bus.peek().unwrap().kind, "open");
150        assert_eq!(bus.update(BusMsg::Clear), None);
151        assert!(bus.is_empty(), "clear drops everything");
152    }
153
154    #[test]
155    fn state_json_and_serde_round_trip() {
156        let mut bus = ActionBus::new();
157        bus.dispatch("kanban", "move", serde_json::json!({ "to": "done" }));
158        let s = bus.state_json();
159        assert_eq!(s["len"], 1);
160        assert_eq!(s["pending"][0]["kind"], "move");
161        let json = serde_json::to_string(&bus).unwrap();
162        let back: ActionBus = serde_json::from_str(&json).unwrap();
163        assert_eq!(bus, back);
164    }
165}