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 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 other_error_display_is_verbatim() {
120 let err = ApiError::Other("unexpected failure".into());
121 assert_eq!(err.to_string(), "unexpected failure");
122 }
123
124 #[test]
125 fn http_error_source_is_underlying_reqwest_error() {
126 let rt = tokio::runtime::Runtime::new().unwrap();
127 let reqwest_err = rt.block_on(async {
128 reqwest::Client::new()
129 .get("http://127.0.0.1:1")
130 .send()
131 .await
132 .unwrap_err()
133 });
134 let api_err = ApiError::Http(reqwest_err);
135 assert!(api_err.source().is_some());
136 }
137
138 #[test]
139 fn non_http_variants_have_no_source() {
140 assert!(ApiError::Auth("x".into()).source().is_none());
141 assert!(ApiError::NotFound("x".into()).source().is_none());
142 assert!(ApiError::InvalidInput("x".into()).source().is_none());
143 assert!(ApiError::RateLimit.source().is_none());
144 assert!(ApiError::Other("x".into()).source().is_none());
145 }
146}