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

nordisk/gunnar

crates/gunnar-server/tests/open_build_speaks_no_money.rs

//! **The open build speaks no money.** gunnar is the OPEN half of a
//! two-product split: it reports `Usage`, it receives a signed `Entitlement`
//! only in the `metered` build, and it takes no money ever. Every payment word
//! belongs to the private `monetize` half, which is a separate binary talking
//! to this one over a signed seam.
//!
//! # The owner's ruling, 2026-09-15
//!
//! > The OPEN-SOURCE gunnar (built WITHOUT `metered`) shall NOT mention money
//! > anywhere a user or operator can see it — UI text, `--help`,
//! > error/refusal messages, served field names and values — stays UNLIMITED
//! > (no quotas, no buy/request-more flow), but SHOULD show how much storage
//! > the box has.
//!
//! That ruling widened this file twice over. Before it, the guard covered the
//! command line only and deliberately let `plan` and `quota` through as
//! "resource words". They are not resource words in an open build that has no
//! plans and no quotas; they are the vocabulary of the metered product leaking
//! into one that has neither. So:
//!
//! 1. [`the_open_command_line_offers_no_money`] — the **declared command-line
//!    surface** of every `gunnar-server` / `gunnar` subcommand, walked from
//!    `clap` itself via [`clap::CommandFactory`]: names, about text, argument
//!    spellings, value names, environment variables, help, before/after help
//!    and possible values. The same tree `--help` prints from.
//! 2. [`the_open_sources_say_no_money`] — every **string literal an open build
//!    compiles** in the crates the appliance and the operator CLI link:
//!    refusal `Status` messages, `anyhow!`/`thiserror` messages, log lines,
//!    dashboard rows, `Admin.Capacity`'s `reason`. Comments are not scanned
//!    (a comment saying "nothing here is a price" states the rule), nor is
//!    code the open build does not contain: `#[cfg(feature = "metered")]`
//!    items and modules, and `#[cfg(test)]` / `#[test]` items.
//! 3. [`a_live_guests_answers_say_no_money`] — the same words over text
//!    CAPTURED from a running open appliance (`--help` of the shipped binary,
//!    `Admin.Capacity` and friends as the client printed them, the console's
//!    JSON). The capture is made by `gunnar-growbench`'s `O1` scenario under
//!    KVM; this test is the one judge, pointed at it with
//!    `GUNNAR_OPEN_GUARD_CAPTURE=<dir>`. Without the variable it says so and
//!    returns — it has nothing to read, and halves 1 and 2 still ran.
//!
//! A strings-scan of the linked binary was considered and rejected: it would
//! measure every transitive dependency's test fixtures rather than gunnar, and
//! `cargo test` does not know where the binary was linked. The compile-out is
//! proven by construction (`entitlement` modules are `cfg(feature =
//! "metered")`, `monetize-embed` is optional behind the same feature).
//!
//! # The word list, and the three words that need a sentence to judge
//!
//! [`MONEY`] is matched on whole words, lowercased, after splitting on
//! everything that is not a letter or a digit — so `pay` is not found inside
//! `payload`.
//!
//! Three words are real technical words in gunnar and are judged by the
//! sentence they sit in ([`CONTEXTUAL`]):
//!
//! * **`plan` / `plans`** — a network plan (`GUNNAR_NET`), a DHCP lease plan,
//!   a grow plan (sectors, blocks), a reset plan. Commercial otherwise (the
//!   community plan, a tenant's plan).
//! * **`checkout`** — git's worktree. Commercial otherwise (a payment page).
//! * **`renew` / `renewal`** — a DHCP lease renewal. Commercial otherwise (a
//!   subscription).
//!
//! **The rule:** such a word is allowed only when the SAME literal (or clap
//! text) also carries one of [`TECHNICAL_CONTEXT`]'s words; otherwise it is a
//! leak. A literal too short to say which it is, is a leak — reword it.
//!
//! **`stripe`** is the RAID stripe everywhere in gunnar (`off-stripe`, "back on
//! the stripe") and the payment vendor nowhere an open build reaches. It is a
//! leak when the original text spells the vendor: `Stripe` capitalised, or
//! `stripe.com`.
//!
//! OUT, and still out: `cap`, `ceiling`, `budget`, `allowance` (resource
//! words — though the open build shows no caps), `charge`/`cost` (the memory
//! ledger's own vocabulary), `free` (free space), `tenant`/`namespace`, and
//! `monetize` — the NAME of the other half, which the open build must still
//! recognise in `--monetize-pubkey` so it can refuse that flag by name at boot.

use std::path::{Path, PathBuf};

use clap::CommandFactory;

/// Words the open product has no use for. See the module docs.
const MONEY: &[&str] = &[
    "price",
    "prices",
    "priced",
    "pricing",
    "pay",
    "pays",
    "paying",
    "paid",
    "payable",
    "payment",
    "payments",
    "paywall",
    "buy",
    "buys",
    "buying",
    "bought",
    "buyer",
    "buyers",
    "purchase",
    "purchases",
    "purchased",
    "purchasing",
    "sell",
    "sells",
    "selling",
    "sold",
    "seller",
    "sellers",
    "bill",
    "bills",
    "billed",
    "billing",
    "billable",
    "invoice",
    "invoices",
    "invoicing",
    "checkout",
    "checkouts",
    "money",
    "currency",
    "currencies",
    "sek",
    "eur",
    "usd",
    "stripe",
    "plan",
    "plans",
    "quota",
    "quotas",
    "subscription",
    "subscriptions",
    "subscribe",
    "renew",
    "renews",
    "renewal",
    "renewals",
    "refund",
    "refunds",
    "revenue",
];

/// Words that are money only when nothing in the same text makes them
/// technical. See the module docs for the rule.
const CONTEXTUAL: &[&str] = &[
    "plan",
    "plans",
    "checkout",
    "checkouts",
    "renew",
    "renews",
    "renewal",
    "renewals",
    "subscription",
    "subscriptions",
    "subscribe",
];

/// What makes a [`CONTEXTUAL`] word technical: networking and DHCP, git's
/// worktree, the event bus (a `Watch` subscription, a replication or webhook
/// subscriber), the data-set grow and the console reset.
const TECHNICAL_CONTEXT: &[&str] = &[
    // network / DHCP
    "interface",
    "interfaces",
    "lease",
    "leases",
    "dhcp",
    "dhcprequest",
    "address",
    "addresses",
    "route",
    "routes",
    "network",
    "net",
    // the event bus
    "event",
    "events",
    "bus",
    "watch",
    "watchrequest",
    "replication",
    "webhook",
    "runner",
    "subscriber",
    "stream",
    // git
    "git",
    "tree",
    "worktree",
    "commit",
    "branch",
    "clone",
    "entry",
    "build",
    // grow / install / reset
    "grow",
    "growth",
    "sectors",
    "blocks",
    "resize",
    "layout",
    "install",
    "reset",
    "passkeys",
];

/// `"a lapsed tenant renews"` -> `["a", "lapsed", "tenant", "renews"]`.
fn words(text: &str) -> Vec<String> {
    text.split(|c: char| !c.is_ascii_alphanumeric())
        .filter(|w| !w.is_empty())
        .map(|w| w.to_ascii_lowercase())
        .collect()
}

/// **The one judge**: the money words in `text`, after the contextual rule.
fn leaks_in(text: &str) -> Vec<String> {
    let ws = words(text);
    let technical = ws.iter().any(|w| TECHNICAL_CONTEXT.contains(&w.as_str()));
    let mut out = Vec::new();
    for w in &ws {
        if !MONEY.contains(&w.as_str()) {
            continue;
        }
        if CONTEXTUAL.contains(&w.as_str()) && technical {
            continue;
        }
        if w == "stripe"
            && !(text.contains("Stripe") || text.contains("STRIPE") || text.contains("stripe.com"))
        {
            continue;
        }
        out.push(w.clone());
    }
    out.sort();
    out.dedup();
    out
}

#[test]
fn the_judge_reads_the_sentence() {
    assert_eq!(
        leaks_in("a disk would be billed and invisible"),
        vec!["billed"]
    );
    assert!(
        leaks_in("payload bytes").is_empty(),
        "substring, not a word"
    );
    assert_eq!(leaks_in("the community plan"), vec!["plan"]);
    assert!(leaks_in("Two plans for one interface").is_empty());
    assert!(leaks_in("would write outside the checkout of tree {tree}").is_empty());
    assert_eq!(leaks_in("return to checkout"), vec!["checkout"]);
    assert!(leaks_in("an unchanged lease must renew").is_empty());
    assert!(leaks_in("off-stripe: p3 /dev/vda3").is_empty());
    assert_eq!(leaks_in("https://buy.stripe.com/x"), vec!["buy", "stripe"]);
    assert_eq!(leaks_in("cgroup cpu quota"), vec!["quota"]);
}

// ── half 1: the command line ────────────────────────────────────────────────

/// Every place a `clap` tree shows text to a human, flattened to
/// `(where, what)` so a failure names the flag and not just the word.
fn surface(cmd: &clap::Command, path: &str, out: &mut Vec<(String, String)>) {
    let here = if path.is_empty() {
        cmd.get_name().to_owned()
    } else {
        format!("{path} {}", cmd.get_name())
    };
    out.push((here.clone(), cmd.get_name().to_owned()));
    for text in [
        cmd.get_about(),
        cmd.get_long_about(),
        cmd.get_before_help(),
        cmd.get_before_long_help(),
        cmd.get_after_help(),
        cmd.get_after_long_help(),
    ]
    .into_iter()
    .flatten()
    {
        out.push((here.clone(), text.to_string()));
    }
    for arg in cmd.get_arguments() {
        let at = format!("{here} --{}", arg.get_id());
        out.push((at.clone(), arg.get_id().to_string()));
        if let Some(long) = arg.get_long() {
            out.push((at.clone(), long.to_owned()));
        }
        for name in arg.get_value_names().unwrap_or(&[]) {
            out.push((at.clone(), name.to_string()));
        }
        if let Some(env) = arg.get_env() {
            out.push((at.clone(), env.to_string_lossy().into_owned()));
        }
        for text in [arg.get_help(), arg.get_long_help()].into_iter().flatten() {
            out.push((at.clone(), text.to_string()));
        }
        for value in arg.get_possible_values() {
            out.push((at.clone(), value.get_name().to_owned()));
            if let Some(help) = value.get_help() {
                out.push((at.clone(), help.to_string()));
            }
        }
    }
    for sub in cmd.get_subcommands() {
        surface(sub, &here, out);
    }
}

/// A `metered` build is ALLOWED to talk about money — that build is the one
/// paired with monetize — so the assertion is scoped to the open build.
#[cfg(not(feature = "metered"))]
#[test]
fn the_open_command_line_offers_no_money() {
    let mut found = Vec::new();
    surface(&gunnar_common::cli::Cli::command(), "", &mut found);

    let mut leaks: Vec<String> = Vec::new();
    for (at, text) in &found {
        for word in leaks_in(text) {
            leaks.push(format!("{at}: `{word}` in {text:?}"));
        }
    }
    leaks.sort();
    leaks.dedup();

    assert!(
        leaks.is_empty(),
        "the OPEN gunnar command line offers money vocabulary to its operator ({} hits). gunnar \
         takes no money; every word below belongs to the private monetize half, behind the \
         `metered` feature.\n  {}",
        leaks.len(),
        leaks.join("\n  ")
    );
}

// ── half 2: the literals an open build compiles ─────────────────────────────

/// The crates whose words an operator or a user of the open appliance can meet:
/// the server and everything it links for serving, and the operator CLI.
const SCANNED_CRATES: &[&str] = &[
    "gunnar-server",
    "gunnar-grpc",
    "gunnar-ssh",
    "gunnar-http",
    "gunnar-wire",
    "gunnar-core",
    "gunnar-store",
    "gunnar-lfs",
    "gunnar-policy",
    "gunnar-common",
    "gunnar-cli",
    "gunnar-client",
];

/// One string literal, where it is, and whether it sits in an attribute.
#[derive(Debug)]
struct Literal {
    line: usize,
    text: String,
}

/// What one file yields: its literals outside skipped items, and the `mod`
/// names declared under a skipping attribute (their files are skipped whole).
#[derive(Default)]
struct Scanned {
    literals: Vec<Literal>,
    skipped_mods: Vec<String>,
}

#[derive(Debug, Clone, PartialEq)]
enum Tok {
    Str(String),
    Ident(String),
    Punct(char),
}

/// A small Rust lexer: comments dropped, string/char/raw/byte literals as
/// single tokens, lifetimes told apart from char literals. Enough to find
/// every literal and every attribute; not a parser.
fn lex(src: &str) -> Vec<(usize, Tok)> {
    let b: Vec<char> = src.chars().collect();
    let mut out = Vec::new();
    let (mut i, mut line) = (0usize, 1usize);
    while i < b.len() {
        let c = b[i];
        if c == '\n' {
            line += 1;
            i += 1;
            continue;
        }
        if c.is_whitespace() {
            i += 1;
            continue;
        }
        // comments
        if c == '/' && b.get(i + 1) == Some(&'/') {
            while i < b.len() && b[i] != '\n' {
                i += 1;
            }
            continue;
        }
        if c == '/' && b.get(i + 1) == Some(&'*') {
            let mut depth = 0;
            while i < b.len() {
                if b[i] == '/' && b.get(i + 1) == Some(&'*') {
                    depth += 1;
                    i += 2;
                } else if b[i] == '*' && b.get(i + 1) == Some(&'/') {
                    depth -= 1;
                    i += 2;
                    if depth == 0 {
                        break;
                    }
                } else {
                    if b[i] == '\n' {
                        line += 1;
                    }
                    i += 1;
                }
            }
            continue;
        }
        // raw strings: r"..", r#".."#, br#".."#
        let raw_start = if c == 'r' && matches!(b.get(i + 1), Some('"') | Some('#')) {
            Some(i + 1)
        } else if c == 'b'
            && b.get(i + 1) == Some(&'r')
            && matches!(b.get(i + 2), Some('"') | Some('#'))
        {
            Some(i + 2)
        } else {
            None
        };
        if let Some(mut j) = raw_start {
            let mut hashes = 0;
            while b.get(j) == Some(&'#') {
                hashes += 1;
                j += 1;
            }
            if b.get(j) == Some(&'"') {
                let start_line = line;
                j += 1;
                let mut text = String::new();
                'raw: while j < b.len() {
                    if b[j] == '"' && (0..hashes).all(|k| b.get(j + 1 + k) == Some(&'#')) {
                        j += 1 + hashes;
                        break 'raw;
                    }
                    if b[j] == '\n' {
                        line += 1;
                    }
                    text.push(b[j]);
                    j += 1;
                }
                out.push((start_line, Tok::Str(text)));
                i = j;
                continue;
            }
        }
        // strings, byte strings
        if c == '"' || (c == 'b' && b.get(i + 1) == Some(&'"')) {
            let start_line = line;
            let mut j = if c == 'b' { i + 2 } else { i + 1 };
            let mut text = String::new();
            while j < b.len() {
                match b[j] {
                    '\\' => {
                        // `\` + newline is a continuation: the indentation
                        // that follows is not part of the text.
                        if b.get(j + 1) == Some(&'\n') {
                            line += 1;
                            j += 2;
                            while j < b.len() && b[j].is_whitespace() {
                                if b[j] == '\n' {
                                    line += 1;
                                }
                                j += 1;
                            }
                            text.push(' ');
                            continue;
                        }
                        if let Some(n) = b.get(j + 1) {
                            text.push(match n {
                                'n' => '\n',
                                't' => ' ',
                                other => *other,
                            });
                        }
                        j += 2;
                    }
                    '"' => {
                        j += 1;
                        break;
                    }
                    ch => {
                        if ch == '\n' {
                            line += 1;
                        }
                        text.push(ch);
                        j += 1;
                    }
                }
            }
            out.push((start_line, Tok::Str(text)));
            i = j;
            continue;
        }
        // char literal or lifetime
        if c == '\'' {
            if b.get(i + 1) == Some(&'\\') {
                let mut j = i + 2;
                while j < b.len() && b[j] != '\'' {
                    j += 1;
                }
                i = j + 1;
                continue;
            }
            if b.get(i + 2) == Some(&'\'') {
                i += 3;
                continue;
            }
            // a lifetime: skip the tick, the ident follows
            i += 1;
            continue;
        }
        if c.is_alphanumeric() || c == '_' {
            let mut j = i;
            let mut ident = String::new();
            while j < b.len() && (b[j].is_alphanumeric() || b[j] == '_') {
                ident.push(b[j]);
                j += 1;
            }
            out.push((line, Tok::Ident(ident)));
            i = j;
            continue;
        }
        out.push((line, Tok::Punct(c)));
        i += 1;
    }
    out
}

/// Does this attribute's token text remove the item from an OPEN, non-test
/// build? `cfg(test)`, `cfg(all(test, …))`, `test`, `tokio::test`,
/// `cfg(feature = "metered")` — and never anything under `not(…)`.
fn removes_from_open_build(attr: &[Tok]) -> bool {
    let text: String = attr
        .iter()
        .map(|t| match t {
            Tok::Str(s) => format!("\"{s}\""),
            Tok::Ident(s) => format!("{s} "),
            Tok::Punct(c) => c.to_string(),
        })
        .collect();
    let t = text.replace(' ', "");
    if t == "test" || t == "tokio::test" || t.starts_with("tokio::test(") {
        return true;
    }
    if !t.starts_with("cfg(") || t.contains("not(") {
        return false;
    }
    t == "cfg(test)"
        || t.starts_with("cfg(all(test")
        || t.contains("feature=\"metered\"")
        || (t.starts_with("cfg(all(") && t.contains("test,"))
}

fn scan_source(src: &str) -> Scanned {
    let toks = lex(src);
    let mut out = Scanned::default();
    let mut i = 0usize;
    // Inner attribute `#![cfg(...)]` at the top that removes the whole file.
    while i < toks.len() {
        match &toks[i].1 {
            Tok::Punct('#') => {
                let inner = matches!(toks.get(i + 1), Some((_, Tok::Punct('!'))));
                let open = if inner { i + 2 } else { i + 1 };
                if !matches!(toks.get(open), Some((_, Tok::Punct('[')))) {
                    i += 1;
                    continue;
                }
                // the attribute's tokens
                let mut depth = 0i32;
                let mut j = open;
                let start = open + 1;
                loop {
                    match toks.get(j).map(|t| &t.1) {
                        Some(Tok::Punct('[')) => depth += 1,
                        Some(Tok::Punct(']')) => {
                            depth -= 1;
                            if depth == 0 {
                                break;
                            }
                        }
                        None => break,
                        _ => {}
                    }
                    j += 1;
                }
                let attr: Vec<Tok> = toks[start..j.min(toks.len())]
                    .iter()
                    .map(|t| t.1.clone())
                    .collect();
                // `doc` is a comment by another spelling; `cfg`/`cfg_attr`
                // name features, which no user or operator reads.
                let is_doc = matches!(attr.first(), Some(Tok::Ident(s)) if s == "doc" || s == "cfg" || s == "cfg_attr");
                if removes_from_open_build(&attr) {
                    if inner {
                        return Scanned::default();
                    }
                    i = skip_item(&toks, j + 1, &mut out.skipped_mods);
                    continue;
                }
                if !is_doc {
                    for (line, t) in &toks[start..j.min(toks.len())] {
                        if let Tok::Str(s) = t {
                            out.literals.push(Literal {
                                line: *line,
                                text: s.clone(),
                            });
                        }
                    }
                }
                i = j + 1;
            }
            Tok::Str(s) => {
                out.literals.push(Literal {
                    line: toks[i].0,
                    text: s.clone(),
                });
                i += 1;
            }
            _ => i += 1,
        }
    }
    out
}

/// Skip the item that starts at `i` (after its attribute): further attributes,
/// then tokens up to a `;` or `,` at depth 0, or through the first balanced
/// `{…}`. Returns the index after it.
fn skip_item(toks: &[(usize, Tok)], mut i: usize, mods: &mut Vec<String>) -> usize {
    // stacked attributes
    while matches!(toks.get(i), Some((_, Tok::Punct('#'))))
        && matches!(toks.get(i + 1), Some((_, Tok::Punct('['))))
    {
        let mut depth = 0;
        let mut j = i + 1;
        while j < toks.len() {
            match toks[j].1 {
                Tok::Punct('[') => depth += 1,
                Tok::Punct(']') => {
                    depth -= 1;
                    if depth == 0 {
                        break;
                    }
                }
                _ => {}
            }
            j += 1;
        }
        i = j + 1;
    }
    let mut depth = 0i32;
    // `<…>` at depth 0 — a signature's `Result<A, B>`, a field's `HashMap<K, V>` — so the
    // comma inside generics does not end the item (the item went on, unskipped, into its
    // body). `->` and `=>` are arrows, not a closing bracket.
    let mut angle = 0i32;
    let mut prev_mod = false;
    while i < toks.len() {
        let arrow = matches!(
            toks.get(i.wrapping_sub(1)).map(|t| &t.1),
            Some(Tok::Punct('-')) | Some(Tok::Punct('='))
        );
        match &toks[i].1 {
            Tok::Punct('<') if depth == 0 => angle += 1,
            Tok::Punct('>') if depth == 0 && !arrow && angle > 0 => angle -= 1,
            Tok::Ident(s) if s == "mod" => prev_mod = true,
            Tok::Ident(s) if prev_mod => {
                mods.push(s.clone());
                prev_mod = false;
            }
            Tok::Punct('(') | Tok::Punct('[') => depth += 1,
            Tok::Punct(')') | Tok::Punct(']') => depth -= 1,
            Tok::Punct('{') => {
                depth += 1;
            }
            Tok::Punct('}') => {
                depth -= 1;
                if depth == 0 {
                    return i + 1;
                }
                if depth < 0 {
                    return i;
                }
            }
            Tok::Punct(';') | Tok::Punct(',') if depth == 0 && angle == 0 => return i + 1,
            _ => {}
        }
        i += 1;
    }
    i
}

/// Every `.rs` under `src/` of one crate, minus files an open non-test build
/// does not compile.
fn open_sources(krate: &Path) -> Vec<(PathBuf, Scanned)> {
    let src = krate.join("src");
    let mut files = Vec::new();
    let mut stack = vec![src.clone()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for e in entries.filter_map(Result::ok) {
            let p = e.path();
            if p.is_dir() {
                if p.file_name().is_some_and(|n| n == "tests") {
                    continue;
                }
                stack.push(p);
            } else if p.extension().is_some_and(|x| x == "rs") {
                files.push(p);
            }
        }
    }
    files.sort();
    let mut scanned: Vec<(PathBuf, Scanned)> = files
        .into_iter()
        .filter(|p| {
            let name = p.file_name().unwrap().to_string_lossy();
            !(name == "tests.rs"
                || name.ends_with("_tests.rs")
                || name == "testgit.rs"
                || name == "testing.rs")
        })
        .map(|p| {
            let text = std::fs::read_to_string(&p).unwrap_or_default();
            let s = scan_source(&text);
            (p, s)
        })
        .collect();
    // Files of modules declared under a skipping attribute.
    let mut dead: Vec<PathBuf> = Vec::new();
    for (p, s) in &scanned {
        let name = p.file_stem().unwrap().to_string_lossy();
        let base = if matches!(name.as_ref(), "lib" | "main" | "mod") {
            p.parent().unwrap().to_path_buf()
        } else {
            p.parent().unwrap().join(name.as_ref())
        };
        for m in &s.skipped_mods {
            dead.push(base.join(format!("{m}.rs")));
            dead.push(base.join(m));
        }
    }
    scanned.retain(|(p, _)| !dead.iter().any(|d| p == d || p.starts_with(d)));
    scanned
}

/// **Deliberate exceptions**, each with the reason it cannot reach an open
/// build's user or operator. `(file suffix, text fragment, reason)`. Every
/// entry must still match something, or the test fails: a stale exception is
/// a hole nobody is looking at.
const EXCEPTIONS: &[(&str, &str, &str)] = &[
    (
        "gunnar-server/src/dashboard.rs",
        "paid {}  grace {}  suspended {}",
        "the console band's `tenants` row is drawn only from `facts.tenants.counts()`, which is \
         `None` on a community build: the slot is filled by the metered entitlement at start, \
         and the open build has no entitlement module to fill it",
    ),
    (
        "gunnar-server/src/dashboard.rs",
        "next_renew_in_secs",
        "a structured-log FIELD NAME of the DHCP client's lease timer, looked up by key; it is \
         never shown as words, and the line it feeds reads `renewed via … next in Ns`",
    ),
    (
        "gunnar-common/src/cli.rs",
        "set-quota",
        "the HIDDEN clap alias of `namespace set-budget`, kept so a script written before \
         2026-09-15 still runs; clap never prints a hidden alias in `--help` (half 1 walks the \
         visible tree and passes)",
    ),
    (
        "gunnar-client/src/attributes.rs",
        "after checkout",
        "git's checkout: the worktree `guard_checkout` has just written is reopened",
    ),
    (
        "gunnar-server/src/feed.rs",
        "sells_room",
        "a feed KEY (`FEED_SELLS_ROOM`), looked up by name in what the installer feeds; it is \
         never shown as words, and the audit line it drives (`serve.rs`, `shop_room`) speaks of \
         the shop's room, not of selling",
    ),
    (
        "gunnar-server/src/product_report.rs",
        "BILLED AND INVISIBLE",
        "`Report.product`'s growth remediation (sealed): the PLUGIN's sentence (monetize \
         products/gunnar `growth_from`), mirrored byte for byte into the JSON the main reports to \
         the ONE monetize's `Fleet` — monetize's operator reads it on monetize's console; this \
         box never shows it",
    ),
    (
        "gunnar-server/src/product_report.rs",
        "nothing should be bought for this box",
        "`Report.product`'s growth remediation (unknown), the plugin's sentence for monetize, as above",
    ),
    (
        "gunnar-server/src/product_report.rs",
        "GROWS AT ITS NEXT RESTART",
        "`Report.product`'s growth remediation (at restart), the plugin's sentence for monetize, as above",
    ),
    (
        "gunnar-server/src/product_report.rs",
        "GROWS IN PLACE AT RUNTIME",
        "`Report.product`'s growth remediation (at runtime), the plugin's sentence for monetize, as above",
    ),
    (
        "gunnar-server/src/product_report.rs",
        "A disk may be bought",
        "`Report.product`'s iron note (the plugin's `absorbs` table), for monetize's operator, as above",
    ),
    (
        "gunnar-cli/src/crypt.rs",
        "checkout",
        "git's own subcommand, an ARGV word (`git checkout -- <files>` re-runs the crypt filter \
         over the worktree after an unlock); it is passed to git, never shown as words",
    ),
];

#[cfg(not(feature = "metered"))]
#[test]
fn the_open_sources_say_no_money() {
    let crates = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
    let mut leaks = Vec::new();
    let mut used = vec![false; EXCEPTIONS.len()];
    let mut scanned_files = 0usize;
    for krate in SCANNED_CRATES {
        for (path, s) in open_sources(&crates.join(krate)) {
            scanned_files += 1;
            let rel = path
                .strip_prefix(&crates)
                .unwrap_or(&path)
                .to_string_lossy()
                .replace('\\', "/");
            for lit in &s.literals {
                let hits = leaks_in(&lit.text);
                if hits.is_empty() {
                    continue;
                }
                if let Some(k) = EXCEPTIONS
                    .iter()
                    .position(|(file, frag, _)| rel.ends_with(file) && lit.text.contains(frag))
                {
                    used[k] = true;
                    continue;
                }
                leaks.push(format!("{rel}:{}: {hits:?} in {:?}", lit.line, lit.text));
            }
        }
    }
    assert!(
        scanned_files > 100,
        "the scan found only {scanned_files} files; the crate layout moved"
    );
    let stale: Vec<_> = EXCEPTIONS
        .iter()
        .zip(&used)
        .filter(|(_, u)| !**u)
        .map(|(e, _)| e.0)
        .collect();
    assert!(
        stale.is_empty(),
        "exceptions that no longer match anything: {stale:?}"
    );
    assert!(
        leaks.is_empty(),
        "an OPEN gunnar build compiles money vocabulary into what it says to a user or an \
         operator ({} literals). Reword neutrally, or gate the code under `metered`.\n  {}",
        leaks.len(),
        leaks.join("\n  ")
    );
}

#[test]
fn the_scanner_skips_what_the_open_build_does_not_contain() {
    let src = r####"
        // a comment about price
        /// a doc about billing
        fn open() { let _ = "free space"; }
        #[cfg(feature = "metered")]
        fn metered() { let _ = "paid"; }
        #[cfg(feature = "metered")]
        pub fn metered_result(a: Vec<u8>) -> Result<Vec<u8>, String> { Err(format!("billed {a:?}")) }
        #[cfg(not(feature = "metered"))]
        fn community() { let _ = "sealed"; }
        #[cfg(test)]
        mod tests { fn t() { let _ = "invoice"; } }
        #[cfg(feature = "metered")]
        pub mod entitlement;
        #[derive(Debug)]
        #[error("a refusal about quota")]
        struct E;
        const C: char = '"';
        fn tail() { let _ = "after"; }
    "####;
    let s = scan_source(src);
    let texts: Vec<&str> = s.literals.iter().map(|l| l.text.as_str()).collect();
    assert_eq!(
        texts,
        vec!["free space", "sealed", "a refusal about quota", "after"]
    );
    assert_eq!(s.skipped_mods, vec!["tests", "entitlement"]);
}

// ── half 3: a live guest ────────────────────────────────────────────────────

/// **Text captured from a running open appliance.** Every regular file under
/// `$GUNNAR_OPEN_GUARD_CAPTURE` is judged line by line (a sentence can wrap,
/// so each paragraph is judged too). `gunnar-growbench --scenarios O1` writes
/// that directory.
#[test]
fn a_live_guests_answers_say_no_money() {
    let Some(dir) = std::env::var_os("GUNNAR_OPEN_GUARD_CAPTURE") else {
        eprintln!(
            "GUNNAR_OPEN_GUARD_CAPTURE is not set: no live capture to judge (halves 1 and 2 ran)"
        );
        return;
    };
    let dir = PathBuf::from(dir);
    let mut files = Vec::new();
    let mut stack = vec![dir.clone()];
    while let Some(d) = stack.pop() {
        for e in std::fs::read_dir(&d)
            .unwrap_or_else(|e| panic!("{}: {e}", d.display()))
            .filter_map(Result::ok)
        {
            let p = e.path();
            if p.is_dir() {
                stack.push(p);
            } else {
                files.push(p);
            }
        }
    }
    files.sort();
    assert!(!files.is_empty(), "{} holds no capture", dir.display());
    let mut leaks = Vec::new();
    let mut judged = 0usize;
    for f in &files {
        let text = std::fs::read_to_string(f).unwrap_or_else(|e| panic!("{}: {e}", f.display()));
        for (n, para) in text.split("\n\n").enumerate() {
            judged += 1;
            let hits = leaks_in(para);
            if !hits.is_empty() {
                leaks.push(format!(
                    "{} ¶{}: {hits:?} in {:?}",
                    f.display(),
                    n + 1,
                    para.trim()
                ));
            }
        }
    }
    eprintln!(
        "live capture: {} files, {judged} paragraphs judged, {} leaks",
        files.len(),
        leaks.len()
    );
    assert!(
        leaks.is_empty(),
        "the live open guest says money words:\n  {}",
        leaks.join("\n  ")
    );
}