Skip to main content

toddy_core/protocol/
mod.rs

1//! Wire protocol types for host-renderer communication.
2//!
3//! [`IncomingMessage`] is deserialized from the host. [`OutgoingEvent`]
4//! and response types are serialized back. The transport (stdin/stdout,
5//! socket, test harness) is handled by the binary crate, not here.
6//!
7//! Every wire message carries a `session` field identifying the logical
8//! session it belongs to. [`SessionMessage`] pairs a session ID with a
9//! deserialized [`IncomingMessage`]. All outgoing types include a
10//! `session` field that echoes the originating session ID back.
11
12mod incoming;
13mod outgoing;
14mod types;
15
16/// Protocol version number. Sent in the `hello` handshake message on startup
17/// and checked against the value the host embeds in Settings.
18pub const PROTOCOL_VERSION: u32 = 1;
19
20pub use incoming::{ExtensionCommandItem, IncomingMessage};
21pub use outgoing::{
22    EffectResponse, InteractResponse, KeyModifiers, OutgoingEvent, QueryResponse, ResetResponse,
23    TreeHashResponse,
24};
25pub use types::{PatchOp, TreeNode};
26
27/// An incoming message paired with its session ID.
28///
29/// The `session` field is extracted from the raw wire object before
30/// deserializing the rest as [`IncomingMessage`]. This keeps
31/// `IncomingMessage` free of session concerns -- the session is
32/// routing metadata, not message content.
33#[derive(Debug)]
34pub struct SessionMessage {
35    pub session: String,
36    pub message: IncomingMessage,
37}
38
39impl SessionMessage {
40    /// Extract `session` from a JSON value and deserialize the rest as
41    /// [`IncomingMessage`].
42    ///
43    /// If the `session` key is absent, defaults to an empty string
44    /// (single-session backwards compatibility).
45    pub fn from_value(mut value: serde_json::Value) -> Result<Self, serde_json::Error> {
46        let session = value
47            .as_object_mut()
48            .and_then(|obj| obj.remove("session"))
49            .and_then(|v| v.as_str().map(String::from))
50            .unwrap_or_default();
51
52        let message = serde_json::from_value(value)?;
53        Ok(Self { session, message })
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use serde_json::json;
61
62    #[test]
63    fn session_message_extracts_session() {
64        let val = json!({
65            "session": "test_1",
66            "type": "snapshot",
67            "tree": {"id": "r", "type": "column", "props": {}, "children": []}
68        });
69        let sm = SessionMessage::from_value(val).unwrap();
70        assert_eq!(sm.session, "test_1");
71        assert!(matches!(sm.message, IncomingMessage::Snapshot { .. }));
72    }
73
74    #[test]
75    fn session_message_defaults_to_empty() {
76        let val = json!({
77            "type": "reset",
78            "id": "r1"
79        });
80        let sm = SessionMessage::from_value(val).unwrap();
81        assert_eq!(sm.session, "");
82        assert!(matches!(sm.message, IncomingMessage::Reset { .. }));
83    }
84
85    #[test]
86    fn session_message_preserves_all_fields() {
87        let val = json!({
88            "session": "s42",
89            "type": "query",
90            "id": "q1",
91            "target": "find",
92            "selector": {"id": "btn"}
93        });
94        let sm = SessionMessage::from_value(val).unwrap();
95        assert_eq!(sm.session, "s42");
96        match sm.message {
97            IncomingMessage::Query { id, target, .. } => {
98                assert_eq!(id, "q1");
99                assert_eq!(target, "find");
100            }
101            _ => panic!("expected Query"),
102        }
103    }
104
105    #[test]
106    fn outgoing_event_includes_session() {
107        let evt = OutgoingEvent::click("btn".to_string());
108        let json = serde_json::to_value(&evt).unwrap();
109        assert_eq!(json["session"], "");
110    }
111
112    #[test]
113    fn outgoing_event_with_session() {
114        let evt = OutgoingEvent::click("btn".to_string()).with_session("s1".to_string());
115        let json = serde_json::to_value(&evt).unwrap();
116        assert_eq!(json["session"], "s1");
117    }
118
119    #[test]
120    fn effect_response_includes_session() {
121        let resp =
122            EffectResponse::ok("e1".to_string(), json!("data")).with_session("s2".to_string());
123        let json = serde_json::to_value(&resp).unwrap();
124        assert_eq!(json["session"], "s2");
125    }
126
127    #[test]
128    fn reset_response_includes_session() {
129        let resp = ResetResponse::ok("r1".to_string()).with_session("s3");
130        let json = serde_json::to_value(&resp).unwrap();
131        assert_eq!(json["session"], "s3");
132    }
133
134    #[test]
135    fn interact_response_propagates_session_to_events() {
136        let events = vec![
137            OutgoingEvent::click("btn".to_string()),
138            OutgoingEvent::input("inp".to_string(), "text".to_string()),
139        ];
140        let resp = InteractResponse::new("i1".to_string(), events).with_session("s4");
141        let json = serde_json::to_value(&resp).unwrap();
142        assert_eq!(json["session"], "s4");
143        assert_eq!(json["events"][0]["session"], "s4");
144        assert_eq!(json["events"][1]["session"], "s4");
145    }
146
147    #[test]
148    fn session_message_rejects_non_object() {
149        let val = json!([1, 2, 3]);
150        let result = SessionMessage::from_value(val);
151        assert!(result.is_err());
152    }
153
154    #[test]
155    fn session_message_ignores_non_string_session() {
156        let val = json!({
157            "session": 42,
158            "type": "reset",
159            "id": "r1"
160        });
161        let sm = SessionMessage::from_value(val).unwrap();
162        assert_eq!(sm.session, "");
163    }
164}