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

nordisk/holger-ui

src/util.rs

//! Pure helpers — no web APIs, so they compile and unit-test on the native
//! target too (the rest of the crate is wasm-only; hence the allow — native
//! builds see no callers).
#![allow(dead_code)]

/// `namespace/name` when a namespace exists, else just the name.
pub fn display_name(namespace: &str, name: &str) -> String {
    if namespace.is_empty() {
        name.to_string()
    } else {
        format!("{namespace}/{name}")
    }
}

/// Percent-encode one URL query component (RFC 3986 unreserved kept) — raw
/// artifact names carry `/`, so every coordinate that rides a query string
/// goes through here. Pure Rust: testable natively, no js interop.
pub fn enc(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                out.push(b as char)
            }
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

/// The shareable address of an artifact's detail view (query-addressed —
/// names contain slashes).
pub fn artifact_href(repo: &str, namespace: &str, name: &str, version: &str) -> String {
    format!(
        "/artifact?repo={}&ns={}&name={}&version={}",
        enc(repo),
        enc(namespace),
        enc(name),
        enc(version)
    )
}

/// `1234567` → `1.2 MB` — sizes are for scanning, not accounting.
pub fn fmt_bytes(n: i64) -> String {
    let n = n.max(0) as f64;
    const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
    let mut v = n;
    let mut u = 0;
    while v >= 1000.0 && u < UNITS.len() - 1 {
        v /= 1000.0;
        u += 1;
    }
    if u == 0 {
        format!("{} {}", v as i64, UNITS[u])
    } else {
        format!("{v:.1} {}", UNITS[u])
    }
}

/// One property filter as it rides the search URL: `key` alone (presence) or
/// `key=value` (exact value among the key's values) — the FIRST `=` splits,
/// values may contain more.
pub fn parse_prop_param(raw: &str) -> (String, String) {
    match raw.split_once('=') {
        Some((k, v)) => (k.to_string(), v.to_string()),
        None => (raw.to_string(), String::new()),
    }
}

pub fn prop_param(key: &str, value: &str) -> String {
    if value.is_empty() {
        key.to_string()
    } else {
        format!("{key}={value}")
    }
}

/// Does the typed confirmation match the phrase the dialog demands?
///
/// Exact match after trimming the ends — a paste that picked up a trailing
/// newline should not block a legitimate confirmation, but a DIFFERENT name
/// must never pass, and an empty requirement can never be satisfied (a
/// type-to-confirm gate with nothing to type would be no gate at all).
pub fn confirm_phrase_matches(typed: &str, required: &str) -> bool {
    let required = required.trim();
    !required.is_empty() && typed.trim() == required
}

// ─── Shared vocabulary ───────────────────────────────────────────────────────

/// Semantic tone — never color alone: every badge carries its text. Lives here
/// rather than in `components::feedback` (which re-exports it) so the pages'
/// state→tone decisions are pure functions a native test can pin.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tone {
    Good,
    Warn,
    Bad,
    Neutral,
}

impl Tone {
    /// The CSS classes a toned badge carries: the shared `badge` base plus the
    /// tone's own modifier. The base is what gives the badge its box; a tone
    /// that forgot it would render as bare text.
    pub fn class(self) -> &'static str {
        match self {
            Tone::Good => "badge badge-good",
            Tone::Warn => "badge badge-warn",
            Tone::Bad => "badge badge-bad",
            Tone::Neutral => "badge badge-neutral",
        }
    }
}

// ─── Search addresses ────────────────────────────────────────────────────────
//
// The URL *is* the search state: running a search is navigating, a shared
// address auto-runs, and removing a chip is re-navigating without it. So every
// address the search view builds goes through these two functions — an axis
// value that escapes wrong would silently search for something else.

/// The named axes of the search URL, in the order the console writes them.
/// Both the address builder and the chip list walk this, so a new axis lands
/// in the URL, the chips and the removal path at once.
pub const SEARCH_AXES: [&str; 6] = ["name", "namespace", "version", "path", "checksum", "repo"];

/// `/search?…` from the axis values as typed and the property-filter rows.
/// Blank axes and blank property keys are left out entirely — an axis present
/// but empty would be a filter on the empty string, not "no filter". Values
/// are trimmed (paste noise) and percent-encoded (a name may carry `/`, `&`,
/// `=` or non-ASCII, and must not be able to invent a second parameter).
pub fn search_href(axes: &[(&str, String)], props: &[(String, String)]) -> String {
    let mut parts: Vec<String> = Vec::new();
    for (key, value) in axes {
        let value = value.trim();
        if !value.is_empty() {
            parts.push(format!("{key}={}", enc(value)));
        }
    }
    for (key, value) in props {
        let key = key.trim();
        if !key.is_empty() {
            parts.push(format!("prop={}", enc(&prop_param(key, value.trim()))));
        }
    }
    search_query_href(parts)
}

/// `/search?…` rebuilt from the active filter chips — the address behind each
/// chip's ✕ is this list minus that one. Each chip is (kind, raw value) where
/// kind is an axis name or `prop`, and the value is re-encoded on the way out,
/// so a value that arrived decoded (`a/b`, `k=v`) survives the round trip.
pub fn search_href_from_chips(chips: &[(String, String)]) -> String {
    search_query_href(
        chips
            .iter()
            .map(|(kind, value)| format!("{kind}={}", enc(value)))
            .collect(),
    )
}

fn search_query_href(parts: Vec<String>) -> String {
    if parts.is_empty() {
        // No filters left is the bare route: `/search?` would be the same
        // empty query with a stray `?` in the address bar.
        "/search".to_string()
    } else {
        format!("/search?{}", parts.join("&"))
    }
}

// ─── Repository list ─────────────────────────────────────────────────────────

/// `/repositories?…` — the filter and sort that make a listing a shareable
/// address. Both values are percent-encoded: `sort` comes back off the URL and
/// is re-emitted on the next click, so an injected `sort=x&q=y` must ride as
/// ONE parameter, not become two.
pub fn repositories_href(filter: &str, sort: &str) -> String {
    let mut parts: Vec<String> = Vec::new();
    if !filter.is_empty() {
        parts.push(format!("q={}", enc(filter)));
    }
    if !sort.is_empty() {
        parts.push(format!("sort={}", enc(sort)));
    }
    if parts.is_empty() {
        "/repositories".to_string()
    } else {
        format!("/repositories?{}", parts.join("&"))
    }
}

/// Clicking a column sorts by it; clicking the column already sorted by clears
/// the sort (back to the server's configuration order). Returns the `sort=`
/// value the next address should carry — empty means "no sort parameter".
pub fn toggled_sort(current: &str, clicked: &'static str) -> &'static str {
    if current == clicked {
        ""
    } else {
        clicked
    }
}

/// The repository filter: case-insensitive substring. An EMPTY needle matches
/// everything — no filter is not a filter that excludes everything.
pub fn matches_name_filter(name: &str, needle: &str) -> bool {
    needle.is_empty() || name.to_lowercase().contains(&needle.to_lowercase())
}

/// Row order under the URL's `sort=` key, over `(name, repo_type)`. An unknown
/// (or absent) key compares every row equal, which a STABLE sort leaves in the
/// server's configuration order — the console never invents an order the
/// operator did not ask for. Type-sorting breaks ties by name so a repeated
/// sort is deterministic.
pub fn repo_order(a: (&str, &str), b: (&str, &str), sort: &str) -> std::cmp::Ordering {
    match sort {
        "name" => a.0.cmp(b.0),
        "type" => a.1.cmp(b.1).then(a.0.cmp(b.0)),
        _ => std::cmp::Ordering::Equal,
    }
}

// ─── Repository facts off the wire ───────────────────────────────────────────

/// The group text beside a repository type: `group · N members`, or nothing
/// for a repository that owns its own storage. Both facts are holger's
/// (`RepositoryInfo.is_group` / `members`), never inferred from the type.
pub fn group_label(is_group: bool, members: usize) -> Option<String> {
    is_group.then(|| {
        let noun = if members == 1 { "member" } else { "members" };
        format!("group · {members} {noun}")
    })
}

/// What an EMPTY browse listing means, decided by
/// `RepositoryInfo.supports_listing` and not by a guess over the repo type.
/// `None` is "the facts have not arrived (or failed)", which is neither claim.
pub fn empty_listing_note(supports_listing: Option<bool>) -> &'static str {
    match supports_listing {
        Some(true) => "nothing listed — the repository is empty (this type enumerates for real)",
        Some(false) => {
            "nothing listed — this repository does not support generic listing, so an empty 
             list is NOT proof of an empty repository; search and the native package 
             protocol can still reach it"
        }
        None => "nothing listed — whether this repository supports listing is unknown until its facts load",
    }
}

// ─── Overview counts ─────────────────────────────────────────────────────────

/// The repositories card's second line. Read-only is derived, not counted:
/// `total - writable` (saturating — a count that outran its total is a bug, not
/// a panic on the operator's screen).
pub fn repo_totals_line(total: usize, writable: usize, archived: usize) -> String {
    format!(
        "{writable} writable · {} read-only · {archived} archive-backed",
        total.saturating_sub(writable)
    )
}

/// Pure: **an uptime in words** (HCON-4), the ONE formatter every page uses: seconds under a
/// minute, minutes under an hour, hours and minutes under a day, then days and hours.
/// Takes either proto's integer (Health's `i64`, the admin snapshot's); a negative is 0.
pub fn uptime_words(secs: impl Into<i128>) -> String {
    let secs = secs.into().max(0) as u128;
    let (d, h, m) = (secs / 86_400, secs % 86_400 / 3600, secs % 3600 / 60);
    match secs {
        0..=59 => format!("{secs} s"),
        60..=3599 => format!("{m} min"),
        3600..=86_399 => format!("{h} h {m} min"),
        _ => format!("{d} d {h} h"),
    }
}

/// Pure: **the session in plain words** (HCON-2): how this person signed in and their role
/// here, as holger answered WhoAmI. The raw subject stays out of it.
pub fn session_words(method: &str, role: &str) -> String {
    let how = match method {
        "passkey" => "a passkey",
        "oidc" | "oauth2" => "your organisation's sign-in",
        "mtls" => "a client certificate",
        _ => "a token",
    };
    format!("You are signed in with {how}. Your role here: {role}.")
}

/// Pure: **the read gate in one sentence** (HCON-5): what it does for this Holger's packages.
pub fn read_gate_words(on: bool) -> &'static str {
    if on {
        "Reading packages needs a sign-in: package clients and visitors need a token."
    } else {
        "Anyone who can reach this Holger can read its packages without signing in. That is meant for an airgapped downstream only."
    }
}

/// Pure: **what is open on this Holger** (HCON-5): the server's own words for its RON's
/// `access` block and public repositories; a Holger too old to say them is drawn from its read
/// gate alone.
pub fn access_words(server_words: &str, read_gate: bool) -> String {
    match server_words.trim() {
        "" => read_gate_words(read_gate).to_string(),
        words => words.to_string(),
    }
}

// ─── Artifact detail ─────────────────────────────────────────────────────────

/// The file name a downloaded artifact is saved under: the last path segment
/// of its name (raw coordinates carry `/`). Never empty — an empty `download`
/// attribute silently hands the browser its own guess, so a name that ends in
/// a separator, or is empty, falls back to `artifact`.
pub fn download_file_name(artifact_name: &str) -> String {
    match artifact_name.rsplit('/').next() {
        Some(last) if !last.is_empty() => last.to_string(),
        _ => "artifact".to_string(),
    }
}

/// The property editor's comma-separated values. Whitespace around each value
/// is paste noise; an empty value is not a value. An all-empty input yields no
/// values at all, which is what makes the editor demand at least one — empty
/// `values` is the proto's REMOVE idiom, and a stray comma must not delete a
/// property the operator meant to edit.
pub fn parse_property_values(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(|v| v.trim().to_string())
        .filter(|v| !v.is_empty())
        .collect()
}

// ─── Replication ─────────────────────────────────────────────────────────────

/// The two refusals that never need the server: nothing chosen, and a pair
/// that would copy a repository onto itself. `None` = the pair is worth sending.
pub fn replication_pair_error(from: &str, to: &str) -> Option<&'static str> {
    if from.is_empty() || to.is_empty() {
        return Some("choose both repositories");
    }
    if from == to {
        return Some("source and destination must differ");
    }
    None
}

/// May the Execute button be armed? Only while the CURRENT pair is the pair a
/// preview was actually seen for. No preview (including a run that consumed
/// its own) never arms; changing either end disarms; and an empty pair — which
/// the preview path refuses anyway — can never arm, so the gate cannot be
/// satisfied by two blanks.
pub fn execute_armed(previewed: Option<(&str, &str)>, from: &str, to: &str) -> bool {
    match previewed {
        Some((f, t)) => !from.is_empty() && !to.is_empty() && f == from && t == to,
        None => false,
    }
}

// ─── Transport ───────────────────────────────────────────────────────────────

/// The gRPC-web door's base URL: the build-time `HOLGER_GRPC_WEB_URL` when one
/// was compiled in, else the page's own origin under `/grpc` (same-origin, so
/// no CORS ceremony). Trailing slashes are trimmed off both — `origin//grpc`
/// and `base//` are 404s that look like configuration.
pub fn grpc_base_url(override_url: Option<&str>, origin: Option<&str>) -> String {
    if let Some(url) = override_url {
        return url.trim_end_matches('/').to_string();
    }
    let origin = origin.unwrap_or("http://127.0.0.1:8090");
    format!("{}/grpc", origin.trim_end_matches('/'))
}

/// The topbar's environment chip: which server this console talks to, with the
/// scheme and the door's `/grpc` suffix stripped — the operator recognizes the
/// HOST, and a chip that reads the same for dev and prod is the whole risk.
/// Pure: **the topbar's domain chip** (lane HOL-CDS): holger's `X-Holger-Domain`, trimmed,
/// at most 64 characters (holger's own limit); nothing when holger names none.
pub fn domain_label(header: Option<&str>) -> Option<String> {
    let d = header?.trim();
    (!d.is_empty()).then(|| d.chars().take(64).collect())
}

pub fn environment_label(base_url: &str) -> String {
    base_url
        .trim_start_matches("https://")
        .trim_start_matches("http://")
        .trim_end_matches("/grpc")
        .to_string()
}

/// What a failed call MEANS, by status code — the sentence a FailBox leads
/// with, before the server's verbatim message. These are different situations
/// demanding different next steps (log in · ask for a role · this build cannot
/// · the backend is down), so they must not collapse into one "error". Any
/// other code gets the honest, non-committal "request failed".
/// What every pane says when holger answers UNIMPLEMENTED: the service is
/// not compiled into this holger (the lean default build leaves supply-chain,
/// mirror, migration and admission out). Said, never hidden, never a crash.
pub const NOT_IN_BUILD: &str = "not in this build";

/// Pure: the sentence for a gated service holger does not serve.
pub fn not_in_build(service: &str) -> String {
    format!("{service}: {NOT_IN_BUILD} — this holger was built without it")
}

pub fn fail_summary(code: tonic::Code) -> &'static str {
    match code {
        tonic::Code::Unauthenticated => "authentication required",
        tonic::Code::PermissionDenied => {
            "permission denied — the server's role mapping does not allow this"
        }
        tonic::Code::Unimplemented => NOT_IN_BUILD,
        tonic::Code::Unavailable => "backend unavailable",
        tonic::Code::NotFound => "not found",
        _ => "request failed",
    }
}

/// Does this failure have a login link next to its summary? Exactly the code
/// that says authentication is required — offering "log in" for a permission
/// denial would send an already-authenticated operator round a loop that
/// cannot help them.
pub fn fail_suggests_login(code: tonic::Code) -> bool {
    code == tonic::Code::Unauthenticated
}

// ─── Migration (Operations → Migrate) ────────────────────────────────────────
//
// Everything the Migrate page DECIDES lives here, as pure functions a native
// test can pin — and, above all, as the only place a source credential is ever
// turned into words. The page renders these strings and no others about the
// source, so `a_source_password_never_reaches_anything_the_page_renders` covers
// the real renderers and not a copy of them.

/// The source form as the page holds it: what an operator typed into Operations
/// → Migrate before pressing Discover.
///
/// # ⚠ The password
///
/// It is a PARAMETER of one call. It is never written to storage, never put in
/// a URL, never logged, and never left in this struct after the call it was
/// typed for — [`forgotten`] is what the page applies the moment a request has
/// been built. `Debug` is implemented by hand for the same reason: the derived
/// one printed the secret, and that exact class of leak is live elsewhere in
/// this codebase (`ConnectorAuth`), so it is not a hypothetical.
#[derive(Clone, PartialEq, Eq)]
pub struct SourceForm {
    /// `MigrationSource.Kind`: 1 = Nexus, 2 = Artifactory, 0 = nothing chosen.
    pub kind: i32,
    pub base_url: String,
    pub username: String,
    pub password: String,
    /// Empty = the whole registry. Non-empty = that one source repository.
    pub repository: String,
}

impl Default for SourceForm {
    fn default() -> Self {
        Self {
            kind: MIGRATION_KIND_NEXUS,
            base_url: String::new(),
            username: String::new(),
            password: String::new(),
            repository: String::new(),
        }
    }
}

impl std::fmt::Debug for SourceForm {
    /// Redacted BY CONSTRUCTION. A derived `Debug` would print the password in
    /// full the first time anything formatted this struct — a panic message, a
    /// future log line, an `assert_eq!` failure. There is nothing to remember
    /// not to do, because the secret is not reachable through this impl.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SourceForm")
            .field("kind", &self.kind)
            .field("host", &source_host(&self.base_url))
            .field("username", &self.username)
            .field("password", &"<redacted>")
            .field("repository", &self.repository)
            .finish()
    }
}

pub const MIGRATION_KIND_NEXUS: i32 = 1;
pub const MIGRATION_KIND_ARTIFACTORY: i32 = 2;

/// The operator's word for a `MigrationSource.Kind`.
pub fn source_kind_label(kind: i32) -> &'static str {
    match kind {
        MIGRATION_KIND_NEXUS => "Nexus",
        MIGRATION_KIND_ARTIFACTORY => "Artifactory",
        _ => "unchosen",
    }
}

/// The host a source URL names, with **any userinfo stripped**.
///
/// `https://alice:hunter2@nexus.example/` is a URL a person will paste, and
/// echoing it back into the page would put the password on the screen, into the
/// DOM, and into anything that later copies the DOM. Everything before the last
/// `@` of the authority is a credential and is dropped here.
pub fn source_host(base_url: &str) -> String {
    let rest = base_url
        .split_once("://")
        .map(|(_, r)| r)
        .unwrap_or(base_url);
    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
    match authority.rsplit_once('@') {
        Some((_, host)) => host.to_string(),
        None => authority.to_string(),
    }
}

/// True when the URL's authority carries `user[:password]@`.
pub fn source_url_carries_a_credential(base_url: &str) -> bool {
    let rest = base_url
        .split_once("://")
        .map(|(_, r)| r)
        .unwrap_or(base_url);
    rest.split(['/', '?', '#'])
        .next()
        .is_some_and(|a| a.contains('@'))
}

/// What the page says it is about to talk to: the kind, the host, and which
/// identity — never the URL verbatim, and never the password.
pub fn source_summary(form: &SourceForm) -> String {
    let host = source_host(&form.base_url);
    let host = if host.is_empty() { "—" } else { host.as_str() };
    let who = if form.username.trim().is_empty() {
        "anonymous".to_string()
    } else {
        format!("as {}", form.username.trim())
    };
    let scope = if form.repository.trim().is_empty() {
        "every repository it can find".to_string()
    } else {
        format!("only '{}'", form.repository.trim())
    };
    format!(
        "{} at {host} · {who} · {scope}",
        source_kind_label(form.kind)
    )
}

/// The refusals that never need the server. `None` = worth sending.
///
/// None of these sentences echoes the URL or the password: an error message is
/// the most-copied text on any page, and a credential that reaches one has
/// escaped.
pub fn source_form_error(form: &SourceForm) -> Option<String> {
    if form.kind != MIGRATION_KIND_NEXUS && form.kind != MIGRATION_KIND_ARTIFACTORY {
        return Some("choose the source registry: Nexus or Artifactory".into());
    }
    let url = form.base_url.trim();
    if url.is_empty() {
        return Some("the source URL is required".into());
    }
    if !(url.starts_with("http://") || url.starts_with("https://")) {
        return Some("the source URL must start with http:// or https://".into());
    }
    if source_url_carries_a_credential(url) {
        return Some(
            "that URL carries a credential inside it — put the user and password in the \
             fields instead. A URL is echoed, copied and logged; these fields are not."
                .into(),
        );
    }
    None
}

/// The form as it must be left the instant a request has been built: same
/// everything, no password. The page applies this BEFORE the call is awaited,
/// so the secret is alive only inside the request that needs it.
pub fn forgotten(form: &SourceForm) -> SourceForm {
    SourceForm {
        password: String::new(),
        ..form.clone()
    }
}

/// May this planned repository be filled by pressing Start *now*?
///
/// **Only `existing`.** A `create` row is the one holger cannot do anything
/// about while it runs: the route table is built from the config at boot and is
/// immutable for the process lifetime, so a Start that included it would refuse
/// that repository at run time, asset by asset. The page therefore does not
/// offer it — it shows the declaration to paste instead. `collision` and
/// `unmapped_format` refuse in the plan itself.
pub fn migration_row_selectable(disposition: &str) -> bool {
    disposition == "existing"
}

/// The badge tone for a planned repository's disposition.
pub fn disposition_tone(disposition: &str) -> Tone {
    match disposition {
        "existing" => Tone::Good,
        "create" => Tone::Warn,
        "collision" | "unmapped_format" => Tone::Bad,
        _ => Tone::Neutral,
    }
}

/// What a disposition MEANS, in the operator's terms and in the imperative
/// where there is something to do.
pub fn disposition_note(disposition: &str) -> &'static str {
    match disposition {
        "existing" => "declared here already — Start fills it",
        "create" => "NOT declared here. holger cannot create it while it runs: \
                     paste the declaration below into the config, restart, then discover again",
        "collision" => "the same name is declared here in a different format — nothing will run \
                        until that is resolved",
        "unmapped_format" => "holger has no repository format for what the source calls this",
        _ => "unknown disposition",
    }
}

/// The one-line verdict above the repository table.
pub fn migration_headline(repos: usize, fillable: usize, to_declare: usize, refused: usize) -> String {
    let mut s = format!("{repos} repositories found · {fillable} fillable now");
    if to_declare > 0 {
        s.push_str(&format!(" · {to_declare} must be declared first"));
    }
    if refused > 0 {
        s.push_str(&format!(" · {refused} refused"));
    }
    s
}

/// The banner that makes the real operator flow impossible to miss. `None`
/// when nothing needs declaring.
pub fn declare_first_note(to_declare: usize) -> Option<String> {
    (to_declare > 0).then(|| {
        format!(
            "{to_declare} of these repositories do not exist on this holger, and holger CANNOT \
             create them while it is running — its route table is built from the config at boot \
             and is immutable thereafter. Copy each declaration below into the wired config, \
             restart holger, then run Discover again: they will come back as fillable.",
        )
    })
}

/// What pressing Start will actually do — decided here, so the page never has
/// to guess and a test can pin every case.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum StartPlan {
    /// Nothing is selected. Start is not offered.
    Nothing,
    /// Every repository in the plan is selected: ONE job, whole registry, which
    /// is what `MigrationSource.repository = ""` means on the wire.
    WholeRegistry,
    /// A strict subset: one job per repository, run in turn. The contract has no
    /// "these five" — it has "all" and "this one" — so the page spells the
    /// subset out as the jobs it really is rather than pretending otherwise.
    PerRepository(Vec<String>),
}

pub fn start_plan(all: &[String], selected: &[String]) -> StartPlan {
    if selected.is_empty() {
        return StartPlan::Nothing;
    }
    if !all.is_empty() && all.iter().all(|r| selected.contains(r)) {
        return StartPlan::WholeRegistry;
    }
    let mut chosen: Vec<String> = all
        .iter()
        .filter(|r| selected.contains(r))
        .cloned()
        .collect();
    if chosen.is_empty() {
        chosen = selected.to_vec();
    }
    StartPlan::PerRepository(chosen)
}

/// How many jobs a start plan is, for the sentence on the button's right.
pub fn start_plan_note(plan: &StartPlan) -> String {
    match plan {
        StartPlan::Nothing => "nothing selected".to_string(),
        StartPlan::WholeRegistry => {
            "one job, the whole registry — every repository the source lists".to_string()
        }
        StartPlan::PerRepository(v) if v.len() == 1 => {
            format!("one job for '{}'", v[0])
        }
        StartPlan::PerRepository(v) => {
            format!("{} jobs, one per repository, run in turn", v.len())
        }
    }
}

/// One repository's progress row, as a sentence.
///
/// The server sends `done: report.moved` and `skipped: report.skipped` — two
/// disjoint counts, not a total and a part of it. So `done` IS the moved count,
/// and the numerator against `total` is everything settled: moved plus already
/// present. Subtracting `skipped` from `done` erased moved assets on any run
/// that both moved something and found something already there.
pub fn migration_progress_line(done: i64, skipped: i64, failed: i64, total: i64, bytes: i64) -> String {
    let moved = done.max(0);
    let settled = moved.saturating_add(skipped.max(0));
    format!(
        "{settled}/{total} · {moved} moved · {skipped} already present · {failed} failed · {}",
        fmt_bytes(bytes)
    )
}

/// Percent complete for a progress bar, clamped. A zero denominator is 0%, not
/// a division by zero and not a finished-looking 100%.
///
/// `done` counts assets MOVED only; an asset the destination already held is
/// counted in `skipped`. Both are settled work against the same denominator, so
/// an idempotent re-run — where every asset is already present — reads 100% and
/// not the 0% a moved-only numerator would show beside a complete verdict.
pub fn migration_percent(done: i64, skipped: i64, total: i64) -> u32 {
    if total <= 0 {
        return 0;
    }
    let settled = done.max(0).saturating_add(skipped.max(0));
    let pct = (settled as f64 / total as f64 * 100.0).round();
    pct.clamp(0.0, 100.0) as u32
}

/// The terminal verdict of the WHOLE JOB. An Ok stream is NOT success: a run
/// that finished with failures is one an operator would wrongly believe is done.
///
/// `complete` is only meaningful on the row that carries `job_finished`, so this
/// is the job row's verdict and nobody else's. A per-repository row must use
/// `migration_repo_outcome`, which never reads `complete`.
pub fn migration_outcome(complete: bool, cancelled: bool, failed: i64) -> (String, Tone) {
    if cancelled {
        return ("cancelled — a partial migration is on disk".to_string(), Tone::Warn);
    }
    if failed > 0 || !complete {
        return (
            format!("finished INCOMPLETE — {failed} assets failed; nothing may be assumed moved"),
            Tone::Bad,
        );
    }
    ("complete".to_string(), Tone::Good)
}

/// The verdict of ONE REPOSITORY's row. The per-repo event never sets
/// `complete` — the server leaves it false until the job's terminal row — so a
/// repository is judged on its own counters: nothing failed, nothing was cut
/// short, and every asset the source listed is settled, whether it was moved or
/// was already present.
pub fn migration_repo_outcome(
    done: i64,
    skipped: i64,
    failed: i64,
    total: i64,
    cancelled: bool,
) -> (String, Tone) {
    if cancelled {
        return ("cancelled — a partial migration is on disk".to_string(), Tone::Warn);
    }
    let settled = done.max(0).saturating_add(skipped.max(0));
    if failed > 0 || settled < total.max(0) {
        return (
            format!("finished INCOMPLETE — {failed} assets failed; nothing may be assumed moved"),
            Tone::Bad,
        );
    }
    ("complete".to_string(), Tone::Good)
}

/// The sentence for "this session cannot call the migration door". `None` when
/// the role is admin, which is the only role the server accepts here.
pub fn migration_role_refusal(authenticated: bool, global_role: &str) -> Option<String> {
    if global_role == "admin" {
        return None;
    }
    if !authenticated {
        return Some(
            "this session is anonymous. Every migration RPC is ADMIN-gated and fails closed — \
             log in with an admin credential before Discover."
                .to_string(),
        );
    }
    Some(format!(
        "this session's global role is '{global_role}'. Every migration RPC is ADMIN-gated and \
         fails closed, so Discover and Start will be refused until an admin credential is used."
    ))
}

/// **What the Disk panel says** — holger's own `disk_bytes` reading of one
/// repository (`UsageService.ReadUsage`): what it OCCUPIES on holger's disk, the
/// number a paid limit is measured against, and WHERE (owner 2026-09-27: "the
/// customer can see where their bytes are").
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiskView {
    /// Counted: the bytes, and each place with its own share, as holger said it.
    Counted { bytes: u64, places: Vec<String> },
    /// holger refused to count it, in its own words.
    Refused(String),
    /// This holger does not report `disk_bytes` (an older server).
    NotReported,
}

/// Pure over `(name, repository-scoped, counted, value, source, refusal_reason)`
/// of each reading,
/// so it is tested without a proto. Only the REPOSITORY-scoped reading counts;
/// holger joins the places with `"; "`.
pub fn disk_view<'a>(readings: impl IntoIterator<Item = (&'a str, bool, bool, u64, &'a str, &'a str)>) -> DiskView {
    for (name, repository_scope, counted, value, source, refusal) in readings {
        if name != "disk_bytes" || !repository_scope {
            continue;
        }
        return if counted {
            DiskView::Counted {
                bytes: value,
                places: source.split("; ").filter(|s| !s.trim().is_empty()).map(str::to_string).collect(),
            }
        } else {
            DiskView::Refused(if refusal.is_empty() { "not counted".to_string() } else { refusal.to_string() })
        };
    }
    DiskView::NotReported
}

// ─── The menu (lane HOL-UI-FIX) ──────────────────────────────────────────────

/// **The console's menu, one entry per job** (owner 2026-10-31, "3 menus doing the same flow"):
/// the estate at a glance, the flow picture, the repositories (and what works on them: search,
/// replicate, migrate), the security gateway, and the person's own account (their session,
/// repositories, tokens and plan). Admin is offered beside it to an admin's session only.
pub const MENU: [(&str, &str); 5] = [("/overview", "Overview"), ("/flow", "Flow"), ("/repositories", "Repositories"), ("/security", "Security"), ("/account", "Account")];
/// The admin's page, the menu's sixth entry for an admin's session.
pub const ADMIN_ENTRY: &str = "/admin";

/// Pure: **which menu entry a page belongs to**, so the menu marks it: every page sits under
/// exactly one entry. `None` for an address no entry owns.
pub fn menu_entry(path: &str) -> Option<&'static str> {
    let under = |p: &str| path == p || path.starts_with(&format!("{p}/"));
    Some(match path {
        "/" | "/overview" => "/overview",
        _ if under("/flow") => "/flow",
        _ if under("/repositories") || under("/repo") || under("/artifact") || under("/search") || under("/operations") || under("/replicate") || under("/migrate") => "/repositories",
        _ if under("/security") => "/security",
        // The Account entry's tabs (`mine::TABS`): the session, the person's repositories and
        // tokens, and a metered console's plan.
        _ if crate::mine::TABS.iter().any(|(p, _)| under(p)) => "/account",
        _ if under(ADMIN_ENTRY) => ADMIN_ENTRY,
        _ => return None,
    })
}

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

    /// ★ HOL-UI-FIX: every page sits under one menu entry, and the entries are five jobs: no
    /// second Overview, no Operations and no Mine of their own.
    #[test]
    fn every_page_sits_under_exactly_one_menu_entry() {
        let labels: Vec<&str> = MENU.iter().map(|(_, l)| *l).collect();
        assert_eq!(labels, ["Overview", "Flow", "Repositories", "Security", "Account"]);
        for (path, entry) in [
            ("/", "/overview"),
            ("/overview", "/overview"),
            ("/flow", "/flow"),
            ("/repositories", "/repositories"),
            ("/repo/incoming", "/repositories"),
            ("/artifact", "/repositories"),
            ("/search", "/repositories"),
            ("/operations", "/repositories"),
            ("/operations/migrate", "/repositories"),
            ("/replicate", "/repositories"),
            ("/migrate", "/repositories"),
            ("/security", "/security"),
            ("/security/artifact", "/security"),
            ("/account", "/account"),
            ("/mine", "/account"),
            ("/tokens", "/account"),
            ("/admin", "/admin"),
        ] {
            assert_eq!(menu_entry(path), Some(entry), "{path}");
        }
        #[cfg(feature = "metered")]
        assert_eq!(menu_entry(crate::mine::TABS[2].0), Some("/account"), "a metered console's plan");
        assert_eq!(menu_entry("/flowers"), None, "a prefix is not a page of the entry");
        assert_eq!(menu_entry("/no-such-page"), None);
    }
}

#[cfg(test)]
mod domain_tests {
    use super::domain_label;

    #[test]
    fn a_domain_label_is_holgers_trimmed_or_nothing() {
        assert_eq!(domain_label(Some(" onprem-lab ")), Some("onprem-lab".to_string()));
        assert_eq!(domain_label(Some("")), None);
        assert_eq!(domain_label(Some("   ")), None);
        assert_eq!(domain_label(None), None);
        assert_eq!(domain_label(Some(&"x".repeat(80))).map(|s| s.len()), Some(64), "cut, never grown");
    }
}

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

    #[test]
    fn the_disk_panel_shows_holgers_own_reading_and_each_place() {
        let v = disk_view([
            ("artifacts", true, true, 3, "crates", ""),
            ("disk_bytes", false, true, 999, "server", ""),
            ("disk_bytes", true, true, 61440, "archive (writable) /d/crates.znippy (57344 B); files /d/crates (4096 B)", ""),
        ]);
        assert_eq!(
            v,
            DiskView::Counted {
                bytes: 61440,
                places: vec!["archive (writable) /d/crates.znippy (57344 B)".into(), "files /d/crates (4096 B)".into()],
            }
        );
        assert_eq!(disk_view([("disk_bytes", true, false, 0, "", "no storage")]), DiskView::Refused("no storage".into()));
        assert_eq!(disk_view([("artifacts", true, true, 1, "", "")]), DiskView::NotReported);
    }
    #[test]
    fn display_name_joins_namespace_only_when_present() {
        assert_eq!(display_name("", "uploads/a.txt"), "uploads/a.txt");
        assert_eq!(display_name("org.acme", "drive"), "org.acme/drive");
    }

    #[test]
    fn enc_keeps_unreserved_and_escapes_the_rest() {
        assert_eq!(enc("abc-XYZ_0.9~"), "abc-XYZ_0.9~");
        assert_eq!(enc("a/b c+å"), "a%2Fb%20c%2B%C3%A5");
    }

    #[test]
    fn artifact_href_is_query_addressed_and_escaped() {
        assert_eq!(
            artifact_href("bundles", "", "acme/drive/2.0.0/firmware.bin", ""),
            "/artifact?repo=bundles&ns=&name=acme%2Fdrive%2F2.0.0%2Ffirmware.bin&version="
        );
    }

    #[test]
    fn fmt_bytes_scans_well() {
        assert_eq!(fmt_bytes(0), "0 B");
        assert_eq!(fmt_bytes(999), "999 B");
        assert_eq!(fmt_bytes(1000), "1.0 kB");
        assert_eq!(fmt_bytes(1_250_646), "1.3 MB");
        assert_eq!(fmt_bytes(-5), "0 B");
    }

    #[test]
    fn prop_param_round_trips_first_equals_split() {
        assert_eq!(parse_prop_param("env=prod"), ("env".into(), "prod".into()));
        assert_eq!(parse_prop_param("env"), ("env".into(), String::new()));
        assert_eq!(parse_prop_param("k=v=w"), ("k".into(), "v=w".into()));
        assert_eq!(prop_param("env", ""), "env");
        assert_eq!(prop_param("env", "prod"), "env=prod");
    }

    #[test]
    fn confirm_phrase_demands_the_exact_name() {
        assert!(confirm_phrase_matches("prod-bundles", "prod-bundles"));
        // Paste noise at the ends is forgiven…
        assert!(confirm_phrase_matches("  prod-bundles\n", "prod-bundles"));
        // …but a different, prefix, or near-miss name is NOT the name.
        assert!(!confirm_phrase_matches("prod-bundle", "prod-bundles"));
        assert!(!confirm_phrase_matches("prod-bundles-2", "prod-bundles"));
        assert!(!confirm_phrase_matches("PROD-BUNDLES", "prod-bundles"));
        assert!(!confirm_phrase_matches("prod bundles", "prod-bundles"));
        assert!(!confirm_phrase_matches("", "prod-bundles"));
    }

    #[test]
    fn an_empty_requirement_is_never_satisfied() {
        // A gate with nothing to type would silently arm the button.
        assert!(!confirm_phrase_matches("", ""));
        assert!(!confirm_phrase_matches("   ", "   "));
        assert!(!confirm_phrase_matches("anything", ""));
    }

    // ─── Tone ────────────────────────────────────────────────────────────────

    #[test]
    fn every_tone_carries_the_shared_badge_class() {
        // The modifier alone would render as bare text — and two tones sharing
        // a class would make two different states look identical.
        let all = [Tone::Good, Tone::Warn, Tone::Bad, Tone::Neutral];
        for t in all {
            assert!(
                t.class().starts_with("badge badge-"),
                "{t:?} lost the base class: {}",
                t.class()
            );
        }
        let mut classes: Vec<&str> = all.iter().map(|t| t.class()).collect();
        classes.sort_unstable();
        classes.dedup();
        assert_eq!(classes.len(), 4, "two tones share one class");
    }

    // ─── Search addresses ────────────────────────────────────────────────────

    #[test]
    fn the_search_axes_are_the_six_the_proto_offers() {
        // Order is the address's order; a seventh axis must be a decision here,
        // in the chips and in the removal path at once.
        assert_eq!(
            SEARCH_AXES,
            ["name", "namespace", "version", "path", "checksum", "repo"]
        );
    }

    fn axes(pairs: &[(&'static str, &str)]) -> Vec<(&'static str, String)> {
        pairs.iter().map(|(k, v)| (*k, v.to_string())).collect()
    }

    #[test]
    fn search_href_omits_blank_axes_and_escapes_the_rest() {
        assert_eq!(
            search_href(&axes(&[("name", " firmware "), ("namespace", "")]), &[]),
            "/search?name=firmware",
            "a blank axis is no filter at all, and paste noise is not part of the term"
        );
        // A term may carry anything a name can: separators, URL syntax, non-ASCII.
        assert_eq!(
            search_href(&axes(&[("name", "acme/drive&v=2"), ("repo", "caché")]), &[]),
            "/search?name=acme%2Fdrive%26v%3D2&repo=cach%C3%A9",
            "an unescaped term could invent a second parameter"
        );
    }

    #[test]
    fn search_href_carries_each_property_filter_as_one_parameter() {
        let props = vec![
            ("env".to_string(), " prod ".to_string()),
            ("signed".to_string(), String::new()),
            // A blank key is not a filter — the editor's empty row must not
            // become `prop=` and match everything (or nothing) server-side.
            ("  ".to_string(), "orphan".to_string()),
        ];
        assert_eq!(
            search_href(&axes(&[]), &props),
            "/search?prop=env%3Dprod&prop=signed"
        );
        // The value's own `=` rides inside the encoded parameter, not as syntax.
        let tricky = vec![("k".to_string(), "v=w&x".to_string())];
        assert_eq!(
            search_href(&axes(&[]), &tricky),
            "/search?prop=k%3Dv%3Dw%26x"
        );
        // …and it survives the round trip the server sees.
        assert_eq!(
            parse_prop_param("k=v=w&x"),
            ("k".into(), "v=w&x".into()),
            "the FIRST = splits; the value keeps the rest"
        );
    }

    #[test]
    fn a_search_with_nothing_in_it_is_the_bare_route() {
        assert_eq!(search_href(&axes(&[("name", "   ")]), &[]), "/search");
        assert_eq!(search_href_from_chips(&[]), "/search");
    }

    #[test]
    fn removing_a_chip_leaves_every_other_filter_intact_and_escaped() {
        let chips = vec![
            ("name".to_string(), "acme/drive".to_string()),
            ("repo".to_string(), "bundles".to_string()),
            ("prop".to_string(), "env=prod".to_string()),
            ("prop".to_string(), "signed".to_string()),
        ];
        assert_eq!(
            search_href_from_chips(&chips),
            "/search?name=acme%2Fdrive&repo=bundles&prop=env%3Dprod&prop=signed"
        );
        // Removing one property filter keeps the OTHER one — a chip is removed
        // by identity (kind AND value), never by kind alone.
        let kept: Vec<(String, String)> = chips
            .iter()
            .filter(|(k, v)| !(k == "prop" && v == "env=prod"))
            .cloned()
            .collect();
        assert_eq!(
            search_href_from_chips(&kept),
            "/search?name=acme%2Fdrive&repo=bundles&prop=signed"
        );
    }

    // ─── Repository list ─────────────────────────────────────────────────────

    #[test]
    fn repositories_href_carries_filter_and_sort() {
        assert_eq!(repositories_href("", ""), "/repositories");
        assert_eq!(repositories_href("dev", ""), "/repositories?q=dev");
        assert_eq!(repositories_href("", "name"), "/repositories?sort=name");
        assert_eq!(
            repositories_href("crates dev", "type"),
            "/repositories?q=crates%20dev&sort=type"
        );
    }

    #[test]
    fn a_sort_value_off_the_url_cannot_invent_a_second_parameter() {
        // `sort` is read from the address and written back on the next click,
        // so a crafted link must ride as ONE opaque (and simply unknown) value.
        assert_eq!(
            repositories_href("", "name&q=evil"),
            "/repositories?sort=name%26q%3Devil"
        );
        assert_eq!(
            repositories_href("a&b", "name"),
            "/repositories?q=a%26b&sort=name"
        );
    }

    #[test]
    fn clicking_the_active_sort_column_clears_it() {
        assert_eq!(toggled_sort("", "name"), "name");
        assert_eq!(toggled_sort("type", "name"), "name");
        assert_eq!(
            toggled_sort("name", "name"),
            "",
            "a second click returns to the server's configuration order"
        );
    }

    #[test]
    fn the_repository_filter_is_case_insensitive_and_empty_means_all() {
        assert!(matches_name_filter("crates-dev", "DEV"));
        assert!(matches_name_filter("CRATES-DEV", "dev"));
        assert!(matches_name_filter("anything", ""), "no filter excludes nothing");
        assert!(!matches_name_filter("crates-dev", "prod"));
        // Substring, not prefix — the filter is a search, not a namespace.
        assert!(matches_name_filter("prod-bundles", "bundle"));
    }

    #[test]
    fn repo_order_sorts_by_name_by_type_and_otherwise_not_at_all() {
        let rows = [("zeta", "maven"), ("alpha", "pip"), ("mid", "maven")];
        let sorted = |key: &str| {
            let mut v = rows.to_vec();
            v.sort_by(|a, b| repo_order((a.0, a.1), (b.0, b.1), key));
            v.iter().map(|r| r.0).collect::<Vec<_>>()
        };
        assert_eq!(sorted("name"), ["alpha", "mid", "zeta"]);
        // Type first, then name — a repeated sort must be deterministic.
        assert_eq!(sorted("type"), ["mid", "zeta", "alpha"]);
        // An absent or unknown key compares equal: a STABLE sort then leaves
        // the server's configuration order exactly as it arrived.
        assert_eq!(sorted(""), ["zeta", "alpha", "mid"]);
        assert_eq!(sorted("size"), ["zeta", "alpha", "mid"]);
    }

    // ─── Repository facts off the wire ───────────────────────────────────

    #[test]
    fn a_group_is_named_with_its_member_count_and_a_plain_repo_is_not() {
        assert_eq!(group_label(false, 3), None);
        assert_eq!(group_label(true, 0).as_deref(), Some("group · 0 members"));
        assert_eq!(group_label(true, 1).as_deref(), Some("group · 1 member"));
        assert_eq!(group_label(true, 4).as_deref(), Some("group · 4 members"));
    }

    #[test]
    fn the_empty_listing_note_comes_from_supports_listing_not_the_type() {
        assert!(empty_listing_note(Some(true)).contains("is empty"));
        assert!(empty_listing_note(Some(false)).contains("does not support generic listing"));
        assert!(empty_listing_note(None).contains("unknown"));
    }

    // ─── Overview counts ─────────────────────────────────────────────────────

    #[test]
    fn repo_totals_line_derives_read_only_from_the_total() {
        assert_eq!(
            repo_totals_line(7, 2, 3),
            "2 writable · 5 read-only · 3 archive-backed"
        );
        assert_eq!(
            repo_totals_line(0, 0, 0),
            "0 writable · 0 read-only · 0 archive-backed"
        );
        // Impossible input is a wrong number, never a panic on the operator's
        // overview.
        assert_eq!(
            repo_totals_line(1, 4, 0),
            "4 writable · 0 read-only · 0 archive-backed"
        );
    }

    /// ★ HCON-4: **one formatter for every uptime**, in words a person reads.
    #[test]
    fn an_uptime_reads_in_hours_and_minutes() {
        assert_eq!(uptime_words(0), "0 s");
        assert_eq!(uptime_words(59), "59 s");
        assert_eq!(uptime_words(60), "1 min");
        assert_eq!(uptime_words(3599), "59 min");
        assert_eq!(uptime_words(3600), "1 h 0 min");
        assert_eq!(uptime_words(56_305), "15 h 38 min", "the owner's case");
        assert_eq!(uptime_words(86_399), "23 h 59 min");
        assert_eq!(uptime_words(86_400), "1 d 0 h");
        assert_eq!(uptime_words(3 * 86_400 + 5 * 3600 + 59), "3 d 5 h");
    }

    /// ★ HCON-2/HCON-5: **the session and the read gate in plain words**: who, how, the role,
    /// and one sentence on what the read gate does; never a raw subject or a field name.
    #[test]
    fn what_is_open_is_the_servers_words() {
        let said = "Reading packages needs a sign-in. Public, readable with no sign-in: crates-nordisk. The front page and the sign-up are open.";
        assert_eq!(access_words(said, true), said, "the server's words, as it says them");
        assert_eq!(access_words("", true), read_gate_words(true), "an older Holger: its read gate alone");
        assert_eq!(access_words("  ", false), read_gate_words(false));
    }

    #[test]
    fn the_session_and_the_read_gate_read_in_plain_words() {
        assert_eq!(session_words("passkey", "admin"), "You are signed in with a passkey. Your role here: admin.");
        assert_eq!(session_words("oidc", "reader"), "You are signed in with your organisation's sign-in. Your role here: reader.");
        assert_eq!(session_words("static-token", "writer"), "You are signed in with a token. Your role here: writer.");
        assert_eq!(read_gate_words(true), "Reading packages needs a sign-in: package clients and visitors need a token.");
        assert_eq!(
            read_gate_words(false),
            "Anyone who can reach this Holger can read its packages without signing in. That is meant for an airgapped downstream only."
        );
        for w in [session_words("passkey", "admin"), read_gate_words(true).into(), read_gate_words(false).into()] {
            assert!(!w.contains("passkey:") && !w.contains("read gate") && !w.contains("global role"), "no internals: {w}");
        }
    }

    // ─── Artifact detail ─────────────────────────────────────────────────────

    #[test]
    fn download_file_name_is_the_last_segment_and_never_empty() {
        assert_eq!(download_file_name("firmware.bin"), "firmware.bin");
        assert_eq!(
            download_file_name("acme/drive/2.0.0/firmware.bin"),
            "firmware.bin"
        );
        // An empty `download` attribute silently hands the browser its own
        // guess — the fallback name is the honest one.
        assert_eq!(download_file_name(""), "artifact");
        assert_eq!(download_file_name("dir/"), "artifact");
        assert_eq!(download_file_name("/"), "artifact");
        assert_eq!(download_file_name("a/b/.hidden"), ".hidden");
    }

    #[test]
    fn property_values_are_trimmed_and_blanks_dropped() {
        assert_eq!(
            parse_property_values(" prod , staging "),
            vec!["prod".to_string(), "staging".to_string()]
        );
        assert_eq!(parse_property_values("solo"), vec!["solo".to_string()]);
        // Nothing typable that means "no values" may reach the wire: empty
        // values is the proto's REMOVE idiom, and the editor demands one.
        assert!(parse_property_values("").is_empty());
        assert!(parse_property_values("  ").is_empty());
        assert!(parse_property_values(",, ,").is_empty());
        // An inner comma is a separator, never part of a value.
        assert_eq!(
            parse_property_values("a,,b"),
            vec!["a".to_string(), "b".to_string()]
        );
    }

    // ─── Replication ─────────────────────────────────────────────────────────

    #[test]
    fn replication_refuses_an_incomplete_or_self_directed_pair() {
        assert_eq!(
            replication_pair_error("", "prod"),
            Some("choose both repositories")
        );
        assert_eq!(
            replication_pair_error("dev", ""),
            Some("choose both repositories")
        );
        assert_eq!(
            replication_pair_error("dev", "dev"),
            Some("source and destination must differ")
        );
        assert_eq!(replication_pair_error("dev", "prod"), None);
    }

    #[test]
    fn execute_is_armed_only_by_a_preview_of_the_very_same_pair() {
        assert!(execute_armed(Some(("dev", "prod")), "dev", "prod"));
        // No preview — including the `None` an executed run leaves behind, so
        // one preview can never authorize two copies.
        assert!(!execute_armed(None, "dev", "prod"));
        // Either end moving disarms: the preview described a different copy.
        assert!(!execute_armed(Some(("dev", "prod")), "dev", "staging"));
        assert!(!execute_armed(Some(("dev", "prod")), "staging", "prod"));
        // …and the pair reversed is a copy in the other direction.
        assert!(!execute_armed(Some(("dev", "prod")), "prod", "dev"));
        // Two blanks are not a pair, however they got there.
        assert!(!execute_armed(Some(("", "")), "", ""));
    }

    // ─── Transport ───────────────────────────────────────────────────────────

    #[test]
    fn grpc_base_url_prefers_the_build_time_override() {
        assert_eq!(
            grpc_base_url(Some("https://holger.example/grpc/"), Some("https://console.example")),
            "https://holger.example/grpc",
            "the override wins, and a trailing slash would double up on every path"
        );
    }

    #[test]
    fn grpc_base_url_defaults_to_the_page_origin_under_grpc() {
        assert_eq!(
            grpc_base_url(None, Some("https://pve.example.ts.net")),
            "https://pve.example.ts.net/grpc"
        );
        assert_eq!(
            grpc_base_url(None, Some("https://pve.example.ts.net/")),
            "https://pve.example.ts.net/grpc",
            "origin//grpc is a 404 that reads as a broken server"
        );
        // No window, no origin: the dev loopback, never a URL with a hole.
        assert_eq!(grpc_base_url(None, None), "http://127.0.0.1:8090/grpc");
    }

    #[test]
    fn the_environment_chip_names_the_host_not_the_door() {
        assert_eq!(
            environment_label("https://pve.example.ts.net/grpc"),
            "pve.example.ts.net"
        );
        assert_eq!(
            environment_label("http://127.0.0.1:8090/grpc"),
            "127.0.0.1:8090"
        );
        // An override that is not the conventional door keeps its path — the
        // chip's whole job is that dev and prod cannot read the same.
        assert_eq!(
            environment_label("https://holger.example/api"),
            "holger.example/api"
        );
        assert_eq!(environment_label(""), "");
    }

    /// Every gRPC status code tonic can hand the console — the vocabulary the
    /// summary map must be total over.
    const ALL_CODES: [tonic::Code; 17] = [
        tonic::Code::Ok,
        tonic::Code::Cancelled,
        tonic::Code::Unknown,
        tonic::Code::InvalidArgument,
        tonic::Code::DeadlineExceeded,
        tonic::Code::NotFound,
        tonic::Code::AlreadyExists,
        tonic::Code::PermissionDenied,
        tonic::Code::ResourceExhausted,
        tonic::Code::FailedPrecondition,
        tonic::Code::Aborted,
        tonic::Code::OutOfRange,
        tonic::Code::Unimplemented,
        tonic::Code::Internal,
        tonic::Code::Unavailable,
        tonic::Code::DataLoss,
        tonic::Code::Unauthenticated,
    ];

    #[test]
    fn fail_summary_separates_the_situations_that_need_different_next_steps() {
        assert_eq!(
            fail_summary(tonic::Code::Unauthenticated),
            "authentication required"
        );
        assert!(fail_summary(tonic::Code::PermissionDenied).contains("role mapping"));
        assert_eq!(fail_summary(tonic::Code::Unimplemented), "not in this build");
        assert_eq!(not_in_build("Promotion"), "Promotion: not in this build — this holger was built without it");
        assert_eq!(fail_summary(tonic::Code::Unavailable), "backend unavailable");
        assert_eq!(fail_summary(tonic::Code::NotFound), "not found");
        // Anything else is honestly non-committal rather than guessed at.
        for code in [
            tonic::Code::Internal,
            tonic::Code::ResourceExhausted,
            tonic::Code::DataLoss,
            tonic::Code::Cancelled,
        ] {
            assert_eq!(fail_summary(code), "request failed", "{code:?}");
        }
    }

    #[test]
    fn every_status_code_has_a_summary_and_only_one_offers_login() {
        for code in ALL_CODES {
            assert!(!fail_summary(code).is_empty(), "{code:?} renders no sentence");
            assert_eq!(
                fail_suggests_login(code),
                code == tonic::Code::Unauthenticated,
                "{code:?} sends the operator to the wrong next step"
            );
        }
        // A permission denial must NOT offer a login link: the operator is
        // already signed in and looping through the IdP cannot grant a role.
        assert!(!fail_suggests_login(tonic::Code::PermissionDenied));
    }

    // ─── Migration ───────────────────────────────────────────────────────────

    /// A canary that is not a word, so a substring match cannot be a false
    /// positive, and so a `contains` over any rendered string is conclusive.
    const CANARY: &str = "zzCANARYpw7913zz";

    fn canary_form() -> SourceForm {
        SourceForm {
            kind: MIGRATION_KIND_NEXUS,
            base_url: "https://nexus.example.test".into(),
            username: "migrator".into(),
            password: CANARY.into(),
            repository: "libs-release".into(),
        }
    }

    /// **The password-leak test.**
    ///
    /// Every string the Migrate page can render, store or copy about its source
    /// is produced by one of the functions exercised here — the page renders
    /// these and no others — so this is the page's whole text surface, checked
    /// against a canary password.
    ///
    /// It covers, in order: the summary line under the form; the `Debug` impl
    /// (the derived one leaked, which is how `ConnectorAuth` leaks today); every
    /// branch of the form validator, including the one that FIRES on a URL with
    /// the credential embedded; and the state the form is left in after a call.
    #[test]
    fn a_source_password_never_reaches_anything_the_page_renders() {
        let form = canary_form();

        let mut rendered = vec![
            source_summary(&form),
            format!("{form:?}"),
            source_host(&form.base_url),
            start_plan_note(&start_plan(
                &["libs-release".to_string()],
                &["libs-release".to_string()],
            )),
        ];

        // Every refusal branch, including a URL that carries the canary inside
        // it — the case where the secret arrives through the URL field.
        for bad in [
            SourceForm { kind: 0, ..form.clone() },
            SourceForm { base_url: String::new(), ..form.clone() },
            SourceForm { base_url: format!("nexus.example.test/{CANARY}"), ..form.clone() },
            SourceForm {
                base_url: format!("https://migrator:{CANARY}@nexus.example.test"),
                ..form.clone()
            },
        ] {
            if let Some(message) = source_form_error(&bad) {
                rendered.push(message);
            }
            rendered.push(source_summary(&bad));
            rendered.push(format!("{bad:?}"));
            rendered.push(source_host(&bad.base_url));
        }

        for text in &rendered {
            assert!(
                !text.contains(CANARY),
                "a source password reached rendered text: {text}"
            );
        }

        // …and it is gone from the page's own state the moment a request has
        // been built.
        let after = forgotten(&form);
        assert!(after.password.is_empty(), "the form still holds the password");
        assert_eq!(after.base_url, form.base_url, "only the password is dropped");
        assert_eq!(after.username, form.username);
        assert_eq!(after.repository, form.repository);
    }

    /// A URL with `user:pass@` in it must be REFUSED, not quietly sent: it is
    /// the one way a credential gets into a field the page echoes.
    #[test]
    fn a_url_that_carries_a_credential_is_refused_before_the_call() {
        assert!(source_url_carries_a_credential("https://a:b@nexus.test/x"));
        assert!(!source_url_carries_a_credential("https://nexus.test/x?q=a@b"));
        let form = SourceForm {
            base_url: "https://a:b@nexus.test".into(),
            ..canary_form()
        };
        let message = source_form_error(&form).expect("must refuse");
        assert!(message.contains("credential inside it"), "{message}");
        assert_eq!(source_host(&form.base_url), "nexus.test");
    }

    #[test]
    fn the_form_refuses_what_never_needs_the_server() {
        assert!(source_form_error(&canary_form()).is_none());
        assert!(source_form_error(&SourceForm { kind: 0, ..canary_form() })
            .unwrap()
            .contains("Nexus or Artifactory"));
        assert!(
            source_form_error(&SourceForm { base_url: "  ".into(), ..canary_form() })
                .unwrap()
                .contains("required")
        );
        assert!(
            source_form_error(&SourceForm { base_url: "nexus.test".into(), ..canary_form() })
                .unwrap()
                .contains("http://")
        );
    }

    /// The whole point of the page: a repository holger has not been told about
    /// is NOT something Start can do, so it is not offered.
    #[test]
    fn only_a_declared_repository_can_be_started() {
        assert!(migration_row_selectable("existing"));
        assert!(!migration_row_selectable("create"));
        assert!(!migration_row_selectable("collision"));
        assert!(!migration_row_selectable("unmapped_format"));
        assert!(!migration_row_selectable(""));

        assert_eq!(disposition_tone("existing"), Tone::Good);
        assert_eq!(disposition_tone("create"), Tone::Warn);
        assert_eq!(disposition_tone("collision"), Tone::Bad);
        assert!(disposition_note("create").contains("restart"));
    }

    #[test]
    fn the_declare_first_banner_appears_exactly_when_something_must_be_declared() {
        assert!(declare_first_note(0).is_none());
        let note = declare_first_note(3).unwrap();
        assert!(note.starts_with("3 of these"), "{note}");
        assert!(note.contains("restart holger"), "{note}");
        assert!(
            migration_headline(5, 2, 3, 0).contains("3 must be declared first"),
            "{}",
            migration_headline(5, 2, 3, 0)
        );
        assert!(migration_headline(5, 5, 0, 0).ends_with("5 fillable now"));
        assert!(migration_headline(5, 1, 3, 1).contains("1 refused"));
    }

    #[test]
    fn start_plan_says_whether_it_is_one_job_or_many() {
        let all = vec!["a".to_string(), "b".to_string(), "c".to_string()];
        assert_eq!(start_plan(&all, &[]), StartPlan::Nothing);
        assert_eq!(start_plan(&all, &all), StartPlan::WholeRegistry);
        assert_eq!(
            start_plan(&all, &["b".to_string()]),
            StartPlan::PerRepository(vec!["b".to_string()])
        );
        // Selection order never changes the jobs: the plan's own order wins.
        assert_eq!(
            start_plan(&all, &["c".to_string(), "a".to_string()]),
            StartPlan::PerRepository(vec!["a".to_string(), "c".to_string()])
        );
        assert!(start_plan_note(&StartPlan::WholeRegistry).contains("whole registry"));
        assert!(start_plan_note(&start_plan(&all, &["b".to_string()])).contains("one job for 'b'"));
        assert!(start_plan_note(&start_plan(&all, &["a".into(), "c".into()])).contains("2 jobs"));
    }

    /// A run that ended with failures is not a success, and the console must not
    /// render it as one.
    #[test]
    fn a_job_that_failed_anything_never_reads_as_complete() {
        assert_eq!(migration_outcome(true, false, 0).1, Tone::Good);
        let (text, tone) = migration_outcome(true, false, 2);
        assert_eq!(tone, Tone::Bad);
        assert!(text.contains("INCOMPLETE"), "{text}");
        assert!(!text.contains("complete —"), "{text}");
        assert_eq!(migration_outcome(false, true, 0).1, Tone::Warn);
        assert!(migration_outcome(false, false, 0).0.contains("INCOMPLETE"));
    }

    #[test]
    fn progress_survives_a_source_that_advertised_no_totals() {
        assert_eq!(migration_percent(0, 0, 0), 0);
        assert_eq!(migration_percent(5, 0, 0), 0);
        assert_eq!(migration_percent(5, 0, 10), 50);
        assert_eq!(migration_percent(99, 0, 10), 100);
        // `done` and `skipped` are DISJOINT counts from the server, so 10 moved
        // beside 4 already present is 14 of 20 settled — not "10/20 · 6 moved",
        // which is what this test asserted while the code subtracted one count
        // from the other and erased four moved assets.
        let line = migration_progress_line(10, 4, 1, 20, 2048);
        assert!(line.starts_with("14/20 · 10 moved · 4 already present · 1 failed"), "{line}");
    }

    /// MEASURED shape: a repository that moved 2 assets and found 1 already
    /// present printed "1 moved" — the sentence ate a moved asset, because
    /// `moved` was computed as `done - skipped` when `done` is already the
    /// moved count. Everything settled is the numerator, and the bar agrees
    /// with the sentence.
    #[test]
    fn a_row_that_both_moved_and_skipped_counts_every_asset_once() {
        let line = migration_progress_line(2, 1, 0, 3, 53);
        assert!(line.starts_with("3/3 · 2 moved · 1 already present · 0 failed"), "{line}");
        // The sentence and the bar tell the same story, and the verdict agrees.
        assert_eq!(migration_percent(2, 1, 3), 100);
        assert_eq!(migration_repo_outcome(2, 1, 0, 3, false).1, Tone::Good);
        // Negative or absent counts never invent work.
        let none = migration_progress_line(0, 0, 0, 0, 0);
        assert!(none.starts_with("0/0 · 0 moved · 0 already present · 0 failed"), "{none}");
    }

    /// MEASURED, in a browser against the deployed console: a perfect run's
    /// per-repository row read `100% | 2/2 · 2 moved · 0 already present ·
    /// 0 failed | finished INCOMPLETE` — because the row was judged on
    /// `complete`, a field the per-repo event never sets. A repository row is
    /// judged on its own counters, and the job row keeps `complete`.
    #[test]
    fn a_perfect_repository_row_does_not_call_itself_incomplete() {
        let (text, tone) = migration_repo_outcome(2, 0, 0, 2, false);
        assert_eq!(tone, Tone::Good, "{text}");
        assert_eq!(text, "complete");
        // The old reading, kept as the thing that must never come back: the
        // per-repo event carries `complete: false` and this must not be fed it.
        assert!(migration_outcome(false, false, 0).0.contains("INCOMPLETE"));
        // A repository that is genuinely short, or that failed anything, still
        // refuses to read as done.
        assert_eq!(migration_repo_outcome(1, 0, 0, 2, false).1, Tone::Bad);
        assert_eq!(migration_repo_outcome(2, 0, 1, 3, false).1, Tone::Bad);
        assert_eq!(migration_repo_outcome(2, 0, 0, 2, true).1, Tone::Warn);
    }

    /// MEASURED: the idempotent second run read `0% | 0/3 · 0 moved ·
    /// 3 already present · 0 failed | complete` — 0% beside a complete verdict,
    /// because the bar counted moved assets only. An asset the destination
    /// already holds is settled work; the bar counts it.
    #[test]
    fn an_idempotent_rerun_reads_a_hundred_percent_and_complete() {
        assert_eq!(migration_percent(0, 3, 3), 100, "every asset already present");
        let (text, tone) = migration_repo_outcome(0, 3, 0, 3, false);
        assert_eq!(tone, Tone::Good, "{text}");
        assert_eq!(text, "complete");
        // Half moved, half already present is still a full bar.
        assert_eq!(migration_percent(1, 1, 2), 100);
        // Skipped work never inflates past the denominator, and a short run
        // still reads short.
        assert_eq!(migration_percent(0, 1, 4), 25);
        assert_eq!(migration_percent(9, 9, 10), 100);
    }

    #[test]
    fn the_admin_gate_is_stated_before_the_call_is_made() {
        assert!(migration_role_refusal(true, "admin").is_none());
        assert!(migration_role_refusal(false, "").unwrap().contains("anonymous"));
        let writer = migration_role_refusal(true, "writer").unwrap();
        assert!(writer.contains("'writer'"), "{writer}");
        assert!(writer.contains("ADMIN-gated"), "{writer}");
    }
}