crates/gunnar-ui-web/src/clans.rs
//! **What You → Clans draws**, on the host side of the wasm boundary (owner,
//! 2026-10-03/05: ordinary accounts group in a clan, its admin administers
//! what is made in it, every member gives it part of its own room).
//!
//! gunnar decides everything about a clan; `GET /api/account/clans` carries
//! its answer — the person's standing in each clan, the clan's room (the sum
//! of what its members give), what it stores, how low the person's own
//! contribution may go, and what the person's account could still give. This
//! module decides only how to DRAW that: which controls a standing gets (the
//! server refuses anybody else whatever is drawn), and the lines, which never
//! say a number gunnar did not send. `pages::clans` re-uses it and draws.
use serde::Deserialize;
use crate::fmt;
/// One member, as `api::clans::Member` sends it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Member {
pub id: String,
pub admin: bool,
pub contribution_bytes: u64,
/// Its share is out of force — its paid period has ended (round 2).
#[serde(default)]
pub lapsed: bool,
}
/// A repository offered out of a clan, as `api::clans::Offer` sends it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Offer {
pub clan: String,
pub repo: String,
pub to: String,
pub by: String,
pub stored_bytes: Option<u64>,
}
/// One invitation, as `api::clans::Invite` sends it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Invite {
pub id: String,
pub invited_by: String,
}
/// One clan, as `api::clans::ClanView` sends it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Clan {
pub name: String,
#[serde(default)]
pub created_by: String,
/// `admin`, `member`, `invited`, or empty.
pub role: String,
pub room_bytes: u64,
pub used_bytes: Option<u64>,
pub your_contribution_bytes: u64,
pub your_floor_bytes: Option<u64>,
pub your_room_left_bytes: Option<u64>,
#[serde(default)]
pub members: Vec<Member>,
#[serde(default)]
pub invites: Vec<Invite>,
/// The room the door holds the clan to now (round 2).
#[serde(default)]
pub room_in_force_bytes: u64,
/// Repositories offered out of the clan (its admin's view).
#[serde(default)]
pub offers: Vec<Offer>,
}
/// `GET /api/account/clans`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Clans {
pub clans: Vec<Clan>,
/// Repositories offered to the reader's own account.
#[serde(default)]
pub offers: Vec<Offer>,
}
/// **The controls a standing is drawn**. Not a permission: gunnar refuses
/// whoever may not, whatever is drawn. It only keeps a page from offering a
/// member the admin's buttons, to be refused when pressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Controls {
/// Invite an account, remove a member, make one admin, dissolve.
pub administer: bool,
/// Set one's own contribution, leave.
pub give_and_leave: bool,
/// Accept or decline the invitation.
pub answer: bool,
}
/// What the standing gunnar reported gets drawn.
pub fn controls(role: &str) -> Controls {
match role {
"admin" => Controls {
administer: true,
give_and_leave: true,
answer: false,
},
"member" => Controls {
give_and_leave: true,
..Controls::default()
},
"invited" => Controls {
answer: true,
..Controls::default()
},
_ => Controls::default(),
}
}
/// **Is "Make a clan" drawn for this person?** gunnar's word about the ACCOUNT the person
/// acts in (`/auth/status` `account_role`): an account's member never makes a clan — gunnar
/// refuses an employee every clan (`clan_table`), so the form would be a door to a refusal
/// (wiring tracer B 2026-10-08: `/account/clans` opened by URL drew it for a member).
pub fn may_make_clan(account_role: Option<&str>) -> bool {
!crate::nav::is_member_role(account_role)
}
/// The standing, in words.
pub fn role_line(role: &str) -> &'static str {
match role {
"admin" => "You administer this group",
"member" => "You are a member",
"invited" => "You are invited",
_ => "",
}
}
/// The words for a clan nobody has given room to yet (owner 2026-10-14:
/// "Room 0 B · stores 0 B" reads as nothing).
pub const NO_ROOM_YET: &str = "This group has no room yet.";
/// **The clan's room and what it stores**: "Room 3.0 GiB · stores 117.2 KiB".
/// What the clan stores is said as gunnar's number or as "not measured" —
/// never a 0 this page made up. A clan with no room and nothing stored says
/// [`NO_ROOM_YET`].
pub fn room_line(clan: &Clan) -> String {
if clan.room_bytes == 0 && clan.used_bytes == Some(0) {
return NO_ROOM_YET.to_owned();
}
let stored = clan
.used_bytes
.map_or_else(|| "not measured".to_owned(), fmt::bytes);
// A lapsed share is on the row and not at the door (round 2): when the
// room in force differs, it is said beside the room.
let in_force = if clan.room_in_force_bytes != clan.room_bytes && clan.members.iter().any(|m| m.lapsed) {
format!(" · {} in force", fmt::bytes(clan.room_in_force_bytes))
} else {
String::new()
};
format!("Room {}{in_force} · stores {stored}", fmt::bytes(clan.room_bytes))
}
/// **How low the person's own contribution may go**, when gunnar said.
pub fn floor_line(clan: &Clan) -> Option<String> {
let floor = clan.your_floor_bytes?;
if clan.your_contribution_bytes == 0 {
// Nothing given: said by [`nothing_to_give`] or not at all, never
// "You give 0 B; it may go down to 0 B" (owner 2026-10-14).
return None;
}
Some(format!("You give {}. You can lower it to {}.", fmt::bytes(clan.your_contribution_bytes), fmt::bytes(floor)))
}
/// **Has the person nothing to give?** gunnar's own numbers: they give the
/// clan nothing, and their account could still give 0. Then a METERED console
/// says so, with where room is bought (`purchase::NOTHING_TO_GIVE`); an open
/// console says nothing — it sells nothing. Where nothing caps the
/// account (`None`), nothing is said.
pub fn nothing_to_give(clan: &Clan) -> bool {
clan.your_contribution_bytes == 0 && clan.your_room_left_bytes == Some(0)
}
/// The fields' own words (owner 2026-10-14: "its namespace", "its name" and
/// "the repository" said nothing).
pub const HINT_CLAN_NAME: &str = "Group name";
pub const HINT_INVITE: &str = "Account to invite";
pub const HINT_MOVE_REPO: &str = "Repository to move";
pub const HINT_MOVE_TO: &str = "Account to move it into";
/// Why a member's row draws no working [Remove] or [Make a member].
pub const KEEPS_AN_ADMIN: &str = "A group keeps at least one admin: make another member admin first.";
pub const REMOVE_IS_LEAVING: &str = "You cannot remove yourself: use Leave.";
/// **What an admin may press on one member's row**, by gunnar's own rules
/// (`clan_table`: the last admin is never unmade, and an admin does not remove
/// itself) — `Err` is the reason the button is drawn disabled. gunnar refuses
/// either press whatever is drawn; this keeps the page from offering it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemberButtons {
pub remove: Result<(), &'static str>,
pub toggle_admin: Result<(), &'static str>,
}
pub fn member_buttons(clan: &Clan, member: &Member, you: &str) -> MemberButtons {
let last_admin = member.admin && clan.members.iter().filter(|m| m.admin).count() == 1;
MemberButtons {
remove: if member.id == you {
Err(REMOVE_IS_LEAVING)
} else if last_admin {
Err(KEEPS_AN_ADMIN)
} else {
Ok(())
},
toggle_admin: if last_admin { Err(KEEPS_AN_ADMIN) } else { Ok(()) },
}
}
/// May [Dissolve] be sent? Only with the clan's name typed exactly.
pub fn may_dissolve(typed: &str, clan: &str) -> bool {
!clan.is_empty() && typed.trim() == clan
}
/// **What the person's own account could still give**, when something caps
/// it on this box. Nothing caps it: no line — never "unlimited" invented here.
pub fn left_line(clan: &Clan) -> Option<String> {
clan.your_room_left_bytes
.map(|left| format!("Your own account could still give {}", fmt::bytes(left)))
}
/// One member's row: "kim · admin · gives 2.0 GiB".
pub fn member_line(member: &Member) -> String {
format!(
"{}{} · gives {}{}",
member.id,
if member.admin { " · admin" } else { "" },
fmt::bytes(member.contribution_bytes),
if member.lapsed { " · share lapsed (its period ended)" } else { "" }
)
}
/// One offer: "fellows/app → kim · 1.5 MiB", what it stores when gunnar
/// measured it.
pub fn offer_line(offer: &Offer) -> String {
format!(
"{}/{} → {}{}",
offer.clan,
offer.repo,
offer.to,
offer.stored_bytes.map(|b| format!(" · {}", fmt::bytes(b))).unwrap_or_default()
)
}
#[cfg(test)]
mod tests {
use super::*;
/// What `api::clans` sends, as JSON — the two crates' shapes kept in step.
const SENT: &str = r#"{"clans":[
{"name":"fellows","created_by":"bob","role":"member","room_bytes":3221225472,
"used_bytes":120000,"your_contribution_bytes":2147483648,"your_floor_bytes":0,
"your_room_left_bytes":null,
"members":[{"id":"bob","admin":true,"contribution_bytes":1073741824},
{"id":"kim","admin":false,"contribution_bytes":2147483648}],
"invites":[]},
{"name":"others","created_by":"alice","role":"invited","room_bytes":0,
"used_bytes":null,"your_contribution_bytes":0,"your_floor_bytes":null,
"your_room_left_bytes":4294967296,"members":[],
"invites":[{"id":"kim","invited_by":"alice"}]}
]}"#;
fn sent() -> Clans {
serde_json::from_str(SENT).expect("the server's shape parses")
}
/// ★ **An account's member is drawn no "Make a clan"** — gunnar's word, not the page's:
/// `employee` is the one standing gunnar refuses every clan to.
#[test]
fn an_accounts_member_is_drawn_no_make_a_clan_form() {
assert!(!may_make_clan(Some("employee")), "a member is drawn a form gunnar refuses");
assert!(!may_make_clan(Some(crate::nav::APPOINTED_ADMIN)), "an appointed admin is still the account's member");
for role in [Some("admin"), Some("individual"), None] {
assert!(may_make_clan(role), "{role:?} makes clans");
}
}
/// ★ **A member is never drawn the admin's controls; an admin is; an
/// invited account is drawn only its answer.**
#[test]
fn each_standing_is_drawn_its_own_controls_and_no_more() {
let admin = controls("admin");
assert!(admin.administer && admin.give_and_leave && !admin.answer);
let member = controls("member");
assert!(!member.administer, "a member is drawn the admin's controls");
assert!(member.give_and_leave && !member.answer);
let invited = controls("invited");
assert!(invited.answer && !invited.administer && !invited.give_and_leave);
assert_eq!(controls(""), Controls::default(), "no standing, no control");
assert_eq!(controls("owner"), Controls::default(), "an unknown word draws nothing");
}
/// ★ **Every line is gunnar's numbers**: an unmeasured store is said as
/// such, a floor only when gunnar sent one, room left only when something
/// caps the account.
#[test]
fn the_lines_say_gunnars_numbers_and_nothing_it_did_not_send() {
let clans = sent().clans;
let fellows = &clans[0];
assert_eq!(room_line(fellows), "Room 3.0 GiB · stores 117.2 KiB");
assert_eq!(floor_line(fellows).as_deref(), Some("You give 2.0 GiB. You can lower it to 0 B."));
assert_eq!(left_line(fellows), None, "nothing caps kim's account: no line, never 'unlimited'");
assert_eq!(member_line(&fellows.members[0]), "bob · admin · gives 1.0 GiB");
let others = &clans[1];
assert_eq!(room_line(others), "Room 0 B · stores not measured", "an unmeasured store is not 0");
assert_eq!(floor_line(others), None, "an invited account has no floor");
assert_eq!(left_line(others).as_deref(), Some("Your own account could still give 4.0 GiB"));
assert_eq!(role_line(&others.role), "You are invited");
}
/// ★ **Round 2: a lapsed share and the offers are drawn as gunnar sent
/// them** — the room in force beside the room only when a share lapsed,
/// the lapsed member marked, an offer with what it stores when measured.
#[test]
fn a_lapsed_share_and_an_offer_are_drawn_as_gunnar_sent_them() {
let sent: Clans = serde_json::from_str(r#"{"clans":[
{"name":"fellows","created_by":"alice","role":"admin","room_bytes":3221225472,
"room_in_force_bytes":1073741824,"used_bytes":1024,"your_contribution_bytes":1073741824,
"your_floor_bytes":1024,"your_room_left_bytes":null,
"members":[{"id":"alice","admin":true,"contribution_bytes":1073741824,"lapsed":false},
{"id":"bob","admin":false,"contribution_bytes":2147483648,"lapsed":true}],
"invites":[],
"offers":[{"clan":"fellows","repo":"app","to":"kim","by":"alice","stored_bytes":1572864}]}],
"offers":[{"clan":"others","repo":"tool","to":"alice","by":"carol","stored_bytes":null}]}"#)
.expect("the server's round-2 shape parses");
let fellows = &sent.clans[0];
assert_eq!(room_line(fellows), "Room 3.0 GiB · 1.0 GiB in force · stores 1.0 KiB");
assert_eq!(member_line(&fellows.members[1]), "bob · gives 2.0 GiB · share lapsed (its period ended)");
assert_eq!(member_line(&fellows.members[0]), "alice · admin · gives 1.0 GiB", "a share in force is not marked");
assert_eq!(offer_line(&fellows.offers[0]), "fellows/app → kim · 1.5 MiB");
assert_eq!(offer_line(&sent.offers[0]), "others/tool → alice", "an unmeasured size is not drawn");
// A round-1 answer (no in-force field) still draws the room alone.
assert_eq!(room_line(&sent_round1()), "Room 3.0 GiB · stores 117.2 KiB");
}
/// ★ Owner 2026-10-14: "Room 0 B · stores 0 B" and "You give 0 B; it may go
/// down to 0 B" are said in plain words, with where a plan is bought.
#[test]
fn a_clan_with_no_room_and_a_person_with_none_to_give_read_plainly() {
let mut empty = sent_round1();
(empty.room_bytes, empty.used_bytes, empty.your_contribution_bytes, empty.your_floor_bytes, empty.your_room_left_bytes) = (0, Some(0), 0, Some(0), Some(0));
assert_eq!(room_line(&empty), "This group has no room yet.");
assert_eq!(floor_line(&empty), None, "never 'You give 0 B; it may go down to 0 B'");
assert!(nothing_to_give(&empty));
// Room of their own left, or nothing capping the account: not said.
empty.your_room_left_bytes = Some(1 << 30);
assert!(!nothing_to_give(&empty));
empty.your_room_left_bytes = None;
assert!(!nothing_to_give(&empty));
assert_eq!([HINT_CLAN_NAME, HINT_INVITE, HINT_MOVE_REPO, HINT_MOVE_TO], ["Group name", "Account to invite", "Repository to move", "Account to move it into"]);
}
/// ★ Owner 2026-10-14: a clan with ONE admin drew "Remove" and "Make a
/// member" on that admin. gunnar refuses both; the page draws them disabled
/// with the reason, and working where gunnar would allow them.
#[test]
fn the_last_admin_is_drawn_no_working_remove_or_make_a_member() {
let m = |id: &str, admin: bool| Member { id: id.into(), admin, contribution_bytes: 0, lapsed: false };
let mut clan = sent_round1();
clan.members = vec![m("rickard", true)];
let alone = member_buttons(&clan, &clan.members[0], "rickard");
assert_eq!((alone.remove, alone.toggle_admin), (Err(REMOVE_IS_LEAVING), Err(KEEPS_AN_ADMIN)));
clan.members.push(m("kim", false));
let kim = member_buttons(&clan, &clan.members[1], "rickard");
assert_eq!((kim.remove, kim.toggle_admin), (Ok(()), Ok(())), "a member may be removed or made admin");
assert_eq!(member_buttons(&clan, &clan.members[0], "rickard").toggle_admin, Err(KEEPS_AN_ADMIN), "still the only admin");
clan.members[1].admin = true;
assert_eq!(member_buttons(&clan, &clan.members[0], "rickard").toggle_admin, Ok(()), "two admins: one may step down");
assert_eq!(member_buttons(&clan, &clan.members[1], "rickard").remove, Ok(()));
}
/// ★ Dissolve is sent only with the clan's name typed.
#[test]
fn dissolve_needs_the_clans_name_typed() {
assert!(may_dissolve(" fellows ", "fellows"));
assert!(!may_dissolve("fellow", "fellows") && !may_dissolve("", "fellows") && !may_dissolve("", ""));
}
/// ★ Owner 2026-10-14 ("clan seems advanced"): at first only "Create a
/// clan" and "Invite someone"; everything else is under "More", and
/// Dissolve sits apart there, never beside Invite. No verb is gone.
#[test]
fn the_page_shows_create_and_invite_first_and_the_rest_under_more() {
let src = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/pages/clans.rs")).expect("pages/clans.rs");
let more = src.find("<details class=\"more clan-more\">").expect("a More section");
for first in ["\"Create a group\"", "\"Invite someone\"", "clan-invite-send"] {
assert!(src.find(first).is_some_and(|at| at < more), "{first} is shown first");
}
for under in ["clan-give-set", "clan-leave", "clan-move-offer", "{member_presses}", "clan-dissolve"] {
assert!(src.find(under).is_some_and(|at| at > more), "{under} is under More");
}
// Remove / Make a member are drawn in one place, the presses under More.
let presses = src.find("let member_presses").expect("the member presses");
for press in ["clan-remove", "clan-admin"] {
assert_eq!(src.matches(press).count(), 1, "{press} is drawn once");
assert!(src.find(press).is_some_and(|at| at > presses), "{press} is one of the presses under More");
}
for verb in ["\"invite\"", "\"contribution\"", "\"leave\"", "\"offer\"", "\"remove\"", "\"admin\"", "\"dissolve\"", "\"accept\"", "\"decline\""] {
assert!(src.contains(&format!("url({verb})")), "the verb {verb} is still on the page");
}
assert!(src.contains("crate::clans::may_dissolve"), "Dissolve asks for the clan's name");
}
fn sent_round1() -> Clan {
sent().clans.into_iter().next().unwrap()
}
/// The page module draws through these and decides nothing of its own:
/// no byte arithmetic and no standing of its own in `pages/clans.rs`.
#[test]
fn the_page_decides_nothing_of_its_own() {
let src = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/pages/clans.rs"))
.expect("pages/clans.rs");
for decided_here in ["== \"admin\"", "== \"member\"", "saturating_sub", "1 << 30", "1u64 << 30"] {
assert!(!src.contains(decided_here), "pages/clans.rs decides {decided_here:?} itself");
}
assert!(src.contains("crate::clans::controls"), "pages/clans.rs does not draw from crate::clans::controls");
}
}