Skip to main content

synaps_cli/sidecar/
protocol.rs

1//! Sidecar protocol types — line-JSON over stdio.
2//!
3//! The host treats sidecars as lego-block processes: it starts them, sends
4//! generic trigger frames, and consumes generic status/text frames. Plugin
5//! semantics live entirely outside core.
6//!
7//! Wire format: one JSON object per line on the sidecar's stdin/stdout.
8
9use serde::{Deserialize, Serialize};
10
11/// Protocol version understood by this build.
12pub const SIDECAR_PROTOCOL_VERSION: u16 = 2;
13
14/// Commands sent from Synaps CLI to the sidecar (one per line, JSON).
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum SidecarCommand {
18    /// Plugin-defined initialization payload. Core forwards the value
19    /// verbatim and does not interpret its schema.
20    Init {
21        config: serde_json::Value,
22    },
23    /// Generic activation trigger. `name` and `payload` are plugin-defined.
24    Trigger {
25        name: String,
26        #[serde(default, skip_serializing_if = "Option::is_none")]
27        payload: Option<serde_json::Value>,
28    },
29    Shutdown,
30}
31
32/// How text emitted by a sidecar should be applied to the input buffer.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum InsertTextMode {
36    Append,
37    Final,
38    Replace,
39}
40
41/// Frames emitted by the sidecar (one per line, JSON).
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "type", rename_all = "snake_case")]
44pub enum SidecarFrame {
45    Hello {
46        protocol_version: u16,
47        extension: String,
48        #[serde(default)]
49        capabilities: Vec<String>,
50    },
51    Status {
52        state: String,
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        label: Option<String>,
55        #[serde(default)]
56        capabilities: Vec<String>,
57    },
58    InsertText {
59        text: String,
60        mode: InsertTextMode,
61    },
62    Error {
63        message: String,
64    },
65    #[serde(other)]
66    Custom,
67}
68
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn command_round_trip_init() {
76        let cmd = SidecarCommand::Init {
77            config: serde_json::json!({
78                "protocol_version": SIDECAR_PROTOCOL_VERSION,
79                "plugin_config": { "mode": "whatever-the-plugin-wants" }
80            }),
81        };
82        let json = serde_json::to_string(&cmd).unwrap();
83        assert!(json.contains("\"type\":\"init\""));
84        assert!(json.contains("whatever-the-plugin-wants"));
85        let parsed: SidecarCommand = serde_json::from_str(&json).unwrap();
86        assert_eq!(parsed, cmd);
87    }
88
89    #[test]
90    fn command_round_trip_trigger_shutdown() {
91        let commands = vec![
92            SidecarCommand::Trigger { name: "press".into(), payload: None },
93            SidecarCommand::Trigger { name: "release".into(), payload: Some(serde_json::json!({"source":"keybind"})) },
94            SidecarCommand::Shutdown,
95        ];
96        for cmd in commands {
97            let json = serde_json::to_string(&cmd).unwrap();
98            let parsed: SidecarCommand = serde_json::from_str(&json).unwrap();
99            assert_eq!(parsed, cmd);
100        }
101    }
102
103    #[test]
104    fn trigger_payload_is_optional() {
105        let without_payload = serde_json::to_string(&SidecarCommand::Trigger {
106            name: "tap".into(),
107            payload: None,
108        }).unwrap();
109        assert!(without_payload.contains("\"type\":\"trigger\""));
110        assert!(!without_payload.contains("payload"));
111
112        let with_payload = serde_json::to_string(&SidecarCommand::Trigger {
113            name: "tap".into(),
114            payload: Some(serde_json::json!({"count":2})),
115        }).unwrap();
116        assert!(with_payload.contains("payload"));
117    }
118
119    #[test]
120    fn frame_round_trip_hello_status_insert_error() {
121        let frames = vec![
122            SidecarFrame::Hello {
123                protocol_version: SIDECAR_PROTOCOL_VERSION,
124                extension: "example-plugin".to_string(),
125                capabilities: vec!["anything".into(), "input.text".into()],
126            },
127            SidecarFrame::Status {
128                state: "busy".into(),
129                label: Some("Working".into()),
130                capabilities: vec![],
131            },
132            SidecarFrame::InsertText {
133                text: "partial".into(),
134                mode: InsertTextMode::Append,
135            },
136            SidecarFrame::InsertText {
137                text: "done".into(),
138                mode: InsertTextMode::Final,
139            },
140            SidecarFrame::Error { message: "missing model".into() },
141        ];
142        for frame in frames {
143            let json = serde_json::to_string(&frame).unwrap();
144            let parsed: SidecarFrame = serde_json::from_str(&json).unwrap();
145            assert_eq!(parsed, frame);
146        }
147    }
148
149    #[test]
150    fn parses_generic_hello_line() {
151        let line = r#"{"type":"hello","protocol_version":2,"extension":"example-plugin","capabilities":["text.insert"]}"#;
152        let parsed: SidecarFrame = serde_json::from_str(line).unwrap();
153        match parsed {
154            SidecarFrame::Hello { protocol_version, extension, capabilities } => {
155                assert_eq!(protocol_version, SIDECAR_PROTOCOL_VERSION);
156                assert_eq!(extension, "example-plugin");
157                assert_eq!(capabilities, vec!["text.insert".to_string()]);
158            }
159            other => panic!("expected Hello, got {:?}", other),
160        }
161    }
162
163    #[test]
164    fn parses_insert_text_final_line() {
165        let line = r#"{"type":"insert_text","text":"hello from a plugin","mode":"final"}"#;
166        let parsed: SidecarFrame = serde_json::from_str(line).unwrap();
167        assert_eq!(
168            parsed,
169            SidecarFrame::InsertText {
170                text: "hello from a plugin".into(),
171                mode: InsertTextMode::Final,
172            }
173        );
174    }
175}