Skip to main content

xcom_rs/
protocol.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Common response envelope for all commands
5#[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    /// Create a successful response
22    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    /// Create a successful response with metadata
34    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    /// Create an error response
52    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    /// Create an error response with metadata
64    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/// Structured error details
81#[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    /// Create an interaction required error with next steps guidance
136    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    /// Create an auth required error with next steps guidance
143    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/// Error code vocabulary
151#[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    /// Determine if this error is retryable
175    pub fn is_retryable(&self) -> bool {
176        matches!(
177            self,
178            ErrorCode::RateLimitExceeded | ErrorCode::NetworkError | ErrorCode::ServiceUnavailable
179        )
180    }
181
182    /// Get the exit code for this error
183    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/// Exit code policy
206#[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}