zoom-cli 0.0.1

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

pub async fn list(
    client: &mut ZoomClient,
    out: &OutputConfig,
    user: &str,
    meeting_type: Option<&str>,
) -> Result<(), ApiError> {
    let result = client.list_meetings(user, meeting_type).await?;

    if out.json {
        out.print_data(&serde_json::to_string_pretty(&result).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.to_string(),
                    m.topic.clone(),
                    m.start_time
                        .as_deref()
                        .map(output::format_timestamp)
                        .unwrap_or_else(|| "-".into()),
                    m.duration
                        .map(|d| format!("{d} min"))
                        .unwrap_or_else(|| "-".into()),
                ]
            })
            .collect();
        out.print_data(&output::table(
            &["ID", "TOPIC", "START TIME", "DURATION"],
            &rows,
        ));
        if let Some(total) = result.total_records {
            out.print_message(&format!("{total} meeting(s) total"));
        }
    }
    Ok(())
}

pub async fn get(
    client: &mut ZoomClient,
    out: &OutputConfig,
    meeting_id: u64,
) -> Result<(), ApiError> {
    let meeting = client.get_meeting(meeting_id).await?;

    if out.json {
        out.print_data(&serde_json::to_string_pretty(&meeting).expect("serialize"));
    } else {
        let join_url = meeting.join_url.clone().unwrap_or_else(|| "-".into());
        out.print_data(&output::kv_block(&[
            ("id", meeting.id.to_string()),
            ("topic", meeting.topic.clone()),
            (
                "start_time",
                meeting
                    .start_time
                    .as_deref()
                    .map(output::format_timestamp)
                    .unwrap_or_else(|| "-".into()),
            ),
            (
                "duration",
                meeting
                    .duration
                    .map(|d| format!("{d} min"))
                    .unwrap_or_else(|| "-".into()),
            ),
            (
                "status",
                meeting.status.clone().unwrap_or_else(|| "-".into()),
            ),
            ("join_url", output::hyperlink(&join_url)),
        ]));
    }
    Ok(())
}

pub async fn create(
    client: &mut ZoomClient,
    out: &OutputConfig,
    topic: String,
    duration: Option<u32>,
    start: Option<String>,
    password: Option<String>,
) -> Result<(), ApiError> {
    let meeting_type = if start.is_some() { 2 } else { 1 };
    let req = CreateMeetingRequest {
        topic,
        start_time: start,
        duration,
        password,
        meeting_type,
    };
    let meeting = client.create_meeting("me", req).await?;

    if out.json {
        out.print_data(&serde_json::to_string_pretty(&meeting).expect("serialize"));
    } else {
        let join_url = meeting.join_url.clone().unwrap_or_else(|| "-".into());
        out.print_result(
            &serde_json::json!({}),
            &format!(
                "Meeting created: {} (ID: {})\nJoin URL: {}",
                meeting.topic,
                meeting.id,
                output::hyperlink(&join_url)
            ),
        );
    }
    Ok(())
}

pub async fn update(
    client: &mut ZoomClient,
    out: &OutputConfig,
    meeting_id: u64,
    topic: Option<String>,
    duration: Option<u32>,
    start: Option<String>,
) -> Result<(), ApiError> {
    let req = UpdateMeetingRequest {
        topic,
        duration,
        start_time: start,
    };
    client.update_meeting(meeting_id, req).await?;

    out.print_result(
        &serde_json::json!({"updated": true, "id": meeting_id}),
        &format!("Meeting {meeting_id} updated."),
    );
    Ok(())
}

pub async fn delete(
    client: &mut ZoomClient,
    out: &OutputConfig,
    meeting_id: u64,
) -> Result<(), ApiError> {
    client.delete_meeting(meeting_id).await?;

    out.print_result(
        &serde_json::json!({"deleted": true, "id": meeting_id}),
        &format!("Meeting {meeting_id} deleted."),
    );
    Ok(())
}

pub async fn end(
    client: &mut ZoomClient,
    out: &OutputConfig,
    meeting_id: u64,
) -> Result<(), ApiError> {
    client.end_meeting(meeting_id).await?;
    out.print_result(
        &serde_json::json!({"ended": true, "id": meeting_id}),
        &format!("Meeting {meeting_id} ended."),
    );
    Ok(())
}

pub async fn participants(
    client: &mut ZoomClient,
    out: &OutputConfig,
    meeting_id: &str,
) -> Result<(), ApiError> {
    let result = client.list_past_meeting_participants(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_else(|| "-".into()),
                    p.join_time
                        .as_deref()
                        .map(output::format_timestamp)
                        .unwrap_or_else(|| "-".into()),
                    p.leave_time
                        .as_deref()
                        .map(output::format_timestamp)
                        .unwrap_or_else(|| "-".into()),
                    p.duration
                        .map(|s| format!("{} min", s / 60))
                        .unwrap_or_else(|| "-".into()),
                ]
            })
            .collect();
        out.print_data(&output::table(
            &["NAME", "EMAIL", "JOIN TIME", "LEAVE 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};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn test_out() -> OutputConfig {
        OutputConfig {
            json: true,
            quiet: true,
        }
    }

    #[tokio::test]
    async fn meetings_list_empty_is_ok() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/users/me/meetings"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "meetings": [], "total_records": 0
            })))
            .mount(&server)
            .await;

        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        list(&mut client, &test_out(), "me", None).await.unwrap();
    }

    #[tokio::test]
    async fn meetings_create_returns_meeting() {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/v2/users/me/meetings"))
            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
                "id": 123456789,
                "topic": "New Meeting",
                "join_url": "https://zoom.us/j/123456789",
                "start_url": "https://zoom.us/s/123456789"
            })))
            .mount(&server)
            .await;

        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        create(
            &mut client,
            &test_out(),
            "New Meeting".into(),
            Some(30),
            None,
            None,
        )
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn meetings_delete_succeeds_on_204() {
        let server = MockServer::start().await;
        Mock::given(method("DELETE"))
            .and(path("/v2/meetings/111222333"))
            .respond_with(ResponseTemplate::new(204))
            .mount(&server)
            .await;

        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        delete(&mut client, &test_out(), 111222333).await.unwrap();
    }

    #[tokio::test]
    async fn meetings_get_not_found_propagates() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/meetings/999"))
            .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
            .mount(&server)
            .await;

        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        let err = get(&mut client, &test_out(), 999).await.unwrap_err();
        assert!(matches!(err, ApiError::NotFound(_)));
    }

    #[tokio::test]
    async fn meetings_update_sends_patch_and_returns_ok() {
        let server = MockServer::start().await;
        Mock::given(method("PATCH"))
            .and(path("/v2/meetings/123"))
            .respond_with(ResponseTemplate::new(204))
            .mount(&server)
            .await;

        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        update(
            &mut client,
            &test_out(),
            123,
            Some("Updated".into()),
            None,
            None,
        )
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn meetings_end_sends_put_and_returns_ok() {
        let server = MockServer::start().await;
        Mock::given(method("PUT"))
            .and(path("/v2/meetings/555666777/status"))
            .respond_with(ResponseTemplate::new(204))
            .mount(&server)
            .await;
        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        end(&mut client, &test_out(), 555666777).await.unwrap();
    }

    #[tokio::test]
    async fn meetings_participants_returns_table_data() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/past_meetings/abc123/participants"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "participants": [
                    {
                        "name": "Alice",
                        "user_email": "alice@example.com",
                        "join_time": "2026-04-01T10:00:00Z",
                        "leave_time": "2026-04-01T10:45:00Z",
                        "duration": 2700
                    }
                ],
                "total_records": 1
            })))
            .mount(&server)
            .await;
        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        participants(&mut client, &test_out(), "abc123").await.unwrap();
    }
}