Skip to main content

zoom_cli/commands/
meetings.rs

1use crate::api::types::{CreateMeetingRequest, UpdateMeetingRequest};
2use crate::api::{ApiError, ZoomClient};
3use crate::output::{self, OutputConfig};
4
5/// Returns `true` when `s` looks like a datetime without timezone information.
6///
7/// A naive datetime has a `T` separator but no `Z`, `+`, or `-` after it
8/// (negative offsets like `-05:00` always appear after the time portion, not
9/// in the date part). Date-only strings (e.g. `"2026-04-01"`) return `false`
10/// because they have no time component to carry timezone ambiguity.
11fn 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}