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}
92
93impl ExecuteCommandRequest {
94    pub fn timeout(&self) -> Option<Duration> {
95        self.timeout_secs.map(Duration::from_secs)
96    }
97}
98
99/// Response for command execution.
100#[derive(Debug, Clone, Serialize)]
101pub struct ExecuteCommandResponse {
102    /// Whether execution was successful.
103    pub success: bool,
104    /// Exit code (if process completed).
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub exit_code: Option<i32>,
107    /// Cleaned output text.
108    pub output: String,
109    /// Raw output (base64 encoded if binary content detected).
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub raw_output: Option<String>,
112    /// Execution duration in milliseconds.
113    pub duration_ms: u64,
114    /// Whether the command timed out.
115    pub timed_out: bool,
116}
117
118impl ExecuteCommandResponse {
119    pub fn from_result(result: &crate::execution::ExecutionResult) -> Self {
120        Self {
121            success: result.exit_code.map(|c| c == 0).unwrap_or(false) && !result.timed_out,
122            exit_code: result.exit_code,
123            output: result.text_output.clone(),
124            raw_output: None, // Only include if requested
125            duration_ms: result.duration.as_millis() as u64,
126            timed_out: result.timed_out,
127        }
128    }
129
130    pub fn with_raw_output(mut self, include: bool, raw: &[u8]) -> Self {
131        if include {
132            // Convert to string, lossy if non-UTF8
133            self.raw_output = Some(String::from_utf8_lossy(raw).to_string());
134        }
135        self
136    }
137}
138
139/// Generic API error response.
140#[derive(Debug, Clone, Serialize)]
141pub struct ErrorResponse {
142    /// Error code (e.g., "SESSION_NOT_FOUND").
143    pub code: String,
144    /// Human-readable error message.
145    pub message: String,
146    /// Additional details (optional).
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub details: Option<String>,
149}
150
151impl ErrorResponse {
152    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
153        Self {
154            code: code.into(),
155            message: message.into(),
156            details: None,
157        }
158    }
159
160    pub fn with_details(mut self, details: impl Into<String>) -> Self {
161        self.details = Some(details.into());
162        self
163    }
164
165    pub fn session_not_found(id: &str) -> Self {
166        Self::new("SESSION_NOT_FOUND", format!("Session '{}' not found", id))
167    }
168
169    pub fn invalid_state(state: SessionState) -> Self {
170        Self::new(
171            "INVALID_STATE",
172            format!(
173                "Session is in {:?} state and cannot execute commands",
174                state
175            ),
176        )
177    }
178
179    pub fn internal_error(message: impl Into<String>) -> Self {
180        Self::new("INTERNAL_ERROR", message)
181    }
182
183    pub fn bad_request(message: impl Into<String>) -> Self {
184        Self::new("BAD_REQUEST", message)
185    }
186}
187
188/// WebSocket message types.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(tag = "type", rename_all = "snake_case")]
191pub enum WsMessage {
192    /// Client sends command to execute.
193    Execute {
194        command: String,
195        #[serde(default)]
196        timeout_secs: Option<u64>,
197    },
198    /// Server sends output chunk.
199    Output {
200        data: String,
201        /// Whether this is the final chunk.
202        #[serde(default)]
203        is_final: bool,
204    },
205    /// Server sends execution result.
206    Result {
207        success: bool,
208        exit_code: Option<i32>,
209        duration_ms: u64,
210        timed_out: bool,
211    },
212    /// Error message.
213    Error {
214        code: String,
215        message: String,
216    },
217    /// Ping/pong for connection health.
218    Ping,
219    Pong,
220}
221
222/// List sessions response.
223#[derive(Debug, Clone, Serialize)]
224pub struct ListSessionsResponse {
225    /// Total number of sessions.
226    pub count: usize,
227    /// Session summaries.
228    pub sessions: Vec<SessionSummary>,
229}
230
231/// Brief session summary for listing.
232#[derive(Debug, Clone, Serialize)]
233pub struct SessionSummary {
234    pub session_id: u64,
235    pub state: String,
236    pub idle_seconds: f64,
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_create_session_request_default() {
245        let req: CreateSessionRequest = serde_json::from_str("{}").unwrap();
246        assert!(req.shell.is_none());
247        assert!(req.working_dir.is_none());
248        assert!(req.env.is_empty());
249    }
250
251    #[test]
252    fn test_create_session_request_with_fields() {
253        let json = r#"{"shell": "bash", "working_dir": "/tmp"}"#;
254        let req: CreateSessionRequest = serde_json::from_str(json).unwrap();
255        assert_eq!(req.shell, Some("bash".to_string()));
256        assert_eq!(req.working_dir, Some("/tmp".to_string()));
257    }
258
259    #[test]
260    fn test_execute_command_request() {
261        let json = r#"{"command": "echo hello", "timeout_secs": 30}"#;
262        let req: ExecuteCommandRequest = serde_json::from_str(json).unwrap();
263        assert_eq!(req.command, "echo hello");
264        assert_eq!(req.timeout(), Some(Duration::from_secs(30)));
265    }
266
267    #[test]
268    fn test_error_response_serialization() {
269        let err = ErrorResponse::new("TEST_ERROR", "Test message");
270        let json = serde_json::to_string(&err).unwrap();
271        assert!(json.contains("TEST_ERROR"));
272        assert!(json.contains("Test message"));
273        assert!(!json.contains("details")); // skip_serializing_if
274    }
275
276    #[test]
277    fn test_ws_message_execute() {
278        let msg = WsMessage::Execute {
279            command: "ls".to_string(),
280            timeout_secs: Some(10),
281        };
282        let json = serde_json::to_string(&msg).unwrap();
283        assert!(json.contains("execute"));
284        assert!(json.contains("ls"));
285    }
286
287    #[test]
288    fn test_ws_message_output() {
289        let msg = WsMessage::Output {
290            data: "hello\n".to_string(),
291            is_final: false,
292        };
293        let json = serde_json::to_string(&msg).unwrap();
294        assert!(json.contains("output"));
295    }
296}