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
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}