use std::borrow::Cow;
use std::collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Running,
Exited,
Acked,
}
impl Phase {
pub const ALL: [Phase; 3] = [Phase::Running, Phase::Exited, Phase::Acked];
pub const fn as_str(self) -> &'static str {
match self {
Phase::Running => "running",
Phase::Exited => "exited",
Phase::Acked => "acked",
}
}
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct Outcome {
pub exit_code: Option<i32>,
pub signal: Option<i32>,
pub stdout: String,
pub stderr: String,
pub truncated: bool,
pub writers_may_be_alive: bool,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamKind {
Stdout,
Stderr,
}
impl StreamKind {
pub const ALL: [StreamKind; 2] = [StreamKind::Stdout, StreamKind::Stderr];
pub const fn as_str(self) -> &'static str {
match self {
StreamKind::Stdout => "stdout",
StreamKind::Stderr => "stderr",
}
}
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct StartRequest {
pub exec_id: String,
pub command: Vec<String>,
#[serde(default)]
pub shell: bool,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub user: Option<u32>,
#[serde(default)]
pub group: Option<u32>,
#[serde(default)]
pub timeout_sec: Option<f64>,
#[serde(default)]
pub stdin: bool,
}
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct StdinRequest {
#[serde(default)]
pub data_b64: Option<String>,
#[serde(default)]
pub signal: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct StdinResponse {
pub exec_id: String,
pub written: usize,
pub eof: bool,
}
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct StreamQuery {
#[serde(default)]
pub offset: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct OutputEvent {
pub offset: u64,
pub stream: StreamKind,
pub output: String,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct GapEvent {
pub from: u64,
pub to: u64,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct ExitEvent {
pub exit_code: Option<i32>,
pub signal: Option<i32>,
pub truncated: bool,
pub writers_may_be_alive: bool,
pub offset: u64,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct StartResponse {
pub exec_id: String,
pub phase: Phase,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct KillResponse {
pub exec_id: String,
pub killed: bool,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct PollResponse {
pub exec_id: String,
pub phase: Phase,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(flatten)]
pub result: Option<Outcome>,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct ErrorBody {
pub error: Cow<'static, str>,
pub detail: String,
}
pub const EVENT_OUTPUT: &str = "output";
pub const EVENT_GAP: &str = "gap";
pub const EVENT_EXIT: &str = "exit";
pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
pub const ERROR_UNKNOWN_EXEC: &str = "unknown_exec";
pub const ERROR_SPAWN_FAILED: &str = "spawn_failed";
pub const ERROR_STILL_RUNNING: &str = "still_running";
pub const ERROR_ALREADY_ACKED: &str = "already_acked";
pub const ERROR_STDIN_NOT_REQUESTED: &str = "stdin_not_requested";
pub const ERROR_STDIN_CLOSED: &str = "stdin_closed";
pub const ERROR_STDIN_WRITE_TIMEOUT: &str = "stdin_write_timeout";
pub const ERROR_STDIN_WRITE_TOO_LARGE: &str = "stdin_write_too_large";
pub const ERROR_STDIN_WRITE_FAILED: &str = "stdin_write_failed";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase_and_stream_kind_round_trip_through_their_serde_names() {
assert_eq!(
serde_json::to_string(&Phase::Running).expect("serializes"),
"\"running\""
);
assert_eq!(
serde_json::from_str::<Phase>("\"acked\"").expect("deserializes"),
Phase::Acked
);
assert_eq!(
serde_json::to_string(&StreamKind::Stderr).expect("serializes"),
"\"stderr\""
);
}
#[test]
fn as_str_agrees_with_serde_for_every_phase_and_stream_kind() {
for phase in Phase::ALL {
assert_eq!(
serde_json::to_string(&phase).expect("serializes"),
format!("\"{}\"", phase.as_str()),
"{phase:?} spells differently through as_str and serde"
);
match phase {
Phase::Running | Phase::Exited | Phase::Acked => {}
}
}
for kind in StreamKind::ALL {
assert_eq!(
serde_json::to_string(&kind).expect("serializes"),
format!("\"{}\"", kind.as_str()),
"{kind:?} spells differently through as_str and serde"
);
match kind {
StreamKind::Stdout | StreamKind::Stderr => {}
}
}
}
#[test]
fn a_response_the_daemon_writes_deserializes_on_the_client_side() {
let written = serde_json::to_string(&StartResponse {
exec_id: "e1".into(),
phase: Phase::Running,
})
.expect("serializes");
let read: StartResponse = serde_json::from_str(&written).expect("deserializes");
assert_eq!(read.exec_id, "e1");
assert_eq!(read.phase, Phase::Running);
}
#[test]
fn a_start_request_omitting_every_defaulted_field_deserializes() {
let request: StartRequest =
serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
assert!(!request.shell);
assert!(request.cwd.is_none());
assert!(request.env.is_empty());
assert!(request.timeout_sec.is_none());
assert!(!request.stdin);
}
#[test]
fn a_poll_response_round_trips_with_and_without_an_outcome() {
let running = serde_json::to_string(&PollResponse {
exec_id: "e1".into(),
phase: Phase::Running,
result: None,
})
.expect("serializes");
assert_eq!(running, r#"{"exec_id":"e1","phase":"running"}"#);
let read: PollResponse = serde_json::from_str(&running).expect("deserializes");
assert!(read.result.is_none());
let exited = serde_json::to_string(&PollResponse {
exec_id: "e1".into(),
phase: Phase::Exited,
result: Some(Outcome {
exit_code: Some(0),
stdout: "hi".into(),
..Outcome::default()
}),
})
.expect("serializes");
let read: PollResponse = serde_json::from_str(&exited).expect("deserializes");
let outcome = read.result.expect("the outcome is flattened into the body");
assert_eq!(outcome.exit_code, Some(0));
assert_eq!(outcome.stdout, "hi");
}
#[test]
fn an_error_body_carries_a_borrowed_slug_and_reads_back_owned() {
let written = serde_json::to_string(&ErrorBody {
error: Cow::Borrowed(ERROR_UNKNOWN_EXEC),
detail: "e1".into(),
})
.expect("serializes");
assert_eq!(written, r#"{"error":"unknown_exec","detail":"e1"}"#);
let read: ErrorBody = serde_json::from_str(&written).expect("deserializes");
assert_eq!(read.error, ERROR_UNKNOWN_EXEC);
}
}