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    /// Tell this client to suspend itself (SIGTSTP on Unix) and resume on `fg`.
170    ///
171    /// Dispatched when the user triggers `Action::SuspendProcess` in session
172    /// mode: only the client should drop back to the shell — the server
173    /// keeps running so the editor state is preserved and picked up cleanly
174    /// when the client resumes.
175    SuspendClient,
176}
177
178/// Wrapper for control channel messages (used for JSON serialization)
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(untagged)]
181pub enum ControlMessage {
182    Client(ClientControl),
183    Server(ServerControl),
184}
185
186/// Read a JSON control message from a reader
187pub fn read_control_message<R: std::io::BufRead>(reader: &mut R) -> std::io::Result<String> {
188    let mut line = String::new();
189    reader.read_line(&mut line)?;
190    Ok(line)
191}
192
193/// Write a JSON control message to a writer
194pub fn write_control_message<W: std::io::Write>(
195    writer: &mut W,
196    msg: &impl Serialize,
197) -> std::io::Result<()> {
198    let json = serde_json::to_string(msg).map_err(|e| std::io::Error::other(e.to_string()))?;
199    writeln!(writer, "{}", json)?;
200    writer.flush()
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_client_hello_captures_protocol_version() {
209        let hello = ClientHello::new(TermSize::new(80, 24));
210        assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
211    }
212
213    #[test]
214    fn test_client_hello_roundtrip() {
215        let hello = ClientHello::new(TermSize::new(120, 40));
216        let json = serde_json::to_string(&hello).unwrap();
217        let parsed: ClientHello = serde_json::from_str(&json).unwrap();
218        assert_eq!(parsed.term_size.cols, 120);
219        assert_eq!(parsed.term_size.rows, 40);
220    }
221
222    #[test]
223    fn test_control_messages_use_snake_case_tags() {
224        let resize = ClientControl::Resize {
225            cols: 100,
226            rows: 50,
227        };
228        let json = serde_json::to_string(&resize).unwrap();
229        // serde(rename_all = "snake_case") should produce "resize"
230        assert!(json.contains("\"type\":\"resize\""));
231    }
232
233    #[test]
234    fn test_server_hello_includes_session_id() {
235        let hello = ServerHello::new("my-session".to_string());
236        assert_eq!(hello.session_id, "my-session");
237        assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
238    }
239
240    #[test]
241    fn test_version_mismatch_roundtrip() {
242        let mismatch = VersionMismatch {
243            server_version: "1.0.0".to_string(),
244            client_version: "2.0.0".to_string(),
245            action: "upgrade_server".to_string(),
246            message: "Version mismatch".to_string(),
247        };
248        let msg = ServerControl::VersionMismatch(mismatch);
249        let json = serde_json::to_string(&msg).unwrap();
250        let parsed: ServerControl = serde_json::from_str(&json).unwrap();
251
252        match parsed {
253            ServerControl::VersionMismatch(m) => {
254                assert_eq!(m.server_version, "1.0.0");
255                assert_eq!(m.client_version, "2.0.0");
256            }
257            _ => panic!("Expected VersionMismatch"),
258        }
259    }
260
261    #[test]
262    fn test_truecolor_detection() {
263        let mut hello = ClientHello::new(TermSize::new(80, 24));
264
265        // No COLORTERM
266        hello.env.remove("COLORTERM");
267        assert!(!hello.supports_truecolor());
268
269        // truecolor
270        hello
271            .env
272            .insert("COLORTERM".to_string(), Some("truecolor".to_string()));
273        assert!(hello.supports_truecolor());
274
275        // 24bit
276        hello
277            .env
278            .insert("COLORTERM".to_string(), Some("24bit".to_string()));
279        assert!(hello.supports_truecolor());
280    }
281
282    #[test]
283    fn test_all_client_control_variants_serialize() {
284        let variants: Vec<ClientControl> = vec![
285            ClientControl::Hello(ClientHello::new(TermSize::new(80, 24))),
286            ClientControl::Resize {
287                cols: 100,
288                rows: 50,
289            },
290            ClientControl::Ping,
291            ClientControl::Detach,
292            ClientControl::Quit,
293            ClientControl::OpenFiles {
294                files: vec![FileRequest {
295                    path: "/test/file.txt".to_string(),
296                    line: Some(10),
297                    column: Some(5),
298                    end_line: None,
299                    end_column: None,
300                    message: None,
301                }],
302                wait: false,
303            },
304        ];
305
306        for variant in variants {
307            let json = serde_json::to_string(&variant).unwrap();
308            let _: ClientControl = serde_json::from_str(&json).unwrap();
309        }
310    }
311
312    #[test]
313    fn test_all_server_control_variants_serialize() {
314        let variants: Vec<ServerControl> = vec![
315            ServerControl::Hello(ServerHello::new("test".to_string())),
316            ServerControl::Pong,
317            ServerControl::SetTitle {
318                title: "Test".to_string(),
319            },
320            ServerControl::Bell,
321            ServerControl::Quit {
322                reason: "test".to_string(),
323            },
324            ServerControl::Error {
325                message: "error".to_string(),
326            },
327            ServerControl::WaitComplete,
328            ServerControl::SetClipboard {
329                text: "hello".to_string(),
330                use_osc52: true,
331                use_system_clipboard: true,
332            },
333            ServerControl::SuspendClient,
334        ];
335
336        for variant in variants {
337            let json = serde_json::to_string(&variant).unwrap();
338            let _: ServerControl = serde_json::from_str(&json).unwrap();
339        }
340    }
341}