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