1use serde::{Deserialize, Serialize};
10
11pub const SIDECAR_PROTOCOL_VERSION: u16 = 2;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum SidecarCommand {
18 Init {
21 config: serde_json::Value,
22 },
23 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#[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#[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#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn command_round_trip_init() {
75 let cmd = SidecarCommand::Init {
76 config: serde_json::json!({
77 "protocol_version": SIDECAR_PROTOCOL_VERSION,
78 "plugin_config": { "mode": "whatever-the-plugin-wants" }
79 }),
80 };
81 let json = serde_json::to_string(&cmd).unwrap();
82 assert!(json.contains("\"type\":\"init\""));
83 assert!(json.contains("whatever-the-plugin-wants"));
84 let parsed: SidecarCommand = serde_json::from_str(&json).unwrap();
85 assert_eq!(parsed, cmd);
86 }
87
88 #[test]
89 fn command_round_trip_trigger_shutdown() {
90 let commands = vec![
91 SidecarCommand::Trigger {
92 name: "press".into(),
93 payload: None,
94 },
95 SidecarCommand::Trigger {
96 name: "release".into(),
97 payload: Some(serde_json::json!({"source":"keybind"})),
98 },
99 SidecarCommand::Shutdown,
100 ];
101 for cmd in commands {
102 let json = serde_json::to_string(&cmd).unwrap();
103 let parsed: SidecarCommand = serde_json::from_str(&json).unwrap();
104 assert_eq!(parsed, cmd);
105 }
106 }
107
108 #[test]
109 fn trigger_payload_is_optional() {
110 let without_payload = serde_json::to_string(&SidecarCommand::Trigger {
111 name: "tap".into(),
112 payload: None,
113 })
114 .unwrap();
115 assert!(without_payload.contains("\"type\":\"trigger\""));
116 assert!(!without_payload.contains("payload"));
117
118 let with_payload = serde_json::to_string(&SidecarCommand::Trigger {
119 name: "tap".into(),
120 payload: Some(serde_json::json!({"count":2})),
121 })
122 .unwrap();
123 assert!(with_payload.contains("payload"));
124 }
125
126 #[test]
127 fn frame_round_trip_hello_status_insert_error() {
128 let frames = vec![
129 SidecarFrame::Hello {
130 protocol_version: SIDECAR_PROTOCOL_VERSION,
131 extension: "example-plugin".to_string(),
132 capabilities: vec!["anything".into(), "input.text".into()],
133 },
134 SidecarFrame::Status {
135 state: "busy".into(),
136 label: Some("Working".into()),
137 capabilities: vec![],
138 },
139 SidecarFrame::InsertText {
140 text: "partial".into(),
141 mode: InsertTextMode::Append,
142 },
143 SidecarFrame::InsertText {
144 text: "done".into(),
145 mode: InsertTextMode::Final,
146 },
147 SidecarFrame::Error {
148 message: "missing model".into(),
149 },
150 ];
151 for frame in frames {
152 let json = serde_json::to_string(&frame).unwrap();
153 let parsed: SidecarFrame = serde_json::from_str(&json).unwrap();
154 assert_eq!(parsed, frame);
155 }
156 }
157
158 #[test]
159 fn parses_generic_hello_line() {
160 let line = r#"{"type":"hello","protocol_version":2,"extension":"example-plugin","capabilities":["text.insert"]}"#;
161 let parsed: SidecarFrame = serde_json::from_str(line).unwrap();
162 match parsed {
163 SidecarFrame::Hello {
164 protocol_version,
165 extension,
166 capabilities,
167 } => {
168 assert_eq!(protocol_version, SIDECAR_PROTOCOL_VERSION);
169 assert_eq!(extension, "example-plugin");
170 assert_eq!(capabilities, vec!["text.insert".to_string()]);
171 }
172 other => panic!("expected Hello, got {:?}", other),
173 }
174 }
175
176 #[test]
177 fn parses_insert_text_final_line() {
178 let line = r#"{"type":"insert_text","text":"hello from a plugin","mode":"final"}"#;
179 let parsed: SidecarFrame = serde_json::from_str(line).unwrap();
180 assert_eq!(
181 parsed,
182 SidecarFrame::InsertText {
183 text: "hello from a plugin".into(),
184 mode: InsertTextMode::Final,
185 }
186 );
187 }
188}