use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::session::{SessionId, SessionState};
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CreateSessionRequest {}
#[derive(Debug, Clone, Serialize)]
pub struct CreateSessionResponse {
pub session_id: u64,
pub session_id_str: String,
}
impl CreateSessionResponse {
pub fn new(id: SessionId) -> Self {
Self {
session_id: id.as_u64(),
session_id_str: id.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionStatusResponse {
pub session_id: u64,
pub running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_exit_code: Option<i32>,
pub execution_count: u64,
pub idle_seconds: f64,
}
impl SessionStatusResponse {
pub fn from_session(session: &crate::session::Session) -> Self {
Self {
session_id: session.id.as_u64(),
running: session.state == SessionState::Active,
last_exit_code: session.context.last_exit_code(),
execution_count: session.context.execution_count(),
idle_seconds: session.idle_duration().as_secs_f64(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecuteCommandRequest {
pub command: String,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub max_output_bytes: Option<u64>,
}
impl ExecuteCommandRequest {
pub fn timeout(&self) -> Option<Duration> {
self.timeout_secs.map(Duration::from_secs)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ExecuteCommandResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub output: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_output: Option<String>,
pub duration_ms: u64,
pub timed_out: bool,
pub total_bytes: u64,
pub truncated: bool,
}
impl ExecuteCommandResponse {
pub fn from_result(result: &crate::execution::ExecutionResult) -> Self {
Self {
success: result.exit_code.map(|c| c == 0).unwrap_or(false) && !result.timed_out,
exit_code: result.exit_code,
output: result.text_output.clone(),
raw_output: None, duration_ms: result.duration.as_millis() as u64,
timed_out: result.timed_out,
total_bytes: result.total_bytes,
truncated: result.truncated,
}
}
pub fn with_raw_output(mut self, include: bool, raw: &[u8]) -> Self {
if include {
self.raw_output = Some(String::from_utf8_lossy(raw).to_string());
}
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ErrorResponse {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl ErrorResponse {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: None,
}
}
pub fn with_details(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self
}
pub fn session_not_found(id: &str) -> Self {
Self::new("SESSION_NOT_FOUND", format!("Session '{}' not found", id))
}
pub fn invalid_state(state: SessionState) -> Self {
Self::new(
"INVALID_STATE",
format!(
"Session is in {:?} state and cannot execute commands",
state
),
)
}
pub fn internal_error(message: impl Into<String>) -> Self {
Self::new("INTERNAL_ERROR", message)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new("BAD_REQUEST", message)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum WsClientMessage {
Execute {
command: String,
#[serde(default)]
timeout_secs: Option<u64>,
},
Ping,
Pong,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WsServerMessage {
Output {
data: String,
#[serde(default)]
is_final: bool,
},
Result {
success: bool,
exit_code: Option<i32>,
duration_ms: u64,
timed_out: bool,
total_bytes: u64,
},
Error {
code: String,
message: String,
},
Ping,
Pong,
}
#[derive(Debug, Clone, Serialize)]
pub struct ListSessionsResponse {
pub count: usize,
pub sessions: Vec<SessionSummary>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionSummary {
pub session_id: u64,
pub running: bool,
pub idle_seconds: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_session_request_default() {
assert!(serde_json::from_str::<CreateSessionRequest>("{}").is_ok());
}
#[test]
fn test_execute_command_request() {
let json = r#"{"command": "echo hello", "timeout_secs": 30}"#;
let req: ExecuteCommandRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.command, "echo hello");
assert_eq!(req.timeout(), Some(Duration::from_secs(30)));
}
#[test]
fn test_error_response_serialization() {
let err = ErrorResponse::new("TEST_ERROR", "Test message");
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("TEST_ERROR"));
assert!(json.contains("Test message"));
assert!(!json.contains("details")); }
#[test]
fn test_ws_message_execute() {
let msg = WsClientMessage::Execute {
command: "ls".to_string(),
timeout_secs: Some(10),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("execute"));
assert!(json.contains("ls"));
}
#[test]
fn test_ws_message_output() {
let msg = WsServerMessage::Output {
data: "hello\n".to_string(),
is_final: false,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("output"));
}
#[test]
fn execute_request_refuses_an_unknown_field() {
let good = r#"{"command":"cd","working_dir":"/tmp","timeout_secs":1}"#;
let req: ExecuteCommandRequest = serde_json::from_str(good).unwrap();
assert_eq!(req.working_dir.as_deref(), Some("/tmp"));
assert_eq!(req.timeout_secs, Some(1));
for bad in [
r#"{"command":"cd","workingDir":"/tmp"}"#,
r#"{"command":"cd","timeoutSecs":1}"#,
r#"{"command":"cmd","args":["/c","echo","hi"]}"#,
] {
let err = serde_json::from_str::<ExecuteCommandRequest>(bad).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unknown field"),
"expected a refusal naming the field, got: {msg}"
);
}
}
#[test]
fn create_session_request_refuses_every_field() {
for body in [
r#"{"shell":"bash"}"#,
r#"{"working_dir":"/tmp"}"#,
r#"{"env":{"A":"b"}}"#,
r#"{"workingDir":"/tmp"}"#,
] {
assert!(
serde_json::from_str::<CreateSessionRequest>(body).is_err(),
"{body} was accepted"
);
}
}
#[test]
fn ws_client_message_refuses_an_unknown_field() {
let good = r#"{"type":"execute","command":"ls","timeout_secs":5}"#;
assert!(serde_json::from_str::<WsClientMessage>(good).is_ok());
let typo = r#"{"type":"execute","command":"ls","timeoutSecs":5}"#;
let err = serde_json::from_str::<WsClientMessage>(typo).unwrap_err();
assert!(err.to_string().contains("unknown field"));
}
#[test]
fn ws_server_message_tolerates_an_unknown_field() {
let from_a_later_server = r#"{"type":"result","success":true,"exit_code":0,
"duration_ms":1,"timed_out":false,"total_bytes":0,"some_new_field":"x"}"#;
assert!(serde_json::from_str::<WsServerMessage>(from_a_later_server).is_ok());
}
}