Skip to main content

zoom_cli/api/
mod.rs

1pub mod client;
2pub mod types;
3
4pub use client::ZoomClient;
5pub use types::*;
6
7use std::fmt;
8
9#[derive(Debug)]
10pub enum ApiError {
11    /// Bad credentials or forbidden (401/403).
12    Auth(String),
13    /// Resource not found (404).
14    NotFound(String),
15    /// Invalid user input or missing config.
16    InvalidInput(String),
17    /// Destructive operation that requires explicit confirmation.
18    ConfirmationRequired(String),
19    /// Resource already exists or state conflict (HTTP 409).
20    Conflict(String),
21    /// HTTP 429 rate limit.
22    RateLimit,
23    /// Non-2xx response from the Zoom API.
24    Api { status: u16, message: String },
25    /// Network / TLS error.
26    Http(reqwest::Error),
27    /// Any other error.
28    Other(String),
29}
30
31impl fmt::Display for ApiError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            ApiError::Auth(msg) => write!(
35                f,
36                "Authentication failed: {msg}\nCheck your credentials or run `zoom config show`."
37            ),
38            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
39            ApiError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
40            ApiError::ConfirmationRequired(msg) => write!(f, "{msg}"),
41            ApiError::Conflict(msg) => write!(f, "Conflict: {msg}"),
42            ApiError::RateLimit => write!(
43                f,
44                "Rate limited by Zoom (429). Please wait and try again.\nNote: meeting creation is capped at 100 requests/day per user."
45            ),
46            ApiError::Api { status, message } => write!(f, "API error {status}: {message}"),
47            ApiError::Http(e) => write!(f, "HTTP error: {e}"),
48            ApiError::Other(msg) => write!(f, "{msg}"),
49        }
50    }
51}
52
53impl std::error::Error for ApiError {
54    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
55        match self {
56            ApiError::Http(e) => Some(e),
57            _ => None,
58        }
59    }
60}
61
62impl ApiError {
63    pub fn kind(&self) -> &'static str {
64        match self {
65            ApiError::Auth(_) => "auth_error",
66            ApiError::NotFound(_) => "not_found",
67            ApiError::InvalidInput(_) => "invalid_input",
68            ApiError::ConfirmationRequired(_) => "confirmation_required",
69            ApiError::Conflict(_) => "conflict",
70            ApiError::RateLimit => "rate_limit",
71            ApiError::Api { .. } => "api_error",
72            ApiError::Http(_) => "http_error",
73            ApiError::Other(_) => "error",
74        }
75    }
76
77    pub fn to_structured_json(&self) -> String {
78        let hint: Option<&str> = match self {
79            ApiError::Auth(_) => Some("Run 'zoom init' to set up credentials."),
80            ApiError::RateLimit => Some("Wait and retry."),
81            ApiError::NotFound(_) => Some("Check the ID and try again."),
82            ApiError::ConfirmationRequired(_) => Some("Pass --yes to confirm."),
83            ApiError::Conflict(_) => {
84                Some("The resource already exists or is in a conflicting state.")
85            }
86            _ => None,
87        };
88        let retryable = matches!(self, ApiError::RateLimit | ApiError::Http(_));
89        let message = self.to_string();
90        let mut error_obj = serde_json::json!({
91            "kind": self.kind(),
92            "message": message,
93            "retryable": retryable
94        });
95        if let Some(h) = hint {
96            error_obj["hint"] = serde_json::Value::String(h.to_string());
97        }
98        serde_json::json!({"error": error_obj}).to_string()
99    }
100}
101
102impl From<reqwest::Error> for ApiError {
103    fn from(e: reqwest::Error) -> Self {
104        ApiError::Http(e)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::error::Error;
112
113    #[test]
114    fn auth_error_display_includes_guidance() {
115        let err = ApiError::Auth("invalid_token".into());
116        let msg = err.to_string();
117        assert!(msg.contains("Authentication failed"));
118        assert!(msg.contains("invalid_token"));
119        assert!(msg.contains("credentials"), "should hint at how to fix");
120        assert!(
121            msg.contains("zoom config show"),
122            "should name the command to inspect config"
123        );
124    }
125
126    #[test]
127    fn not_found_error_display_includes_message() {
128        let err = ApiError::NotFound("meeting 123456789 not found".into());
129        let msg = err.to_string();
130        assert!(msg.contains("Not found"));
131        assert!(msg.contains("123456789"));
132    }
133
134    #[test]
135    fn invalid_input_error_display_includes_message() {
136        let err = ApiError::InvalidInput("account_id is required".into());
137        let msg = err.to_string();
138        assert!(msg.contains("Invalid input"));
139        assert!(msg.contains("account_id is required"));
140    }
141
142    #[test]
143    fn rate_limit_error_mentions_daily_cap() {
144        let err = ApiError::RateLimit;
145        let msg = err.to_string();
146        assert!(msg.to_lowercase().contains("rate limit") || msg.contains("Rate limit"));
147        assert!(
148            msg.contains("100"),
149            "should mention the 100/day meeting cap"
150        );
151    }
152
153    #[test]
154    fn api_error_display_includes_status_and_message() {
155        let err = ApiError::Api {
156            status: 400,
157            message: "Invalid parameter: duration".into(),
158        };
159        let msg = err.to_string();
160        assert!(msg.contains("400"));
161        assert!(msg.contains("Invalid parameter: duration"));
162    }
163
164    #[test]
165    fn api_error_scope_message_is_actionable() {
166        // Simulates what parse_zoom_error produces for a code-4711 response.
167        let err = ApiError::Api {
168            status: 400,
169            message: "Missing required OAuth scope: report:read:user:admin\nAdd this scope to your Zoom Server-to-Server OAuth app, then run `zoom init` to update credentials.".into(),
170        };
171        let msg = err.to_string();
172        assert!(msg.contains("report:read:user:admin"));
173        assert!(msg.contains("zoom init"), "must tell user how to fix it");
174    }
175
176    #[test]
177    fn other_error_display_is_verbatim() {
178        let err = ApiError::Other("unexpected failure".into());
179        assert_eq!(err.to_string(), "unexpected failure");
180    }
181
182    #[test]
183    fn http_error_source_is_underlying_reqwest_error() {
184        let rt = tokio::runtime::Runtime::new().unwrap();
185        let reqwest_err = rt.block_on(async {
186            reqwest::Client::new()
187                .get("http://127.0.0.1:1")
188                .send()
189                .await
190                .unwrap_err()
191        });
192        let api_err = ApiError::Http(reqwest_err);
193        assert!(api_err.source().is_some());
194    }
195
196    #[test]
197    fn non_http_variants_have_no_source() {
198        assert!(ApiError::Auth("x".into()).source().is_none());
199        assert!(ApiError::NotFound("x".into()).source().is_none());
200        assert!(ApiError::InvalidInput("x".into()).source().is_none());
201        assert!(
202            ApiError::ConfirmationRequired("x".into())
203                .source()
204                .is_none()
205        );
206        assert!(ApiError::Conflict("x".into()).source().is_none());
207        assert!(ApiError::RateLimit.source().is_none());
208        assert!(ApiError::Other("x".into()).source().is_none());
209    }
210
211    #[test]
212    fn kind_returns_correct_strings() {
213        assert_eq!(ApiError::Auth("x".into()).kind(), "auth_error");
214        assert_eq!(ApiError::NotFound("x".into()).kind(), "not_found");
215        assert_eq!(ApiError::InvalidInput("x".into()).kind(), "invalid_input");
216        assert_eq!(
217            ApiError::ConfirmationRequired("x".into()).kind(),
218            "confirmation_required"
219        );
220        assert_eq!(ApiError::Conflict("x".into()).kind(), "conflict");
221        assert_eq!(ApiError::RateLimit.kind(), "rate_limit");
222        assert_eq!(
223            ApiError::Api {
224                status: 400,
225                message: "x".into()
226            }
227            .kind(),
228            "api_error"
229        );
230        assert_eq!(ApiError::Other("x".into()).kind(), "error");
231    }
232
233    #[test]
234    fn to_structured_json_is_valid_json_with_error_kind() {
235        let json_str = ApiError::Auth("bad".into()).to_structured_json();
236        let val: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
237        assert_eq!(val["error"]["kind"], "auth_error");
238        assert_eq!(val["error"]["retryable"], false);
239        assert!(val["error"]["hint"].is_string());
240
241        let json_str2 = ApiError::RateLimit.to_structured_json();
242        let val2: serde_json::Value = serde_json::from_str(&json_str2).expect("valid JSON");
243        assert_eq!(val2["error"]["kind"], "rate_limit");
244        assert_eq!(val2["error"]["retryable"], true);
245    }
246}