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/mirror.rs

//! **What the mirror panel DECIDES**, kept where `cargo test` can reach it.
//!
//! `pages::mirrors` is `#[cfg(target_arch = "wasm32")]` like every module that
//! touches the DOM, so nothing inside it is compiled — let alone tested — by
//! `cargo test` on the host. That is fine for a view. It is not fine for the
//! judgement this panel has to make on every poll, which is the one gunnar's
//! own proto puts a warning label on:
//!
//! > Read `has_progress` first: a job that has not reached the pack header yet
//! > has no denominator, and `0/0` would be a progress bar that lies about
//! > being at zero per cent rather than admitting it does not know.
//!
//! Three shapes, and drawing any two of them the same way is a lie:
//!
//! | `has_progress` | pair | what it means | what is drawn |
//! |---|---|---|---|
//! | `false` | `(0, 0)` | the pack header has not arrived | **no bar**, and the byte counter |
//! | `true` | `(0, N)` | the header arrived; nothing indexed yet | a bar at 0%, which is TRUE |
//! | `true` | `(N, N)` | every object indexed | a full bar |
//!
//! So the types and the rules live here — no leptos, no `web-sys` — and
//! `pages::mirrors` re-exports them and does nothing but draw. [`crate::room`]
//! was split out for exactly this reason and it is the layout `lib.rs`
//! describes.
//!
//! # `bytes` is the live signal, and the object pair is the milestone
//!
//! `progress_done` moves in jumps: it is what the ingest reported it indexed,
//! and on a large fetch it can sit at zero for a long minute while the pack
//! streams. `bytes` is the counter `fetch_pack` streams and it moves the whole
//! time. A panel whose only moving number was the pair would look frozen on
//! precisely the repositories worth mirroring — `alice/linux` is 5.8 GiB and
//! 946 refs on gunnar.rs — so the sentence under the badge leads with bytes and
//! the pair follows it.

use serde::Deserialize;

use crate::fmt;

/// One reference a sync moved.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct RefUpdate {
    pub ref_name: String,
    /// Empty for a reference the sync created.
    pub old_oid: String,
    pub new_oid: String,
}

impl RefUpdate {
    /// `refs/heads/main  ·  (new) → cafebabe` / `main  ·  deadbeef → cafebabe`.
    ///
    /// An empty `old_oid` is a reference that did not exist here before, and
    /// rendering it as an empty cell would make a created branch look like a
    /// rendering fault.
    pub fn movement(&self) -> String {
        let new = fmt::short_oid(&self.new_oid);
        if self.old_oid.is_empty() {
            format!("(new) → {new}")
        } else {
            format!("{} → {new}", fmt::short_oid(&self.old_oid))
        }
    }
}

/// One sync job, as `GET /api/mirrors/{ns}/{name}` reports it.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct MirrorJob {
    pub job_id: String,
    pub path: String,
    /// `queued` | `running` | `done` | `failed` | `unknown`.
    pub state: String,
    pub queue_position: u32,
    /// **Read this before the pair.** See the module docs.
    pub has_progress: bool,
    pub progress_done: u64,
    pub progress_total: u64,
    /// Packfile bytes off the wire. The figure that moves continuously.
    pub bytes: u64,
    /// Objects the fetch brought in. `Some` only when `done` — the BFF
    /// withholds it otherwise, so a running job cannot render as "fetched 0".
    pub objects: Option<u64>,
    /// Empty until `done`.
    #[serde(default)]
    pub updates: Vec<RefUpdate>,
    pub error: Option<String>,
    pub enqueued_at_unix_ms: Option<i64>,
    pub started_at_unix_ms: Option<i64>,
    pub finished_at_unix_ms: Option<i64>,
    /// True when gunnar stopped this sync at the namespace's storage cap. The
    /// BFF recognises gunnar's own sentence; this only reads the flag.
    #[serde(default)]
    pub needs_room: bool,
    /// The customer's sentence for such a job, the BFF's words.
    #[serde(default)]
    pub words: Option<String>,
    /// Where to buy more room, on a build that sells it.
    #[serde(default)]
    pub room_href: Option<String>,
}

/// What the object pair is allowed to claim.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Progress {
    /// **The fetch has not reached the packfile header.** There is no
    /// denominator, so there is no bar. Not [`Progress::Counting`] with a zero:
    /// "I do not know yet" and "nought per cent" are different answers and only
    /// one of them is true here.
    NoDenominator,
    /// A real pair. `done` may be 0, and a bar at 0% is then honest.
    Counting { done: u64, total: u64 },
}

impl MirrorJob {
    /// Whether this job is still moving, and therefore whether to keep polling.
    ///
    /// `unknown` counts as settled: a state this build does not recognise must
    /// not make the page poll for ever. Same rule as the gc panel's.
    pub fn in_flight(&self) -> bool {
        self.state == "queued" || self.state == "running"
    }

    /// [`Progress::NoDenominator`] until the pack header arrives.
    ///
    /// `has_progress` is read FIRST and a zero total is treated the same way —
    /// belt and braces, because a bar divided by that total is a panic in a
    /// browser and a lie everywhere else.
    pub fn progress(&self) -> Progress {
        if !self.has_progress || self.progress_total == 0 {
            return Progress::NoDenominator;
        }
        Progress::Counting {
            done: self.progress_done,
            total: self.progress_total,
        }
    }

    /// The bar's width, 0–100, or `None` when there is no bar to draw.
    ///
    /// Saturating at 100: gunnar reports what the ingest said it indexed
    /// against what the header declared, and a pack that delivers more objects
    /// than its header promised is a thin-pack detail the console should not
    /// render as 104%.
    pub fn percent(&self) -> Option<u8> {
        match self.progress() {
            Progress::NoDenominator => None,
            Progress::Counting { done, total } => {
                Some(((done.min(total) * 100) / total.max(1)) as u8)
            }
        }
    }

    /// The word beside the colour. Every status in this console has one.
    pub fn word(&self) -> &'static str {
        match self.state.as_str() {
            "queued" => "queued",
            "running" => "syncing",
            "done" => "synced",
            "failed" => "failed",
            _ => "unknown",
        }
    }

    /// ★ **The one sentence under the badge**, and the whole reason this module
    /// is host-compiled.
    ///
    /// Leads with the figure that is moving. A panel on a 5.8 GiB fetch whose
    /// only number was the object pair would sit at "0 of 9 500 000" for
    /// minutes and read as hung.
    pub fn live_line(&self) -> String {
        match self.state.as_str() {
            "queued" => match self.queue_position {
                0 => "Waiting its turn.".to_owned(),
                1 => "Waiting — one job ahead of it.".to_owned(),
                n => format!("Waiting — {n} jobs ahead of it."),
            },
            "running" => match self.progress() {
                // The honest sentence for "no denominator yet". It says what is
                // known (bytes) and admits what is not, rather than printing a
                // zero that reads as a measurement.
                Progress::NoDenominator => format!(
                    "Fetching — {} so far. The packfile header has not arrived, so there is \
                     no object count yet.",
                    fmt::bytes(self.bytes)
                ),
                Progress::Counting { done, total } => format!(
                    "Fetching — {}, {} of {} objects indexed.",
                    fmt::bytes(self.bytes),
                    fmt::count(done),
                    fmt::count(total)
                ),
            },
            "done" => {
                let objects = self.objects.unwrap_or_default();
                let moved = match self.updates.len() {
                    0 => "nothing moved — it was already up to date".to_owned(),
                    1 => "1 reference moved".to_owned(),
                    n => format!("{n} references moved"),
                };
                if objects == 0 {
                    // The ordinary answer for a poll on an unchanged
                    // repository, and NOT a failure. Said out loud, because a
                    // row reading "0 objects" with no explanation reads as one.
                    format!("Up to date — no new objects, {moved}.")
                } else {
                    format!(
                        "Fetched {} objects, {} — {moved}.",
                        fmt::count(objects),
                        fmt::bytes(self.bytes)
                    )
                }
            }
            // The error itself is rendered verbatim on its own line; this is the
            // summary above it, and must not paraphrase gunnar.
            // Stopped at the cap: the customer's sentence, which says what to do.
            "failed" if self.needs_room => self.words.clone().unwrap_or_else(|| {
                "This sync was stopped because the namespace needs more room.".to_owned()
            }),
            "failed" => "The sync failed. Gunnar's reason is below; no reference moved.".to_owned(),
            _ => "This console does not recognise the state this job is in.".to_owned(),
        }
    }
}

/// One registration, as `GET /api/mirrors` reports it.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Mirror {
    pub path: String,
    pub namespace: String,
    pub name: String,
    /// Userinfo already removed by the BFF. The console never re-derives it.
    pub remote: String,
    /// `ssh` | `https` | `other`.
    pub transport: String,
    pub prune: bool,
    /// `idle` | `syncing` | `failed` | `unknown`.
    pub state: String,
    /// `none` | `ssh-key` | `token`. **A kind, never a value.**
    pub credential: String,
    pub credential_username: String,
    pub last_sync_at_unix_ms: Option<i64>,
    pub last_error: Option<String>,
    pub sync_count: u64,
    pub job: Option<MirrorJob>,
    /// **Whether this session may sync, forget or stop it** — the server's
    /// word. Absent (an older BFF) is false: no word is no buttons.
    #[serde(default)]
    pub may_tend: bool,
    /// **The repository is made by the first sync that reaches the upstream**
    /// (gunnar 2026-10-18): until then there is a registration and no
    /// repository to open.
    #[serde(default)]
    pub repo_pending: bool,
    /// The upstream keeps files in Git LFS, and their content was not copied.
    #[serde(default)]
    pub lfs_not_copied: bool,
}

/// **The one wording of the prune switch**, on both import forms: a mirror
/// that prunes deletes the branches and tags its upstream deleted.
pub const PRUNE_LABEL: &str = " Delete branches and tags the remote no longer has";

impl Mirror {
    /// The warning a mirror of an LFS repository carries, or `None`.
    pub fn lfs_line(&self) -> Option<&'static str> {
        self.lfs_not_copied.then_some(
            "LFS files are not copied yet. This repository keeps some files in Git LFS, and \
             the mirror holds only their small pointer files.",
        )
    }

    /// Whether this row is worth polling.
    pub fn in_flight(&self) -> bool {
        self.job.as_ref().is_some_and(MirrorJob::in_flight)
    }

    /// **What the credential column says**, which is a kind and never a value.
    ///
    /// The username is included for a token because it is not a secret — every
    /// forge documents it, and gunnar's proto says so explicitly — and because
    /// it is the field a person checks when a mirror authenticates against one
    /// forge and not another.
    pub fn credential_line(&self) -> String {
        match self.credential.as_str() {
            "token" if !self.credential_username.is_empty() => {
                format!("token, as {}", self.credential_username)
            }
            "token" => "token".to_owned(),
            "ssh-key" => "deploy key".to_owned(),
            _ => "none".to_owned(),
        }
    }

    /// **Whether the stored credential can be offered on this remote.**
    ///
    /// gunnar refuses a token against an `ssh://` remote and a key against an
    /// `https://` one, **by name** — a credential that cannot be offered is a
    /// mirror that fails on authentication for ever with nothing naming the
    /// cause. The panel says so before the refusal, so the mismatch is visible
    /// rather than discovered.
    ///
    /// `None` means there is nothing to say: no credential, or a transport this
    /// console does not classify.
    pub fn credential_mismatch(&self) -> Option<&'static str> {
        match (self.transport.as_str(), self.credential.as_str()) {
            ("https", "ssh-key") => Some(
                "This remote is https:// and the stored credential is a deploy key. Gunnar \
                 will refuse it: an SSH key cannot be offered over HTTPS.",
            ),
            ("ssh", "token") => Some(
                "This remote is ssh:// and the stored credential is a token. Gunnar will \
                 refuse it: a token cannot be offered over SSH.",
            ),
            ("https", "none") => Some(
                "This remote is https:// with no credential stored. A public repository \
                 mirrors fine; a private one will be refused by the forge.",
            ),
            _ => None,
        }
    }
}

/// **Which repositories the picker shows**, given the archived toggle.
///
/// A free function rather than a method on the row, because it is the decision
/// the table makes and not a fact about one repository — and because it is the
/// half of the picker most likely to be quietly wrong: archived hidden by
/// default is a choice, and a table that hid them with no way back would make a
/// repository somebody deliberately wants to preserve unreachable.
pub fn visible<T: Archived>(rows: &[T], show_archived: bool) -> Vec<&T> {
    rows.iter()
        .filter(|row| show_archived || !row.is_archived())
        .collect()
}

/// What [`visible`] needs to know about a row.
pub trait Archived {
    fn is_archived(&self) -> bool;
}

/// One repository the forge listed, as `GET /api/forges/{forge}/repos` reports
/// it.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct ForgeRepo {
    pub forge: String,
    /// `owner/name`, whole.
    pub full_name: String,
    /// The leaf, which is the name a mirror takes in a namespace.
    pub name: String,
    pub clone_url_https: String,
    pub clone_url_ssh: String,
    pub default_branch: String,
    pub private: bool,
    pub archived: bool,
    /// `None` on GitLab, which reports nothing unless the caller is an
    /// administrator asking for statistics.
    pub size_kib: Option<u64>,
    pub description: Option<String>,
}

impl Archived for ForgeRepo {
    fn is_archived(&self) -> bool {
        self.archived
    }
}

impl ForgeRepo {
    /// The size column. A dash and a reason, never a zero, when the forge did
    /// not say — a `0` would sort every GitLab repository to the bottom of a
    /// table sorted by size and read as "empty".
    pub fn size_line(&self) -> String {
        match self.size_kib {
            Some(kib) => fmt::bytes(kib.saturating_mul(1024)),
            None => "—".to_owned(),
        }
    }

    /// Where this repository would land, given a namespace.
    pub fn lands_at(&self, namespace: &str) -> String {
        format!("{namespace}/{}", self.name)
    }
}

/// One connected forge, as `GET /api/forges` reports it. **No token field, and
/// none to add**: the BFF has none to send.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Connected {
    pub forge: String,
    pub base: String,
    pub account: String,
    /// `token` or `oauth`.
    pub door: String,
    pub repos: usize,
    /// False after an HTTPS import handed the token over.
    pub holds_token: bool,
}

/// What `GET /api/forges` answers.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Forges {
    pub connected: Vec<Connected>,
    /// Which forges have an OAuth button. The console renders exactly these.
    pub oauth: Vec<String>,
    pub tokens: Vec<String>,
}

impl Forges {
    /// The connection for one forge, if any.
    pub fn of(&self, forge: &str) -> Option<&Connected> {
        self.connected.iter().find(|c| c.forge == forge)
    }

    /// **Whether a forge shows an OAuth button.** Absent, not disabled: a
    /// button that answers "this console has no GitHub application" is a
    /// promise printed at somebody who cannot act on it.
    pub fn has_oauth(&self, forge: &str) -> bool {
        self.oauth.iter().any(|f| f == forge)
    }
}

/// ★ **Whether an import can be started, and the reason when it cannot.**
///
/// The button's whole state, in one place, because the failure mode of spelling
/// it across a view is a button that is enabled for a case the server refuses —
/// and the case that matters is the HTTPS import with no token held, which the
/// BFF refuses outright rather than registering mirrors that cannot
/// authenticate.
///
/// `Ok(())` means press it.
pub fn can_import(
    connection: Option<&Connected>,
    over_https: bool,
    namespace: &str,
    chosen: usize,
    ssh_allowed: bool,
) -> Result<(), String> {
    let Some(connection) = connection else {
        return Err("Connect a forge first.".to_owned());
    };
    if namespace.trim().is_empty() {
        return Err("Choose a namespace for the mirrors to land in.".to_owned());
    }
    if chosen == 0 {
        return Err("Pick at least one repository.".to_owned());
    }
    if over_https && !connection.holds_token {
        return Err(format!(
            "This console is no longer holding a {} token — it forgets one after each \
             import. Connect {} again{}.",
            connection.forge,
            connection.forge,
            if ssh_allowed { ", or import over SSH" } else { "" }
        ));
    }
    Ok(())
}

/// ★ **Whether the address form can be sent, and why not.**
///
/// The same three things the BFF refuses first, said before a press: an
/// address in a shape this box can dial (`https://`, `http://`, `ssh://`, or
/// the scp-like `git@host:path`), a namespace to land in, and a token only
/// beside an `https` address — gunnar refuses one against ssh by name, and a
/// form that let it through would be a press that registers nothing.
pub fn can_import_url(url: &str, namespace: &str, with_token: bool, ssh_allowed: bool) -> Result<(), String> {
    let url = url.trim();
    if url.is_empty() {
        return Err("Paste the address of the repository to import.".to_owned());
    }
    if namespace.trim().is_empty() {
        return Err("Choose a namespace for it to land in.".to_owned());
    }
    let https = url.starts_with("https://") || url.starts_with("http://");
    let ssh = url.starts_with("ssh://")
        || (!url.contains("://")
            && url
                .find(':')
                .is_some_and(|colon| colon > 0 && url[..colon].contains('@')));
    if !https && !ssh {
        return Err(
            "That is not an address this box can mirror from. It starts with https:// or \
             ssh://, or looks like git@host:owner/repo.git."
                .to_owned(),
        );
    }
    // SSH import needs a deploy key that only the appliance's operator can
    // set up, so for anybody else the https address is the way.
    if ssh && !ssh_allowed {
        return Err(
            "Use the repository's https:// address. Importing over SSH needs a key that \
             only this box's operator can set up."
                .to_owned(),
        );
    }
    if with_token && !https {
        return Err(
            "A token only works with an https:// address. Clear it, or use the https address."
                .to_owned(),
        );
    }
    Ok(())
}

/// Where an address would land: `<namespace>/<name>`, the name being the one
/// given or the address's last segment without `.git` — the BFF's own rule.
pub fn lands_at_url(namespace: &str, url: &str, name: &str) -> String {
    let name = name.trim();
    let leaf = if name.is_empty() {
        let url = url.trim().trim_end_matches('/');
        let tail = url.rsplit(['/', ':']).next().unwrap_or_default();
        tail.strip_suffix(".git").unwrap_or(tail).to_owned()
    } else {
        name.to_owned()
    };
    format!("{}/{leaf}", namespace.trim())
}

/// ★ **Where an import lands unless the person types another namespace**
/// (round 5.3; the members walkthrough of 2026-10-07: an employee's Import
/// defaulted to `ben/…`, a namespace that does not exist). An import MAKES a
/// repository, which gunnar allows to whoever administers the namespace — the
/// rule `/api/repos/creatable` asks gunnar about for this session
/// (`namespaces`), and `any_namespace` for a session that may type any (an
/// operator). The account gunnar names for the person (`/auth/status`
/// `account`) comes first, then their own name, then the first namespace
/// gunnar confirmed. `None`: gunnar names nowhere this person may import into
/// — the page says so ([`nowhere_to_import`]) instead of offering a form
/// gunnar refuses. Nothing is decided here that gunnar did not answer.
pub fn import_namespace(creatable: &[String], any_namespace: bool, account: Option<&str>, user: &str) -> Option<String> {
    let mine = [account.unwrap_or_default(), user].into_iter().filter(|n| !n.is_empty());
    if let Some(n) = mine.clone().find(|n| creatable.iter().any(|c| c == n)) {
        return Some(n.to_owned());
    }
    if let Some(first) = creatable.first() {
        return Some(first.clone());
    }
    if any_namespace {
        return mine.into_iter().next().map(str::to_owned);
    }
    None
}

/// **Where an import lands by default, from gunnar's answer** (`/api/repos/creatable`):
/// `None` when it was not answered — no name is guessed for the person (round 5.3.1, wiring
/// tracer B 2026-10-08: an unanswered ask filled in the person's own name, a namespace gunnar
/// may hold nothing under).
pub fn import_home(answer: Option<(&[String], bool)>, account: Option<&str>, user: &str) -> Option<String> {
    let (names, any) = answer?;
    import_namespace(names, any, account, user)
}

/// What the import page says when gunnar could not be asked where an import may land.
pub const IMPORT_UNASKED: &str = "Gunnar could not be asked where an import may land, so no namespace is filled in. Reload the page to ask again.";

/// **The sentence where there is nowhere to import into** ([`import_namespace`]
/// answered `None`): what an import needs, and — for an account's member,
/// whose namespaces are the account's — whom to ask, by the names gunnar gave
/// (`/auth/status` `account` and `account_admin`).
pub fn nowhere_to_import(member_of: Option<(&str, &str)>) -> String {
    let needs = "An import makes a repository in a namespace you administer, and Gunnar names none for you.";
    match member_of {
        Some((account, admin)) if !account.is_empty() => {
            let admin = if admin.is_empty() { account } else { admin };
            format!("{needs} Repositories you work on are {account}'s: ask {admin} to make one, or to let you.")
        }
        _ => needs.to_owned(),
    }
}

/// **The way on under "needs more room", for THIS session** (round 5.3) —
/// from the server's own answers (`/auth/status`): a member's `account_line`
/// ("Your space comes from acme. Ask acme for more room."), a sold Gunnar's
/// `shop_line` (its room is bought at its shop), the More room door for
/// whoever may buy here, else nothing. Members and sold consoles were sent to
/// "Add room on your Account page" and a More room button they cannot use.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoomWay {
    /// The More room door (`/account`).
    Buy,
    /// The server's line, drawn as it came.
    Line(String),
    /// Nothing to offer this session.
    Nothing,
}

/// [`RoomWay`] from the server's answers, in this order: a member's line, a
/// sold Gunnar's line, the shop's word on whether this session may buy.
pub fn room_way(member_line: Option<&str>, sells_here: bool, shop_line: Option<&str>, may_buy: Option<bool>) -> RoomWay {
    if let Some(line) = member_line.filter(|l| !l.is_empty()) {
        return RoomWay::Line(line.to_owned());
    }
    if !sells_here {
        return shop_line.filter(|l| !l.is_empty()).map(|l| RoomWay::Line(l.to_owned())).unwrap_or(RoomWay::Nothing);
    }
    if may_buy == Some(false) {
        return RoomWay::Nothing;
    }
    RoomWay::Buy
}

/// **The summary line above the console's OWN import refusals** — the codes
/// `api::mirror_gate` mints, in a customer's words. `None` for every other
/// refusal, which keeps the wording `api::Fail` already gives gunnar's.
pub fn refusal_summary(status: u16, code: &str) -> Option<&'static str> {
    match (status, code) {
        (403, "notyours") => Some("You can only import into a namespace of your own."),
        (403, "needsroom") => Some("This needs more room."),
        (429, "busy") => Some("Too many at once."),
        _ => None,
    }
}

/// The sentence under each transport choice.
///
/// Two sentences, both true, and neither of them a promise this console cannot
/// keep. The SSH one is the honest version: no RPC in gunnar's control plane
/// returns the appliance's public key, so the console cannot show it, and
/// saying where it comes from is better than showing a key it invented.
pub fn transport_note(over_https: bool) -> &'static str {
    if over_https {
        "Uses the token you just connected. It is handed to the appliance once, sealed \
         there, and this console then forgets it."
    } else {
        "The appliance dials the forge with a deploy key, which must be enrolled on the \
         forge before the first sync succeeds. Gunnar's control plane does not publish \
         that key, so it comes from the appliance itself (`gunnar mirror`) rather than \
         from this page."
    }
}

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

    /// ★ An unanswered "where may this land" fills in nothing — never the person's own name;
    /// an answered one is [`import_namespace`]'s.
    #[test]
    fn an_unanswered_ask_fills_in_no_namespace() {
        assert_eq!(import_home(None, Some("acme"), "ben"), None);
        assert_eq!(import_home(None, None, "kim"), None);
        let names = ["acme".to_owned()];
        assert_eq!(import_home(Some((&names, false)), Some("acme"), "ben"), Some("acme".to_owned()));
        assert!(IMPORT_UNASKED.contains("could not be asked") && !IMPORT_UNASKED.contains("ben"));
    }

    /// ★ **"Needs more room" leads each session its own way** (round 5.3):
    /// a member to the account's admin, a sold Gunnar to its shop, an account
    /// that buys here to More room — never "add room" for everyone.
    #[test]
    fn needs_more_room_leads_each_session_its_own_way() {
        let ben = "Your space comes from acme. Ask acme for more room.";
        assert_eq!(room_way(Some(ben), true, None, Some(false)), RoomWay::Line(ben.to_owned()), "a member asks the admin");
        let sold = "This Gunnar was bought at gunnar.rs. More room for it is bought there.";
        assert_eq!(room_way(None, false, Some(sold), Some(true)), RoomWay::Line(sold.to_owned()), "a sold Gunnar's room is its shop's");
        assert_eq!(room_way(None, true, None, Some(true)), RoomWay::Buy, "an account that buys here");
        assert_eq!(room_way(None, true, None, None), RoomWay::Buy, "an older server: the door, as before");
        assert_eq!(room_way(None, true, None, Some(false)), RoomWay::Nothing, "a session that may not buy gets no door");
        assert_eq!(room_way(None, false, None, Some(true)), RoomWay::Nothing);
    }

    /// ★ **An import lands in the namespace gunnar says the person may make
    /// repositories in** (round 5.3; the members walkthrough: ben's Import read
    /// "Lands at ben/." — a namespace that does not exist). The account first,
    /// then the person's own; a member gunnar names nowhere for gets no
    /// default at all, and the page's sentence instead.
    #[test]
    fn an_import_lands_where_gunnar_says_the_person_may_make_repositories() {
        let names = |l: &[&str]| l.iter().map(|s| s.to_string()).collect::<Vec<_>>();
        // acme's admin: the account is hers (and sorted after another she administers).
        assert_eq!(import_namespace(&names(&["acme", "beta-clan"]), false, Some("acme"), "acme").as_deref(), Some("acme"));
        assert_eq!(import_namespace(&names(&["a-clan", "acme"]), false, Some("acme"), "acme").as_deref(), Some("acme"));
        // An individual: her own.
        assert_eq!(import_namespace(&names(&["kim"]), false, Some("kim"), "kim").as_deref(), Some("kim"));
        // ben, acme's member: gunnar names no namespace he administers — not `ben`.
        assert_eq!(import_namespace(&[], false, Some("acme"), "ben"), None, "a member is never defaulted to a namespace of his own name");
        // ben given Admin over acme by its admin: acme.
        assert_eq!(import_namespace(&names(&["acme"]), false, Some("acme"), "ben").as_deref(), Some("acme"));
        // Somebody gunnar names one other namespace for: that one.
        assert_eq!(import_namespace(&names(&["team"]), false, None, "op").as_deref(), Some("team"));
        // An operator, who may type any: their own name as before.
        assert_eq!(import_namespace(&[], true, None, "alice").as_deref(), Some("alice"));
        assert_eq!(import_namespace(&[], true, None, ""), None);
        // Nowhere: the words, naming whom a member asks.
        let ben = nowhere_to_import(Some(("acme", "anna")));
        assert!(ben.contains("Gunnar names none for you") && ben.contains("ask anna"), "{ben}");
        assert!(!ben.contains("ben/"), "{ben}");
        assert_eq!(nowhere_to_import(Some(("acme", ""))), "An import makes a repository in a namespace you administer, and Gunnar names none for you. Repositories you work on are acme's: ask acme to make one, or to let you.");
        assert_eq!(nowhere_to_import(None), "An import makes a repository in a namespace you administer, and Gunnar names none for you.");
    }

    fn running(has_progress: bool, done: u64, total: u64, bytes: u64) -> MirrorJob {
        MirrorJob {
            state: "running".to_owned(),
            has_progress,
            progress_done: done,
            progress_total: total,
            bytes,
            ..Default::default()
        }
    }

    /// ★ **The three shapes, told apart.**
    ///
    /// This is the decision gunnar's proto puts a warning label on, and the
    /// reason this module is host-compiled at all: it only ever ran in a
    /// browser before, which is a rule with no test.
    #[test]
    fn no_denominator_is_not_nought_per_cent() {
        let early = running(false, 0, 0, 4_096);
        assert_eq!(early.progress(), Progress::NoDenominator);
        assert_eq!(
            early.percent(),
            None,
            "a bar here would claim the fetch is at 0% when what is true is that the \
             denominator has not arrived"
        );
        assert!(
            early.live_line().contains("no object count yet"),
            "the sentence has to SAY it does not know: {}",
            early.live_line()
        );
        assert!(
            early.live_line().contains("4.0 KiB"),
            "…while still reporting what it does know: {}",
            early.live_line()
        );

        // The header arrived. Nothing indexed yet, and a bar at 0% is now TRUE.
        let counting = running(true, 0, 9_500_000, 512_000_000);
        assert_eq!(
            counting.progress(),
            Progress::Counting {
                done: 0,
                total: 9_500_000
            }
        );
        assert_eq!(counting.percent(), Some(0));

        let half = running(true, 4_750_000, 9_500_000, 3_000_000_000);
        assert_eq!(half.percent(), Some(50));

        let full = running(true, 9_500_000, 9_500_000, 6_227_020_800);
        assert_eq!(full.percent(), Some(100));
    }

    #[test]
    fn a_zero_total_is_treated_as_no_denominator_however_it_arrives() {
        // Belt and braces: `has_progress` is the field to read, and a division
        // by this total is a panic in a browser and a lie everywhere else.
        assert_eq!(running(true, 0, 0, 1).progress(), Progress::NoDenominator);
        assert_eq!(running(true, 0, 0, 1).percent(), None);
    }

    #[test]
    fn a_pack_that_over_delivers_does_not_render_as_more_than_full() {
        // A thin pack can deliver more objects than its header declared. 104%
        // is a rendering fault, not a fact worth showing.
        assert_eq!(running(true, 12, 10, 1).percent(), Some(100));
    }

    #[test]
    fn only_a_live_job_is_polled_and_an_unknown_state_settles() {
        for (state, live) in [
            ("queued", true),
            ("running", true),
            ("done", false),
            ("failed", false),
            // The one that matters: a state this build does not recognise must
            // not make the page poll for ever.
            ("unknown", false),
            ("something-gunnar-grew-later", false),
        ] {
            let job = MirrorJob {
                state: state.to_owned(),
                ..Default::default()
            };
            assert_eq!(job.in_flight(), live, "{state}");
        }
    }

    #[test]
    fn the_queue_line_counts_in_words_a_person_reads() {
        let queued = |n| MirrorJob {
            state: "queued".to_owned(),
            queue_position: n,
            ..Default::default()
        };
        assert_eq!(queued(0).live_line(), "Waiting its turn.");
        assert_eq!(queued(1).live_line(), "Waiting — one job ahead of it.");
        assert_eq!(queued(4).live_line(), "Waiting — 4 jobs ahead of it.");
    }

    #[test]
    fn a_sync_that_fetched_nothing_says_up_to_date_rather_than_zero() {
        // gunnar's proto: "0 on a mirror that was already up to date, which is
        // the ordinary answer for a poll and not a failure." A row reading
        // "Fetched 0 objects" reads as one.
        let unchanged = MirrorJob {
            state: "done".to_owned(),
            objects: Some(0),
            ..Default::default()
        };
        assert!(
            unchanged.live_line().starts_with("Up to date"),
            "{}",
            unchanged.live_line()
        );
        assert!(unchanged.live_line().contains("nothing moved"));
    }

    #[test]
    fn a_finished_sync_reports_objects_bytes_and_what_moved() {
        let done = MirrorJob {
            state: "done".to_owned(),
            objects: Some(9_500_000),
            bytes: 6_227_020_800,
            updates: vec![
                RefUpdate {
                    ref_name: "refs/heads/master".to_owned(),
                    old_oid: String::new(),
                    new_oid: "cafebabecafebabe".to_owned(),
                },
                RefUpdate {
                    ref_name: "refs/tags/v6.1".to_owned(),
                    old_oid: "deadbeefdeadbeef".to_owned(),
                    new_oid: "0123456789abcdef".to_owned(),
                },
            ],
            ..Default::default()
        };
        let line = done.live_line();

        assert!(line.contains("5.8 GiB"), "{line}");
        assert!(line.contains("2 references moved"), "{line}");
        assert_eq!(done.updates[0].movement(), "(new) → cafebabecafe");
        assert_eq!(done.updates[1].movement(), "deadbeefdead → 0123456789ab");
    }

    #[test]
    fn a_failed_job_summary_never_paraphrases_gunnar() {
        let failed = MirrorJob {
            state: "failed".to_owned(),
            error: Some("the remote refused the credential (HTTP 401)".to_owned()),
            ..Default::default()
        };

        assert!(failed.live_line().contains("no reference moved"));
        assert!(
            !failed.live_line().contains("401"),
            "the summary must not restate gunnar's message; the message is rendered \
             verbatim beside it, and two versions of one refusal is one too many"
        );
    }

    #[test]
    fn a_credential_that_cannot_be_offered_is_named_before_gunnar_refuses_it() {
        let https_with_a_key = Mirror {
            transport: "https".to_owned(),
            credential: "ssh-key".to_owned(),
            ..Default::default()
        };
        assert!(https_with_a_key
            .credential_mismatch()
            .expect("a mismatch")
            .contains("cannot be offered over HTTPS"));

        let ssh_with_a_token = Mirror {
            transport: "ssh".to_owned(),
            credential: "token".to_owned(),
            ..Default::default()
        };
        assert!(ssh_with_a_token
            .credential_mismatch()
            .expect("a mismatch")
            .contains("cannot be offered over SSH"));

        // The matching pairs say nothing.
        for (transport, credential) in [("https", "token"), ("ssh", "ssh-key"), ("ssh", "none")] {
            let fine = Mirror {
                transport: transport.to_owned(),
                credential: credential.to_owned(),
                ..Default::default()
            };
            assert_eq!(fine.credential_mismatch(), None, "{transport}/{credential}");
        }
    }

    #[test]
    fn the_credential_column_shows_a_kind_and_a_username_and_never_a_value() {
        let token = Mirror {
            credential: "token".to_owned(),
            credential_username: "x-access-token".to_owned(),
            ..Default::default()
        };
        assert_eq!(token.credential_line(), "token, as x-access-token");
        assert_eq!(
            Mirror {
                credential: "ssh-key".to_owned(),
                ..Default::default()
            }
            .credential_line(),
            "deploy key"
        );
        assert_eq!(Mirror::default().credential_line(), "none");
    }

    fn repo(name: &str, archived: bool) -> ForgeRepo {
        ForgeRepo {
            full_name: format!("alice/{name}"),
            name: name.to_owned(),
            archived,
            ..Default::default()
        }
    }

    #[test]
    fn archived_repositories_are_hidden_by_default_and_reachable_by_a_toggle() {
        let rows = vec![repo("gunnar", false), repo("old-thing", true)];

        assert_eq!(visible(&rows, false).len(), 1);
        assert_eq!(visible(&rows, false)[0].name, "gunnar");
        assert_eq!(
            visible(&rows, true).len(),
            2,
            "a repository somebody deliberately archived is often exactly the one they \
             want preserved; hiding it with no way back would make it unreachable"
        );
    }

    #[test]
    fn a_size_the_forge_did_not_report_is_a_dash_and_not_a_zero() {
        // GitLab reports nothing unless the caller is an administrator asking
        // for statistics. A 0 sorts every GitLab repository to the bottom of a
        // size-sorted table and reads as "empty".
        assert_eq!(repo("x", false).size_line(), "—");
        assert_eq!(
            ForgeRepo {
                size_kib: Some(5_939_200),
                ..repo("linux", false)
            }
            .size_line(),
            "5.7 GiB"
        );
    }

    #[test]
    fn a_repository_lands_under_the_namespace_by_its_leaf_name() {
        // GitLab's `path_with_namespace` can be `group/subgroup/project`; the
        // mirror takes a namespace of its own, so the name is the leaf.
        let deep = ForgeRepo {
            full_name: "group/subgroup/project".to_owned(),
            name: "project".to_owned(),
            ..Default::default()
        };
        assert_eq!(deep.lands_at("nordisk"), "nordisk/project");
    }

    #[test]
    fn the_import_button_refuses_an_https_import_with_no_token_and_says_why() {
        let held = Connected {
            forge: "github".to_owned(),
            holds_token: true,
            ..Default::default()
        };
        let spent = Connected {
            holds_token: false,
            ..held.clone()
        };

        assert_eq!(can_import(Some(&held), true, "nordisk", 3, true), Ok(()));
        assert_eq!(can_import(Some(&spent), false, "nordisk", 3, true), Ok(()));

        let refused = can_import(Some(&spent), true, "nordisk", 3, true).expect_err("refused");
        assert!(
            refused.contains("forgets one after each import") && refused.contains("SSH"),
            "the refusal must say why AND what to do instead: {refused}"
        );

        assert!(can_import(None, false, "nordisk", 3, true).is_err());
        assert!(can_import(Some(&held), false, "  ", 3, true).is_err());
        assert!(can_import(Some(&held), false, "nordisk", 0, true).is_err());
    }

    #[test]
    fn a_forge_with_no_application_has_no_oauth_button() {
        let forges = Forges {
            oauth: vec!["github".to_owned()],
            tokens: vec![
                "github".to_owned(),
                "codeberg".to_owned(),
                "gitlab".to_owned(),
            ],
            ..Default::default()
        };

        assert!(forges.has_oauth("github"));
        assert!(
            !forges.has_oauth("gitlab"),
            "absent, not disabled: a button that answers 'this console has no application' \
             is a promise printed at somebody who cannot act on it"
        );
    }

    #[test]
    fn the_ssh_note_does_not_promise_a_key_this_console_cannot_show() {
        // No RPC in gunnar's control plane returns the appliance's public key.
        // Saying where it comes from is honest; showing one would mean
        // inventing it.
        let note = transport_note(false);
        assert!(note.contains("must be enrolled on the forge"));
        assert!(note.contains("does not publish that key"));
        assert!(transport_note(true).contains("this console then forgets it"));
    }

    #[test]
    fn a_row_is_polled_only_while_its_job_is_live() {
        let idle = Mirror::default();
        assert!(!idle.in_flight(), "no job at all is not a job in flight");

        let running = Mirror {
            job: Some(MirrorJob {
                state: "running".to_owned(),
                ..Default::default()
            }),
            ..Default::default()
        };
        assert!(running.in_flight());
    }

    #[test]
    fn the_wire_shape_parses_as_the_bff_sends_it() {
        // Field for field against `api::mirrors::Mirror`'s serialisation, so a
        // rename on either side is a failure here rather than a silently empty
        // panel.
        let wire = r#"{
            "path": "nordisk/linux",
            "namespace": "nordisk",
            "name": "linux",
            "remote": "https://github.com/alice/linux.git",
            "transport": "https",
            "prune": true,
            "state": "syncing",
            "credential": "token",
            "credential_username": "x-access-token",
            "last_sync_at_unix_ms": null,
            "last_error": null,
            "sync_count": 3,
            "job": {
                "job_id": "mirror-1",
                "path": "nordisk/linux",
                "state": "running",
                "queue_position": 0,
                "has_progress": false,
                "progress_done": 0,
                "progress_total": 0,
                "bytes": 4096,
                "objects": null,
                "updates": [],
                "error": null,
                "enqueued_at_unix_ms": 1700000000000,
                "started_at_unix_ms": 1700000001000,
                "finished_at_unix_ms": null
            }
        }"#;

        let mirror: Mirror = serde_json::from_str(wire).expect("the BFF's own shape");
        assert_eq!(mirror.path, "nordisk/linux");
        assert_eq!(mirror.credential_line(), "token, as x-access-token");
        assert_eq!(mirror.credential_mismatch(), None);
        assert!(mirror.in_flight());
        let job = mirror.job.expect("a job");
        assert_eq!(job.progress(), Progress::NoDenominator);
        assert_eq!(job.percent(), None);
    }

    // ── importing into one's own namespace (2026-10-02) ───────────────────

    /// ★ A job gunnar stopped at the namespace's cap is shown in the
    /// customer's words, not as an engineer's line about a pack.
    #[test]
    fn a_job_stopped_at_the_cap_says_it_needs_more_room() {
        let job: MirrorJob = serde_json::from_str(
            r#"{"job_id":"j","path":"kim/big","state":"failed","queue_position":0,
                "has_progress":false,"progress_done":0,"progress_total":0,"bytes":9,
                "objects":null,"updates":[],"error":"plan x would pass its pack …",
                "enqueued_at_unix_ms":null,"started_at_unix_ms":null,
                "finished_at_unix_ms":null,"needs_room":true,
                "words":"This sync was stopped because kim needs more room.",
                "room_href":"/account"}"#,
        )
        .expect("the BFF's shape");
        assert!(job.needs_room);
        assert!(job.live_line().contains("needs more room"), "{}", job.live_line());
        assert_eq!(job.room_href.as_deref(), Some("/account"));
        // An older BFF sends none of the three; the job still parses.
        let old: MirrorJob = serde_json::from_str(
            r#"{"job_id":"j","path":"kim/big","state":"failed","queue_position":0,
                "has_progress":false,"progress_done":0,"progress_total":0,"bytes":9,
                "objects":null,"updates":[],"error":"x","enqueued_at_unix_ms":null,
                "started_at_unix_ms":null,"finished_at_unix_ms":null}"#,
        )
        .expect("an older BFF's shape");
        assert!(!old.needs_room);
    }

    #[test]
    fn the_address_form_is_ready_only_for_an_address_this_box_can_mirror() {
        assert!(can_import_url("", "kim", false, true).is_err());
        assert!(can_import_url("https://code.example.org/u/tool.git", "", false, true).is_err());
        assert!(can_import_url("file:///srv/tool.git", "kim", false, true).is_err());
        assert!(can_import_url("tool", "kim", false, true).is_err());
        assert_eq!(can_import_url("https://code.example.org/u/tool.git", "kim", true, true), Ok(()));
        assert_eq!(can_import_url("http://10.0.0.5:8080/tool.git", "kim", false, true), Ok(()));
        assert_eq!(can_import_url("git@code.example.org:u/tool.git", "kim", false, true), Ok(()));
        let ssh_with_token = can_import_url("ssh://git@code.example.org/u/tool.git", "kim", true, true);
        assert!(
            ssh_with_token.as_ref().is_err_and(|why| why.contains("https")),
            "{ssh_with_token:?}"
        );
    }

    #[test]
    fn an_address_lands_at_its_last_segment_without_dot_git() {
        assert_eq!(lands_at_url("kim", "https://code.example.org/u/tool.git", ""), "kim/tool");
        assert_eq!(lands_at_url("kim", "git@code.example.org:u/tool.git", ""), "kim/tool");
        assert_eq!(lands_at_url("kim", "https://code.example.org/u/tool/", ""), "kim/tool");
        assert_eq!(lands_at_url("kim", "https://code.example.org/u/tool.git", "mine"), "kim/mine");
    }

    #[test]
    fn the_consoles_own_import_refusals_are_worded_for_a_customer() {
        assert_eq!(
            refusal_summary(403, "notyours"),
            Some("You can only import into a namespace of your own.")
        );
        assert_eq!(refusal_summary(403, "needsroom"), Some("This needs more room."));
        assert_eq!(refusal_summary(429, "busy"), Some("Too many at once."));
        assert_eq!(refusal_summary(403, "permissiondenied"), None, "gunnar's own keep theirs");
    }

    #[test]
    fn a_row_says_whether_this_session_may_tend_it() {
        let mine: Mirror = serde_json::from_str(
            r#"{"path":"kim/m","namespace":"kim","name":"m","remote":"https://h/m.git",
                "transport":"https","prune":false,"state":"idle","credential":"none",
                "credential_username":"","last_sync_at_unix_ms":null,"last_error":null,
                "sync_count":0,"job":null,"may_tend":true}"#,
        )
        .expect("parses");
        assert!(mine.may_tend);
        let older: Mirror = serde_json::from_str(
            r#"{"path":"kim/m","namespace":"kim","name":"m","remote":"https://h/m.git",
                "transport":"https","prune":false,"state":"idle","credential":"none",
                "credential_username":"","last_sync_at_unix_ms":null,"last_error":null,
                "sync_count":0,"job":null}"#,
        )
        .expect("an older BFF's shape parses");
        assert!(!older.may_tend, "no word from the BFF is no buttons");
    }

    /// ★ **SSH import is the operator's** (owner order 2026-10-18): its deploy
    /// key comes from the appliance, which an ordinary user cannot reach, so
    /// for them it is a dead end. They are sent to the https address, and no
    /// message offers them SSH.
    #[test]
    fn ssh_import_is_offered_to_operators_only() {
        let refused = can_import_url("git@code.example.org:u/tool.git", "kim", false, false)
            .expect_err("an ssh address from a non-operator");
        assert!(refused.contains("https://"), "{refused}");
        assert!(can_import_url("ssh://git@code.example.org/u/tool.git", "kim", false, false).is_err());
        assert_eq!(can_import_url("https://code.example.org/u/tool.git", "kim", false, false), Ok(()));
        assert_eq!(can_import_url("git@code.example.org:u/tool.git", "kim", false, true), Ok(()));

        let spent = Connected { forge: "github".to_owned(), holds_token: false, ..Connected::default() };
        let said = can_import(Some(&spent), true, "nordisk", 3, false).expect_err("no token held");
        assert!(!said.contains("SSH"), "a non-operator is not offered SSH: {said}");
        let said = can_import(Some(&spent), true, "nordisk", 3, true).expect_err("no token held");
        assert!(said.contains("SSH"), "an operator still is: {said}");
    }

    /// ★ **LFS is named, not silently skipped** (owner order 2026-10-18).
    #[test]
    fn a_mirror_whose_upstream_uses_lfs_says_its_files_were_not_copied() {
        let lfs: Mirror = serde_json::from_str(
            r#"{"path":"kim/tool","namespace":"kim","name":"tool","remote":"https://x/y.git",
                "transport":"https","prune":false,"state":"idle","credential":"none",
                "credential_username":"","last_sync_at_unix_ms":null,"last_error":null,
                "sync_count":1,"job":null,"lfs_not_copied":true}"#,
        )
        .expect("a mirror row");
        let line = lfs.lfs_line().expect("an LFS mirror says so");
        assert!(line.contains("LFS files are not copied yet"), "{line}");
        assert_eq!(Mirror::default().lfs_line(), None);
    }

    /// ★ **One wording for the prune switch.** "Delete local refs" and "Delete
    /// local branches" were two wordings of the same option on the two import
    /// forms.
    #[test]
    fn the_prune_switch_reads_the_same_on_both_import_forms() {
        let page = include_str!("pages/mirrors.rs");
        assert!(!page.contains("Delete local refs the remote"), "the old wording is gone");
        assert!(!page.contains("Delete local branches the remote"), "the old wording is gone");
        assert_eq!(page.matches("{PRUNE_LABEL}").count(), 2, "both forms say PRUNE_LABEL");
    }
}

/// **Forge import that is off is a setting, not a failure** (GUN-FIELD-TEST, 2026-11-01):
/// the server's refusal when the console was built without `forges` or runs with it
/// switched off. `Some(its words)` for those two; `None` for anything else.
pub fn forges_off(code: &str, message: &str) -> Option<String> {
    let off = message.starts_with("forge import is switched off") || message.starts_with("forge import is not built");
    (code == "failedprecondition" && off).then(|| message.to_owned())
}

#[cfg(test)]
mod gunfield_tests {
    use super::*;

    /// ★ The two "forge import is off" answers are a setting; any other refusal is not.
    #[test]
    fn forge_import_that_is_off_reads_as_a_setting() {
        let off = "forge import is switched off on this console: it dials the public forges (github.com, gitlab.com, …), so it waits for GUNNAR_UI_FORGES=on";
        assert_eq!(forges_off("failedprecondition", off).as_deref(), Some(off));
        let unbuilt = "forge import is not built into this console: it dials the public forges";
        assert_eq!(forges_off("failedprecondition", unbuilt).as_deref(), Some(unbuilt));
        assert_eq!(forges_off("unavailable", off), None, "another code is a failure");
        assert_eq!(forges_off("failedprecondition", "the token was refused"), None);
    }

    /// ★ The Forges panel says it as a hint, and the import page does not draw the
    /// connected-forge panel at all when forge import is off.
    #[test]
    fn the_mirrors_pages_draw_forges_off_as_a_hint_and_nothing_more() {
        let page = crate::code::of(include_str!("pages/mirrors.rs"));
        assert!(page.matches("crate::mirror::forges_off(").count() >= 2, "the Forges panel and the connected-forge panel");
        assert!(page.contains("class=\"hint forges-off\""), "a hint, not a failure box");
    }
}