crates/nornir-warehouse-embed/tests/proto_drift.rs
//! Guard the hand-trimmed nornir proto stubs against silent wire drift.
//!
//! dwarves speaks several nornir-server services as a THIN gRPC client without
//! linking the `nornir` crate — a `dwarves → nornir` edge would close a
//! dependency cycle. So each embed crate carries a TRIMMED copy of just the
//! methods it calls (`nornir-warehouse-embed/proto/{telemetry,workspaces,
//! warehouse}.proto`, `nornir-funnel-embed/proto/funnel.proto`), and every one
//! of those files says the same thing in a comment: *keep the field numbers
//! identical to `nornir/proto/nornir.proto` so the wire stays byte-compatible*.
//!
//! Nothing enforced that. A field renumbered or retyped on the nornir side
//! would leave both repos compiling happily and corrupt the wire at runtime —
//! the failure would surface as garbled rows in the warehouse, far from the
//! edit that caused it.
//!
//! This test re-derives the contract: for every message and service the stubs
//! share with `nornir.proto`, the field/method sets must match exactly.
//!
//! It SKIPS (never fails) when the nornir checkout is absent, because dwarves
//! must build standalone — the sibling repo is a dev convenience, not a
//! dependency.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// One proto field: `(number, type, name)`. Number leads so the ordering a
/// mismatch reports is the wire ordering.
type Field = (u32, String, String);
/// One rpc: `(method, request, response)`.
type Rpc = (String, String, String);
#[derive(Default)]
struct Proto {
messages: BTreeMap<String, Vec<Field>>,
services: BTreeMap<String, Vec<Rpc>>,
}
/// Strip `//` line comments and collapse whitespace runs to single spaces.
fn scrub(src: &str) -> String {
src.lines()
.map(|l| match l.find("//") {
Some(i) => &l[..i],
None => l,
})
.collect::<Vec<_>>()
.join("\n")
}
/// Parse the subset of proto3 these files actually use: `message`/`service`
/// blocks, scalar and `repeated` fields, and `rpc a (B) returns (C);`.
/// Deliberately not a general proto parser — it only has to be exact about the
/// things that define the wire.
fn parse(src: &str) -> Proto {
let src = scrub(src);
let mut out = Proto::default();
let mut ctx: Option<(bool, String, Vec<Field>, Vec<Rpc>)> = None;
for raw in src.split('\n') {
let line = raw.trim();
if line.is_empty() {
continue;
}
// `message X { ... }` / `message X {}` may close on the same line.
if let Some(rest) = line.strip_prefix("message ") {
let (name, tail) = split_head(rest);
let mut fields = Vec::new();
let inline_closed = collect_inline(&tail, &mut fields);
if inline_closed {
out.messages.insert(name, fields);
} else {
ctx = Some((true, name, fields, Vec::new()));
}
continue;
}
if let Some(rest) = line.strip_prefix("service ") {
let (name, _) = split_head(rest);
ctx = Some((false, name, Vec::new(), Vec::new()));
continue;
}
if let Some((is_msg, _name, fields, rpcs)) = ctx.as_mut() {
if line.starts_with('}') {
let (is_msg, name, fields, rpcs) = ctx.take().unwrap();
if is_msg {
out.messages.insert(name, fields);
} else {
out.services.insert(name, rpcs);
}
let _ = is_msg;
continue;
}
if *is_msg {
if let Some(f) = parse_field(line) {
fields.push(f);
}
} else if let Some(r) = parse_rpc(line) {
rpcs.push(r);
}
}
}
out
}
/// Split `Name { maybe body }` into the name and whatever followed the brace.
fn split_head(rest: &str) -> (String, String) {
match rest.find('{') {
Some(i) => (
rest[..i].trim().to_string(),
rest[i + 1..].trim().to_string(),
),
None => (rest.trim().to_string(), String::new()),
}
}
/// Pull any fields written on the same line as the opening brace. Returns true
/// when that line also closed the block.
fn collect_inline(tail: &str, fields: &mut Vec<Field>) -> bool {
let closed = tail.contains('}');
let body = tail.trim_end_matches('}').trim();
for stmt in body.split(';') {
if let Some(f) = parse_field(stmt) {
fields.push(f);
}
}
closed
}
/// `[repeated] <type> <name> = <n>;`
fn parse_field(line: &str) -> Option<Field> {
let line = line.trim().trim_end_matches(';').trim();
if line.is_empty() {
return None;
}
let (lhs, num) = line.split_once('=')?;
let number: u32 = num.trim().parse().ok()?;
let toks: Vec<&str> = lhs.split_whitespace().collect();
let (ty, name) = match toks.as_slice() {
["repeated", ty, name] => (format!("repeated {ty}"), *name),
[ty, name] => ((*ty).to_string(), *name),
_ => return None,
};
Some((number, ty, name.to_string()))
}
/// `rpc Method (Req) returns (Resp);`
fn parse_rpc(line: &str) -> Option<Rpc> {
let rest = line.trim().strip_prefix("rpc ")?;
let (method, rest) = rest.split_once('(')?;
let (req, rest) = rest.split_once(')')?;
let rest = rest.trim().strip_prefix("returns")?;
let (_, rest) = rest.split_once('(')?;
let (resp, _) = rest.split_once(')')?;
Some((
method.trim().to_string(),
req.trim().to_string(),
resp.trim().to_string(),
))
}
fn repo_root() -> PathBuf {
// <root>/dwarves/crates/nornir-warehouse-embed
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.expect("crate is nested at least 3 deep")
.to_path_buf()
}
/// The `crates/` directory of the dwarves checkout THIS TEST WAS COMPILED FROM.
///
/// It used to be `<root>/dwarves/crates` — a guessed sibling name. Inside a worktree
/// (`dwarves-wt-<suffix>/`, which is how the whole fleet works: LAW 6) that path points
/// at the MAIN checkout, so the guard read stubs nobody was editing and compared them
/// against a proto nobody was editing: a green that could not see the change under test.
/// A stub edit in a worktree was structurally invisible to it.
///
/// `CARGO_MANIFEST_DIR` is `<checkout>/crates/nornir-warehouse-embed`, so its parent is
/// the right `crates/` by construction, worktree or not.
fn embed_crates_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crate lives under <checkout>/crates")
.to_path_buf()
}
/// Every hand-trimmed stub, paired with a label for failure messages.
fn stubs(_root: &Path) -> Vec<(&'static str, PathBuf)> {
let embed = embed_crates_dir();
vec![
(
"nornir-warehouse-embed/telemetry",
embed.join("nornir-warehouse-embed/proto/telemetry.proto"),
),
(
"nornir-warehouse-embed/workspaces",
embed.join("nornir-warehouse-embed/proto/workspaces.proto"),
),
(
"nornir-warehouse-embed/warehouse",
embed.join("nornir-warehouse-embed/proto/warehouse.proto"),
),
(
"nornir-warehouse-embed/mimir",
embed.join("nornir-warehouse-embed/proto/mimir.proto"),
),
(
"nornir-warehouse-embed/bench",
embed.join("nornir-warehouse-embed/proto/bench.proto"),
),
(
// The one-verb `Viz` trim (`Viz.Jobs` + `JobsRequest`). A trimmed stub that
// is NOT in this list is exactly the trap the guard exists for: the Jera tab
// would mis-decode a live ledger the day a field number moved upstream, and
// protobuf decodes by NUMBER — it would not error, it would land the value in
// the wrong slot or drop it.
"nornir-warehouse-embed/viz",
embed.join("nornir-warehouse-embed/proto/viz.proto"),
),
(
// The one-rpc `CodeRoom` door every code-graph pane asks through. Its
// message does not exist upstream before the verb does, so an edda without
// `CodeRoom` fails here ("shares NO message or service name") instead of
// letting the panes ask a door the server never opens.
"nornir-warehouse-embed/coderoom",
embed.join("nornir-warehouse-embed/proto/coderoom.proto"),
),
(
"nornir-funnel-embed/funnel",
embed.join("nornir-funnel-embed/proto/funnel.proto"),
),
]
}
/// Where the canonical `nornir.proto` lives, in preference order.
///
/// It MOVED: the `nornir.v1` proto + its prost/tonic codegen left `nornir/build.rs`
/// for the edda `nornir-wire` leaf, so `nornir/proto/nornir.proto` — the only path
/// this guard used to look at — stopped existing and the whole check silently
/// degraded to SKIP. A drift guard that can only ever pass is not a guard, so the
/// new home leads and the old one is kept as a fallback for an older checkout.
///
/// **Worktree pairing.** A wire change spans two repos, so it is made in two PAIRED
/// worktrees — `dwarves-wt-<suffix>` beside `edda-wt-<suffix>` (LAW 6). Cargo's
/// `../edda` path-dep resolves to the MAIN edda checkout there, so a guard that only
/// looked at `<root>/edda` would call a correctly-paired change "drift" and, worse, call
/// an unpaired one clean. When this test was compiled from a `-wt-<suffix>` checkout the
/// same-suffix edda worktree therefore LEADS; the main checkout stays the fallback, so a
/// non-worktree build (and CI) is unchanged and still strict.
fn nornir_proto_candidates(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let checkout = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
if let Some((_, suffix)) = checkout.split_once("-wt-") {
out.push(root.join(format!(
"edda-wt-{suffix}/crates/nornir-wire/proto/nornir.proto"
)));
}
out.push(root.join("edda/crates/nornir-wire/proto/nornir.proto"));
out.push(root.join("nornir/proto/nornir.proto"));
out
}
#[test]
fn trimmed_stubs_match_nornir_wire_contract() {
let root = repo_root();
let candidates = nornir_proto_candidates(&root);
let Some(nornir_proto) = candidates.iter().find(|p| p.is_file()).cloned() else {
eprintln!(
"SKIP: no nornir.proto at any of {:?} — dwarves builds standalone",
candidates
);
return;
};
let upstream = parse(&std::fs::read_to_string(&nornir_proto).expect("read nornir.proto"));
assert!(
!upstream.messages.is_empty(),
"parsed 0 messages from {} — the parser has gone stale, not the stubs",
nornir_proto.display()
);
let mut problems = Vec::new();
let mut compared = 0usize;
for (label, path) in stubs(&root) {
let Ok(text) = std::fs::read_to_string(&path) else {
problems.push(format!("{label}: missing stub at {}", path.display()));
continue;
};
let stub = parse(&text);
// Only names the two files SHARE are comparable; a stub may legitimately
// define local helpers. But an empty intersection means a rename made
// this check vacuous, which is itself the drift we are hunting.
let mut shared_here = 0usize;
for (name, fields) in &stub.messages {
let Some(up) = upstream.messages.get(name) else {
continue;
};
shared_here += 1;
compared += 1;
if fields != up {
problems.push(format!(
"{label}: message `{name}` drifted\n stub: {fields:?}\n nornir: {up:?}"
));
}
}
for (name, rpcs) in &stub.services {
let Some(up) = upstream.services.get(name) else {
continue;
};
shared_here += 1;
compared += 1;
// The stub trims methods on purpose, so it must be a SUBSET —
// every method it keeps has to match nornir's signature exactly.
for rpc in rpcs {
match up.iter().find(|u| u.0 == rpc.0) {
Some(u) if u == rpc => {}
Some(u) => problems.push(format!(
"{label}: rpc `{name}.{}` drifted\n stub: {rpc:?}\n nornir: {u:?}",
rpc.0
)),
None => problems.push(format!(
"{label}: rpc `{name}.{}` no longer exists in nornir.proto",
rpc.0
)),
}
}
}
if shared_here == 0 {
problems.push(format!(
"{label}: shares NO message or service name with nornir.proto — \
a rename has made this stub's contract uncheckable"
));
}
}
assert!(
problems.is_empty(),
"trimmed nornir proto stubs drifted from {}:\n\n{}\n\n\
Fix the stub to match nornir/proto/nornir.proto (field numbers are the wire).",
nornir_proto.display(),
problems.join("\n")
);
assert!(compared > 0, "compared nothing — the stub list is stale");
eprintln!("proto drift: {compared} shared messages/services verified in sync");
}