Skip to main content

coralstack_cmd_ipc/
message.rs

1//! Wire protocol for the command registry.
2//!
3//! This module defines the seven [`Message`] variants exchanged between
4//! registries. The JSON representation is byte-identical to the TypeScript
5//! implementation's `CommandMessage` union (see
6//! `packages/cmd-ipc/src/registry/command-message-schemas.ts`) so that a
7//! Rust process and a Node.js process can talk to each other over any
8//! channel that carries JSON.
9
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11use serde_json::Value;
12use uuid::Uuid;
13
14use crate::error::{ExecuteErrorCode, RegisterErrorCode};
15
16/// A unique message identifier.
17///
18/// Matches the TypeScript type alias `MessageID = string` — UUIDs are
19/// serialized as hyphenated strings.
20pub type MessageId = Uuid;
21
22/// Description of a command as advertised across a channel.
23///
24/// Mirrors `CommandDefinitionBaseSchema` in the TypeScript protocol.
25#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
26pub struct CommandDef {
27    pub id: String,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub description: Option<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub schema: Option<CommandSchema>,
32}
33
34/// A request/response JSON-Schema pair attached to a [`CommandDef`].
35///
36/// Both slots are optional; absent means "no payload expected" (the
37/// TypeScript equivalent is `v.void()`). When populated the value is a
38/// raw JSON Schema; the registry normalizes incoming schemas so wire
39/// representations remain language-agnostic.
40#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
41pub struct CommandSchema {
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub request: Option<Value>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub response: Option<Value>,
46}
47
48impl CommandSchema {
49    /// Empty schema — both slots unset. Equivalent to advertising a
50    /// command with `request: None, response: None` (the void/void
51    /// shape). Use when the command takes no payload and returns none.
52    pub fn empty() -> Self {
53        Self {
54            request: None,
55            response: None,
56        }
57    }
58
59    /// Maximally permissive schema — both request and response declared
60    /// as open objects with `additionalProperties: true`. Useful for
61    /// runtime plugins whose payload shape isn't known at advertise
62    /// time (e.g. Flow's QuickJS `SourceChannel` when a plugin exports
63    /// an `any → any` function).
64    ///
65    /// Prefer a real schema when you can produce one — consumers use it
66    /// for validation, MCP tool schemas, and generated TS clients.
67    pub fn permissive() -> Self {
68        Self {
69            request: Some(serde_json::json!({
70                "type": "object",
71                "additionalProperties": true,
72            })),
73            response: Some(serde_json::json!({
74                "type": "object",
75                "additionalProperties": true,
76            })),
77        }
78    }
79
80    /// Builder: set only the request schema (leaves response unset).
81    pub fn with_request(mut self, schema: Value) -> Self {
82        self.request = Some(schema);
83        self
84    }
85
86    /// Builder: set only the response schema (leaves request unset).
87    pub fn with_response(mut self, schema: Value) -> Self {
88        self.response = Some(schema);
89        self
90    }
91}
92
93/// Body of an execute-command error response.
94#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
95pub struct ExecuteError {
96    pub code: ExecuteErrorCode,
97    pub message: String,
98}
99
100/// Zero-sized marker that (de)serializes as the JSON literal `true`.
101///
102/// Used to distinguish the `ok: true` / `ok: false` variants of
103/// [`RegisterResult`] and [`ExecuteResult`] while keeping them as proper
104/// Rust enums rather than structs with nullable fields.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub struct True;
107
108impl Serialize for True {
109    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
110        s.serialize_bool(true)
111    }
112}
113
114impl<'de> Deserialize<'de> for True {
115    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
116        match bool::deserialize(d)? {
117            true => Ok(True),
118            false => Err(serde::de::Error::custom("expected literal `true`")),
119        }
120    }
121}
122
123/// Zero-sized marker that (de)serializes as the JSON literal `false`.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
125pub struct False;
126
127impl Serialize for False {
128    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
129        s.serialize_bool(false)
130    }
131}
132
133impl<'de> Deserialize<'de> for False {
134    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
135        match bool::deserialize(d)? {
136            false => Ok(False),
137            true => Err(serde::de::Error::custom("expected literal `false`")),
138        }
139    }
140}
141
142/// Body of a `register.command.response` message.
143#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
144#[serde(untagged)]
145pub enum RegisterResult {
146    Ok { ok: True },
147    Err { ok: False, error: RegisterErrorCode },
148}
149
150/// Body of an `execute.command.response` message.
151#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
152#[serde(untagged)]
153pub enum ExecuteResult {
154    Ok {
155        ok: True,
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        result: Option<Value>,
158    },
159    Err {
160        ok: False,
161        error: ExecuteError,
162    },
163}
164
165/// Discriminated union of every message type carried on a channel.
166///
167/// The `type` tag strings are the same dotted identifiers used by the
168/// TypeScript `MessageType` enum.
169#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
170#[serde(tag = "type")]
171pub enum Message {
172    #[serde(rename = "register.command.request")]
173    RegisterCommandRequest { id: MessageId, command: CommandDef },
174
175    #[serde(rename = "register.command.response")]
176    RegisterCommandResponse {
177        id: MessageId,
178        thid: MessageId,
179        response: RegisterResult,
180    },
181
182    #[serde(rename = "list.commands.request")]
183    ListCommandsRequest { id: MessageId },
184
185    #[serde(rename = "list.commands.response")]
186    ListCommandsResponse {
187        id: MessageId,
188        thid: MessageId,
189        commands: Vec<CommandDef>,
190    },
191
192    #[serde(rename = "execute.command.request")]
193    ExecuteCommandRequest {
194        id: MessageId,
195        #[serde(rename = "commandId")]
196        command_id: String,
197        #[serde(default, skip_serializing_if = "Option::is_none")]
198        request: Option<Value>,
199    },
200
201    #[serde(rename = "execute.command.response")]
202    ExecuteCommandResponse {
203        id: MessageId,
204        thid: MessageId,
205        response: ExecuteResult,
206    },
207
208    #[serde(rename = "event")]
209    Event {
210        id: MessageId,
211        #[serde(rename = "eventId")]
212        event_id: String,
213        #[serde(default, skip_serializing_if = "Option::is_none")]
214        payload: Option<Value>,
215    },
216}
217
218impl Message {
219    /// Returns the message's `id` field.
220    pub fn id(&self) -> MessageId {
221        match self {
222            Self::RegisterCommandRequest { id, .. }
223            | Self::RegisterCommandResponse { id, .. }
224            | Self::ListCommandsRequest { id, .. }
225            | Self::ListCommandsResponse { id, .. }
226            | Self::ExecuteCommandRequest { id, .. }
227            | Self::ExecuteCommandResponse { id, .. }
228            | Self::Event { id, .. } => *id,
229        }
230    }
231
232    /// Returns the `thid` field for messages that carry one.
233    pub fn thid(&self) -> Option<MessageId> {
234        match self {
235            Self::RegisterCommandResponse { thid, .. }
236            | Self::ListCommandsResponse { thid, .. }
237            | Self::ExecuteCommandResponse { thid, .. } => Some(*thid),
238            _ => None,
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use serde_json::json;
247
248    fn uid(s: &str) -> Uuid {
249        Uuid::parse_str(s).unwrap()
250    }
251
252    /// Every fixture below is paired JSON that the TypeScript library
253    /// produces (or accepts). Round-tripping through `Message` must leave
254    /// the semantic JSON tree unchanged.
255    fn check_roundtrip(msg: &Message, expected: Value) {
256        let serialized = serde_json::to_value(msg).unwrap();
257        assert_eq!(
258            serialized, expected,
259            "serialized form does not match fixture"
260        );
261        let parsed: Message = serde_json::from_value(expected.clone()).unwrap();
262        assert_eq!(&parsed, msg, "fixture did not deserialize back to input");
263    }
264
265    #[test]
266    fn register_command_request_roundtrip() {
267        let msg = Message::RegisterCommandRequest {
268            id: uid("11111111-1111-1111-1111-111111111111"),
269            command: CommandDef {
270                id: "math.add".to_string(),
271                description: Some("Adds two numbers".to_string()),
272                schema: Some(CommandSchema {
273                    request: Some(json!({
274                        "type": "object",
275                        "properties": { "a": { "type": "number" }, "b": { "type": "number" } },
276                        "required": ["a", "b"]
277                    })),
278                    response: Some(json!({ "type": "number" })),
279                }),
280            },
281        };
282        check_roundtrip(
283            &msg,
284            json!({
285                "type": "register.command.request",
286                "id": "11111111-1111-1111-1111-111111111111",
287                "command": {
288                    "id": "math.add",
289                    "description": "Adds two numbers",
290                    "schema": {
291                        "request": {
292                            "type": "object",
293                            "properties": { "a": { "type": "number" }, "b": { "type": "number" } },
294                            "required": ["a", "b"]
295                        },
296                        "response": { "type": "number" }
297                    }
298                }
299            }),
300        );
301    }
302
303    #[test]
304    fn register_command_response_ok_roundtrip() {
305        let msg = Message::RegisterCommandResponse {
306            id: uid("22222222-2222-2222-2222-222222222222"),
307            thid: uid("11111111-1111-1111-1111-111111111111"),
308            response: RegisterResult::Ok { ok: True },
309        };
310        check_roundtrip(
311            &msg,
312            json!({
313                "type": "register.command.response",
314                "id": "22222222-2222-2222-2222-222222222222",
315                "thid": "11111111-1111-1111-1111-111111111111",
316                "response": { "ok": true }
317            }),
318        );
319    }
320
321    #[test]
322    fn register_command_response_err_roundtrip() {
323        let msg = Message::RegisterCommandResponse {
324            id: uid("22222222-2222-2222-2222-222222222222"),
325            thid: uid("11111111-1111-1111-1111-111111111111"),
326            response: RegisterResult::Err {
327                ok: False,
328                error: RegisterErrorCode::DuplicateCommand,
329            },
330        };
331        check_roundtrip(
332            &msg,
333            json!({
334                "type": "register.command.response",
335                "id": "22222222-2222-2222-2222-222222222222",
336                "thid": "11111111-1111-1111-1111-111111111111",
337                "response": { "ok": false, "error": "duplicate_command" }
338            }),
339        );
340    }
341
342    #[test]
343    fn list_commands_request_roundtrip() {
344        let msg = Message::ListCommandsRequest {
345            id: uid("33333333-3333-3333-3333-333333333333"),
346        };
347        check_roundtrip(
348            &msg,
349            json!({
350                "type": "list.commands.request",
351                "id": "33333333-3333-3333-3333-333333333333"
352            }),
353        );
354    }
355
356    #[test]
357    fn list_commands_response_roundtrip() {
358        let msg = Message::ListCommandsResponse {
359            id: uid("44444444-4444-4444-4444-444444444444"),
360            thid: uid("33333333-3333-3333-3333-333333333333"),
361            commands: vec![CommandDef {
362                id: "user.create".to_string(),
363                description: None,
364                schema: None,
365            }],
366        };
367        check_roundtrip(
368            &msg,
369            json!({
370                "type": "list.commands.response",
371                "id": "44444444-4444-4444-4444-444444444444",
372                "thid": "33333333-3333-3333-3333-333333333333",
373                "commands": [{ "id": "user.create" }]
374            }),
375        );
376    }
377
378    #[test]
379    fn execute_command_request_roundtrip() {
380        let msg = Message::ExecuteCommandRequest {
381            id: uid("55555555-5555-5555-5555-555555555555"),
382            command_id: "math.add".to_string(),
383            request: Some(json!({ "a": 1, "b": 2 })),
384        };
385        check_roundtrip(
386            &msg,
387            json!({
388                "type": "execute.command.request",
389                "id": "55555555-5555-5555-5555-555555555555",
390                "commandId": "math.add",
391                "request": { "a": 1, "b": 2 }
392            }),
393        );
394    }
395
396    #[test]
397    fn execute_command_request_no_payload() {
398        let msg = Message::ExecuteCommandRequest {
399            id: uid("55555555-5555-5555-5555-555555555555"),
400            command_id: "system.ping".to_string(),
401            request: None,
402        };
403        check_roundtrip(
404            &msg,
405            json!({
406                "type": "execute.command.request",
407                "id": "55555555-5555-5555-5555-555555555555",
408                "commandId": "system.ping"
409            }),
410        );
411    }
412
413    #[test]
414    fn execute_command_response_ok_roundtrip() {
415        let msg = Message::ExecuteCommandResponse {
416            id: uid("66666666-6666-6666-6666-666666666666"),
417            thid: uid("55555555-5555-5555-5555-555555555555"),
418            response: ExecuteResult::Ok {
419                ok: True,
420                result: Some(json!(3)),
421            },
422        };
423        check_roundtrip(
424            &msg,
425            json!({
426                "type": "execute.command.response",
427                "id": "66666666-6666-6666-6666-666666666666",
428                "thid": "55555555-5555-5555-5555-555555555555",
429                "response": { "ok": true, "result": 3 }
430            }),
431        );
432    }
433
434    #[test]
435    fn execute_command_response_err_roundtrip() {
436        let msg = Message::ExecuteCommandResponse {
437            id: uid("66666666-6666-6666-6666-666666666666"),
438            thid: uid("55555555-5555-5555-5555-555555555555"),
439            response: ExecuteResult::Err {
440                ok: False,
441                error: ExecuteError {
442                    code: ExecuteErrorCode::NotFound,
443                    message: "no such command".to_string(),
444                },
445            },
446        };
447        check_roundtrip(
448            &msg,
449            json!({
450                "type": "execute.command.response",
451                "id": "66666666-6666-6666-6666-666666666666",
452                "thid": "55555555-5555-5555-5555-555555555555",
453                "response": {
454                    "ok": false,
455                    "error": { "code": "not_found", "message": "no such command" }
456                }
457            }),
458        );
459    }
460
461    #[test]
462    fn event_roundtrip() {
463        let msg = Message::Event {
464            id: uid("77777777-7777-7777-7777-777777777777"),
465            event_id: "user.created".to_string(),
466            payload: Some(json!({ "userId": "u1" })),
467        };
468        check_roundtrip(
469            &msg,
470            json!({
471                "type": "event",
472                "id": "77777777-7777-7777-7777-777777777777",
473                "eventId": "user.created",
474                "payload": { "userId": "u1" }
475            }),
476        );
477    }
478
479    #[test]
480    fn event_private_prefix_preserved() {
481        // Private events (leading underscore) are still valid wire-level;
482        // privacy is enforced by the registry, not the message schema.
483        let msg = Message::Event {
484            id: uid("77777777-7777-7777-7777-777777777777"),
485            event_id: "_internal.tick".to_string(),
486            payload: None,
487        };
488        check_roundtrip(
489            &msg,
490            json!({
491                "type": "event",
492                "id": "77777777-7777-7777-7777-777777777777",
493                "eventId": "_internal.tick"
494            }),
495        );
496    }
497
498    #[test]
499    fn unknown_type_rejected() {
500        let bad =
501            json!({ "type": "not.a.real.type", "id": "00000000-0000-0000-0000-000000000000" });
502        assert!(serde_json::from_value::<Message>(bad).is_err());
503    }
504
505    #[test]
506    fn register_result_err_requires_ok_false() {
507        // `ok: true` with an `error` field must not parse as Err.
508        let bad = json!({ "ok": true, "error": "duplicate_command" });
509        let parsed: RegisterResult = serde_json::from_value(bad).unwrap();
510        assert!(matches!(parsed, RegisterResult::Ok { .. }));
511    }
512}