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    /// HTTP 429 rate limit.
18    RateLimit,
19    /// Non-2xx response from the Zoom API.
20    Api { status: u16, message: String },
21    /// Network / TLS error.
22    Http(reqwest::Error),
23    /// Any other error.
24    Other(String),
25}
26
27impl fmt::Display for ApiError {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            ApiError::Auth(msg) => write!(
31                f,
32                "Authentication failed: {msg}\nCheck your credentials or run `zoom config show`."
33            ),
34            ApiError::NotFound(msg) => write!(f, "Not found: {msg}"),
35            ApiError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
36            ApiError::RateLimit => write!(
37                f,
38                "Rate limited by Zoom (429). Please wait and try again.\nNote: meeting creation is capped at 100 requests/day per user."
39            ),
40            ApiError::Api { status, message } => write!(f, "API error {status}: {message}"),
41            ApiError::Http(e) => write!(f, "HTTP error: {e}"),
42            ApiError::Other(msg) => write!(f, "{msg}"),
43        }
44    }
45}
46
47impl std::error::Error for ApiError {
48    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
49        match self {
50            ApiError::Http(e) => Some(e),
51            _ => None,
52        }
53    }
54}
55
56impl From<reqwest::Error> for ApiError {
57    fn from(e: reqwest::Error) -> Self {
58        ApiError::Http(e)
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use std::error::Error;
66
67    #[test]
68    fn auth_error_display_includes_guidance() {
69        let err = ApiError::Auth("invalid_token".into());
70        let msg = err.to_string();
71        assert!(msg.contains("Authentication failed"));
72        assert!(msg.contains("invalid_token"));
73        assert!(msg.contains("credentials"), "should hint at how to fix");
74        assert!(
75            msg.contains("zoom config show"),
76            "should name the command to inspect config"
77        );
78    }
79
80    #[test]
81    fn not_found_error_display_includes_message() {
82        let err = ApiError::NotFound("meeting 123456789 not found".into());
83        let msg = err.to_string();
84        assert!(msg.contains("Not found"));
85        assert!(msg.contains("123456789"));
86    }
87
88    #[test]
89    fn invalid_input_error_display_includes_message() {
90        let err = ApiError::InvalidInput("account_id is required".into());
91        let msg = err.to_string();
92        assert!(msg.contains("Invalid input"));
93        assert!(msg.contains("account_id is required"));
94    }
95
96    #[test]
97    fn rate_limit_error_mentions_daily_cap() {
98        let err = ApiError::RateLimit;
99        let msg = err.to_string();
100        assert!(msg.to_lowercase().contains("rate limit") || msg.contains("Rate limit"));
101        assert!(
102            msg.contains("100"),
103            "should mention the 100/day meeting cap"
104        );
105    }
106
107    #[test]
108    fn api_error_display_includes_status_and_message() {
109        let err = ApiError::Api {
110            status: 400,
111            message: "Invalid parameter: duration".into(),
112        };
113        let msg = err.to_string();
114        assert!(msg.contains("400"));
115        assert!(msg.contains("Invalid parameter: duration"));
116    }
117
118    #[test]
119    fn api_error_scope_message_is_actionable() {
120        // Simulates what parse_zoom_error produces for a code-4711 response.
121        let err = ApiError::Api {
122            status: 400,
123            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(),
124        };
125        let msg = err.to_string();
126        assert!(msg.contains("report:read:user:admin"));
127        assert!(msg.contains("zoom init"), "must tell user how to fix it");
128    }
129
130    #[test]
131    fn other_error_display_is_verbatim() {
132        let err = ApiError::Other("unexpected failure".into());
133        assert_eq!(err.to_string(), "unexpected failure");
134    }
135
136    #[test]
137    fn http_error_source_is_underlying_reqwest_error() {
138        let rt = tokio::runtime::Runtime::new().unwrap();
139        let reqwest_err = rt.block_on(async {
140            reqwest::Client::new()
141                .get("http://127.0.0.1:1")
142                .send()
143                .await
144                .unwrap_err()
145        });
146        let api_err = ApiError::Http(reqwest_err);
147        assert!(api_err.source().is_some());
148    }
149
150    #[test]
151    fn non_http_variants_have_no_source() {
152        assert!(ApiError::Auth("x".into()).source().is_none());
153        assert!(ApiError::NotFound("x".into()).source().is_none());
154        assert!(ApiError::InvalidInput("x".into()).source().is_none());
155        assert!(ApiError::RateLimit.source().is_none());
156        assert!(ApiError::Other("x".into()).source().is_none());
157    }
158}