zoom-cli 0.2.7

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

pub async fn list(
    client: &mut ZoomClient,
    out: &OutputConfig,
    status: Option<&str>,
    limit: Option<u32>,
    offset: Option<u32>,
    fields: Option<&[String]>,
) -> Result<(), ApiError> {
    let result = client.list_users(status).await?;

    if out.json {
        let mut items: Vec<serde_json::Value> = result
            .users
            .iter()
            .map(|u| serde_json::to_value(u).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.users.is_empty() {
            out.print_message("No users found.");
            return Ok(());
        }
        let rows: Vec<Vec<String>> = result
            .users
            .iter()
            .map(|u| {
                vec![
                    u.id.clone(),
                    u.email.clone(),
                    u.display_name.clone().unwrap_or_default(),
                    u.status.clone().unwrap_or_default(),
                ]
            })
            .collect();
        out.print_data(&output::table(&["ID", "EMAIL", "NAME", "STATUS"], &rows));
        if let Some(total) = result.total_records {
            out.print_message(&format!("{total} user(s) total"));
        }
    }
    Ok(())
}

pub async fn get(
    client: &mut ZoomClient,
    out: &OutputConfig,
    id_or_email: &str,
) -> Result<(), ApiError> {
    let user = client.get_user(id_or_email).await?;

    if out.json {
        out.print_data(&serde_json::to_string_pretty(&user).expect("serialize"));
    } else {
        let name = user
            .display_name
            .clone()
            .or_else(|| match (&user.first_name, &user.last_name) {
                (Some(f), Some(l)) => Some(format!("{f} {l}")),
                (Some(f), None) => Some(f.clone()),
                _ => None,
            })
            .unwrap_or_default();
        out.print_data(&output::kv_block(&[
            ("id", user.id.clone()),
            ("email", user.email.clone()),
            ("name", name),
            ("status", user.status.clone().unwrap_or_default()),
        ]));
    }
    Ok(())
}

pub async fn me(client: &mut ZoomClient, out: &OutputConfig) -> Result<(), ApiError> {
    get(client, out, "me").await
}

pub async fn create(
    client: &mut ZoomClient,
    out: &OutputConfig,
    email: String,
    first_name: Option<String>,
    last_name: Option<String>,
    user_type: u8,
) -> Result<(), ApiError> {
    let req = CreateUserRequest {
        action: "create".into(),
        user_info: CreateUserInfo {
            email,
            user_type,
            first_name,
            last_name,
        },
    };
    let user = client.create_user(req).await?;
    if out.json {
        out.print_data(&serde_json::to_string_pretty(&user).expect("serialize"));
    } else {
        out.print_message(&format!("Created user: {} ({})", user.email, user.id));
    }
    Ok(())
}

pub async fn deactivate(
    client: &mut ZoomClient,
    out: &OutputConfig,
    id_or_email: &str,
) -> Result<(), ApiError> {
    client.set_user_status(id_or_email, "deactivate").await?;
    out.print_message(&format!("User {} deactivated.", id_or_email));
    Ok(())
}

pub async fn activate(
    client: &mut ZoomClient,
    out: &OutputConfig,
    id_or_email: &str,
) -> Result<(), ApiError> {
    client.set_user_status(id_or_email, "activate").await?;
    out.print_message(&format!("User {} activated.", id_or_email));
    Ok(())
}

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

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

    #[tokio::test]
    async fn users_list_json_includes_total_records() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/users"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "users": [
                    {"id": "u1", "email": "a@test.com", "display_name": "Alice", "status": "active"}
                ],
                "total_records": 1
            })))
            .mount(&server)
            .await;

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

    #[tokio::test]
    async fn users_get_returns_not_found_for_unknown_user() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/users/nobody@example.com"))
            .respond_with(ResponseTemplate::new(404).set_body_string("User 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_json(), "nobody@example.com")
            .await
            .unwrap_err();
        assert!(matches!(err, ApiError::NotFound(_)));
    }

    #[tokio::test]
    async fn users_me_delegates_to_get_with_me() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v2/users/me"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "id": "self-user", "email": "me@example.com", "display_name": "Me"
            })))
            .mount(&server)
            .await;

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

    #[tokio::test]
    async fn users_create_returns_created_user() {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/v2/users"))
            .and(wiremock::matchers::body_json(serde_json::json!({
                "action": "create",
                "user_info": {
                    "email": "jane@example.com",
                    "type": 1,
                    "first_name": "Jane",
                    "last_name": "Doe"
                }
            })))
            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
                "id": "new-user-id",
                "email": "jane@example.com",
                "first_name": "Jane",
                "last_name": "Doe",
                "status": "active"
            })))
            .mount(&server)
            .await;

        let mut client =
            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
        create(
            &mut client,
            &test_out_json(),
            "jane@example.com".into(),
            Some("Jane".into()),
            Some("Doe".into()),
            1,
        )
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn users_deactivate_sends_correct_action() {
        let server = MockServer::start().await;
        Mock::given(method("PUT"))
            .and(path("/v2/users/u1/status"))
            .and(wiremock::matchers::body_json(
                serde_json::json!({"action": "deactivate"}),
            ))
            .respond_with(ResponseTemplate::new(204))
            .mount(&server)
            .await;

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

    #[tokio::test]
    async fn users_activate_sends_correct_action() {
        let server = MockServer::start().await;
        Mock::given(method("PUT"))
            .and(path("/v2/users/u1/status"))
            .and(wiremock::matchers::body_json(
                serde_json::json!({"action": "activate"}),
            ))
            .respond_with(ResponseTemplate::new(204))
            .mount(&server)
            .await;

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