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
11///
12/// v2: added `ClientControl::OpenWindow` (open a directory as a new
13/// orchestrator window), used by the nested-terminal forwarding path.
14pub const PROTOCOL_VERSION: u32 = 2;
15
16/// Terminal size in columns and rows
17#[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/// Client hello message sent during handshake
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ClientHello {
32    /// Protocol version
33    pub protocol_version: u32,
34    /// Client binary version (e.g., "0.15.0")
35    pub client_version: String,
36    /// Initial terminal size
37    pub term_size: TermSize,
38    /// Environment variables relevant for rendering
39    /// Keys: TERM, COLORTERM, LANG, LC_ALL
40    pub env: HashMap<String, Option<String>>,
41}
42
43impl ClientHello {
44    /// Create a new ClientHello with current environment
45    pub fn new(term_size: TermSize) -> Self {
46        let mut env = HashMap::new();
47
48        // Collect terminal-relevant environment variables
49        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    /// Get the TERM value
62    pub fn term(&self) -> Option<&str> {
63        self.env.get("TERM").and_then(|v| v.as_deref())
64    }
65
66    /// Check if truecolor is supported
67    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/// Server hello message sent in response to ClientHello
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ServerHello {
79    /// Protocol version
80    pub protocol_version: u32,
81    /// Server binary version
82    pub server_version: String,
83    /// Session identifier (encoded working directory)
84    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/// Version mismatch error response
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct VersionMismatch {
100    pub server_version: String,
101    pub client_version: String,
102    /// Suggested action: "restart_server", "upgrade_client"
103    pub action: String,
104    pub message: String,
105}
106
107/// Control messages from client to server
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "snake_case")]
110pub enum ClientControl {
111    /// Initial handshake
112    Hello(ClientHello),
113    /// Terminal was resized
114    Resize { cols: u16, rows: u16 },
115    /// Keepalive ping
116    Ping,
117    /// Request to detach (keep server running)
118    Detach,
119    /// Request to quit (shutdown server if last client)
120    Quit,
121    /// Request to open files in the editor
122    OpenFiles {
123        files: Vec<FileRequest>,
124        #[serde(default)]
125        wait: bool,
126    },
127    /// Request to open a directory as a new orchestrator window/session.
128    ///
129    /// Unlike `OpenFiles` (which opens buffers in the current window),
130    /// this pops a brand-new window rooted at `path` and focuses it.
131    /// Used when a `fresh <dir>` is invoked from inside Fresh's own
132    /// embedded terminal: the directory becomes a new session instead
133    /// of launching a second editor in the terminal.
134    OpenWindow { path: String },
135}
136
137/// A file to open with optional line/column position, range, and hover message
138#[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/// Control messages from server to client
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum ServerControl {
155    /// Handshake response
156    Hello(ServerHello),
157    /// Version mismatch error
158    VersionMismatch(VersionMismatch),
159    /// Keepalive pong
160    Pong,
161    /// Set terminal title
162    SetTitle { title: String },
163    /// Ring the bell
164    Bell,
165    /// Server is shutting down
166    Quit { reason: String },
167    /// Error message
168    Error { message: String },
169    /// Signal that a --wait operation has completed
170    WaitComplete,
171    /// Set the system clipboard on the client side
172    /// The client should use the specified methods to copy the text
173    SetClipboard {
174        text: String,
175        /// Whether to use OSC 52 escape sequences
176        use_osc52: bool,
177        /// Whether to use native system clipboard (arboard)
178        use_system_clipboard: bool,
179    },
180    /// Tell this client to suspend itself (SIGTSTP on Unix) and resume on `fg`.
181    ///
182    /// Dispatched when the user triggers `Action::SuspendProcess` in session
183    /// mode: only the client should drop back to the shell — the server
184    /// keeps running so the editor state is preserved and picked up cleanly
185    /// when the client resumes.
186    SuspendClient,
187}
188
189/// Wrapper for control channel messages (used for JSON serialization)
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(untagged)]
192pub enum ControlMessage {
193    Client(ClientControl),
194    Server(ServerControl),
195}
196
197/// Read a JSON control message from a reader
198pub 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
204/// Write a JSON control message to a writer
205pub 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        // serde(rename_all = "snake_case") should tag it "open_window"
240        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        // serde(rename_all = "snake_case") should produce "resize"
255        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        // No COLORTERM
291        hello.env.remove("COLORTERM");
292        assert!(!hello.supports_truecolor());
293
294        // truecolor
295        hello
296            .env
297            .insert("COLORTERM".to_string(), Some("truecolor".to_string()));
298        assert!(hello.supports_truecolor());
299
300        // 24bit
301        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}