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