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
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}