holger-cli/tests/every_ui_call_is_a_command.rs
//! **Every call the console makes is a command here** (lane B3, owner: "every UI action
//! is a CLI command").
//!
//! The console (holger-ui, `src/`) reaches holger through one accessor per service
//! (`grpc::admin()`, `grpc::tokens()`, …) and calls the RPC on it. This test reads those
//! calls two ways and fails when one has no `holger-cli` command:
//!
//! * [`CONSOLE_CALLS`]: the console's calls as they stood when this client was written
//! (holger-ui ca3d57f, 39 calls), so a lone clone of holger checks them too;
//! * the sibling checkout `../holger-ui` (or `$HOLGER_UI_DIR`) when there is one: its
//! source TODAY, so a call the console gains fails here until the client has it.
//!
//! The console's other doors are not gRPC and are not the client's: the passkey
//! ceremony, invites and step-up need a browser's authenticator (`/-/auth/passkey/*`),
//! the certificates pane is the estate front's page, and the purchases are the shop's.
use std::path::{Path, PathBuf};
use holger_cli::table::{command_for, rpc_of};
/// The console's gRPC calls at holger-ui ca3d57f (2026-10-26), `(accessor, method)`.
const CONSOLE_CALLS: &[(&str, &str)] = &[
("account", "create_repository"),
("account", "grant"),
("account", "my_repositories"),
("admin", "admin_snapshot"),
("admin", "health"),
("admin", "list_repositories"),
("admin", "server_profile"),
("admin", "topology"),
("admin", "who_am_i"),
("admission", "admit_plan"),
("admission", "list_usage"),
("archives", "archive_info"),
("archives", "list_archive_files"),
("audit", "get_artifact_trail"),
("jobs", "list_jobs"),
("migration", "cancel"),
("migration", "discover"),
("migration", "start"),
("migration", "watch"),
("promotion", "advance_artifact"),
("promotion", "get_artifact_evidence"),
("promotion", "get_artifact_state"),
("promotion", "list_intake_artifacts"),
("promotion", "promote_artifact"),
("properties", "get_properties"),
("properties", "set_property"),
("replication", "replicate"),
("repositories", "fetch_artifact"),
("repositories", "list_artifacts"),
("repositories", "put_artifact"),
("room", "private_estate"),
("room", "room"),
("sboms", "attach_sbom"),
("sboms", "fetch_sbom"),
("search", "search"),
("tokens", "list"),
("tokens", "mint"),
("tokens", "revoke"),
("usage", "read_usage"),
];
/// The console's service accessors (`src/grpc.rs`).
const ACCESSORS: &[&str] = &[
"admin",
"repositories",
"search",
"sboms",
"properties",
"promotion",
"audit",
"migration",
"replication",
"archives",
"jobs",
"admission",
"room",
"account",
"tokens",
"usage",
];
fn ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
/// Pure: every `accessor().method(` call in `text`, whitespace and newlines between the
/// parts allowed (rustfmt breaks long chains), comments included as text is.
pub fn calls_in(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for acc in ACCESSORS {
let needle = format!("{acc}()");
let mut from = 0;
while let Some(at) = text[from..].find(&needle) {
let start = from + at;
from = start + needle.len();
// A whole word: `grpc::admin()`, ` admin()`, never `xadmin()`.
if text[..start].chars().next_back().is_some_and(ident_char) {
continue;
}
let rest = text[from..].trim_start();
let Some(rest) = rest.strip_prefix('.') else {
continue;
};
let rest = rest.trim_start();
let method: String = rest.chars().take_while(|c| ident_char(*c)).collect();
if method.is_empty() || !rest[method.len()..].trim_start().starts_with('(') {
continue;
}
out.push((acc.to_string(), method));
}
}
out.sort();
out.dedup();
out
}
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
rs_files(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
fn missing(calls: &[(String, String)]) -> Vec<String> {
calls
.iter()
.filter_map(|(a, m)| match rpc_of(a, m) {
Some(rpc) if command_for(&rpc).is_some() => None,
Some(rpc) => Some(format!("{a}().{m}() = {rpc}")),
None => Some(format!("{a}().{m}(): no service for accessor {a}")),
})
.collect()
}
#[test]
fn the_scanner_finds_a_call_across_lines_and_only_whole_accessors() {
let src = "let r = grpc::admin()\n .admin_snapshot(req).await;\nlet x = tokens().mint(m);\nxadmin().health();\nadmin().health;\n";
assert_eq!(
calls_in(src),
vec![
("admin".to_string(), "admin_snapshot".to_string()),
("tokens".to_string(), "mint".to_string())
]
);
}
#[test]
fn every_call_the_console_made_is_a_command() {
let calls: Vec<(String, String)> = CONSOLE_CALLS
.iter()
.map(|(a, m)| (a.to_string(), m.to_string()))
.collect();
assert_eq!(calls.len(), 39);
let gaps = missing(&calls);
assert!(
gaps.is_empty(),
"console calls with no holger-cli command:\n{}",
gaps.join("\n")
);
}
#[test]
fn every_call_the_console_makes_today_is_a_command() {
let dir = std::env::var_os("HOLGER_UI_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../holger-ui"));
let src = dir.join("src");
if !src.join("grpc.rs").is_file() {
sibling_missing(&format!(
"no holger-ui checkout at {} (set HOLGER_UI_DIR); the pinned list was checked",
dir.display()
));
return;
}
let mut files = Vec::new();
rs_files(&src, &mut files);
let mut calls = Vec::new();
for f in &files {
calls.extend(calls_in(&std::fs::read_to_string(f).unwrap()));
}
calls.sort();
calls.dedup();
assert!(
calls.len() >= CONSOLE_CALLS.len(),
"the scan found {} calls in {}: it went blind",
calls.len(),
src.display()
);
let gaps = missing(&calls);
assert!(
gaps.is_empty(),
"the console at {} calls these with no holger-cli command:\n{}",
dir.display(),
gaps.join("\n")
);
}
/// ★ **RA1-8: a missing sibling is RED in CI** — `every_call_the_console_makes_today_is_a_command` reads the sibling checkout, and
/// says SKIP by name only outside CI. Run here as a child of this test binary with the sibling
/// pointed at a directory that is not there: with the runner's container marker
/// (`NORNIR_CI_IN_CONTAINER=1`) it must fail; with no CI marker it passes and says SKIP.
#[test]
fn a_missing_sibling_is_red_in_ci_and_a_named_skip_elsewhere() {
let gone = std::env::temp_dir().join(format!("ra1-8-no-sibling-{}", std::process::id()));
let child = |ci: bool| {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args([
"--exact",
"every_call_the_console_makes_today_is_a_command",
"--nocapture",
"--test-threads",
"1",
])
.env("HOLGER_UI_DIR", &gone);
for k in [
"NORNIR_CI_IN_CONTAINER",
"NORNIR_CI_HOST",
"SIBLINGS_REQUIRED",
"CI",
] {
c.env_remove(k);
}
if ci {
c.env("NORNIR_CI_IN_CONTAINER", "1");
}
let out = c.output().unwrap();
(
out.status.success(),
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
)
};
let (ok, text) = child(false);
assert!(
ok && text.contains("SKIP") && text.contains("1 passed"),
"outside CI a missing sibling is a named skip: {text}"
);
let (ok, text) = child(true);
assert!(
!ok && text.contains("1 failed"),
"in CI a missing sibling is red: {text}"
);
}
/// **RA1-8: is a missing sibling checkout RED here?** In CI (the runner's container marker
/// `NORNIR_CI_IN_CONTAINER=1`, `NORNIR_CI_HOST=1`, `CI=true`) or when asked
/// (`SIBLINGS_REQUIRED=1`) it is; elsewhere a cross-repo test says SKIP by name.
fn siblings_required(env: &dyn Fn(&str) -> Option<String>) -> bool {
let on = |k: &str| env(k).is_some_and(|v| matches!(v.trim(), "1" | "true"));
on("NORNIR_CI_IN_CONTAINER") || on("NORNIR_CI_HOST") || on("CI") || on("SIBLINGS_REQUIRED")
}
/// A missing sibling: a named SKIP outside CI, a failure in it ([`siblings_required`]).
fn sibling_missing(why: &str) {
assert!(!siblings_required(&|k| std::env::var(k).ok()), "{why}: in CI a missing sibling is red (NORNIR_CI_IN_CONTAINER, NORNIR_CI_HOST, CI or SIBLINGS_REQUIRED is set)");
eprintln!("SKIP: {why}");
}
#[test]
fn siblings_are_required_in_ci_or_when_asked() {
let env = |set: &'static [(&'static str, &'static str)]| {
move |k: &str| {
set.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| v.to_string())
}
};
assert!(!siblings_required(&env(&[])));
assert!(!siblings_required(&env(&[
("CI", "0"),
("NORNIR_CI_IN_CONTAINER", "")
])));
for k in [
"NORNIR_CI_IN_CONTAINER",
"NORNIR_CI_HOST",
"CI",
"SIBLINGS_REQUIRED",
] {
let one: &'static [(&'static str, &'static str)] =
Box::leak(vec![(k, "1")].into_boxed_slice());
assert!(siblings_required(&env(one)), "{k}=1");
}
assert!(siblings_required(&env(&[("CI", "true")])));
}