zoom-cli 0.2.7

Agent-friendly Zoom CLI with JSON output, structured exit codes, and schema introspection
Documentation
use crate::api::{ApiError, ZoomClient};
use crate::output::{self, OutputConfig};

#[allow(clippy::too_many_arguments)]
pub async fn meetings(
    client: &mut ZoomClient,
    out: &OutputConfig,
    user: &str,
    from: &str,
    to: Option<&str>,
    limit: Option<u32>,
    offset: Option<u32>,
    fields: Option<&[String]>,
) -> Result<(), ApiError> {
    let result = client.list_user_meeting_reports(user, from, to).await?;

    if out.json {
        let mut items: Vec<serde_json::Value> = result
            .meetings
            .iter()
            .map(|m| serde_json::to_value(m).expect("serialize"))
            .collect();

        if let Some(field_list) = fields {
            items = items
                .into_iter()
                .map(|mut item| {
                    if let Some(obj) = item.as_object_mut() {
                        obj.retain(|k, _| field_list.iter().any(|f| f == k));
                    }
                    item
                })
                .collect();
        }

        let total = result.total_records.unwrap_or(items.len() as u64);
        let offset_val = offset.unwrap_or(0) as usize;
        let limited: Vec<serde_json::Value> = items
            .into_iter()
            .skip(offset_val)
            .take(limit.unwrap_or(u32::MAX) as usize)
            .collect();
        let actual_limit = limit.unwrap_or(limited.len() as u32);

        let envelope = serde_json::json!({
            "items": limited,
            "total": total,
            "limit": actual_limit,
            "offset": offset.unwrap_or(0)
        });
        out.print_data(&serde_json::to_string_pretty(&envelope).expect("serialize"));
    } else {
        if result.meetings.is_empty() {
            out.print_message("No meetings found.");
            return Ok(());
        }
        let rows: Vec<Vec<String>> = result
            .meetings
            .iter()
            .map(|m| {
                vec![
                    m.id.map(|id| id.to_string()).unwrap_or_default(),
                    m.topic.clone().unwrap_or_default(),
                    m.start_time
                        .as_deref()
                        .map(output::format_timestamp)
                        .unwrap_or_else(|| "-".into()),
                    m.duration
                        .map(|d| format!("{d} min"))
                        .unwrap_or_else(|| "-".into()),
                    m.participants_count
                        .map(|c| c.to_string())
                        .unwrap_or_else(|| "-".into()),
                ]
            })
            .collect();
        out.print_data(&output::table(
            &["ID", "TOPIC", "START TIME", "DURATION", "PARTICIPANTS"],
            &rows,
        ));
        if let Some(total) = result.total_records {
            out.print_message(&format!("{total} meeting(s) total"));
        }
    }
    Ok(())
}

pub async fn participants(
    client: &mut ZoomClient,
    out: &OutputConfig,
    meeting_id: &str,
) -> Result<(), ApiError> {
    let result = client.list_meeting_participant_reports(meeting_id).await?;

    if out.json {
        out.print_data(&serde_json::to_string_pretty(&result).expect("serialize"));
    } else {
        if result.participants.is_empty() {
            out.print_message("No participants found.");
            return Ok(());
        }
        let rows: Vec<Vec<String>> = result
            .participants
            .iter()
            .map(|p| {
                vec![
                    p.name.clone().unwrap_or_default(),
                    p.user_email.clone().unwrap_or_default(),
                    p.join_time
                        .as_deref()
                        .map(output::format_timestamp)
                        .unwrap_or_else(|| "-".into()),
                    p.duration
                        .map(|d| format!("{} min", d / 60))
                        .unwrap_or_else(|| "-".into()),
                ]
            })
            .collect();
        out.print_data(&output::table(
            &["NAME", "EMAIL", "JOIN TIME", "DURATION"],
            &rows,
        ));
        if let Some(total) = result.total_records {
            out.print_message(&format!("{total} participant(s) total"));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::api::ZoomClient;
    use wiremock::matchers::{method, path, query_param};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn test_out() -> OutputConfig {
        OutputConfig::for_test()
    }

    #[tokio::test]
    async fn reports_meetings_empty_is_ok() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/report/users/me/meetings"))
            .and(query_param("from", "2026-04-01"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "meetings": [],
                "total_records": 0,
                "from": "2026-04-01",
                "to": "2026-04-30"
            })))
            .mount(&server)
            .await;
        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        meetings(
            &mut client,
            &test_out(),
            "me",
            "2026-04-01",
            None,
            None,
            None,
            None,
        )
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn reports_meetings_renders_topic_and_duration() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/report/users/me/meetings"))
            .and(query_param("from", "2026-04-01"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "meetings": [
                    {
                        "uuid": "abc==",
                        "id": 123456789,
                        "topic": "Weekly Standup",
                        "start_time": "2026-04-01T09:00:00Z",
                        "duration": 30,
                        "participants_count": 8
                    }
                ],
                "total_records": 1,
                "from": "2026-04-01",
                "to": "2026-04-30"
            })))
            .mount(&server)
            .await;
        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        let (out, buf) = OutputConfig::capturing();
        meetings(
            &mut client,
            &out,
            "me",
            "2026-04-01",
            None,
            None,
            None,
            None,
        )
        .await
        .unwrap();
        let captured = buf.lock().unwrap().join("\n");
        assert!(
            captured.contains("Weekly Standup"),
            "table must include topic"
        );
        assert!(captured.contains("30 min"), "table must include duration");
        assert!(
            captured.contains("8"),
            "table must include participant count"
        );
    }

    #[tokio::test]
    async fn reports_participants_renders_name_email_and_duration() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/report/meetings/123456789/participants"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "participants": [
                    {
                        "name": "Alice",
                        "user_email": "alice@example.com",
                        "join_time": "2026-04-01T09:00:00Z",
                        "duration": 1800
                    }
                ],
                "total_records": 1
            })))
            .mount(&server)
            .await;

        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        let (out, buf) = OutputConfig::capturing();
        participants(&mut client, &out, "123456789").await.unwrap();
        let captured = buf.lock().unwrap().join("\n");
        assert!(
            captured.contains("Alice"),
            "table must include participant name"
        );
        assert!(
            captured.contains("alice@example.com"),
            "table must include email"
        );
        assert!(captured.contains("30 min"), "1800s must render as 30 min");
    }
}