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");
}
}