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#[derive(Debug, Clone, Deserialize, Default)]
12pub struct CreateSessionRequest {
13    /// Shell command to use (e.g., "bash", "powershell.exe").
14    #[serde(default)]
15    pub shell: Option<String>,
16    /// Initial working directory.
17    #[serde(default)]
18    pub working_dir: Option<String>,
19    /// Environment variables to set.
20    #[serde(default)]
21    pub env: HashMap<String, String>,
22}
23
24/// Response for session creation.
25#[derive(Debug, Clone, Serialize)]
26pub struct CreateSessionResponse {
27    /// The assigned session ID.
28    pub session_id: u64,
29    /// Human-readable session ID string.
30    pub session_id_str: String,
31}
32
33impl CreateSessionResponse {
34    pub fn new(id: SessionId) -> Self {
35        Self {
36            session_id: id.as_u64(),
37            session_id_str: id.to_string(),
38        }
39    }
40}
41
42/// Response for session status query.
43#[derive(Debug, Clone, Serialize)]
44pub struct SessionStatusResponse {
45    /// Session ID.
46    pub session_id: u64,
47    /// Current state.
48    pub state: String,
49    /// Working directory (if known).
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub working_dir: Option<String>,
52    /// Last exit code (if available).
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub last_exit_code: Option<i32>,
55    /// Total commands executed.
56    pub execution_count: u64,
57    /// Idle duration in seconds.
58    pub idle_seconds: f64,
59}
60
61impl SessionStatusResponse {
62    pub fn from_session(session: &crate::session::Session) -> Self {
63        Self {
64            session_id: session.id.as_u64(),
65            state: format!("{:?}", session.state),
66            working_dir: session
67                .context
68                .cwd()
69                .map(|p| p.to_string_lossy().to_string()),
70            last_exit_code: session.context.last_exit_code(),
71            execution_count: session.context.execution_count(),
72            idle_seconds: session.idle_duration().as_secs_f64(),
73        }
74    }
75}
76
77/// Request to execute a command.
78#[derive(Debug, Clone, Deserialize)]
79pub struct ExecuteCommandRequest {
80    /// The command line to execute.
81    pub command: String,
82    /// Optional working directory override.
83    #[serde(default)]
84    pub working_dir: Option<String>,
85    /// Optional environment variables.
86    #[serde(default)]
87    pub env: HashMap<String, String>,
88    /// Timeout in seconds.
89    #[serde(default)]
90    pub timeout_secs: Option<u64>,
91    /// Cap on the output this response carries, in bytes.
92    ///
93    /// Omitted means the server default. A value above the server's ceiling is
94    /// clamped rather than refused: the caller asked for "as much as possible",
95    /// and `total_bytes` reports what the command actually produced either way.
96    #[serde(default)]
97    pub max_output_bytes: Option<u64>,
98}
99
100impl ExecuteCommandRequest {
101    pub fn timeout(&self) -> Option<Duration> {
102        self.timeout_secs.map(Duration::from_secs)
103    }
104}
105
106/// Response for command execution.
107#[derive(Debug, Clone, Serialize)]
108pub struct ExecuteCommandResponse {
109    /// Whether execution was successful.
110    pub success: bool,
111    /// Exit code (if process completed).
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub exit_code: Option<i32>,
114    /// Cleaned output text.
115    pub output: String,
116    /// Raw output (base64 encoded if binary content detected).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub raw_output: Option<String>,
119    /// Execution duration in milliseconds.
120    pub duration_ms: u64,
121    /// Whether the command timed out.
122    pub timed_out: bool,
123    /// Bytes the command produced, including any this response does not carry.
124    ///
125    /// Equal to the size of `output` unless `truncated` is set.
126    pub total_bytes: u64,
127    /// Whether `output` is a prefix of what the command produced.
128    ///
129    /// Always present, including when false: a caller must not have to infer
130    /// completeness from the absence of a field.
131    pub truncated: bool,
132}
133
134impl ExecuteCommandResponse {
135    pub fn from_result(result: &crate::execution::ExecutionResult) -> Self {
136        Self {
137            success: result.exit_code.map(|c| c == 0).unwrap_or(false) && !result.timed_out,
138            exit_code: result.exit_code,
139            output: result.text_output.clone(),
140            raw_output: None, // Only include if requested
141            duration_ms: result.duration.as_millis() as u64,
142            timed_out: result.timed_out,
143            total_bytes: result.total_bytes,
144            truncated: result.truncated,
145        }
146    }
147
148    pub fn with_raw_output(mut self, include: bool, raw: &[u8]) -> Self {
149        if include {
150            // Convert to string, lossy if non-UTF8
151            self.raw_output = Some(String::from_utf8_lossy(raw).to_string());
152        }
153        self
154    }
155}
156
157/// Generic API error response.
158#[derive(Debug, Clone, Serialize)]
159pub struct ErrorResponse {
160    /// Error code (e.g., "SESSION_NOT_FOUND").
161    pub code: String,
162    /// Human-readable error message.
163    pub message: String,
164    /// Additional details (optional).
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub details: Option<String>,
167}
168
169impl ErrorResponse {
170    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
171        Self {
172            code: code.into(),
173            message: message.into(),
174            details: None,
175        }
176    }
177
178    pub fn with_details(mut self, details: impl Into<String>) -> Self {
179        self.details = Some(details.into());
180        self
181    }
182
183    pub fn session_not_found(id: &str) -> Self {
184        Self::new("SESSION_NOT_FOUND", format!("Session '{}' not found", id))
185    }
186
187    pub fn invalid_state(state: SessionState) -> Self {
188        Self::new(
189            "INVALID_STATE",
190            format!(
191                "Session is in {:?} state and cannot execute commands",
192                state
193            ),
194        )
195    }
196
197    pub fn internal_error(message: impl Into<String>) -> Self {
198        Self::new("INTERNAL_ERROR", message)
199    }
200
201    pub fn bad_request(message: impl Into<String>) -> Self {
202        Self::new("BAD_REQUEST", message)
203    }
204}
205
206/// WebSocket message types.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(tag = "type", rename_all = "snake_case")]
209pub enum WsMessage {
210    /// Client sends command to execute.
211    Execute {
212        command: String,
213        #[serde(default)]
214        timeout_secs: Option<u64>,
215    },
216    /// Server sends output chunk.
217    Output {
218        data: String,
219        /// Whether this is the final chunk.
220        #[serde(default)]
221        is_final: bool,
222    },
223    /// Server sends execution result.
224    ///
225    /// Carries `total_bytes` but not `truncated`, unlike the REST response:
226    /// every chunk reaches a streaming consumer as it arrives, so there is
227    /// nothing here for a cap to discard. The figure is what a consumer needs
228    /// to confirm it received the whole stream.
229    Result {
230        success: bool,
231        exit_code: Option<i32>,
232        duration_ms: u64,
233        timed_out: bool,
234        total_bytes: u64,
235    },
236    /// Error message.
237    Error {
238        code: String,
239        message: String,
240    },
241    /// Ping/pong for connection health.
242    Ping,
243    Pong,
244}
245
246/// List sessions response.
247#[derive(Debug, Clone, Serialize)]
248pub struct ListSessionsResponse {
249    /// Total number of sessions.
250    pub count: usize,
251    /// Session summaries.
252    pub sessions: Vec<SessionSummary>,
253}
254
255/// Brief session summary for listing.
256#[derive(Debug, Clone, Serialize)]
257pub struct SessionSummary {
258    pub session_id: u64,
259    pub state: String,
260    pub idle_seconds: f64,
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_create_session_request_default() {
269        let req: CreateSessionRequest = serde_json::from_str("{}").unwrap();
270        assert!(req.shell.is_none());
271        assert!(req.working_dir.is_none());
272        assert!(req.env.is_empty());
273    }
274
275    #[test]
276    fn test_create_session_request_with_fields() {
277        let json = r#"{"shell": "bash", "working_dir": "/tmp"}"#;
278        let req: CreateSessionRequest = serde_json::from_str(json).unwrap();
279        assert_eq!(req.shell, Some("bash".to_string()));
280        assert_eq!(req.working_dir, Some("/tmp".to_string()));
281    }
282
283    #[test]
284    fn test_execute_command_request() {
285        let json = r#"{"command": "echo hello", "timeout_secs": 30}"#;
286        let req: ExecuteCommandRequest = serde_json::from_str(json).unwrap();
287        assert_eq!(req.command, "echo hello");
288        assert_eq!(req.timeout(), Some(Duration::from_secs(30)));
289    }
290
291    #[test]
292    fn test_error_response_serialization() {
293        let err = ErrorResponse::new("TEST_ERROR", "Test message");
294        let json = serde_json::to_string(&err).unwrap();
295        assert!(json.contains("TEST_ERROR"));
296        assert!(json.contains("Test message"));
297        assert!(!json.contains("details")); // skip_serializing_if
298    }
299
300    #[test]
301    fn test_ws_message_execute() {
302        let msg = WsMessage::Execute {
303            command: "ls".to_string(),
304            timeout_secs: Some(10),
305        };
306        let json = serde_json::to_string(&msg).unwrap();
307        assert!(json.contains("execute"));
308        assert!(json.contains("ls"));
309    }
310
311    #[test]
312    fn test_ws_message_output() {
313        let msg = WsMessage::Output {
314            data: "hello\n".to_string(),
315            is_final: false,
316        };
317        let json = serde_json::to_string(&msg).unwrap();
318        assert!(json.contains("output"));
319    }
320}