Skip to main content

cognis_graph/
command.rs

1//! `Command<S>` — fluent node return type bundling `update + goto + payload`.
2//!
3//! Equivalent in expressive power to building a [`crate::node::NodeOut`]
4//! directly, but reads more naturally inside node bodies:
5//!
6//! ```ignore
7//! Command::new(MyUpdate { count: 1 })
8//!     .goto("next")
9//!     .with_payload(serde_json::json!({"hint": "..."}))
10//!     .into()
11//! ```
12//!
13//! `Command<S>: Into<NodeOut<S>>`, so existing `node_fn` closures returning
14//! a `NodeOut<S>` work unchanged. `.payload(...)` is only honoured when the
15//! goto fans out via `Goto::Send`; otherwise it's dropped (with a debug log).
16
17use crate::goto::Goto;
18use crate::node::NodeOut;
19use crate::state::GraphState;
20
21/// Fluent builder for what a node returns: state delta + routing + optional payload.
22#[derive(Debug, Clone)]
23pub struct Command<S: GraphState> {
24    update: S::Update,
25    goto: Goto,
26    payload: Option<serde_json::Value>,
27}
28
29impl<S: GraphState> Command<S> {
30    /// Build a `Command` with a state delta. Default routing is `Goto::End`.
31    pub fn new(update: S::Update) -> Self {
32        Self {
33            update,
34            goto: Goto::End,
35            payload: None,
36        }
37    }
38
39    /// Build with default state delta and a custom goto.
40    pub fn goto_only(goto: Goto) -> Self
41    where
42        S::Update: Default,
43    {
44        Self {
45            update: S::Update::default(),
46            goto,
47            payload: None,
48        }
49    }
50
51    /// Route to a single named node.
52    pub fn goto(mut self, name: impl Into<String>) -> Self {
53        self.goto = Goto::Node(name.into());
54        self
55    }
56
57    /// Fan out to multiple named nodes.
58    pub fn goto_multiple<I, N>(mut self, names: I) -> Self
59    where
60        I: IntoIterator<Item = N>,
61        N: Into<String>,
62    {
63        self.goto = Goto::Multiple(names.into_iter().map(Into::into).collect());
64        self
65    }
66
67    /// Dispatch with per-target payloads (`Goto::Send`).
68    pub fn send<I, N>(mut self, targets: I) -> Self
69    where
70        I: IntoIterator<Item = (N, serde_json::Value)>,
71        N: Into<String>,
72    {
73        self.goto = Goto::Send(targets.into_iter().map(|(n, p)| (n.into(), p)).collect());
74        self
75    }
76
77    /// Terminate the graph.
78    pub fn end(mut self) -> Self {
79        self.goto = Goto::End;
80        self
81    }
82
83    /// Attach a payload. If the goto is a single-target `Goto::Node`, this
84    /// promotes it to `Goto::Send` so the receiving node can read the payload.
85    /// On `Goto::End` the payload is dropped.
86    pub fn with_payload(mut self, payload: serde_json::Value) -> Self {
87        match std::mem::replace(&mut self.goto, Goto::End) {
88            Goto::Node(name) => {
89                self.goto = Goto::Send(vec![(name, payload)]);
90            }
91            other @ Goto::End => {
92                self.goto = other;
93            }
94            other => {
95                // Multiple/Send: keep the existing routing; stash payload as a hint
96                // for nodes that read NodeCtx::payload (only valid via Send).
97                self.goto = other;
98                self.payload = Some(payload);
99            }
100        }
101        self
102    }
103}
104
105impl<S: GraphState> From<Command<S>> for NodeOut<S> {
106    fn from(c: Command<S>) -> Self {
107        // The unused `payload` on Multiple/Send is intentional — Send already
108        // carries payloads per target. We log so users who set both notice.
109        if c.payload.is_some() && !matches!(c.goto, Goto::End) {
110            tracing::debug!(
111                "Command::with_payload set on a non-Node goto; payload ignored \
112                 (use Command::send for per-target payloads)"
113            );
114        }
115        NodeOut {
116            update: c.update,
117            goto: c.goto,
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[derive(Debug, Default, Clone, PartialEq)]
127    struct S {
128        n: u32,
129    }
130    #[derive(Debug, Default, Clone)]
131    struct SU {
132        n: u32,
133    }
134    impl GraphState for S {
135        type Update = SU;
136        fn apply(&mut self, u: Self::Update) {
137            self.n += u.n;
138        }
139    }
140
141    #[test]
142    fn defaults_to_end() {
143        let cmd: Command<S> = Command::new(SU { n: 1 });
144        let out: NodeOut<S> = cmd.into();
145        assert!(matches!(out.goto, Goto::End));
146        assert_eq!(out.update.n, 1);
147    }
148
149    #[test]
150    fn goto_routes_single() {
151        let cmd: Command<S> = Command::new(SU { n: 0 }).goto("next");
152        let out: NodeOut<S> = cmd.into();
153        assert_eq!(out.goto, Goto::Node("next".into()));
154    }
155
156    #[test]
157    fn with_payload_promotes_node_to_send() {
158        let cmd: Command<S> = Command::new(SU { n: 0 })
159            .goto("worker")
160            .with_payload(serde_json::json!({"x": 1}));
161        let out: NodeOut<S> = cmd.into();
162        if let Goto::Send(t) = out.goto {
163            assert_eq!(t.len(), 1);
164            assert_eq!(t[0].0, "worker");
165            assert_eq!(t[0].1["x"], 1);
166        } else {
167            panic!("expected Send");
168        }
169    }
170
171    #[test]
172    fn send_with_multiple_targets() {
173        let cmd: Command<S> = Command::new(SU { n: 0 }).send([
174            ("a", serde_json::json!({"i": 0})),
175            ("b", serde_json::json!({"i": 1})),
176        ]);
177        let out: NodeOut<S> = cmd.into();
178        if let Goto::Send(t) = out.goto {
179            assert_eq!(t.len(), 2);
180        } else {
181            panic!("expected Send");
182        }
183    }
184
185    #[test]
186    fn goto_only_skips_update() {
187        let cmd: Command<S> = Command::goto_only(Goto::node("x"));
188        let out: NodeOut<S> = cmd.into();
189        assert_eq!(out.goto, Goto::Node("x".into()));
190        assert_eq!(out.update.n, 0);
191    }
192}