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

nordisk/gunnar-ui

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