Gunnar

nordisk/holger-egui

tests/tier_ladder.rs

//! **The UI tier ladder, measured.**
//!
//! `holger-server-ui` ships four tiers from one source tree — `gui-embedded` (bin
//! `holger-server-ui-embedded`), `gui` (bin `holger-server-ui`), `gui-mini` (bin
//! `holger-server-ui-mini`) and `gui-mikro` (bin `holger-server-ui-mikro`; the four
//! bins, and the package, were `holger-ui*` until 2026-08-01, when they were
//! renamed to say WHICH component they drive) — and each is supposed
//! to be strictly leaner than the one above it.
//!
//! Only the bottom three are SHIPPED clients; `gui-embedded` is the opt-in build
//! that may open state in its own process, and it sits at the top of the ladder
//! precisely because it is the fattest thing this tree can produce. Whether each
//! tier is THIN is a different question, asked by `tests/thin_client.rs`.
//!
//! ## Why this file exists
//!
//! It was not. `server/ui/Cargo.toml` declared `mini = ["gui"]`, and **a Cargo
//! feature cannot subtract a dependency** — `mini` enabled `gui` and removed
//! nothing, so the "mini" binary linked the identical closure as the full one and
//! then added tab-filtering code on top. Measured 2026-07-22 in debug, under the
//! bin names of the day:
//!
//! ```text
//! holger-ui        1 048 139 472 B
//! holger-ui-mikro  1 050 137 592 B    ← the LEAN tier, 2 MB BIGGER
//! ```
//!
//! Every declaration in the repo said otherwise. `.nornir/build-variants.toml`
//! described a lean read-only client; `app.rs` cut eleven tabs down to six; the
//! variant's own comment called it a tier. All true, all about the SURFACE, and
//! none of it about the link — because nothing ever compared the two artifacts.
//!
//! ## What it asserts, and why in this order
//!
//! * [`the_tier_ladder_is_strictly_leaner_by_crate_count`] — always runs, costs a
//!   few seconds, and is the guard that would have caught `mini = ["gui"]` the day
//!   it was written: under that declaration the three closures are IDENTICAL, and
//!   "identical" fails a *strictly* leaner assertion. It also asserts that the two
//!   read-only tiers link no outbound HTTP client and no skade.
//! * [`the_tier_ladder_is_strictly_leaner_in_bytes`] — `#[ignore]`d because it
//!   release-builds four egui applications. This is the half that measures the
//!   artifact rather than the resolve: bytes, plus `nm` symbol probes for
//!   `reqwest` / `ureq` / `skade`.
//!
//! Run the expensive half with:
//!
//! ```text
//! cargo test -p holger-server-ui --test tier_ladder -- --ignored --nocapture
//! ```
//!
//! Measured 2026-07-23, release:
//!
//! ```text
//! gui-embedded  holger-server-ui-embedded  64 732 640 B  608 crates
//! gui           holger-server-ui           31 603 160 B  534 crates
//! gui-mini      holger-server-ui-mini      20 685 888 B  493 crates
//! gui-mikro     holger-server-ui-mikro     20 488 072 B  492 crates
//! ```
//!
//! **Neither test reads `Cargo.toml`.** A declaration is not evidence; that is the
//! whole lesson of the bug this file guards.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;

/// The tiers, fattest first. The ladder must hold pairwise down this list.
///
/// `(tier feature, bin name)` — the bin is what `[[bin]] required-features` pairs
/// with the feature in `server/ui/Cargo.toml`, and what the byte half measures.
const LADDER: [(&str, &str); 4] = [
    ("gui-embedded", "holger-server-ui-embedded"),
    ("gui", "holger-server-ui"),
    ("gui-mini", "holger-server-ui-mini"),
    ("gui-mikro", "holger-server-ui-mikro"),
];

/// Crates that must not appear in a read-only tier's closure.
///
/// `reqwest` / `ureq` are the outbound HTTP clients; `openssl` / `native-tls` ride
/// in with them. `skade` is the Iceberg warehouse the Tools advisory scan reaches
/// through `modgunn/warehouse`, and it is the single heaviest thing the full tier
/// pulls that a read-only client has no use for.
///
/// `hyper` / `tonic` / `h2` are deliberately ABSENT from this list: they are the
/// gRPC channel every tier legitimately links to talk to the registry at all. A
/// check that failed a client for being able to reach its own server would be
/// switched off within a week.
const FORBIDDEN_IN_READ_ONLY: [&str; 5] = ["reqwest", "ureq", "skade", "openssl", "native-tls"];

/// The tiers the forbidden list applies to.
const READ_ONLY_TIERS: [&str; 2] = ["gui-mini", "gui-mikro"];

fn manifest_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")
}

mod common;
use common::release_dir;

/// The RESOLVED dependency closure of one tier, as a set of crate names.
///
/// `cargo tree -e no-dev` is the resolve the build actually uses, so this measures
/// what will be linked rather than what the manifest says. Dev-dependencies are
/// excluded on purpose: they are not in the shipped artifact, and holger-server-ui's
/// dev-deps deliberately pull the full `server-lib` so the headless tests can run.
fn closure(tier: &str) -> BTreeSet<String> {
    let out = Command::new(env!("CARGO"))
        .args([
            "tree",
            "--manifest-path",
            manifest_path().to_str().unwrap(),
            "-p",
            "holger-server-ui",
            "--no-default-features",
            "--features",
            tier,
            "-e",
            "no-dev",
            "--prefix",
            "none",
        ])
        .output()
        .unwrap_or_else(|e| panic!("`cargo tree` for '{tier}' failed to start: {e}"));
    assert!(
        out.status.success(),
        "`cargo tree` for '{tier}' failed:\n{}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter_map(|l| l.split_whitespace().next())
        .filter(|c| !c.is_empty() && *c != "(*)")
        .map(|c| c.to_string())
        .collect()
}

/// **RED-when-broken: each tier's dependency closure is strictly smaller than the
/// tier above it, and it is a strict SUBSET — not merely a different set of the
/// same size.**
///
/// Subset, not just cardinality: a "lean" tier that dropped ten crates and added
/// nine would pass a count check while linking something the tier above it does
/// not, which is not a rung on a ladder — it is a fork.
#[test]
fn the_tier_ladder_is_strictly_leaner_by_crate_count() {
    let measured: Vec<(&str, BTreeSet<String>)> =
        LADDER.iter().map(|(tier, _)| (*tier, closure(tier))).collect();

    for (tier, set) in &measured {
        println!("{tier:>10}: {} crates", set.len());
    }

    for pair in measured.windows(2) {
        let (fat, fat_set) = &pair[0];
        let (lean, lean_set) = &pair[1];
        let added: Vec<&String> = lean_set.difference(fat_set).collect();
        assert!(
            added.is_empty(),
            "'{lean}' must be a SUBSET of '{fat}', but it links crates '{fat}' does not: {added:?}\n\
             A tier that adds a dependency to the tier above it is a fork, not a rung."
        );
        let dropped: Vec<&String> = fat_set.difference(lean_set).collect();
        assert!(
            !dropped.is_empty(),
            "'{lean}' links the SAME {} crates as '{fat}' — it is not leaner, it only \
             looks leaner.\n\
             This is exactly the `mini = [\"gui\"]` bug: a Cargo feature cannot subtract a \
             dependency, so a tier that merely ADDS to the tier above it removes nothing. \
             Make the heavy deps optional and have each tier select what it needs.",
            fat_set.len()
        );
        println!("{fat} → {lean}: {} crates dropped", dropped.len());
    }

    // The read-only tiers link no general-purpose HTTP client and no skade. This is
    // the claim that pairs them with the airgapped `mini` SERVER, and it is checked
    // against the resolve rather than trusted.
    for (tier, set) in &measured {
        if !READ_ONLY_TIERS.contains(tier) {
            continue;
        }
        for bad in FORBIDDEN_IN_READ_ONLY {
            assert!(
                !set.contains(bad),
                "'{tier}' is a read-only client tier and must not link '{bad}'"
            );
        }
    }
}

/// **RED-when-broken: each tier's release ARTIFACT is strictly smaller than the
/// tier above it, and the read-only tiers carry no `reqwest` / `ureq` / `skade`
/// symbols.**
///
/// `#[ignore]` because it release-builds three egui applications. The crate-count
/// test above is the everyday guard; this one is the proof, and it is the only
/// measurement that would have caught the original bug in the units the bug was
/// reported in — bytes on disk.
///
/// Note on the `skade` probe: a release binary of ANY tier reports two `skade`
/// hits from `facett_core::look::Theme::skade_vinter`, which is a THEME NAME, not
/// the crate. The threshold below is set accordingly, and the crate-level check in
/// the test above is the one that is exact.
#[test]
#[ignore = "release-builds four egui binaries; run with -- --ignored"]
fn the_tier_ladder_is_strictly_leaner_in_bytes() {
    let release = release_dir();
    let mut sizes: Vec<(&str, u64)> = Vec::new();

    for (tier, bin) in LADDER {
        let status = Command::new(env!("CARGO"))
            .args([
                "build",
                "--release",
                "--manifest-path",
                manifest_path().to_str().unwrap(),
                "-p",
                "holger-server-ui",
                "--no-default-features",
                "--features",
                tier,
                "--bin",
                bin,
            ])
            .status()
            .unwrap_or_else(|e| panic!("building '{bin}' failed to start: {e}"));
        // `cargo build --all-targets` SILENTLY SKIPS a `required-features` bin. Each
        // one is therefore named explicitly here — that omission is how five holger
        // binaries went unbuilt for months.
        assert!(status.success(), "building '{bin}' (--features {tier}) failed");

        let path = release.join(bin);
        let bytes = std::fs::metadata(&path)
            .unwrap_or_else(|e| panic!("no artifact at {}: {e}", path.display()))
            .len();
        let probe = |sym: &str| -> usize {
            let out = Command::new("nm").arg("-C").arg(&path).output();
            match out {
                Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
                    .lines()
                    .filter(|l| l.contains(sym))
                    .count(),
                // No `nm` (or a stripped binary): the byte ladder still stands, and
                // the crate-level check in the other test is the exact one.
                _ => 0,
            }
        };
        let (reqwest, ureq, skade) = (probe("reqwest"), probe("ureq"), probe("skade"));
        println!("{tier:>10} {bin:<16} {bytes:>12} B  reqwest={reqwest} ureq={ureq} skade={skade}");

        if READ_ONLY_TIERS.contains(&tier) {
            assert_eq!(reqwest, 0, "'{tier}' must carry no reqwest symbols");
            assert_eq!(ureq, 0, "'{tier}' must carry no ureq symbols");
            assert!(
                skade <= 2,
                "'{tier}' carries {skade} skade symbols — more than the two \
                 `Theme::skade_vinter` hits every tier has, so the skade CRATE is linked"
            );
        }
        sizes.push((tier, bytes));
    }

    for pair in sizes.windows(2) {
        let (fat, fat_bytes) = pair[0];
        let (lean, lean_bytes) = pair[1];
        assert!(
            lean_bytes < fat_bytes,
            "'{lean}' is {lean_bytes} B and '{fat}' is {fat_bytes} B — the lean tier is \
             NOT smaller.\n\
             That is the exact shape of the bug this file exists for: on 2026-07-22 the \
             'mini' UI measured 2 MB LARGER than the full one, because `mini = [\"gui\"]` \
             added tab-filtering code and removed no dependency."
        );
    }
}