use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandOutputEvent {
Text { content: String },
System { content: String },
Error { content: String },
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
Done,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommandOutputFrame {
pub request_id: String,
pub event: CommandOutputEvent,
}
pub fn parse_command_output(params: &Value) -> Result<CommandOutputFrame, String> {
let obj = params
.as_object()
.ok_or_else(|| "command.output params must be a JSON object".to_string())?;
let request_id = obj
.get("request_id")
.and_then(Value::as_str)
.ok_or_else(|| "command.output missing request_id".to_string())?
.to_string();
if request_id.is_empty() {
return Err("command.output request_id must be non-empty".to_string());
}
let event_value: Value = match obj.get("event") {
Some(v) => v.clone(),
None => {
let mut clone = obj.clone();
clone.remove("request_id");
Value::Object(clone)
}
};
let event = parse_command_output_event(&event_value)?;
Ok(CommandOutputFrame { request_id, event })
}
pub fn parse_command_output_event(event: &Value) -> Result<CommandOutputEvent, String> {
let obj = event
.as_object()
.ok_or_else(|| "command.output event must be a JSON object".to_string())?;
let kind = obj
.get("kind")
.and_then(Value::as_str)
.ok_or_else(|| "command.output event missing 'kind'".to_string())?;
match kind {
"text" => {
let content = obj
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| "command.output text event missing 'content'".to_string())?
.to_string();
Ok(CommandOutputEvent::Text { content })
}
"system" => {
let content = obj
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| "command.output system event missing 'content'".to_string())?
.to_string();
Ok(CommandOutputEvent::System { content })
}
"error" => {
let content = obj
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| "command.output error event missing 'content'".to_string())?
.to_string();
if content.is_empty() {
return Err("command.output error content must be non-empty".to_string());
}
Ok(CommandOutputEvent::Error { content })
}
"table" => {
let headers = obj
.get("headers")
.and_then(Value::as_array)
.ok_or_else(|| "command.output table missing 'headers' array".to_string())?
.iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.ok_or_else(|| "command.output table header must be string".to_string())
})
.collect::<Result<Vec<_>, _>>()?;
let rows = obj
.get("rows")
.and_then(Value::as_array)
.ok_or_else(|| "command.output table missing 'rows' array".to_string())?
.iter()
.map(|row| {
row.as_array()
.ok_or_else(|| "command.output table row must be array".to_string())?
.iter()
.map(|cell| {
cell.as_str()
.map(str::to_string)
.ok_or_else(|| "command.output table cell must be string".to_string())
})
.collect::<Result<Vec<_>, _>>()
})
.collect::<Result<Vec<_>, _>>()?;
Ok(CommandOutputEvent::Table { headers, rows })
}
"done" => Ok(CommandOutputEvent::Done),
other => Err(format!("unknown command.output event kind: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_text_event_nested() {
let v = json!({"request_id": "r1", "event": {"kind": "text", "content": "hello"}});
let frame = parse_command_output(&v).unwrap();
assert_eq!(frame.request_id, "r1");
assert_eq!(
frame.event,
CommandOutputEvent::Text { content: "hello".into() }
);
}
#[test]
fn parses_text_event_flat() {
let v = json!({"request_id": "r2", "kind": "text", "content": "hi"});
let frame = parse_command_output(&v).unwrap();
assert_eq!(frame.request_id, "r2");
assert_eq!(
frame.event,
CommandOutputEvent::Text { content: "hi".into() }
);
}
#[test]
fn parses_system_error_done() {
let frame = parse_command_output(
&json!({"request_id":"x","event":{"kind":"system","content":"sys"}}),
)
.unwrap();
assert!(matches!(frame.event, CommandOutputEvent::System { .. }));
let frame = parse_command_output(
&json!({"request_id":"x","event":{"kind":"error","content":"oops"}}),
)
.unwrap();
assert!(matches!(frame.event, CommandOutputEvent::Error { .. }));
let frame = parse_command_output(&json!({"request_id":"x","event":{"kind":"done"}})).unwrap();
assert_eq!(frame.event, CommandOutputEvent::Done);
}
#[test]
fn parses_table() {
let v = json!({
"request_id": "t",
"event": {
"kind": "table",
"headers": ["id", "size"],
"rows": [["tiny", "75 MB"], ["base", "142 MB"]]
}
});
let frame = parse_command_output(&v).unwrap();
match frame.event {
CommandOutputEvent::Table { headers, rows } => {
assert_eq!(headers, vec!["id".to_string(), "size".to_string()]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], vec!["tiny".to_string(), "75 MB".to_string()]);
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn rejects_missing_request_id() {
let v = json!({"event": {"kind": "done"}});
assert!(parse_command_output(&v).is_err());
}
#[test]
fn rejects_empty_request_id() {
let v = json!({"request_id": "", "event": {"kind": "done"}});
assert!(parse_command_output(&v).is_err());
}
#[test]
fn rejects_unknown_kind() {
let v = json!({"request_id": "x", "event": {"kind": "weird"}});
let err = parse_command_output(&v).unwrap_err();
assert!(err.contains("unknown"));
}
#[test]
fn rejects_text_without_content() {
let v = json!({"request_id":"x","event":{"kind":"text"}});
assert!(parse_command_output(&v).is_err());
}
#[test]
fn rejects_error_with_empty_content() {
let v = json!({"request_id":"x","event":{"kind":"error","content":""}});
assert!(parse_command_output(&v).is_err());
}
#[test]
fn rejects_table_with_non_string_cell() {
let v = json!({
"request_id":"x",
"event":{"kind":"table","headers":["a"],"rows":[[1]]}
});
assert!(parse_command_output(&v).is_err());
}
}