1use crate::api::types::{CreateMeetingRequest, UpdateMeetingRequest};
2use crate::api::{ApiError, ZoomClient};
3use crate::output::{self, OutputConfig};
4
5fn is_naive_datetime(s: &str) -> bool {
12 let Some(t_pos) = s.find('T') else {
13 return false;
14 };
15 let time_part = &s[t_pos..];
16 !time_part.ends_with('Z') && !time_part.contains('+') && !time_part.contains('-')
17}
18
19fn warn_naive_start(start: &str) {
20 if is_naive_datetime(start) {
21 eprintln!(
22 "Warning: --start '{start}' has no timezone offset. Zoom will interpret it as UTC. \
23 Append 'Z' for UTC or a UTC offset (e.g. +02:00) to be explicit."
24 );
25 }
26}
27
28pub async fn list(
29 client: &mut ZoomClient,
30 out: &OutputConfig,
31 user: &str,
32 meeting_type: Option<&str>,
33) -> Result<(), ApiError> {
34 let result = client.list_meetings(user, meeting_type).await?;
35
36 if out.json {
37 out.print_data(&serde_json::to_string_pretty(&result).expect("serialize"));
38 } else {
39 if result.meetings.is_empty() {
40 out.print_message("No meetings found.");
41 return Ok(());
42 }
43 let rows: Vec<Vec<String>> = result
44 .meetings
45 .iter()
46 .map(|m| {
47 vec![
48 m.id.to_string(),
49 m.topic.clone(),
50 m.start_time
51 .as_deref()
52 .map(output::format_timestamp)
53 .unwrap_or_else(|| "-".into()),
54 m.duration
55 .map(|d| format!("{d} min"))
56 .unwrap_or_else(|| "-".into()),
57 ]
58 })
59 .collect();
60 out.print_data(&output::table(
61 &["ID", "TOPIC", "START TIME", "DURATION"],
62 &rows,
63 ));
64 if let Some(total) = result.total_records {
65 out.print_message(&format!("{total} meeting(s) total"));
66 }
67 }
68 Ok(())
69}
70
71pub async fn get(
72 client: &mut ZoomClient,
73 out: &OutputConfig,
74 meeting_id: u64,
75) -> Result<(), ApiError> {
76 let meeting = client.get_meeting(meeting_id).await?;
77
78 if out.json {
79 out.print_data(&serde_json::to_string_pretty(&meeting).expect("serialize"));
80 } else {
81 let join_url = meeting.join_url.clone().unwrap_or_else(|| "-".into());
82 out.print_data(&output::kv_block(&[
83 ("id", meeting.id.to_string()),
84 ("topic", meeting.topic.clone()),
85 (
86 "start_time",
87 meeting
88 .start_time
89 .as_deref()
90 .map(output::format_timestamp)
91 .unwrap_or_else(|| "-".into()),
92 ),
93 (
94 "duration",
95 meeting
96 .duration
97 .map(|d| format!("{d} min"))
98 .unwrap_or_else(|| "-".into()),
99 ),
100 (
101 "status",
102 meeting.status.clone().unwrap_or_else(|| "-".into()),
103 ),
104 ("join_url", output::hyperlink(&join_url)),
105 ]));
106 }
107 Ok(())
108}
109
110pub async fn create(
111 client: &mut ZoomClient,
112 out: &OutputConfig,
113 topic: String,
114 duration: Option<u32>,
115 start: Option<String>,
116 password: Option<String>,
117) -> Result<(), ApiError> {
118 if let Some(s) = &start {
119 warn_naive_start(s);
120 }
121 let meeting_type = if start.is_some() { 2 } else { 1 };
122 let req = CreateMeetingRequest {
123 topic,
124 start_time: start,
125 duration,
126 password,
127 meeting_type,
128 };
129 let meeting = client.create_meeting("me", req).await?;
130
131 if out.json {
132 out.print_data(&serde_json::to_string_pretty(&meeting).expect("serialize"));
133 } else {
134 let join_url = meeting.join_url.clone().unwrap_or_else(|| "-".into());
135 out.print_result(
136 &serde_json::json!({}),
137 &format!(
138 "Meeting created: {} (ID: {})\nJoin URL: {}",
139 meeting.topic,
140 meeting.id,
141 output::hyperlink(&join_url)
142 ),
143 );
144 }
145 Ok(())
146}
147
148pub async fn update(
149 client: &mut ZoomClient,
150 out: &OutputConfig,
151 meeting_id: u64,
152 topic: Option<String>,
153 duration: Option<u32>,
154 start: Option<String>,
155) -> Result<(), ApiError> {
156 if let Some(s) = &start {
157 warn_naive_start(s);
158 }
159 let req = UpdateMeetingRequest {
160 topic,
161 duration,
162 start_time: start,
163 };
164 client.update_meeting(meeting_id, req).await?;
165
166 out.print_result(
167 &serde_json::json!({"updated": true, "id": meeting_id}),
168 &format!("Meeting {meeting_id} updated."),
169 );
170 Ok(())
171}
172
173pub async fn delete(
174 client: &mut ZoomClient,
175 out: &OutputConfig,
176 meeting_id: u64,
177) -> Result<(), ApiError> {
178 client.delete_meeting(meeting_id).await?;
179
180 out.print_result(
181 &serde_json::json!({"deleted": true, "id": meeting_id}),
182 &format!("Meeting {meeting_id} deleted."),
183 );
184 Ok(())
185}
186
187pub async fn end(
188 client: &mut ZoomClient,
189 out: &OutputConfig,
190 meeting_id: u64,
191) -> Result<(), ApiError> {
192 client.end_meeting(meeting_id).await?;
193 out.print_result(
194 &serde_json::json!({"ended": true, "id": meeting_id}),
195 &format!("Meeting {meeting_id} ended."),
196 );
197 Ok(())
198}
199
200pub async fn participants(
201 client: &mut ZoomClient,
202 out: &OutputConfig,
203 meeting_id: &str,
204) -> Result<(), ApiError> {
205 let result = client.list_past_meeting_participants(meeting_id).await?;
206
207 if out.json {
208 out.print_data(&serde_json::to_string_pretty(&result).expect("serialize"));
209 } else {
210 if result.participants.is_empty() {
211 out.print_message("No participants found.");
212 return Ok(());
213 }
214 let rows: Vec<Vec<String>> = result
215 .participants
216 .iter()
217 .map(|p| {
218 vec![
219 p.name.clone().unwrap_or_default(),
220 p.user_email.clone().unwrap_or_else(|| "-".into()),
221 p.join_time
222 .as_deref()
223 .map(output::format_timestamp)
224 .unwrap_or_else(|| "-".into()),
225 p.leave_time
226 .as_deref()
227 .map(output::format_timestamp)
228 .unwrap_or_else(|| "-".into()),
229 p.duration
230 .map(|s| format!("{} min", s / 60))
231 .unwrap_or_else(|| "-".into()),
232 ]
233 })
234 .collect();
235 out.print_data(&output::table(
236 &["NAME", "EMAIL", "JOIN TIME", "LEAVE TIME", "DURATION"],
237 &rows,
238 ));
239 if let Some(total) = result.total_records {
240 out.print_message(&format!("{total} participant(s) total"));
241 }
242 }
243 Ok(())
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::api::ZoomClient;
250 use wiremock::matchers::{method, path};
251 use wiremock::{Mock, MockServer, ResponseTemplate};
252
253 fn test_out() -> OutputConfig {
254 OutputConfig {
255 json: true,
256 quiet: true,
257 }
258 }
259
260 #[test]
261 fn is_naive_datetime_identifies_naive_strings() {
262 assert!(
263 is_naive_datetime("2026-04-01T09:00:00"),
264 "no timezone = naive"
265 );
266 assert!(
267 is_naive_datetime("2026-04-01T09:00:00.000"),
268 "fractional seconds, no tz = naive"
269 );
270 }
271
272 #[test]
273 fn is_naive_datetime_accepts_tz_aware_strings() {
274 assert!(
275 !is_naive_datetime("2026-04-01T09:00:00Z"),
276 "Z suffix = tz-aware"
277 );
278 assert!(
279 !is_naive_datetime("2026-04-01T09:00:00+05:30"),
280 "positive offset = tz-aware"
281 );
282 assert!(
283 !is_naive_datetime("2026-04-01T09:00:00-05:00"),
284 "negative offset = tz-aware"
285 );
286 }
287
288 #[test]
289 fn is_naive_datetime_returns_false_for_date_only() {
290 assert!(
291 !is_naive_datetime("2026-04-01"),
292 "date-only has no time component"
293 );
294 assert!(!is_naive_datetime(""), "empty string");
295 }
296
297 #[tokio::test]
298 async fn meetings_list_empty_is_ok() {
299 let server = MockServer::start().await;
300 Mock::given(method("GET"))
301 .and(path("/v2/users/me/meetings"))
302 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
303 "meetings": [], "total_records": 0
304 })))
305 .mount(&server)
306 .await;
307
308 let mut client =
309 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
310 list(&mut client, &test_out(), "me", None).await.unwrap();
311 }
312
313 #[tokio::test]
314 async fn meetings_create_returns_meeting() {
315 let server = MockServer::start().await;
316 Mock::given(method("POST"))
317 .and(path("/v2/users/me/meetings"))
318 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
319 "id": 123456789,
320 "topic": "New Meeting",
321 "join_url": "https://zoom.us/j/123456789",
322 "start_url": "https://zoom.us/s/123456789"
323 })))
324 .mount(&server)
325 .await;
326
327 let mut client =
328 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
329 create(
330 &mut client,
331 &test_out(),
332 "New Meeting".into(),
333 Some(30),
334 None,
335 None,
336 )
337 .await
338 .unwrap();
339 }
340
341 #[tokio::test]
342 async fn meetings_delete_succeeds_on_204() {
343 let server = MockServer::start().await;
344 Mock::given(method("DELETE"))
345 .and(path("/v2/meetings/111222333"))
346 .respond_with(ResponseTemplate::new(204))
347 .mount(&server)
348 .await;
349
350 let mut client =
351 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
352 delete(&mut client, &test_out(), 111222333).await.unwrap();
353 }
354
355 #[tokio::test]
356 async fn meetings_get_not_found_propagates() {
357 let server = MockServer::start().await;
358 Mock::given(method("GET"))
359 .and(path("/v2/meetings/999"))
360 .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
361 .mount(&server)
362 .await;
363
364 let mut client =
365 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
366 let err = get(&mut client, &test_out(), 999).await.unwrap_err();
367 assert!(matches!(err, ApiError::NotFound(_)));
368 }
369
370 #[tokio::test]
371 async fn meetings_update_sends_patch_and_returns_ok() {
372 let server = MockServer::start().await;
373 Mock::given(method("PATCH"))
374 .and(path("/v2/meetings/123"))
375 .respond_with(ResponseTemplate::new(204))
376 .mount(&server)
377 .await;
378
379 let mut client =
380 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
381 update(
382 &mut client,
383 &test_out(),
384 123,
385 Some("Updated".into()),
386 None,
387 None,
388 )
389 .await
390 .unwrap();
391 }
392
393 #[tokio::test]
394 async fn meetings_end_sends_put_and_returns_ok() {
395 let server = MockServer::start().await;
396 Mock::given(method("PUT"))
397 .and(path("/v2/meetings/555666777/status"))
398 .respond_with(ResponseTemplate::new(204))
399 .mount(&server)
400 .await;
401 let mut client =
402 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
403 end(&mut client, &test_out(), 555666777).await.unwrap();
404 }
405
406 #[tokio::test]
407 async fn meetings_participants_returns_table_data() {
408 let server = MockServer::start().await;
409 Mock::given(method("GET"))
410 .and(path("/v2/past_meetings/abc123/participants"))
411 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
412 "participants": [
413 {
414 "name": "Alice",
415 "user_email": "alice@example.com",
416 "join_time": "2026-04-01T10:00:00Z",
417 "leave_time": "2026-04-01T10:45:00Z",
418 "duration": 2700
419 }
420 ],
421 "total_records": 1
422 })))
423 .mount(&server)
424 .await;
425 let mut client =
426 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
427 participants(&mut client, &test_out(), "abc123")
428 .await
429 .unwrap();
430 }
431}