Skip to main content

eoka_protocol/
response.rs

1use 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}