1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Envelope<T> {
7 pub ok: bool,
8 #[serde(rename = "type")]
9 pub response_type: String,
10 #[serde(rename = "schemaVersion")]
11 pub schema_version: u32,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub data: Option<T>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub error: Option<ErrorDetails>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub meta: Option<HashMap<String, serde_json::Value>>,
18}
19
20impl<T> Envelope<T> {
21 pub fn success(response_type: impl Into<String>, data: T) -> Self {
23 Self {
24 ok: true,
25 response_type: response_type.into(),
26 schema_version: 1,
27 data: Some(data),
28 error: None,
29 meta: None,
30 }
31 }
32
33 pub fn success_with_meta(
35 response_type: impl Into<String>,
36 data: T,
37 meta: HashMap<String, serde_json::Value>,
38 ) -> Self {
39 Self {
40 ok: true,
41 response_type: response_type.into(),
42 schema_version: 1,
43 data: Some(data),
44 error: None,
45 meta: Some(meta),
46 }
47 }
48}
49
50impl Envelope<()> {
51 pub fn error(response_type: impl Into<String>, error: ErrorDetails) -> Self {
53 Self {
54 ok: false,
55 response_type: response_type.into(),
56 schema_version: 1,
57 data: None,
58 error: Some(error),
59 meta: None,
60 }
61 }
62
63 pub fn error_with_meta(
65 response_type: impl Into<String>,
66 error: ErrorDetails,
67 meta: HashMap<String, serde_json::Value>,
68 ) -> Self {
69 Self {
70 ok: false,
71 response_type: response_type.into(),
72 schema_version: 1,
73 data: None,
74 error: Some(error),
75 meta: Some(meta),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ErrorDetails {
83 pub code: ErrorCode,
84 pub message: String,
85 #[serde(rename = "isRetryable")]
86 pub is_retryable: bool,
87 #[serde(rename = "retryAfterMs", skip_serializing_if = "Option::is_none")]
88 pub retry_after_ms: Option<u64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub details: Option<HashMap<String, serde_json::Value>>,
91}
92
93impl ErrorDetails {
94 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
95 let is_retryable = code.is_retryable();
96 Self {
97 code,
98 message: message.into(),
99 is_retryable,
100 retry_after_ms: None,
101 details: None,
102 }
103 }
104
105 pub fn with_details(
106 code: ErrorCode,
107 message: impl Into<String>,
108 details: HashMap<String, serde_json::Value>,
109 ) -> Self {
110 let is_retryable = code.is_retryable();
111 Self {
112 code,
113 message: message.into(),
114 is_retryable,
115 retry_after_ms: None,
116 details: Some(details),
117 }
118 }
119
120 pub fn with_retry_after(
121 code: ErrorCode,
122 message: impl Into<String>,
123 retry_after_ms: u64,
124 ) -> Self {
125 let is_retryable = code.is_retryable();
126 Self {
127 code,
128 message: message.into(),
129 is_retryable,
130 retry_after_ms: Some(retry_after_ms),
131 details: None,
132 }
133 }
134
135 pub fn interaction_required(message: impl Into<String>, next_steps: Vec<String>) -> Self {
137 let mut details = HashMap::new();
138 details.insert("nextSteps".to_string(), serde_json::json!(next_steps));
139 Self::with_details(ErrorCode::InteractionRequired, message, details)
140 }
141
142 pub fn auth_required(message: impl Into<String>, next_steps: Vec<String>) -> Self {
144 let mut details = HashMap::new();
145 details.insert("nextSteps".to_string(), serde_json::json!(next_steps));
146 Self::with_details(ErrorCode::AuthRequired, message, details)
147 }
148}
149
150#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "snake_case")]
153pub enum ErrorCode {
154 InvalidArgument,
155 MissingArgument,
156 UnknownCommand,
157 AuthenticationFailed,
158 AuthorizationFailed,
159 AuthRequired,
160 #[serde(rename = "rate_limited")]
161 RateLimitExceeded,
162 NetworkError,
163 ServiceUnavailable,
164 InternalError,
165 NotFound,
166 InvalidState,
167 InteractionRequired,
168 IdempotencyConflict,
169 CostLimitExceeded,
170 DailyBudgetExceeded,
171}
172
173impl ErrorCode {
174 pub fn is_retryable(&self) -> bool {
176 matches!(
177 self,
178 ErrorCode::RateLimitExceeded | ErrorCode::NetworkError | ErrorCode::ServiceUnavailable
179 )
180 }
181
182 pub fn exit_code(&self) -> i32 {
184 match self {
185 ErrorCode::InvalidArgument | ErrorCode::MissingArgument | ErrorCode::UnknownCommand => {
186 ExitCode::InvalidArgument.into()
187 }
188 ErrorCode::AuthenticationFailed
189 | ErrorCode::AuthorizationFailed
190 | ErrorCode::AuthRequired => ExitCode::AuthenticationError.into(),
191 ErrorCode::RateLimitExceeded
192 | ErrorCode::NetworkError
193 | ErrorCode::ServiceUnavailable
194 | ErrorCode::NotFound
195 | ErrorCode::InvalidState
196 | ErrorCode::InteractionRequired
197 | ErrorCode::IdempotencyConflict
198 | ErrorCode::CostLimitExceeded
199 | ErrorCode::DailyBudgetExceeded => ExitCode::OperationFailed.into(),
200 ErrorCode::InternalError => ExitCode::OperationFailed.into(),
201 }
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum ExitCode {
208 Success = 0,
209 InvalidArgument = 2,
210 AuthenticationError = 3,
211 OperationFailed = 4,
212}
213
214impl From<ExitCode> for i32 {
215 fn from(code: ExitCode) -> i32 {
216 code as i32
217 }
218}
219
220impl ExitCode {
221 pub fn from_error_code(error_code: ErrorCode) -> Self {
222 match error_code {
223 ErrorCode::InvalidArgument | ErrorCode::MissingArgument | ErrorCode::UnknownCommand => {
224 ExitCode::InvalidArgument
225 }
226 ErrorCode::AuthenticationFailed
227 | ErrorCode::AuthorizationFailed
228 | ErrorCode::AuthRequired => ExitCode::AuthenticationError,
229 _ => ExitCode::OperationFailed,
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_success_envelope() {
240 let envelope = Envelope::success("test", "data");
241 assert!(envelope.ok);
242 assert_eq!(envelope.schema_version, 1);
243 assert_eq!(envelope.response_type, "test");
244 assert_eq!(envelope.data, Some("data"));
245 assert!(envelope.error.is_none());
246 }
247
248 #[test]
249 fn test_error_envelope() {
250 let error = ErrorDetails::new(ErrorCode::InvalidArgument, "test error");
251 let envelope = Envelope::<()>::error("test", error);
252 assert!(!envelope.ok);
253 assert_eq!(envelope.schema_version, 1);
254 assert!(envelope.data.is_none());
255 assert!(envelope.error.is_some());
256 }
257
258 #[test]
259 fn test_error_retryable() {
260 assert!(ErrorCode::RateLimitExceeded.is_retryable());
261 assert!(ErrorCode::NetworkError.is_retryable());
262 assert!(ErrorCode::ServiceUnavailable.is_retryable());
263 assert!(!ErrorCode::InvalidArgument.is_retryable());
264 assert!(!ErrorCode::AuthenticationFailed.is_retryable());
265 }
266
267 #[test]
268 fn test_exit_codes() {
269 assert_eq!(ErrorCode::InvalidArgument.exit_code(), 2);
270 assert_eq!(ErrorCode::AuthenticationFailed.exit_code(), 3);
271 assert_eq!(ErrorCode::AuthRequired.exit_code(), 3);
272 assert_eq!(ErrorCode::NetworkError.exit_code(), 4);
273 assert_eq!(ErrorCode::InteractionRequired.exit_code(), 4);
274 assert_eq!(ErrorCode::CostLimitExceeded.exit_code(), 4);
275 }
276
277 #[test]
278 fn test_interaction_required_error() {
279 let error = ErrorDetails::interaction_required(
280 "Authentication required",
281 vec!["Run 'xcom-rs auth login' first".to_string()],
282 );
283 assert_eq!(error.code, ErrorCode::InteractionRequired);
284 assert!(!error.is_retryable);
285 assert!(error.details.is_some());
286 let details = error.details.unwrap();
287 assert!(details.contains_key("nextSteps"));
288 }
289}