Skip to main content

fresh/server/
protocol.rs

1//! Protocol definitions for client-server communication
2//!
3//! The protocol uses two channels:
4//! - **Data channel**: Raw bytes, no framing (stdin→server, server→stdout)
5//! - **Control channel**: JSON messages for out-of-band communication
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Protocol version - must match between client and server
11pub const PROTOCOL_VERSION: u32 = 1;
12
13/// Terminal size in columns and rows
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub struct TermSize {
16    pub cols: u16,
17    pub rows: u16,
18}
19
20impl TermSize {
21    pub fn new(cols: u16, rows: u16) -> Self {
22        Self { cols, rows }
23    }
24}
25
26/// Client hello message sent during handshake
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ClientHello {
29    /// Protocol version
30    pub protocol_version: u32,
31    /// Client binary version (e.g., "0.15.0")
32    pub client_version: String,
33    /// Initial terminal size
34    pub term_size: TermSize,
35    /// Environment variables relevant for rendering
36    /// Keys: TERM, COLORTERM, LANG, LC_ALL
37    pub env: HashMap<String, Option<String>>,
38}
39
40impl ClientHello {
41    /// Create a new ClientHello with current environment
42    pub fn new(term_size: TermSize) -> Self {
43        let mut env = HashMap::new();
44
45        // Collect terminal-relevant environment variables
46        for key in &["TERM", "COLORTERM", "LANG", "LC_ALL"] {
47            env.insert(key.to_string(), std::env::var(key).ok());
48        }
49
50        Self {
51            protocol_version: PROTOCOL_VERSION,
52            client_version: env!("CARGO_PKG_VERSION").to_string(),
53            term_size,
54            env,
55        }
56    }
57
58    /// Get the TERM value
59    pub fn term(&self) -> Option<&str> {
60        self.env.get("TERM").and_then(|v| v.as_deref())
61    }
62
63    /// Check if truecolor is supported
64    pub fn supports_truecolor(&self) -> bool {
65        self.env
66            .get("COLORTERM")
67            .and_then(|v| v.as_deref())
68            .map(|v| v == "truecolor" || v == "24bit")
69            .unwrap_or(false)
70    }
71}
72
73/// Server hello message sent in response to ClientHello
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ServerHello {
76    /// Protocol version
77    pub protocol_version: u32,
78    /// Server binary version
79    pub server_version: String,
80    /// Session identifier (encoded working directory)
81    pub session_id: String,
82}
83
84impl ServerHello {
85    pub fn new(session_id: String) -> Self {
86        Self {
87            protocol_version: PROTOCOL_VERSION,
88            server_version: env!("CARGO_PKG_VERSION").to_string(),
89            session_id,
90        }
91    }
92}
93
94/// Version mismatch error response
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct VersionMismatch {
97    pub server_version: String,
98    pub client_version: String,
99    /// Suggested action: "restart_server", "upgrade_client"
100    pub action: String,
101    pub message: String,
102}
103
104/// Control messages from client to server
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(tag = "type", rename_all = "snake_case")]
107pub enum ClientControl {
108    /// Initial handshake
109    Hello(ClientHello),
110    /// Terminal was resized
111    Resize { cols: u16, rows: u16 },
112    /// Keepalive ping
113    Ping,
114    /// Request to detach (keep server running)
115    Detach,
116    /// Request to quit (shutdown server if last client)
117    Quit,
118    /// Request to open files in the editor
119    OpenFiles {
120        files: Vec<FileRequest>,
121        #[serde(default)]
122        wait: bool,
123    },
124}
125
126/// A file to open with optional line/column position, range, and hover message
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct FileRequest {
129    pub path: String,
130    pub line: Option<usize>,
131    pub column: Option<usize>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub end_line: Option<usize>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub end_column: Option<usize>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub message: Option<String>,
138}
139
140/// Control messages from server to client
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(tag = "type", rename_all = "snake_case")]
143pub enum ServerControl {
144    /// Handshake response
145    Hello(ServerHello),
146    /// Version mismatch error
147    VersionMismatch(VersionMismatch),
148    /// Keepalive pong
149    Pong,
150    /// Set terminal title
151    SetTitle { title: String },
152    /// Ring the bell
153    Bell,
154    /// Server is shutting down
155    Quit { reason: String },
156    /// Error message
157    Error { message: String },
158    /// Signal that a --wait operation has completed
159    WaitComplete,
160    /// Set the system clipboard on the client side
161    /// The client should use the specified methods to copy the text
162    SetClipboard {
163        text: String,
164        /// Whether to use OSC 52 escape sequences
165        use_osc52: bool,
166        /// Whether to use native system clipboard (arboard)
167        use_system_clipboard: bool,
168    },
169}
170
171/// Wrapper for control channel messages (used for JSON serialization)
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(untagged)]
174pub enum ControlMessage {
175    Client(ClientControl),
176    Server(ServerControl),
177}
178
179/// Read a JSON control message from a reader
180pub fn read_control_message<R: std::io::BufRead>(reader: &mut R) -> std::io::Result<String> {
181    let mut line = String::new();
182    reader.read_line(&mut line)?;
183    Ok(line)
184}
185
186/// Write a JSON control message to a writer
187pub fn write_control_message<W: std::io::Write>(
188    writer: &mut W,
189    msg: &impl Serialize,
190) -> std::io::Result<()> {
191    let json = serde_json::to_string(msg).map_err(|e| std::io::Error::other(e.to_string()))?;
192    writeln!(writer, "{}", json)?;
193    writer.flush()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_client_hello_captures_protocol_version() {
202        let hello = ClientHello::new(TermSize::new(80, 24));
203        assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
204    }
205
206    #[test]
207    fn test_client_hello_roundtrip() {
208        let hello = ClientHello::new(TermSize::new(120, 40));
209        let json = serde_json::to_string(&hello).unwrap();
210        let parsed: ClientHello = serde_json::from_str(&json).unwrap();
211        assert_eq!(parsed.term_size.cols, 120);
212        assert_eq!(parsed.term_size.rows, 40);
213    }
214
215    #[test]
216    fn test_control_messages_use_snake_case_tags() {
217        let resize = ClientControl::Resize {
218            cols: 100,
219            rows: 50,
220        };
221        let json = serde_json::to_string(&resize).unwrap();
222        // serde(rename_all = "snake_case") should produce "resize"
223        assert!(json.contains("\"type\":\"resize\""));
224    }
225
226    #[test]
227    fn test_server_hello_includes_session_id() {
228        let hello = ServerHello::new("my-session".to_string());
229        assert_eq!(hello.session_id, "my-session");
230        assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
231    }
232
233    #[test]
234    fn test_version_mismatch_roundtrip() {
235        let mismatch = VersionMismatch {
236            server_version: "1.0.0".to_string(),
237            client_version: "2.0.0".to_string(),
238            action: "upgrade_server".to_string(),
239            message: "Version mismatch".to_string(),
240        };
241        let msg = ServerControl::VersionMismatch(mismatch);
242        let json = serde_json::to_string(&msg).unwrap();
243        let parsed: ServerControl = serde_json::from_str(&json).unwrap();
244
245        match parsed {
246            ServerControl::VersionMismatch(m) => {
247                assert_eq!(m.server_version, "1.0.0");
248                assert_eq!(m.client_version, "2.0.0");
249            }
250            _ => panic!("Expected VersionMismatch"),
251        }
252    }
253
254    #[test]
255    fn test_truecolor_detection() {
256        let mut hello = ClientHello::new(TermSize::new(80, 24));
257
258        // No COLORTERM
259        hello.env.remove("COLORTERM");
260        assert!(!hello.supports_truecolor());
261
262        // truecolor
263        hello
264            .env
265            .insert("COLORTERM".to_string(), Some("truecolor".to_string()));
266        assert!(hello.supports_truecolor());
267
268        // 24bit
269        hello
270            .env
271            .insert("COLORTERM".to_string(), Some("24bit".to_string()));
272        assert!(hello.supports_truecolor());
273    }
274
275    #[test]
276    fn test_all_client_control_variants_serialize() {
277        let variants: Vec<ClientControl> = vec![
278            ClientControl::Hello(ClientHello::new(TermSize::new(80, 24))),
279            ClientControl::Resize {
280                cols: 100,
281                rows: 50,
282            },
283            ClientControl::Ping,
284            ClientControl::Detach,
285            ClientControl::Quit,
286            ClientControl::OpenFiles {
287                files: vec![FileRequest {
288                    path: "/test/file.txt".to_string(),
289                    line: Some(10),
290                    column: Some(5),
291                    end_line: None,
292                    end_column: None,
293                    message: None,
294                }],
295                wait: false,
296            },
297        ];
298
299        for variant in variants {
300            let json = serde_json::to_string(&variant).unwrap();
301            let _: ClientControl = serde_json::from_str(&json).unwrap();
302        }
303    }
304
305    #[test]
306    fn test_all_server_control_variants_serialize() {
307        let variants: Vec<ServerControl> = vec![
308            ServerControl::Hello(ServerHello::new("test".to_string())),
309            ServerControl::Pong,
310            ServerControl::SetTitle {
311                title: "Test".to_string(),
312            },
313            ServerControl::Bell,
314            ServerControl::Quit {
315                reason: "test".to_string(),
316            },
317            ServerControl::Error {
318                message: "error".to_string(),
319            },
320            ServerControl::WaitComplete,
321            ServerControl::SetClipboard {
322                text: "hello".to_string(),
323                use_osc52: true,
324                use_system_clipboard: true,
325            },
326        ];
327
328        for variant in variants {
329            let json = serde_json::to_string(&variant).unwrap();
330            let _: ServerControl = serde_json::from_str(&json).unwrap();
331        }
332    }
333}