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

nordisk/dwarves

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");
}