offline-cli 0.3.3

The Offline Protocol developer CLI
//! `offline orgs`: list and create organizations.

use anyhow::Result;
use clap::{Args, Subcommand};
use serde_json::json;

use crate::commands::provision::{client_from_config, developer_session};
use crate::config::CliConfig;
use crate::output;

#[derive(Subcommand)]
pub enum OrgsCommand {
    /// List organizations you belong to
    List(ListArgs),
    /// Create a new organization
    Create(CreateArgs),
    /// Set the organization later commands use by default (id or name)
    Use(UseArgs),
}

#[derive(Args)]
pub struct UseArgs {
    /// Organization id or name (see `offline orgs list`)
    pub org: String,
}

#[derive(Args)]
pub struct ListArgs {
    #[arg(long)]
    pub json: bool,
}

#[derive(Args)]
pub struct CreateArgs {
    pub name: String,
    #[arg(long)]
    pub json: bool,
}

pub async fn run(command: OrgsCommand) -> Result<()> {
    match command {
        OrgsCommand::List(list) => list_orgs(list).await,
        OrgsCommand::Create(create) => create_org(create).await,
        OrgsCommand::Use(args) => use_org(args).await,
    }
}

async fn use_org(args: UseArgs) -> Result<()> {
    let mut config = CliConfig::load()?;
    let token = developer_session(&mut config, "orgs").await?;
    let client = client_from_config(&config).await?;
    let orgs = client.list_organizations(&token).await?;
    let org = find_org(&orgs, &args.org)?;
    let switched = config.org_id.as_deref() != Some(org.id.as_str());
    config.org_id = Some(org.id.clone());
    // An application belongs to one organization; keep the stored one only if it is this org's.
    if switched {
        config.app_id = None;
        config.api_key = None;
    }
    config.save()?;
    output::success(&format!("using organization `{}` ({})", org.name, org.id));
    if switched {
        output::note("Run `offline setup` to pick or create an application in it");
    }
    Ok(())
}

/// The org whose id or (case-insensitive) name is `wanted`.
pub(crate) fn find_org<'a>(
    orgs: &'a [crate::api::types::Organization],
    wanted: &str,
) -> Result<&'a crate::api::types::Organization> {
    let wanted = wanted.trim();
    let mut by_name = orgs.iter().filter(|o| o.name.eq_ignore_ascii_case(wanted));
    if let Some(org) = orgs.iter().find(|o| o.id == wanted) {
        return Ok(org);
    }
    match (by_name.next(), by_name.next()) {
        (Some(org), None) => Ok(org),
        (Some(_), Some(_)) => anyhow::bail!(
            "more than one organization is named `{wanted}` — pass its id (`offline orgs list`)"
        ),
        _ => anyhow::bail!("no organization `{wanted}` — see `offline orgs list`"),
    }
}

async fn list_orgs(args: ListArgs) -> Result<()> {
    let mut config = CliConfig::load()?;
    let token = developer_session(&mut config, "orgs").await?;
    let client = client_from_config(&config).await?;
    let orgs = client.list_organizations(&token).await?;

    if args.json {
        output::json(&json!({ "organizations": orgs }))?;
        return Ok(());
    }

    output::heading("organizations");
    if orgs.is_empty() {
        output::note("no organizations — run `offline orgs create \"My Org\"`");
        return Ok(());
    }
    for org in &orgs {
        let role = org.role.as_deref().unwrap_or("member");
        println!("  {}  {} ({role})", org.id, org.name);
    }
    if orgs.len() == 1 {
        config.org_id = Some(orgs[0].id.clone());
        config.save()?;
    }
    Ok(())
}

async fn create_org(args: CreateArgs) -> Result<()> {
    let mut config = CliConfig::load()?;
    let token = developer_session(&mut config, "orgs").await?;
    let client = client_from_config(&config).await?;
    let org = client.create_organization(&token, &args.name).await?;
    config.org_id = Some(org.id.clone());
    config.save()?;

    if args.json {
        output::json(&json!({ "organization": org }))?;
    } else {
        output::success(&format!("created organization `{}` ({})", org.name, org.id));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::api::types::Organization;

    fn org(id: &str, name: &str) -> Organization {
        serde_json::from_value(serde_json::json!({ "id": id, "name": name })).unwrap()
    }

    #[test]
    fn org_use_matches_an_id_or_a_unique_name() {
        let orgs = vec![
            org("org_1", "Acme"),
            org("org_2", "Beta"),
            org("org_3", "beta"),
        ];
        assert_eq!(find_org(&orgs, "org_1").unwrap().id, "org_1");
        assert_eq!(find_org(&orgs, "acme").unwrap().id, "org_1");
        assert!(find_org(&orgs, "Beta")
            .unwrap_err()
            .to_string()
            .contains("more than one"));
        assert!(find_org(&orgs, "nope")
            .unwrap_err()
            .to_string()
            .contains("no organization"));
    }
}