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 limit: Option<u32>,
34 offset: Option<u32>,
35 fields: Option<&[String]>,
36) -> Result<(), ApiError> {
37 let result = client.list_meetings(user, meeting_type).await?;
38
39 if out.json {
40 let mut items: Vec<serde_json::Value> = result
41 .meetings
42 .iter()
43 .map(|m| serde_json::to_value(m).expect("serialize"))
44 .collect();
45
46 if let Some(field_list) = fields {
47 items = items
48 .into_iter()
49 .map(|mut item| {
50 if let Some(obj) = item.as_object_mut() {
51 obj.retain(|k, _| field_list.iter().any(|f| f == k));
52 }
53 item
54 })
55 .collect();
56 }
57
58 let total = result.total_records.unwrap_or(items.len() as u64);
59 let offset_val = offset.unwrap_or(0) as usize;
60 let limited: Vec<serde_json::Value> = items
61 .into_iter()
62 .skip(offset_val)
63 .take(limit.unwrap_or(u32::MAX) as usize)
64 .collect();
65 let actual_limit = limit.unwrap_or(limited.len() as u32);
66
67 let envelope = serde_json::json!({
68 "items": limited,
69 "total": total,
70 "limit": actual_limit,
71 "offset": offset.unwrap_or(0)
72 });
73 out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
74 } else {
75 if result.meetings.is_empty() {
76 out.print_message("No meetings found.");
77 return Ok(());
78 }
79 let rows: Vec<Vec<String>> = result
80 .meetings
81 .iter()
82 .map(|m| {
83 vec![
84 m.id.to_string(),
85 m.topic.clone(),
86 m.start_time
87 .as_deref()
88 .map(output::format_timestamp)
89 .unwrap_or_else(|| "-".into()),
90 m.duration
91 .map(|d| format!("{d} min"))
92 .unwrap_or_else(|| "-".into()),
93 ]
94 })
95 .collect();
96 out.print_data(&output::table(
97 &["ID", "TOPIC", "START TIME", "DURATION"],
98 &rows,
99 ));
100 if let Some(total) = result.total_records {
101 out.print_message(&format!("{total} meeting(s) total"));
102 }
103 }
104 Ok(())
105}
106
107pub async fn get(
108 client: &mut ZoomClient,
109 out: &OutputConfig,
110 meeting_id: u64,
111) -> Result<(), ApiError> {
112 let meeting = client.get_meeting(meeting_id).await?;
113
114 if out.json {
115 out.print_data(&serde_json::to_string_pretty(&meeting).expect("serialize"));
116 } else {
117 let join_url = meeting.join_url.clone().unwrap_or_else(|| "-".into());
118 out.print_data(&output::kv_block(&[
119 ("id", meeting.id.to_string()),
120 ("topic", meeting.topic.clone()),
121 (
122 "start_time",
123 meeting
124 .start_time
125 .as_deref()
126 .map(output::format_timestamp)
127 .unwrap_or_else(|| "-".into()),
128 ),
129 (
130 "duration",
131 meeting
132 .duration
133 .map(|d| format!("{d} min"))
134 .unwrap_or_else(|| "-".into()),
135 ),
136 (
137 "status",
138 meeting.status.clone().unwrap_or_else(|| "-".into()),
139 ),
140 ("join_url", output::hyperlink(&join_url)),
141 ]));
142 }
143 Ok(())
144}
145
146pub async fn create(
147 client: &mut ZoomClient,
148 out: &OutputConfig,
149 topic: String,
150 duration: Option<u32>,
151 start: Option<String>,
152 password: Option<String>,
153) -> Result<(), ApiError> {
154 if let Some(s) = &start {
155 warn_naive_start(s);
156 }
157 let meeting_type = if start.is_some() { 2 } else { 1 };
158 let req = CreateMeetingRequest {
159 topic,
160 start_time: start,
161 duration,
162 password,
163 meeting_type,
164 };
165 let meeting = client.create_meeting("me", req).await?;
166
167 if out.json {
168 out.print_data(&serde_json::to_string_pretty(&meeting).expect("serialize"));
169 } else {
170 let join_url = meeting.join_url.clone().unwrap_or_else(|| "-".into());
171 out.print_result(
172 &serde_json::json!({}),
173 &format!(
174 "Meeting created: {} (ID: {})\nJoin URL: {}",
175 meeting.topic,
176 meeting.id,
177 output::hyperlink(&join_url)
178 ),
179 );
180 }
181 Ok(())
182}
183
184pub async fn update(
185 client: &mut ZoomClient,
186 out: &OutputConfig,
187 meeting_id: u64,
188 topic: Option<String>,
189 duration: Option<u32>,
190 start: Option<String>,
191) -> Result<(), ApiError> {
192 if let Some(s) = &start {
193 warn_naive_start(s);
194 }
195 let req = UpdateMeetingRequest {
196 topic,
197 duration,
198 start_time: start,
199 };
200 client.update_meeting(meeting_id, req).await?;
201
202 out.print_result(
203 &serde_json::json!({"updated": true, "id": meeting_id}),
204 &format!("Meeting {meeting_id} updated."),
205 );
206 Ok(())
207}
208
209pub async fn delete(
210 client: &mut ZoomClient,
211 out: &OutputConfig,
212 meeting_id: u64,
213 yes: bool,
214) -> Result<(), ApiError> {
215 if !yes {
216 return Err(ApiError::ConfirmationRequired(
217 "Deleting a meeting is irreversible. Pass --yes to confirm.".into(),
218 ));
219 }
220 client.delete_meeting(meeting_id).await?;
221
222 out.print_result(
223 &serde_json::json!({"deleted": true, "id": meeting_id}),
224 &format!("Meeting {meeting_id} deleted."),
225 );
226 Ok(())
227}
228
229pub async fn end(
230 client: &mut ZoomClient,
231 out: &OutputConfig,
232 meeting_id: u64,
233) -> Result<(), ApiError> {
234 client.end_meeting(meeting_id).await?;
235 out.print_result(
236 &serde_json::json!({"ended": true, "id": meeting_id}),
237 &format!("Meeting {meeting_id} ended."),
238 );
239 Ok(())
240}
241
242pub async fn participants(
243 client: &mut ZoomClient,
244 out: &OutputConfig,
245 meeting_id: &str,
246) -> Result<(), ApiError> {
247 let result = client.list_past_meeting_participants(meeting_id).await?;
248
249 if out.json {
250 out.print_data(&serde_json::to_string_pretty(&result).expect("serialize"));
251 } else {
252 if result.participants.is_empty() {
253 out.print_message("No participants found.");
254 return Ok(());
255 }
256 let rows: Vec<Vec<String>> = result
257 .participants
258 .iter()
259 .map(|p| {
260 vec![
261 p.name.clone().unwrap_or_default(),
262 p.user_email.clone().unwrap_or_else(|| "-".into()),
263 p.join_time
264 .as_deref()
265 .map(output::format_timestamp)
266 .unwrap_or_else(|| "-".into()),
267 p.leave_time
268 .as_deref()
269 .map(output::format_timestamp)
270 .unwrap_or_else(|| "-".into()),
271 p.duration
272 .map(|s| format!("{} min", s / 60))
273 .unwrap_or_else(|| "-".into()),
274 ]
275 })
276 .collect();
277 out.print_data(&output::table(
278 &["NAME", "EMAIL", "JOIN TIME", "LEAVE TIME", "DURATION"],
279 &rows,
280 ));
281 if let Some(total) = result.total_records {
282 out.print_message(&format!("{total} participant(s) total"));
283 }
284 }
285 Ok(())
286}
287
288pub async fn invite(
289 client: &mut ZoomClient,
290 out: &OutputConfig,
291 meeting_id: u64,
292) -> Result<(), ApiError> {
293 let inv = client.get_meeting_invitation(meeting_id).await?;
294 if out.json {
295 out.print_data(&serde_json::to_string_pretty(&inv).expect("serialize"));
296 } else {
297 out.print_data(&inv.invitation);
298 }
299 Ok(())
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::api::ZoomClient;
306 use wiremock::matchers::{method, path};
307 use wiremock::{Mock, MockServer, ResponseTemplate};
308
309 fn test_out() -> OutputConfig {
310 OutputConfig::for_test()
311 }
312
313 #[test]
314 fn is_naive_datetime_identifies_naive_strings() {
315 assert!(
316 is_naive_datetime("2026-04-01T09:00:00"),
317 "no timezone = naive"
318 );
319 assert!(
320 is_naive_datetime("2026-04-01T09:00:00.000"),
321 "fractional seconds, no tz = naive"
322 );
323 }
324
325 #[test]
326 fn is_naive_datetime_accepts_tz_aware_strings() {
327 assert!(
328 !is_naive_datetime("2026-04-01T09:00:00Z"),
329 "Z suffix = tz-aware"
330 );
331 assert!(
332 !is_naive_datetime("2026-04-01T09:00:00+05:30"),
333 "positive offset = tz-aware"
334 );
335 assert!(
336 !is_naive_datetime("2026-04-01T09:00:00-05:00"),
337 "negative offset = tz-aware"
338 );
339 }
340
341 #[test]
342 fn is_naive_datetime_returns_false_for_date_only() {
343 assert!(
344 !is_naive_datetime("2026-04-01"),
345 "date-only has no time component"
346 );
347 assert!(!is_naive_datetime(""), "empty string");
348 }
349
350 #[tokio::test]
351 async fn meetings_list_empty_is_ok() {
352 let server = MockServer::start().await;
353 Mock::given(method("GET"))
354 .and(path("/v2/users/me/meetings"))
355 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
356 "meetings": [], "total_records": 0
357 })))
358 .mount(&server)
359 .await;
360
361 let mut client =
362 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
363 list(&mut client, &test_out(), "me", None, None, None, None)
364 .await
365 .unwrap();
366 }
367
368 #[tokio::test]
369 async fn meetings_create_returns_meeting() {
370 let server = MockServer::start().await;
371 Mock::given(method("POST"))
372 .and(path("/v2/users/me/meetings"))
373 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
374 "id": 123456789,
375 "topic": "New Meeting",
376 "join_url": "https://zoom.us/j/123456789",
377 "start_url": "https://zoom.us/s/123456789"
378 })))
379 .mount(&server)
380 .await;
381
382 let mut client =
383 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
384 create(
385 &mut client,
386 &test_out(),
387 "New Meeting".into(),
388 Some(30),
389 None,
390 None,
391 )
392 .await
393 .unwrap();
394 }
395
396 #[tokio::test]
397 async fn meetings_delete_without_yes_returns_confirmation_required() {
398 let server = MockServer::start().await;
399 let mut client =
400 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
401 let err = delete(&mut client, &test_out(), 111222333, false)
402 .await
403 .unwrap_err();
404 assert!(
405 matches!(err, ApiError::ConfirmationRequired(_)),
406 "deleting without --yes must return ConfirmationRequired"
407 );
408 }
409
410 #[tokio::test]
411 async fn meetings_delete_succeeds_on_204() {
412 let server = MockServer::start().await;
413 Mock::given(method("DELETE"))
414 .and(path("/v2/meetings/111222333"))
415 .respond_with(ResponseTemplate::new(204))
416 .mount(&server)
417 .await;
418
419 let mut client =
420 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
421 delete(&mut client, &test_out(), 111222333, true)
422 .await
423 .unwrap();
424 }
425
426 #[tokio::test]
427 async fn meetings_get_not_found_propagates() {
428 let server = MockServer::start().await;
429 Mock::given(method("GET"))
430 .and(path("/v2/meetings/999"))
431 .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
432 .mount(&server)
433 .await;
434
435 let mut client =
436 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
437 let err = get(&mut client, &test_out(), 999).await.unwrap_err();
438 assert!(matches!(err, ApiError::NotFound(_)));
439 }
440
441 #[tokio::test]
442 async fn meetings_update_sends_patch_and_returns_ok() {
443 let server = MockServer::start().await;
444 Mock::given(method("PATCH"))
445 .and(path("/v2/meetings/123"))
446 .respond_with(ResponseTemplate::new(204))
447 .mount(&server)
448 .await;
449
450 let mut client =
451 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
452 update(
453 &mut client,
454 &test_out(),
455 123,
456 Some("Updated".into()),
457 None,
458 None,
459 )
460 .await
461 .unwrap();
462 }
463
464 #[tokio::test]
465 async fn meetings_end_sends_put_and_returns_ok() {
466 let server = MockServer::start().await;
467 Mock::given(method("PUT"))
468 .and(path("/v2/meetings/555666777/status"))
469 .respond_with(ResponseTemplate::new(204))
470 .mount(&server)
471 .await;
472 let mut client =
473 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
474 end(&mut client, &test_out(), 555666777).await.unwrap();
475 }
476
477 #[tokio::test]
478 async fn meetings_invite_returns_invitation_text() {
479 let server = MockServer::start().await;
480 Mock::given(method("GET"))
481 .and(path("/v2/meetings/123456789/invitation"))
482 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
483 "invitation": "Join Zoom Meeting\nhttps://zoom.us/j/123456789"
484 })))
485 .mount(&server)
486 .await;
487
488 let mut client =
489 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
490 invite(&mut client, &test_out(), 123456789).await.unwrap();
491 }
492
493 #[tokio::test]
494 async fn meetings_participants_returns_table_data() {
495 let server = MockServer::start().await;
496 Mock::given(method("GET"))
497 .and(path("/v2/past_meetings/abc123/participants"))
498 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
499 "participants": [
500 {
501 "name": "Alice",
502 "user_email": "alice@example.com",
503 "join_time": "2026-04-01T10:00:00Z",
504 "leave_time": "2026-04-01T10:45:00Z",
505 "duration": 2700
506 }
507 ],
508 "total_records": 1
509 })))
510 .mount(&server)
511 .await;
512 let mut client =
513 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
514 participants(&mut client, &test_out(), "abc123")
515 .await
516 .unwrap();
517 }
518}