1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10pub const PROTOCOL_VERSION: u32 = 2;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub struct TermSize {
19 pub cols: u16,
20 pub rows: u16,
21}
22
23impl TermSize {
24 pub fn new(cols: u16, rows: u16) -> Self {
25 Self { cols, rows }
26 }
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ClientHello {
32 pub protocol_version: u32,
34 pub client_version: String,
36 pub term_size: TermSize,
38 pub env: HashMap<String, Option<String>>,
41}
42
43impl ClientHello {
44 pub fn new(term_size: TermSize) -> Self {
46 let mut env = HashMap::new();
47
48 for key in &["TERM", "COLORTERM", "LANG", "LC_ALL"] {
50 env.insert(key.to_string(), std::env::var(key).ok());
51 }
52
53 Self {
54 protocol_version: PROTOCOL_VERSION,
55 client_version: env!("CARGO_PKG_VERSION").to_string(),
56 term_size,
57 env,
58 }
59 }
60
61 pub fn term(&self) -> Option<&str> {
63 self.env.get("TERM").and_then(|v| v.as_deref())
64 }
65
66 pub fn supports_truecolor(&self) -> bool {
68 self.env
69 .get("COLORTERM")
70 .and_then(|v| v.as_deref())
71 .map(|v| v == "truecolor" || v == "24bit")
72 .unwrap_or(false)
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ServerHello {
79 pub protocol_version: u32,
81 pub server_version: String,
83 pub session_id: String,
85}
86
87impl ServerHello {
88 pub fn new(session_id: String) -> Self {
89 Self {
90 protocol_version: PROTOCOL_VERSION,
91 server_version: env!("CARGO_PKG_VERSION").to_string(),
92 session_id,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct VersionMismatch {
100 pub server_version: String,
101 pub client_version: String,
102 pub action: String,
104 pub message: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "snake_case")]
110pub enum ClientControl {
111 Hello(ClientHello),
113 Resize { cols: u16, rows: u16 },
115 Ping,
117 Detach,
119 Quit,
121 OpenFiles {
123 files: Vec<FileRequest>,
124 #[serde(default)]
125 wait: bool,
126 },
127 OpenWindow { path: String },
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct FileRequest {
140 pub path: String,
141 pub line: Option<usize>,
142 pub column: Option<usize>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub end_line: Option<usize>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub end_column: Option<usize>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub message: Option<String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum ServerControl {
155 Hello(ServerHello),
157 VersionMismatch(VersionMismatch),
159 Pong,
161 SetTitle { title: String },
163 Bell,
165 Quit { reason: String },
167 Error { message: String },
169 WaitComplete,
171 SetClipboard {
174 text: String,
175 use_osc52: bool,
177 use_system_clipboard: bool,
179 },
180 SuspendClient,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(untagged)]
192pub enum ControlMessage {
193 Client(ClientControl),
194 Server(ServerControl),
195}
196
197pub fn read_control_message<R: std::io::BufRead>(reader: &mut R) -> std::io::Result<String> {
199 let mut line = String::new();
200 reader.read_line(&mut line)?;
201 Ok(line)
202}
203
204pub fn write_control_message<W: std::io::Write>(
206 writer: &mut W,
207 msg: &impl Serialize,
208) -> std::io::Result<()> {
209 let json = serde_json::to_string(msg).map_err(|e| std::io::Error::other(e.to_string()))?;
210 writeln!(writer, "{}", json)?;
211 writer.flush()
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn test_client_hello_captures_protocol_version() {
220 let hello = ClientHello::new(TermSize::new(80, 24));
221 assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
222 }
223
224 #[test]
225 fn test_client_hello_roundtrip() {
226 let hello = ClientHello::new(TermSize::new(120, 40));
227 let json = serde_json::to_string(&hello).unwrap();
228 let parsed: ClientHello = serde_json::from_str(&json).unwrap();
229 assert_eq!(parsed.term_size.cols, 120);
230 assert_eq!(parsed.term_size.rows, 40);
231 }
232
233 #[test]
234 fn test_open_window_roundtrip() {
235 let msg = ClientControl::OpenWindow {
236 path: "/home/user/project".to_string(),
237 };
238 let json = serde_json::to_string(&msg).unwrap();
239 assert!(json.contains("\"type\":\"open_window\""));
241 match serde_json::from_str::<ClientControl>(&json).unwrap() {
242 ClientControl::OpenWindow { path } => assert_eq!(path, "/home/user/project"),
243 other => panic!("expected OpenWindow, got {:?}", other),
244 }
245 }
246
247 #[test]
248 fn test_control_messages_use_snake_case_tags() {
249 let resize = ClientControl::Resize {
250 cols: 100,
251 rows: 50,
252 };
253 let json = serde_json::to_string(&resize).unwrap();
254 assert!(json.contains("\"type\":\"resize\""));
256 }
257
258 #[test]
259 fn test_server_hello_includes_session_id() {
260 let hello = ServerHello::new("my-session".to_string());
261 assert_eq!(hello.session_id, "my-session");
262 assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
263 }
264
265 #[test]
266 fn test_version_mismatch_roundtrip() {
267 let mismatch = VersionMismatch {
268 server_version: "1.0.0".to_string(),
269 client_version: "2.0.0".to_string(),
270 action: "upgrade_server".to_string(),
271 message: "Version mismatch".to_string(),
272 };
273 let msg = ServerControl::VersionMismatch(mismatch);
274 let json = serde_json::to_string(&msg).unwrap();
275 let parsed: ServerControl = serde_json::from_str(&json).unwrap();
276
277 match parsed {
278 ServerControl::VersionMismatch(m) => {
279 assert_eq!(m.server_version, "1.0.0");
280 assert_eq!(m.client_version, "2.0.0");
281 }
282 _ => panic!("Expected VersionMismatch"),
283 }
284 }
285
286 #[test]
287 fn test_truecolor_detection() {
288 let mut hello = ClientHello::new(TermSize::new(80, 24));
289
290 hello.env.remove("COLORTERM");
292 assert!(!hello.supports_truecolor());
293
294 hello
296 .env
297 .insert("COLORTERM".to_string(), Some("truecolor".to_string()));
298 assert!(hello.supports_truecolor());
299
300 hello
302 .env
303 .insert("COLORTERM".to_string(), Some("24bit".to_string()));
304 assert!(hello.supports_truecolor());
305 }
306
307 #[test]
308 fn test_all_client_control_variants_serialize() {
309 let variants: Vec<ClientControl> = vec![
310 ClientControl::Hello(ClientHello::new(TermSize::new(80, 24))),
311 ClientControl::Resize {
312 cols: 100,
313 rows: 50,
314 },
315 ClientControl::Ping,
316 ClientControl::Detach,
317 ClientControl::Quit,
318 ClientControl::OpenFiles {
319 files: vec![FileRequest {
320 path: "/test/file.txt".to_string(),
321 line: Some(10),
322 column: Some(5),
323 end_line: None,
324 end_column: None,
325 message: None,
326 }],
327 wait: false,
328 },
329 ];
330
331 for variant in variants {
332 let json = serde_json::to_string(&variant).unwrap();
333 let _: ClientControl = serde_json::from_str(&json).unwrap();
334 }
335 }
336
337 #[test]
338 fn test_all_server_control_variants_serialize() {
339 let variants: Vec<ServerControl> = vec![
340 ServerControl::Hello(ServerHello::new("test".to_string())),
341 ServerControl::Pong,
342 ServerControl::SetTitle {
343 title: "Test".to_string(),
344 },
345 ServerControl::Bell,
346 ServerControl::Quit {
347 reason: "test".to_string(),
348 },
349 ServerControl::Error {
350 message: "error".to_string(),
351 },
352 ServerControl::WaitComplete,
353 ServerControl::SetClipboard {
354 text: "hello".to_string(),
355 use_osc52: true,
356 use_system_clipboard: true,
357 },
358 ServerControl::SuspendClient,
359 ];
360
361 for variant in variants {
362 let json = serde_json::to_string(&variant).unwrap();
363 let _: ServerControl = serde_json::from_str(&json).unwrap();
364 }
365 }
366}