eoka_protocol/
response.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
4pub struct ErrorDetail {
5 pub code: String,
6 pub message: String,
7 #[serde(skip_serializing_if = "Option::is_none")]
8 pub retryable: Option<bool>,
9 #[serde(skip_serializing_if = "Option::is_none")]
10 pub hint: Option<String>,
11}
12
13impl ErrorDetail {
14 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
15 Self {
16 code: code.into(),
17 message: message.into(),
18 retryable: None,
19 hint: None,
20 }
21 }
22
23 pub fn retryable(mut self, retryable: bool) -> Self {
24 self.retryable = Some(retryable);
25 self
26 }
27
28 pub fn hint(mut self, hint: impl Into<String>) -> Self {
29 self.hint = Some(hint.into());
30 self
31 }
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
35pub struct ResponseMeta {
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub session: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub cmd: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub socket: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub log: Option<String>,
44}
45
46impl ResponseMeta {
47 pub fn is_empty(&self) -> bool {
48 self.session.is_none() && self.cmd.is_none() && self.socket.is_none() && self.log.is_none()
49 }
50}
51
52#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
53pub struct Response {
54 pub ok: bool,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub data: Option<serde_json::Value>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub error: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub error_detail: Option<ErrorDetail>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub meta: Option<ResponseMeta>,
63}
64
65impl Response {
66 pub fn ok(data: impl Into<serde_json::Value>) -> Self {
67 Self {
68 ok: true,
69 data: Some(data.into()),
70 error: None,
71 error_detail: None,
72 meta: None,
73 }
74 }
75
76 pub fn ok_text(msg: impl Into<String>) -> Self {
77 Self::ok(serde_json::Value::String(msg.into()))
78 }
79
80 pub fn err(msg: impl Into<String>) -> Self {
81 let msg = msg.into();
82 Self {
83 ok: false,
84 data: None,
85 error: Some(msg.clone()),
86 error_detail: Some(classify_error(&msg)),
87 meta: None,
88 }
89 }
90
91 pub fn err_detail(detail: ErrorDetail) -> Self {
92 Self {
93 ok: false,
94 data: None,
95 error: Some(detail.message.clone()),
96 error_detail: Some(detail),
97 meta: None,
98 }
99 }
100
101 pub fn with_meta(mut self, meta: ResponseMeta) -> Self {
102 if !meta.is_empty() {
103 self.meta = Some(meta);
104 }
105 self
106 }
107}
108
109fn classify_error(message: &str) -> ErrorDetail {
110 let lower = message.to_ascii_lowercase();
111 if lower.contains("no browser open") {
112 return ErrorDetail::new("no_browser_open", message)
113 .retryable(true)
114 .hint("Run eoka open before browser observation or interaction commands.");
115 }
116 if lower.contains("unknown") || lower.contains("invalid") {
117 return ErrorDetail::new("invalid_input", message).retryable(false);
118 }
119 if lower.contains("daemon failed to start") || lower.contains("transport") {
120 return ErrorDetail::new("transport_error", message).retryable(true);
121 }
122 ErrorDetail::new("eoka_error", message)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn structured_error_keeps_legacy_error_string() {
131 let response = Response::err("No browser open. Use 'open' first.");
132
133 assert_eq!(
134 response.error.as_deref(),
135 Some("No browser open. Use 'open' first.")
136 );
137 let detail = response.error_detail.unwrap();
138 assert_eq!(detail.code, "no_browser_open");
139 assert_eq!(detail.retryable, Some(true));
140 assert!(detail.hint.unwrap().contains("eoka open"));
141 }
142
143 #[test]
144 fn empty_meta_is_not_serialized() {
145 let value =
146 serde_json::to_value(Response::ok("ok").with_meta(ResponseMeta::default())).unwrap();
147
148 assert!(value.get("meta").is_none());
149 }
150}