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 RateLimit,
19 Api { status: u16, message: String },
21 Http(reqwest::Error),
23 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 }
75
76 #[test]
77 fn not_found_error_display_includes_message() {
78 let err = ApiError::NotFound("meeting 123456789 not found".into());
79 let msg = err.to_string();
80 assert!(msg.contains("Not found"));
81 assert!(msg.contains("123456789"));
82 }
83
84 #[test]
85 fn invalid_input_error_display_includes_message() {
86 let err = ApiError::InvalidInput("account_id is required".into());
87 let msg = err.to_string();
88 assert!(msg.contains("Invalid input"));
89 assert!(msg.contains("account_id is required"));
90 }
91
92 #[test]
93 fn rate_limit_error_mentions_daily_cap() {
94 let err = ApiError::RateLimit;
95 let msg = err.to_string();
96 assert!(msg.to_lowercase().contains("rate limit") || msg.contains("Rate limit"));
97 assert!(
98 msg.contains("100"),
99 "should mention the 100/day meeting cap"
100 );
101 }
102
103 #[test]
104 fn api_error_display_includes_status_and_message() {
105 let err = ApiError::Api {
106 status: 400,
107 message: "Invalid parameter: duration".into(),
108 };
109 let msg = err.to_string();
110 assert!(msg.contains("400"));
111 assert!(msg.contains("Invalid parameter: duration"));
112 }
113
114 #[test]
115 fn other_error_display_is_verbatim() {
116 let err = ApiError::Other("unexpected failure".into());
117 assert_eq!(err.to_string(), "unexpected failure");
118 }
119
120 #[test]
121 fn http_error_source_is_underlying_reqwest_error() {
122 let rt = tokio::runtime::Runtime::new().unwrap();
123 let reqwest_err = rt.block_on(async {
124 reqwest::Client::new()
125 .get("http://127.0.0.1:1")
126 .send()
127 .await
128 .unwrap_err()
129 });
130 let api_err = ApiError::Http(reqwest_err);
131 assert!(api_err.source().is_some());
132 }
133
134 #[test]
135 fn non_http_variants_have_no_source() {
136 assert!(ApiError::Auth("x".into()).source().is_none());
137 assert!(ApiError::NotFound("x".into()).source().is_none());
138 assert!(ApiError::InvalidInput("x".into()).source().is_none());
139 assert!(ApiError::RateLimit.source().is_none());
140 assert!(ApiError::Other("x".into()).source().is_none());
141 }
142}