synaps_cli/extensions/
commands.rs1use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24pub enum CommandOutputEvent {
25 Text { content: String },
27 System { content: String },
29 Error { content: String },
31 Table {
33 headers: Vec<String>,
34 rows: Vec<Vec<String>>,
35 },
36 Done,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub struct CommandOutputFrame {
43 pub request_id: String,
44 pub event: CommandOutputEvent,
45}
46
47pub fn parse_command_output(params: &Value) -> Result<CommandOutputFrame, String> {
52 let obj = params
53 .as_object()
54 .ok_or_else(|| "command.output params must be a JSON object".to_string())?;
55
56 let request_id = obj
57 .get("request_id")
58 .and_then(Value::as_str)
59 .ok_or_else(|| "command.output missing request_id".to_string())?
60 .to_string();
61 if request_id.is_empty() {
62 return Err("command.output request_id must be non-empty".to_string());
63 }
64
65 let event_value: Value = match obj.get("event") {
66 Some(v) => v.clone(),
67 None => {
68 let mut clone = obj.clone();
70 clone.remove("request_id");
71 Value::Object(clone)
72 }
73 };
74
75 let event = parse_command_output_event(&event_value)?;
76 Ok(CommandOutputFrame { request_id, event })
77}
78
79pub fn parse_command_output_event(event: &Value) -> Result<CommandOutputEvent, String> {
81 let obj = event
82 .as_object()
83 .ok_or_else(|| "command.output event must be a JSON object".to_string())?;
84 let kind = obj
85 .get("kind")
86 .and_then(Value::as_str)
87 .ok_or_else(|| "command.output event missing 'kind'".to_string())?;
88
89 match kind {
90 "text" => {
91 let content = obj
92 .get("content")
93 .and_then(Value::as_str)
94 .ok_or_else(|| "command.output text event missing 'content'".to_string())?
95 .to_string();
96 Ok(CommandOutputEvent::Text { content })
97 }
98 "system" => {
99 let content = obj
100 .get("content")
101 .and_then(Value::as_str)
102 .ok_or_else(|| "command.output system event missing 'content'".to_string())?
103 .to_string();
104 Ok(CommandOutputEvent::System { content })
105 }
106 "error" => {
107 let content = obj
108 .get("content")
109 .and_then(Value::as_str)
110 .ok_or_else(|| "command.output error event missing 'content'".to_string())?
111 .to_string();
112 if content.is_empty() {
113 return Err("command.output error content must be non-empty".to_string());
114 }
115 Ok(CommandOutputEvent::Error { content })
116 }
117 "table" => {
118 let headers = obj
119 .get("headers")
120 .and_then(Value::as_array)
121 .ok_or_else(|| "command.output table missing 'headers' array".to_string())?
122 .iter()
123 .map(|v| {
124 v.as_str()
125 .map(str::to_string)
126 .ok_or_else(|| "command.output table header must be string".to_string())
127 })
128 .collect::<Result<Vec<_>, _>>()?;
129 let rows = obj
130 .get("rows")
131 .and_then(Value::as_array)
132 .ok_or_else(|| "command.output table missing 'rows' array".to_string())?
133 .iter()
134 .map(|row| {
135 row.as_array()
136 .ok_or_else(|| "command.output table row must be array".to_string())?
137 .iter()
138 .map(|cell| {
139 cell.as_str()
140 .map(str::to_string)
141 .ok_or_else(|| "command.output table cell must be string".to_string())
142 })
143 .collect::<Result<Vec<_>, _>>()
144 })
145 .collect::<Result<Vec<_>, _>>()?;
146 Ok(CommandOutputEvent::Table { headers, rows })
147 }
148 "done" => Ok(CommandOutputEvent::Done),
149 other => Err(format!("unknown command.output event kind: {other}")),
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use serde_json::json;
157
158 #[test]
159 fn parses_text_event_nested() {
160 let v = json!({"request_id": "r1", "event": {"kind": "text", "content": "hello"}});
161 let frame = parse_command_output(&v).unwrap();
162 assert_eq!(frame.request_id, "r1");
163 assert_eq!(
164 frame.event,
165 CommandOutputEvent::Text { content: "hello".into() }
166 );
167 }
168
169 #[test]
170 fn parses_text_event_flat() {
171 let v = json!({"request_id": "r2", "kind": "text", "content": "hi"});
172 let frame = parse_command_output(&v).unwrap();
173 assert_eq!(frame.request_id, "r2");
174 assert_eq!(
175 frame.event,
176 CommandOutputEvent::Text { content: "hi".into() }
177 );
178 }
179
180 #[test]
181 fn parses_system_error_done() {
182 let frame = parse_command_output(
183 &json!({"request_id":"x","event":{"kind":"system","content":"sys"}}),
184 )
185 .unwrap();
186 assert!(matches!(frame.event, CommandOutputEvent::System { .. }));
187
188 let frame = parse_command_output(
189 &json!({"request_id":"x","event":{"kind":"error","content":"oops"}}),
190 )
191 .unwrap();
192 assert!(matches!(frame.event, CommandOutputEvent::Error { .. }));
193
194 let frame = parse_command_output(&json!({"request_id":"x","event":{"kind":"done"}})).unwrap();
195 assert_eq!(frame.event, CommandOutputEvent::Done);
196 }
197
198 #[test]
199 fn parses_table() {
200 let v = json!({
201 "request_id": "t",
202 "event": {
203 "kind": "table",
204 "headers": ["id", "size"],
205 "rows": [["tiny", "75 MB"], ["base", "142 MB"]]
206 }
207 });
208 let frame = parse_command_output(&v).unwrap();
209 match frame.event {
210 CommandOutputEvent::Table { headers, rows } => {
211 assert_eq!(headers, vec!["id".to_string(), "size".to_string()]);
212 assert_eq!(rows.len(), 2);
213 assert_eq!(rows[0], vec!["tiny".to_string(), "75 MB".to_string()]);
214 }
215 other => panic!("expected Table, got {other:?}"),
216 }
217 }
218
219 #[test]
220 fn rejects_missing_request_id() {
221 let v = json!({"event": {"kind": "done"}});
222 assert!(parse_command_output(&v).is_err());
223 }
224
225 #[test]
226 fn rejects_empty_request_id() {
227 let v = json!({"request_id": "", "event": {"kind": "done"}});
228 assert!(parse_command_output(&v).is_err());
229 }
230
231 #[test]
232 fn rejects_unknown_kind() {
233 let v = json!({"request_id": "x", "event": {"kind": "weird"}});
234 let err = parse_command_output(&v).unwrap_err();
235 assert!(err.contains("unknown"));
236 }
237
238 #[test]
239 fn rejects_text_without_content() {
240 let v = json!({"request_id":"x","event":{"kind":"text"}});
241 assert!(parse_command_output(&v).is_err());
242 }
243
244 #[test]
245 fn rejects_error_with_empty_content() {
246 let v = json!({"request_id":"x","event":{"kind":"error","content":""}});
247 assert!(parse_command_output(&v).is_err());
248 }
249
250 #[test]
251 fn rejects_table_with_non_string_cell() {
252 let v = json!({
253 "request_id":"x",
254 "event":{"kind":"table","headers":["a"],"rows":[[1]]}
255 });
256 assert!(parse_command_output(&v).is_err());
257 }
258}