Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/src/control/clan.rs

//! `gunnar control clan …` — clans (2026-10-05). Every verb is the caller's
//! own, signed with its key; gunnar decides from the clan who may, and this
//! prints what it answered.

use anyhow::{bail, Result};
use gunnar_proto::clans_client::ClansClient;

use super::{authed, connect, explain, human_bytes, private_key, runtime};
use crate::cli::ControlClanCommand;

/// `10GiB`, `512MiB`, `1.5GiB`, `4096` (bytes). Binary units only, which is
/// what gunnar prints back.
fn size(text: &str) -> Result<u64> {
    let t = text.trim();
    let split = t
        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
        .unwrap_or(t.len());
    let (number, unit) = t.split_at(split);
    let number: f64 = match number.parse() {
        Ok(n) if n >= 0.0 => n,
        _ => bail!("{text:?} is not a size: write e.g. 10GiB, 512MiB or a number of bytes"),
    };
    let scale: u64 = match unit.trim().to_ascii_lowercase().as_str() {
        "" | "b" => 1,
        "kib" | "k" => 1 << 10,
        "mib" | "m" => 1 << 20,
        "gib" | "g" => 1 << 30,
        "tib" | "t" => 1 << 40,
        other => bail!("{other:?} is not a unit gunnar reads: KiB, MiB, GiB, TiB or bytes"),
    };
    Ok((number * scale as f64).round() as u64)
}

fn role(clan: &gunnar_proto::Clan) -> &'static str {
    match gunnar_proto::ClanRole::try_from(clan.your_role).unwrap_or_default() {
        gunnar_proto::ClanRole::Admin => "admin",
        gunnar_proto::ClanRole::Member => "member",
        gunnar_proto::ClanRole::Invited => "invited",
        gunnar_proto::ClanRole::Unspecified => "-",
    }
}

fn used(clan: &gunnar_proto::Clan) -> String {
    clan.used_bytes
        .map_or_else(|| "not measured".to_owned(), human_bytes)
}

fn print(clan: &gunnar_proto::Clan) {
    println!(
        "clan {}  you: {}  room {}  stores {}",
        clan.name,
        role(clan),
        human_bytes(clan.room_bytes),
        used(clan)
    );
    for m in &clan.members {
        println!(
            "  {}{}\tgives {}",
            m.principal_id,
            if m.admin { " (admin)" } else { "" },
            human_bytes(m.contribution_bytes)
        );
    }
    for i in &clan.invites {
        println!("  {}\tinvited by {}", i.principal_id, i.invited_by);
    }
    for o in &clan.offers {
        println!(
            "  {}/{}\toffered to {} by {}",
            o.clan, o.repo, o.to_account, o.offered_by
        );
    }
    if clan.room_in_force_bytes != clan.room_bytes {
        println!(
            "  in force: {} (a lapsed member's share is not counted)",
            human_bytes(clan.room_in_force_bytes)
        );
    }
    if let Some(floor) = clan.your_floor_bytes {
        println!(
            "  you give {}; it may go down to {}",
            human_bytes(clan.your_contribution_bytes),
            human_bytes(floor)
        );
    }
    if let Some(left) = clan.your_room_left_bytes {
        println!("  your own account could still give {}", human_bytes(left));
    }
}

pub(crate) fn run(command: ControlClanCommand) -> Result<()> {
    let (identity, at) = match &command {
        ControlClanCommand::Create { identity, at, .. }
        | ControlClanCommand::Show { identity, at, .. }
        | ControlClanCommand::Invite { identity, at, .. }
        | ControlClanCommand::Accept { identity, at, .. }
        | ControlClanCommand::Decline { identity, at, .. }
        | ControlClanCommand::Give { identity, at, .. }
        | ControlClanCommand::Leave { identity, at, .. }
        | ControlClanCommand::Remove { identity, at, .. }
        | ControlClanCommand::Admin { identity, at, .. }
        | ControlClanCommand::Dissolve { identity, at, .. }
        | ControlClanCommand::Offer { identity, at, .. }
        | ControlClanCommand::Take { identity, at, .. } => (identity.clone(), at.clone()),
    };
    let signer = private_key(identity.as_deref())?;
    let rt = runtime()?;
    let channel = connect(&rt, &at)?;
    let mut client = ClansClient::new(channel);
    let fail = |status: tonic::Status| anyhow::anyhow!("{}", explain(&status));
    let one = |answer: gunnar_proto::ClanResponse| -> Result<()> {
        match answer.clan {
            Some(clan) => {
                print(&clan);
                Ok(())
            }
            None => bail!("gunnar answered with no clan"),
        }
    };
    match command {
        ControlClanCommand::Create { name, give, .. } => {
            let request = gunnar_proto::CreateClanRequest {
                name,
                contribution_bytes: size(&give)?,
            };
            one(rt
                .block_on(client.create(authed(&signer, request)?))
                .map_err(fail)?
                .into_inner())
        }
        ControlClanCommand::Show {
            clan: Some(clan), ..
        } => one(rt
            .block_on(client.get(authed(&signer, gunnar_proto::GetClanRequest { clan })?))
            .map_err(fail)?
            .into_inner()),
        ControlClanCommand::Show { clan: None, .. } => {
            let answer = rt
                .block_on(client.list(authed(&signer, gunnar_proto::ListClansRequest {})?))
                .map_err(fail)?
                .into_inner();
            let clans = answer.clans;
            if clans.is_empty() {
                println!("you are in no clan, and invited to none");
            }
            for o in &answer.offers {
                println!(
                    "offered to you: {}/{} (by {}){} — `gunnar control clan take {} {}`",
                    o.clan,
                    o.repo,
                    o.offered_by,
                    o.stored_bytes
                        .map(|b| format!(", {}", human_bytes(b)))
                        .unwrap_or_default(),
                    o.clan,
                    o.repo
                );
            }
            for clan in &clans {
                println!(
                    "{}\t{}\troom {}\tstores {}\tyou give {}",
                    clan.name,
                    role(clan),
                    human_bytes(clan.room_bytes),
                    used(clan),
                    human_bytes(clan.your_contribution_bytes)
                );
            }
            Ok(())
        }
        ControlClanCommand::Invite { clan, account, .. } => one(rt
            .block_on(client.invite(authed(
                &signer,
                gunnar_proto::InviteToClanRequest {
                    clan,
                    principal_id: account,
                },
            )?))
            .map_err(fail)?
            .into_inner()),
        ControlClanCommand::Accept { clan, give, .. } => one(rt
            .block_on(client.accept(authed(
                &signer,
                gunnar_proto::AcceptClanInviteRequest {
                    clan,
                    contribution_bytes: size(&give)?,
                },
            )?))
            .map_err(fail)?
            .into_inner()),
        ControlClanCommand::Decline { clan, account, .. } => {
            let answer = rt
                .block_on(client.decline(authed(
                    &signer,
                    gunnar_proto::DeclineClanInviteRequest {
                        clan: clan.clone(),
                        principal_id: account.clone().unwrap_or_default(),
                    },
                )?))
                .map_err(fail)?
                .into_inner();
            match account {
                Some(account) => {
                    println!("{account}'s invitation to {clan} is withdrawn");
                    one(answer)
                }
                None => {
                    println!("you declined {clan}'s invitation");
                    Ok(())
                }
            }
        }
        ControlClanCommand::Give {
            clan, size: text, ..
        } => one(rt
            .block_on(client.set_contribution(authed(
                &signer,
                gunnar_proto::SetClanContributionRequest {
                    clan,
                    contribution_bytes: size(&text)?,
                },
            )?))
            .map_err(fail)?
            .into_inner()),
        ControlClanCommand::Leave { clan, .. } => {
            rt.block_on(client.leave(authed(
                &signer,
                gunnar_proto::LeaveClanRequest { clan: clan.clone() },
            )?))
            .map_err(fail)?;
            println!("you left {clan}; what you gave it is yours again");
            Ok(())
        }
        ControlClanCommand::Remove { clan, account, .. } => one(rt
            .block_on(client.remove_member(authed(
                &signer,
                gunnar_proto::RemoveClanMemberRequest {
                    clan,
                    principal_id: account,
                },
            )?))
            .map_err(fail)?
            .into_inner()),
        ControlClanCommand::Admin {
            clan, account, no, ..
        } => one(rt
            .block_on(client.set_admin(authed(
                &signer,
                gunnar_proto::SetClanAdminRequest {
                    clan,
                    principal_id: account,
                    admin: !no,
                },
            )?))
            .map_err(fail)?
            .into_inner()),
        ControlClanCommand::Offer {
            clan,
            repo,
            account,
            ..
        } => one(rt
            .block_on(client.offer_repo(authed(
                &signer,
                gunnar_proto::OfferRepoRequest {
                    clan,
                    repo,
                    account,
                },
            )?))
            .map_err(fail)?
            .into_inner()),
        ControlClanCommand::Take { clan, repo, no, .. } => {
            let answer = rt
                .block_on(client.answer_repo_offer(authed(
                    &signer,
                    gunnar_proto::AnswerRepoOfferRequest {
                        clan: clan.clone(),
                        repo: repo.clone(),
                        accept: !no,
                    },
                )?))
                .map_err(fail)?
                .into_inner();
            match answer.repo.and_then(|r| r.path) {
                Some(path) => println!("{clan}/{repo} moved to {}/{}", path.namespace, path.repo),
                None => println!("the offer of {clan}/{repo} is closed; nothing moved"),
            }
            Ok(())
        }
        ControlClanCommand::Dissolve { clan, .. } => {
            rt.block_on(client.dissolve(authed(
                &signer,
                gunnar_proto::DissolveClanRequest { clan: clan.clone() },
            )?))
            .map_err(fail)?;
            println!("clan {clan} is dissolved; every member's room is its own again");
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::size;

    #[test]
    fn sizes_are_read_in_binary_units() {
        assert_eq!(size("0").unwrap(), 0);
        assert_eq!(size("4096").unwrap(), 4096);
        assert_eq!(size("10GiB").unwrap(), 10 << 30);
        assert_eq!(size("512MiB").unwrap(), 512 << 20);
        assert_eq!(size("1.5GiB").unwrap(), 3 << 29);
        assert!(size("ten").is_err());
        assert!(size("5GB").is_err(), "GB is not GiB, and gunnar prints GiB");
    }
}