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) -> 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
246pub async fn invite(
247    client: &mut ZoomClient,
248    out: &OutputConfig,
249    meeting_id: u64,
250) -> Result<(), ApiError> {
251    let inv = client.get_meeting_invitation(meeting_id).await?;
252    if out.json {
253        out.print_data(&serde_json::to_string_pretty(&inv).expect("serialize"));
254    } else {
255        out.print_data(&inv.invitation);
256    }
257    Ok(())
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::api::ZoomClient;
264    use wiremock::matchers::{method, path};
265    use wiremock::{Mock, MockServer, ResponseTemplate};
266
267    fn test_out() -> OutputConfig {
268        OutputConfig::for_test()
269    }
270
271    #[test]
272    fn is_naive_datetime_identifies_naive_strings() {
273        assert!(
274            is_naive_datetime("2026-04-01T09:00:00"),
275            "no timezone = naive"
276        );
277        assert!(
278            is_naive_datetime("2026-04-01T09:00:00.000"),
279            "fractional seconds, no tz = naive"
280        );
281    }
282
283    #[test]
284    fn is_naive_datetime_accepts_tz_aware_strings() {
285        assert!(
286            !is_naive_datetime("2026-04-01T09:00:00Z"),
287            "Z suffix = tz-aware"
288        );
289        assert!(
290            !is_naive_datetime("2026-04-01T09:00:00+05:30"),
291            "positive offset = tz-aware"
292        );
293        assert!(
294            !is_naive_datetime("2026-04-01T09:00:00-05:00"),
295            "negative offset = tz-aware"
296        );
297    }
298
299    #[test]
300    fn is_naive_datetime_returns_false_for_date_only() {
301        assert!(
302            !is_naive_datetime("2026-04-01"),
303            "date-only has no time component"
304        );
305        assert!(!is_naive_datetime(""), "empty string");
306    }
307
308    #[tokio::test]
309    async fn meetings_list_empty_is_ok() {
310        let server = MockServer::start().await;
311        Mock::given(method("GET"))
312            .and(path("/v2/users/me/meetings"))
313            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
314                "meetings": [], "total_records": 0
315            })))
316            .mount(&server)
317            .await;
318
319        let mut client =
320            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
321        list(&mut client, &test_out(), "me", None).await.unwrap();
322    }
323
324    #[tokio::test]
325    async fn meetings_create_returns_meeting() {
326        let server = MockServer::start().await;
327        Mock::given(method("POST"))
328            .and(path("/v2/users/me/meetings"))
329            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
330                "id": 123456789,
331                "topic": "New Meeting",
332                "join_url": "https://zoom.us/j/123456789",
333                "start_url": "https://zoom.us/s/123456789"
334            })))
335            .mount(&server)
336            .await;
337
338        let mut client =
339            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
340        create(
341            &mut client,
342            &test_out(),
343            "New Meeting".into(),
344            Some(30),
345            None,
346            None,
347        )
348        .await
349        .unwrap();
350    }
351
352    #[tokio::test]
353    async fn meetings_delete_succeeds_on_204() {
354        let server = MockServer::start().await;
355        Mock::given(method("DELETE"))
356            .and(path("/v2/meetings/111222333"))
357            .respond_with(ResponseTemplate::new(204))
358            .mount(&server)
359            .await;
360
361        let mut client =
362            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
363        delete(&mut client, &test_out(), 111222333).await.unwrap();
364    }
365
366    #[tokio::test]
367    async fn meetings_get_not_found_propagates() {
368        let server = MockServer::start().await;
369        Mock::given(method("GET"))
370            .and(path("/v2/meetings/999"))
371            .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
372            .mount(&server)
373            .await;
374
375        let mut client =
376            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
377        let err = get(&mut client, &test_out(), 999).await.unwrap_err();
378        assert!(matches!(err, ApiError::NotFound(_)));
379    }
380
381    #[tokio::test]
382    async fn meetings_update_sends_patch_and_returns_ok() {
383        let server = MockServer::start().await;
384        Mock::given(method("PATCH"))
385            .and(path("/v2/meetings/123"))
386            .respond_with(ResponseTemplate::new(204))
387            .mount(&server)
388            .await;
389
390        let mut client =
391            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
392        update(
393            &mut client,
394            &test_out(),
395            123,
396            Some("Updated".into()),
397            None,
398            None,
399        )
400        .await
401        .unwrap();
402    }
403
404    #[tokio::test]
405    async fn meetings_end_sends_put_and_returns_ok() {
406        let server = MockServer::start().await;
407        Mock::given(method("PUT"))
408            .and(path("/v2/meetings/555666777/status"))
409            .respond_with(ResponseTemplate::new(204))
410            .mount(&server)
411            .await;
412        let mut client =
413            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
414        end(&mut client, &test_out(), 555666777).await.unwrap();
415    }
416
417    #[tokio::test]
418    async fn meetings_invite_returns_invitation_text() {
419        let server = MockServer::start().await;
420        Mock::given(method("GET"))
421            .and(path("/v2/meetings/123456789/invitation"))
422            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
423                "invitation": "Join Zoom Meeting\nhttps://zoom.us/j/123456789"
424            })))
425            .mount(&server)
426            .await;
427
428        let mut client =
429            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
430        invite(&mut client, &test_out(), 123456789).await.unwrap();
431    }
432
433    #[tokio::test]
434    async fn meetings_participants_returns_table_data() {
435        let server = MockServer::start().await;
436        Mock::given(method("GET"))
437            .and(path("/v2/past_meetings/abc123/participants"))
438            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
439                "participants": [
440                    {
441                        "name": "Alice",
442                        "user_email": "alice@example.com",
443                        "join_time": "2026-04-01T10:00:00Z",
444                        "leave_time": "2026-04-01T10:45:00Z",
445                        "duration": 2700
446                    }
447                ],
448                "total_records": 1
449            })))
450            .mount(&server)
451            .await;
452        let mut client =
453            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
454        participants(&mut client, &test_out(), "abc123")
455            .await
456            .unwrap();
457    }
458}