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 Auth(String),
13 NotFound(String),
15 InvalidInput(String),
17 ConfirmationRequired(String),
19 Conflict(String),
21 RateLimit,
23 Api { status: u16, message: String },
25 Http(reqwest::Error),
27 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 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}