use serde::{Deserialize, Serialize};
use crate::Response;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AckState {
Ok,
Failed,
}
pub const CODE_OK: i32 = 0;
pub const CODE_FAILED: i32 = -1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ack {
pub command: String,
pub state: AckState,
pub code: i32,
pub description: String,
pub data: String,
}
impl Ack {
#[must_use]
pub fn ok(command: impl Into<String>, description: impl Into<String>) -> Self {
Self {
command: command.into(),
state: AckState::Ok,
code: CODE_OK,
description: description.into(),
data: String::new(),
}
}
#[must_use]
pub fn failed(
command: impl Into<String>,
code: Option<i32>,
description: impl Into<String>,
) -> Self {
Self {
command: command.into(),
state: AckState::Failed,
code: code.unwrap_or(CODE_FAILED),
description: description.into(),
data: String::new(),
}
}
#[must_use]
pub fn with_data(mut self, data: impl Into<String>) -> Self {
self.data = data.into();
self
}
#[must_use]
pub const fn is_failure(&self) -> bool {
matches!(self.state, AckState::Failed)
}
}
impl From<&Response> for Ack {
fn from(response: &Response) -> Self {
match response {
Response::VersionString { engine, .. } => {
Self::ok("version_string", format!("engine {engine}"))
.with_data(serialize(response))
}
Response::Started { execution_id } => {
Self::ok("start", format!("execution {execution_id} started"))
.with_data(serialize(response))
}
Response::Done { command } => Self::ok(command.clone(), "done"),
Response::LoggedIn {
user_name,
full_name,
} => Self::ok("login", format!("logged in as {user_name} ({full_name})"))
.with_data(serialize(response)),
Response::Loaded { path, sequences } => {
Self::ok("load_file", format!("loaded {path}, {sequences} sequences"))
.with_data(serialize(response))
}
Response::Value { lookup, value } => {
Self::ok("read_value", format!("read {lookup}")).with_data(value.clone())
}
Response::Failed { command, reason } => {
Self::failed(command.clone(), None, reason.clone())
}
}
}
}
fn serialize(response: &Response) -> String {
serde_json::to_string(response).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{Ack, AckState, CODE_FAILED, CODE_OK};
use crate::Response;
#[test]
fn every_acknowledgement_has_the_same_five_fields() {
let expected = ["command", "state", "code", "description", "data"];
for response in [
Response::Started { execution_id: 3 },
Response::Done {
command: "shutdown".to_owned(),
},
Response::Failed {
command: "terminate".to_owned(),
reason: "no such execution".to_owned(),
},
] {
let text = serde_json::to_string(&Ack::from(&response)).unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
let object = parsed.as_object().cloned().unwrap_or_default();
assert_eq!(object.len(), expected.len(), "{text}");
for field in expected {
assert!(object.contains_key(field), "{field} missing from {text}");
}
}
}
#[test]
fn hello_does_not_repeat_the_process_width() {
let ack = Ack::from(&Response::VersionString {
engine: "2026 Q1 (26.0.0.49152) 64-bit".to_owned(),
is_64bit: true,
});
assert_eq!(ack.description, "engine 2026 Q1 (26.0.0.49152) 64-bit");
assert!(ack.data.contains("is_64bit"), "the flag stays in data");
}
#[test]
fn success_and_failure_carry_distinct_codes() {
let ok = Ack::ok("start", "execution 1 started");
assert_eq!(ok.state, AckState::Ok);
assert_eq!(ok.code, CODE_OK);
assert!(!ok.is_failure());
let failed = Ack::failed("terminate", None, "no such execution");
assert_eq!(failed.code, CODE_FAILED);
assert!(failed.is_failure());
let engine_failure = Ack::failed("load_file", Some(-17308), "unexpected type");
assert_eq!(engine_failure.code, -17308);
}
#[test]
fn a_read_puts_the_tree_in_data_without_wrapping_it_again() {
let ack = Ack::from(&Response::Value {
lookup: "Locals.Result".to_owned(),
value: r#"{"serial":"SN-1"}"#.to_owned(),
});
assert_eq!(ack.data, r#"{"serial":"SN-1"}"#);
assert_eq!(ack.command, "read_value");
}
#[test]
fn an_event_carries_no_command_so_the_two_can_be_told_apart() {
let event = crate::MessageEvent {
code: 2,
numeric: 0.0,
text: String::new(),
payload: None,
synchronous: false,
execution_id: None,
};
let text = serde_json::to_string(&event).unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
assert!(
parsed.get("command").is_none(),
"an event must not carry `command`: {text}"
);
let ack = serde_json::to_string(&Ack::ok("start", "started")).unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&ack).unwrap_or_default();
assert!(
parsed.get("command").is_some(),
"an ack must carry `command`"
);
}
#[test]
fn acknowledgements_round_trip() {
let ack = Ack::ok("version_string", "engine 2026 Q1, 64-bit").with_data(r#"{"a":1}"#);
let text = serde_json::to_string(&ack).unwrap_or_default();
let back: Ack = serde_json::from_str(&text).unwrap_or_else(|_| Ack::ok("", ""));
assert_eq!(back, ack);
}
}