1use std::collections::HashMap;
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7
8use crate::session::{SessionId, SessionState};
9
10#[derive(Debug, Clone, Deserialize, Default)]
15#[serde(deny_unknown_fields)]
16pub struct CreateSessionRequest {
17 #[serde(default)]
19 pub shell: Option<String>,
20 #[serde(default)]
22 pub working_dir: Option<String>,
23 #[serde(default)]
25 pub env: HashMap<String, String>,
26}
27
28#[derive(Debug, Clone, Serialize)]
30pub struct CreateSessionResponse {
31 pub session_id: u64,
33 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#[derive(Debug, Clone, Serialize)]
48pub struct SessionStatusResponse {
49 pub session_id: u64,
51 pub state: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub working_dir: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub last_exit_code: Option<i32>,
59 pub execution_count: u64,
61 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#[derive(Debug, Clone, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct ExecuteCommandRequest {
102 pub command: String,
104 #[serde(default)]
106 pub working_dir: Option<String>,
107 #[serde(default)]
109 pub env: HashMap<String, String>,
110 #[serde(default)]
112 pub timeout_secs: Option<u64>,
113 #[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#[derive(Debug, Clone, Serialize)]
130pub struct ExecuteCommandResponse {
131 pub success: bool,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub exit_code: Option<i32>,
136 pub output: String,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub raw_output: Option<String>,
141 pub duration_ms: u64,
143 pub timed_out: bool,
145 pub total_bytes: u64,
149 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, 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 self.raw_output = Some(String::from_utf8_lossy(raw).to_string());
174 }
175 self
176 }
177}
178
179#[derive(Debug, Clone, Serialize)]
181pub struct ErrorResponse {
182 pub code: String,
184 pub message: String,
186 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
239pub enum WsClientMessage {
240 Execute {
242 command: String,
243 #[serde(default)]
244 timeout_secs: Option<u64>,
245 },
246 Ping,
248 Pong,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
258#[serde(tag = "type", rename_all = "snake_case")]
259pub enum WsServerMessage {
260 Output {
262 data: String,
263 #[serde(default)]
265 is_final: bool,
266 },
267 Result {
274 success: bool,
275 exit_code: Option<i32>,
276 duration_ms: u64,
277 timed_out: bool,
278 total_bytes: u64,
279 },
280 Error {
282 code: String,
283 message: String,
284 },
285 Ping,
287 Pong,
288}
289
290#[derive(Debug, Clone, Serialize)]
292pub struct ListSessionsResponse {
293 pub count: usize,
295 pub sessions: Vec<SessionSummary>,
297}
298
299#[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")); }
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 #[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 for bad in [
380 r#"{"command":"cd","workingDir":"/tmp"}"#,
381 r#"{"command":"cd","timeoutSecs":1}"#,
382 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 #[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}