Skip to main content

shell_tunnel/api/
types.rs

1//! API request and response types.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7
8use crate::session::{SessionId, SessionState};
9
10/// Request to create a new session.
11///
12/// Strict about field names — see `ExecuteCommandRequest` for why every request
13/// type here is.
14#[derive(Debug, Clone, Deserialize, Default)]
15#[serde(deny_unknown_fields)]
16pub struct CreateSessionRequest {
17    /// Shell command to use (e.g., "bash", "powershell.exe").
18    #[serde(default)]
19    pub shell: Option<String>,
20    /// Initial working directory.
21    #[serde(default)]
22    pub working_dir: Option<String>,
23    /// Environment variables to set.
24    #[serde(default)]
25    pub env: HashMap<String, String>,
26}
27
28/// Response for session creation.
29#[derive(Debug, Clone, Serialize)]
30pub struct CreateSessionResponse {
31    /// The assigned session ID.
32    pub session_id: u64,
33    /// Human-readable session ID string.
34    pub session_id_str: String,
35}
36
37impl CreateSessionResponse {
38    pub fn new(id: SessionId) -> Self {
39        Self {
40            session_id: id.as_u64(),
41            session_id_str: id.to_string(),
42        }
43    }
44}
45
46/// Response for session status query.
47#[derive(Debug, Clone, Serialize)]
48pub struct SessionStatusResponse {
49    /// Session ID.
50    pub session_id: u64,
51    /// Current state.
52    pub state: String,
53    /// Working directory (if known).
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub working_dir: Option<String>,
56    /// Last exit code (if available).
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub last_exit_code: Option<i32>,
59    /// Total commands executed.
60    pub execution_count: u64,
61    /// Idle duration in seconds.
62    pub idle_seconds: f64,
63}
64
65impl SessionStatusResponse {
66    pub fn from_session(session: &crate::session::Session) -> Self {
67        Self {
68            session_id: session.id.as_u64(),
69            state: format!("{:?}", session.state),
70            working_dir: session
71                .context
72                .cwd()
73                .map(|p| p.to_string_lossy().to_string()),
74            last_exit_code: session.context.last_exit_code(),
75            execution_count: session.context.execution_count(),
76            idle_seconds: session.idle_duration().as_secs_f64(),
77        }
78    }
79}
80
81/// Request to execute a command.
82///
83/// A misspelled field is refused rather than ignored, and that is a safety
84/// property rather than pedantry. Every optional field on this API either asks
85/// for something *safer* (`timeout_secs`, `dry_run` on the delete route) or
86/// says *where* to act (`working_dir`). Serde's default is to drop a field it
87/// does not recognise, which leaves the less safe default in place and reports
88/// success — a caller who wrote `timeoutSecs` got no timeout and a
89/// `timed_out: false` that looked like the command finished within one, and a
90/// caller who wrote `workingDir` had their command run somewhere else entirely.
91/// The same slip on `?dryRun=true` deleted the file it was asked to preview.
92///
93/// The refusal names the offending field and lists the accepted ones, so the
94/// caller can fix it from the response alone.
95///
96/// Note for callers coming from `spawn`-style APIs: `command` is the whole
97/// command line, and there is no `args` array. Sending one used to start a
98/// bare shell and report `success: true` without ever running the command.
99#[derive(Debug, Clone, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct ExecuteCommandRequest {
102    /// The command line to execute.
103    pub command: String,
104    /// Optional working directory override.
105    #[serde(default)]
106    pub working_dir: Option<String>,
107    /// Optional environment variables.
108    #[serde(default)]
109    pub env: HashMap<String, String>,
110    /// Timeout in seconds.
111    #[serde(default)]
112    pub timeout_secs: Option<u64>,
113    /// Cap on the output this response carries, in bytes.
114    ///
115    /// Omitted means the server default. A value above the server's ceiling is
116    /// clamped rather than refused: the caller asked for "as much as possible",
117    /// and `total_bytes` reports what the command actually produced either way.
118    #[serde(default)]
119    pub max_output_bytes: Option<u64>,
120}
121
122impl ExecuteCommandRequest {
123    pub fn timeout(&self) -> Option<Duration> {
124        self.timeout_secs.map(Duration::from_secs)
125    }
126}
127
128/// Response for command execution.
129#[derive(Debug, Clone, Serialize)]
130pub struct ExecuteCommandResponse {
131    /// Whether execution was successful.
132    pub success: bool,
133    /// Exit code (if process completed).
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub exit_code: Option<i32>,
136    /// Cleaned output text.
137    pub output: String,
138    /// Raw output (base64 encoded if binary content detected).
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub raw_output: Option<String>,
141    /// Execution duration in milliseconds.
142    pub duration_ms: u64,
143    /// Whether the command timed out.
144    pub timed_out: bool,
145    /// Bytes the command produced, including any this response does not carry.
146    ///
147    /// Equal to the size of `output` unless `truncated` is set.
148    pub total_bytes: u64,
149    /// Whether `output` is a prefix of what the command produced.
150    ///
151    /// Always present, including when false: a caller must not have to infer
152    /// completeness from the absence of a field.
153    pub truncated: bool,
154}
155
156impl ExecuteCommandResponse {
157    pub fn from_result(result: &crate::execution::ExecutionResult) -> Self {
158        Self {
159            success: result.exit_code.map(|c| c == 0).unwrap_or(false) && !result.timed_out,
160            exit_code: result.exit_code,
161            output: result.text_output.clone(),
162            raw_output: None, // Only include if requested
163            duration_ms: result.duration.as_millis() as u64,
164            timed_out: result.timed_out,
165            total_bytes: result.total_bytes,
166            truncated: result.truncated,
167        }
168    }
169
170    pub fn with_raw_output(mut self, include: bool, raw: &[u8]) -> Self {
171        if include {
172            // Convert to string, lossy if non-UTF8
173            self.raw_output = Some(String::from_utf8_lossy(raw).to_string());
174        }
175        self
176    }
177}
178
179/// Generic API error response.
180#[derive(Debug, Clone, Serialize)]
181pub struct ErrorResponse {
182    /// Error code (e.g., "SESSION_NOT_FOUND").
183    pub code: String,
184    /// Human-readable error message.
185    pub message: String,
186    /// Additional details (optional).
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub details: Option<String>,
189}
190
191impl ErrorResponse {
192    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
193        Self {
194            code: code.into(),
195            message: message.into(),
196            details: None,
197        }
198    }
199
200    pub fn with_details(mut self, details: impl Into<String>) -> Self {
201        self.details = Some(details.into());
202        self
203    }
204
205    pub fn session_not_found(id: &str) -> Self {
206        Self::new("SESSION_NOT_FOUND", format!("Session '{}' not found", id))
207    }
208
209    pub fn invalid_state(state: SessionState) -> Self {
210        Self::new(
211            "INVALID_STATE",
212            format!(
213                "Session is in {:?} state and cannot execute commands",
214                state
215            ),
216        )
217    }
218
219    pub fn internal_error(message: impl Into<String>) -> Self {
220        Self::new("INTERNAL_ERROR", message)
221    }
222
223    pub fn bad_request(message: impl Into<String>) -> Self {
224        Self::new("BAD_REQUEST", message)
225    }
226}
227
228/// A message the server accepts from a WebSocket client.
229///
230/// Split from `WsServerMessage` because the two directions want opposite
231/// strictness, and one shared type could only have one. Input is refused when
232/// it carries a field this server does not know — `timeoutSecs` for
233/// `timeout_secs` otherwise runs the command with no timeout at all and reports
234/// `timed_out: false`, which reads as "finished within the limit". Output stays
235/// permissive, so a client built against an older version of this crate keeps
236/// working when a later server adds a field.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
239pub enum WsClientMessage {
240    /// Run a command and stream its output back.
241    Execute {
242        command: String,
243        #[serde(default)]
244        timeout_secs: Option<u64>,
245    },
246    /// Connection health. The server answers `Ping` with `Pong`.
247    Ping,
248    Pong,
249}
250
251/// A message the server sends to a WebSocket client.
252///
253/// Deliberately *not* `deny_unknown_fields`: this is the type a consumer
254/// deserialises the server's output with, and a new field on a later server
255/// must not make an older consumer reject the whole message. The strictness
256/// that fixes silently-dropped input belongs on `WsClientMessage` only.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258#[serde(tag = "type", rename_all = "snake_case")]
259pub enum WsServerMessage {
260    /// One chunk of a running command's output.
261    Output {
262        data: String,
263        /// Whether this is the final chunk.
264        #[serde(default)]
265        is_final: bool,
266    },
267    /// Terminal result of an execution.
268    ///
269    /// Carries `total_bytes` but not `truncated`, unlike the REST response:
270    /// every chunk reaches a streaming consumer as it arrives, so there is
271    /// nothing here for a cap to discard. The figure is what a consumer needs
272    /// to confirm it received the whole stream.
273    Result {
274        success: bool,
275        exit_code: Option<i32>,
276        duration_ms: u64,
277        timed_out: bool,
278        total_bytes: u64,
279    },
280    /// Error message.
281    Error {
282        code: String,
283        message: String,
284    },
285    /// Connection health.
286    Ping,
287    Pong,
288}
289
290/// List sessions response.
291#[derive(Debug, Clone, Serialize)]
292pub struct ListSessionsResponse {
293    /// Total number of sessions.
294    pub count: usize,
295    /// Session summaries.
296    pub sessions: Vec<SessionSummary>,
297}
298
299/// Brief session summary for listing.
300#[derive(Debug, Clone, Serialize)]
301pub struct SessionSummary {
302    pub session_id: u64,
303    pub state: String,
304    pub idle_seconds: f64,
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_create_session_request_default() {
313        let req: CreateSessionRequest = serde_json::from_str("{}").unwrap();
314        assert!(req.shell.is_none());
315        assert!(req.working_dir.is_none());
316        assert!(req.env.is_empty());
317    }
318
319    #[test]
320    fn test_create_session_request_with_fields() {
321        let json = r#"{"shell": "bash", "working_dir": "/tmp"}"#;
322        let req: CreateSessionRequest = serde_json::from_str(json).unwrap();
323        assert_eq!(req.shell, Some("bash".to_string()));
324        assert_eq!(req.working_dir, Some("/tmp".to_string()));
325    }
326
327    #[test]
328    fn test_execute_command_request() {
329        let json = r#"{"command": "echo hello", "timeout_secs": 30}"#;
330        let req: ExecuteCommandRequest = serde_json::from_str(json).unwrap();
331        assert_eq!(req.command, "echo hello");
332        assert_eq!(req.timeout(), Some(Duration::from_secs(30)));
333    }
334
335    #[test]
336    fn test_error_response_serialization() {
337        let err = ErrorResponse::new("TEST_ERROR", "Test message");
338        let json = serde_json::to_string(&err).unwrap();
339        assert!(json.contains("TEST_ERROR"));
340        assert!(json.contains("Test message"));
341        assert!(!json.contains("details")); // skip_serializing_if
342    }
343
344    #[test]
345    fn test_ws_message_execute() {
346        let msg = WsClientMessage::Execute {
347            command: "ls".to_string(),
348            timeout_secs: Some(10),
349        };
350        let json = serde_json::to_string(&msg).unwrap();
351        assert!(json.contains("execute"));
352        assert!(json.contains("ls"));
353    }
354
355    #[test]
356    fn test_ws_message_output() {
357        let msg = WsServerMessage::Output {
358            data: "hello\n".to_string(),
359            is_final: false,
360        };
361        let json = serde_json::to_string(&msg).unwrap();
362        assert!(json.contains("output"));
363    }
364
365    // --- Unknown fields are refused, not dropped. ---
366    //
367    // Each of these asserts the *pair*: the correct spelling parses and the
368    // near-miss is refused. Asserting only the refusal would pass just as well
369    // against a type that refuses everything.
370
371    #[test]
372    fn execute_request_refuses_an_unknown_field() {
373        let good = r#"{"command":"cd","working_dir":"/tmp","timeout_secs":1}"#;
374        let req: ExecuteCommandRequest = serde_json::from_str(good).unwrap();
375        assert_eq!(req.working_dir.as_deref(), Some("/tmp"));
376        assert_eq!(req.timeout_secs, Some(1));
377
378        // The two that ran wrong and reported success before this was strict.
379        for bad in [
380            r#"{"command":"cd","workingDir":"/tmp"}"#,
381            r#"{"command":"cd","timeoutSecs":1}"#,
382            // No `args` array exists on this API; sending one used to start a
383            // bare shell and answer `success: true`.
384            r#"{"command":"cmd","args":["/c","echo","hi"]}"#,
385        ] {
386            let err = serde_json::from_str::<ExecuteCommandRequest>(bad).unwrap_err();
387            let msg = err.to_string();
388            assert!(
389                msg.contains("unknown field"),
390                "expected a refusal naming the field, got: {msg}"
391            );
392        }
393    }
394
395    #[test]
396    fn create_session_request_refuses_an_unknown_field() {
397        let req: CreateSessionRequest = serde_json::from_str(r#"{"working_dir":"/tmp"}"#).unwrap();
398        assert_eq!(req.working_dir.as_deref(), Some("/tmp"));
399        assert!(serde_json::from_str::<CreateSessionRequest>(r#"{"workingDir":"/tmp"}"#).is_err());
400    }
401
402    #[test]
403    fn ws_client_message_refuses_an_unknown_field() {
404        let good = r#"{"type":"execute","command":"ls","timeout_secs":5}"#;
405        assert!(serde_json::from_str::<WsClientMessage>(good).is_ok());
406
407        let typo = r#"{"type":"execute","command":"ls","timeoutSecs":5}"#;
408        let err = serde_json::from_str::<WsClientMessage>(typo).unwrap_err();
409        assert!(err.to_string().contains("unknown field"));
410    }
411
412    /// The other half of the split: server output must stay permissive so a
413    /// consumer built against this version keeps parsing a later server that
414    /// added a field. Locking this down would trade one silent failure for
415    /// another.
416    #[test]
417    fn ws_server_message_tolerates_an_unknown_field() {
418        let from_a_later_server = r#"{"type":"result","success":true,"exit_code":0,
419            "duration_ms":1,"timed_out":false,"total_bytes":0,"some_new_field":"x"}"#;
420        assert!(serde_json::from_str::<WsServerMessage>(from_a_later_server).is_ok());
421    }
422}