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    #[serde(skip_serializing_if = "Option::is_none")]
12    pub context: Option<Box<crate::ErrorContext>>,
13}
14
15impl ErrorDetail {
16    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
17        Self {
18            code: code.into(),
19            message: message.into(),
20            retryable: None,
21            hint: None,
22            context: None,
23        }
24    }
25
26    pub fn retryable(mut self, retryable: bool) -> Self {
27        self.retryable = Some(retryable);
28        self
29    }
30
31    pub fn hint(mut self, hint: impl Into<String>) -> Self {
32        self.hint = Some(hint.into());
33        self
34    }
35
36    pub fn context(mut self, context: crate::ErrorContext) -> Self {
37        self.context = Some(Box::new(context));
38        self
39    }
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
43pub struct ResponseMeta {
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub session: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub cmd: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub socket: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub log: Option<String>,
52}
53
54impl ResponseMeta {
55    pub fn is_empty(&self) -> bool {
56        self.session.is_none() && self.cmd.is_none() && self.socket.is_none() && self.log.is_none()
57    }
58}
59
60#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
61pub struct Response {
62    pub ok: bool,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub data: Option<serde_json::Value>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub error: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub error_detail: Option<ErrorDetail>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub meta: Option<ResponseMeta>,
71}
72
73impl Response {
74    pub fn ok(data: impl Into<serde_json::Value>) -> Self {
75        Self {
76            ok: true,
77            data: Some(data.into()),
78            error: None,
79            error_detail: None,
80            meta: None,
81        }
82    }
83
84    pub fn ok_text(msg: impl Into<String>) -> Self {
85        Self::ok(serde_json::Value::String(msg.into()))
86    }
87
88    pub fn err(msg: impl Into<String>) -> Self {
89        let msg = msg.into();
90        Self {
91            ok: false,
92            data: None,
93            error: Some(msg.clone()),
94            error_detail: Some(classify_error(&msg)),
95            meta: None,
96        }
97    }
98
99    pub fn err_detail(detail: ErrorDetail) -> Self {
100        Self {
101            ok: false,
102            data: None,
103            error: Some(detail.message.clone()),
104            error_detail: Some(detail),
105            meta: None,
106        }
107    }
108
109    pub fn with_meta(mut self, meta: ResponseMeta) -> Self {
110        if !meta.is_empty() {
111            self.meta = Some(meta);
112        }
113        self
114    }
115}
116
117fn classify_error(message: &str) -> ErrorDetail {
118    let lower = message.to_ascii_lowercase();
119    if lower.contains("no browser open") {
120        return ErrorDetail::new("no_browser_open", message)
121            .retryable(true)
122            .hint("Run eoka open before browser observation or interaction commands.");
123    }
124    if lower.contains("unknown") || lower.contains("invalid") {
125        return ErrorDetail::new("invalid_input", message).retryable(false);
126    }
127    if lower.contains("daemon failed to start") || lower.contains("transport") {
128        return ErrorDetail::new("transport_error", message).retryable(true);
129    }
130    ErrorDetail::new("eoka_error", message)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn error_context_is_additive_and_round_trips_without_changing_legacy_data() {
139        let old: Response = serde_json::from_value(serde_json::json!({
140            "ok":false,"error":"missing", "error_detail":{"code":"missing","message":"missing"}
141        }))
142        .unwrap();
143        assert!(old.error_detail.unwrap().context.is_none());
144        let response = Response::err_detail(
145            ErrorDetail::new("download_wait_timeout", "Still running").context(
146                crate::ErrorContext {
147                    download_id: Some("job".into()),
148                    action_status: Some(crate::ActionStatus::MayHaveExecuted),
149                    ..Default::default()
150                },
151            ),
152        );
153        let value = serde_json::to_value(&response).unwrap();
154        assert_eq!(value["error"], "Still running");
155        assert_eq!(
156            value["error_detail"]["context"]["action_status"],
157            "may_have_executed"
158        );
159        let restored: Response = serde_json::from_value(value).unwrap();
160        assert_eq!(
161            restored
162                .error_detail
163                .unwrap()
164                .context
165                .unwrap()
166                .download_id
167                .as_deref(),
168            Some("job")
169        );
170    }
171
172    #[test]
173    fn structured_error_keeps_legacy_error_string() {
174        let response = Response::err("No browser open. Use 'open' first.");
175
176        assert_eq!(
177            response.error.as_deref(),
178            Some("No browser open. Use 'open' first.")
179        );
180        let detail = response.error_detail.unwrap();
181        assert_eq!(detail.code, "no_browser_open");
182        assert_eq!(detail.retryable, Some(true));
183        assert!(detail.hint.unwrap().contains("eoka open"));
184    }
185
186    #[test]
187    fn empty_meta_is_not_serialized() {
188        let value =
189            serde_json::to_value(Response::ok("ok").with_meta(ResponseMeta::default())).unwrap();
190
191        assert!(value.get("meta").is_none());
192    }
193}