tests/mcp_release_doctor_blast.rs
//! End to end: the MCP tool `release_doctor` with `semantic_blast: true`.
//!
//! ```text
//! two repos on disk (web calls shop), consumed by the REAL nightly driver
//! → `nornir-server mcp` over stdio (embedded, its own code room)
//! → release_doctor { semantic_blast: true, json: true }
//! → the dirty tree of `shop` is dumped by the driver (nothing written
//! to the lake), diffed against what was consumed, and the sites in
//! `web` come back in the report
//! ```
//!
//! Until this file the tool compiled with the argument and nothing called it.
//! The driver is built from `crates/mimir-driver` (own workspace, pinned
//! nightly) on first use, as `crates/mimir-ingest/tests/ingest/real.rs` does.
#![cfg(all(feature = "mcp", feature = "server"))]
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use nornir::code_room::CodeRoom;
use nornir::code_room::consumer_loop::{DriverDumpers, Member, MemberStatus, Request};
use nornir::code_room::driver::Settings;
use serde_json::{Value, json};
fn driver_dir() -> PathBuf {
PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/crates/mimir-driver"))
}
/// Build mimir-driver once (its own workspace, with the outer `cargo test`'s
/// environment scrubbed so its `rust-toolchain.toml` picks the nightly).
fn build_driver() -> PathBuf {
let mut c = Command::new("cargo");
for (k, _) in std::env::vars() {
if (k.starts_with("CARGO_") && k != "CARGO_HOME") || matches!(k.as_str(), "RUSTFLAGS" | "RUSTC_WRAPPER" | "RUSTC_WORKSPACE_WRAPPER" | "RUSTUP_TOOLCHAIN" | "RUSTC") {
c.env_remove(k);
}
}
let st = c.current_dir(driver_dir()).args(["build", "--release", "--bins"]).status().expect("run cargo for mimir-driver");
assert!(st.success(), "building mimir-driver failed");
driver_dir().join("target/release")
}
fn write(path: &Path, text: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
const SHOP_LIB: &str = "pub fn checkout(cart: u32) -> u32 {\n cart\n}\n\npub fn refund() {}\n";
// `shop::checkout` is called on line 2, `shop::refund` on line 6
const WEB_LIB: &str = "pub fn render() -> u32 {\n shop::checkout(1)\n}\n\npub fn undo() {\n shop::refund()\n}\n";
/// `shop` and `web` (which depends on `shop` by path) as two git repos under
/// `root`, each with one commit. The lock files are ignored so the dry run's
/// `cargo check` does not dirty a tree by itself.
fn two_repos(root: &Path) -> (PathBuf, PathBuf) {
let (shop, web) = (root.join("shop"), root.join("web"));
write(&shop.join("Cargo.toml"), "[package]\nname = \"shop\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
write(&shop.join("src/lib.rs"), SHOP_LIB);
write(
&web.join("Cargo.toml"),
"[package]\nname = \"web\"\nversion = \"0.2.0\"\nedition = \"2021\"\n\n[dependencies]\nshop = { path = \"../shop\", version = \"0.1.0\" }\n",
);
write(&web.join("src/lib.rs"), WEB_LIB);
for repo in [&shop, &web] {
write(&repo.join(".gitignore"), "Cargo.lock\ntarget/\n");
nornir::gitio::init(repo).unwrap();
nornir::gitio::commit_all(repo, "seed").unwrap();
}
(shop, web)
}
fn member(name: &str, source: &Path) -> Member {
Member { name: name.to_string(), source: source.to_path_buf(), branch: None, head: nornir::gitio::head_sha(source).unwrap() }
}
/// The MCP child over stdio, embedded (no `NORNIR_SERVER`).
struct Mcp {
child: Child,
stdin: ChildStdin,
out: BufReader<ChildStdout>,
next_id: i64,
}
impl Mcp {
fn spawn(root: &Path, config: &Path, driver: &Path) -> Self {
// the child's HOME is the scratch root (its warehouse and code room
// are home-derived); rustup and cargo keep the real ones, or the
// driver's `cargo +nightly` would find no toolchain
let home = PathBuf::from(std::env::var_os("HOME").expect("HOME"));
let rustup_home = std::env::var_os("RUSTUP_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".rustup"));
let cargo_home = std::env::var_os("CARGO_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".cargo"));
let mut cmd = Command::new(env!("CARGO_BIN_EXE_nornir-server"));
cmd.arg("mcp")
.env("NORNIR_CONFIG", config)
.env("HOME", root)
.env("RUSTUP_HOME", rustup_home)
.env("CARGO_HOME", cargo_home)
.env("NORNIR_MIMIR_DRIVER", driver)
.env("NORNIR_MCP_NO_TELEMETRY", "1")
.env_remove("NORNIR_SERVER")
.env_remove("NORNIR_SERVER_TOKEN")
.env_remove("NORNIR_WORKSPACE")
.env_remove("NORNIR_MIMIR_DRIVER_HOST")
.env_remove("NORNIR_MIMIR_TOOLCHAIN");
// the outer `cargo test` must not steer the driver's cargo
for (k, _) in std::env::vars() {
if (k.starts_with("CARGO_") && k != "CARGO_HOME") || matches!(k.as_str(), "RUSTFLAGS" | "RUSTC_WRAPPER" | "RUSTC_WORKSPACE_WRAPPER" | "RUSTUP_TOOLCHAIN" | "RUSTC") {
cmd.env_remove(k);
}
}
let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn().expect("spawn nornir-server mcp");
let stdin = child.stdin.take().unwrap();
let out = BufReader::new(child.stdout.take().unwrap());
let mut m = Mcp { child, stdin, out, next_id: 1 };
let init = m.request("initialize", json!({"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "mcp_release_doctor_blast", "version": "1"}}));
assert!(init["result"].is_object(), "initialize failed: {init}");
writeln!(m.stdin, "{}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).unwrap();
m.stdin.flush().unwrap();
m
}
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
self.stdin.flush().unwrap();
let mut line = String::new();
loop {
line.clear();
assert!(self.out.read_line(&mut line).unwrap() > 0, "the MCP child closed its stdout before answering id={id}");
if let Ok(v) = serde_json::from_str::<Value>(line.trim()) {
if v["id"].as_i64() == Some(id) {
return v;
}
}
}
}
/// `release_doctor` with the semantic blast: `Ok(report)` or `Err(what the tool said)`.
fn doctor(&mut self) -> Result<Value, String> {
let resp = self.request("tools/call", json!({"name": "release_doctor", "arguments": {"semantic_blast": true, "json": true}}));
if let Some(e) = resp.get("error") {
return Err(e["message"].as_str().unwrap_or_default().to_string());
}
let text: String = resp["result"]["content"].as_array().map(|a| a.iter().filter_map(|c| c["text"].as_str()).collect()).unwrap_or_default();
if resp["result"]["isError"].as_bool().unwrap_or(false) {
return Err(text);
}
Ok(serde_json::from_str(&text).unwrap_or_else(|e| panic!("the report is not JSON ({e}): {text}")))
}
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// The call sites of one impact as `(crate, caller, kind, file, line)`.
fn sites(impact: &Value) -> Vec<(String, String, String, String, u64)> {
let s = |v: &Value| v.as_str().unwrap_or_default().to_string();
impact["call_sites"].as_array().unwrap().iter().map(|c| (s(&c["crate_name"]), s(&c["caller"]), s(&c["call_kind"]), s(&c["file"]), c["line"].as_u64().unwrap())).collect()
}
#[test]
fn release_doctor_reports_what_a_dirty_tree_breaks_in_another_repo_and_refuses_a_tree_that_does_not_compile() {
let bin = build_driver();
let driver = bin.join("cargo-mimir-dump");
let tmp = tempfile::tempdir().unwrap();
// (a canonical root: the child resolves its paths from it)
let root = tmp.path().canonicalize().unwrap();
let (shop, web) = two_repos(&root);
let config = root.join("_cfg/_rel/nornir.toml");
write(&config, "[storage]\nkind = \"local\"\n\n[repo.shop]\n\n[repo.web]\n");
// ---- consume both repos into the lake the child will open: the embedded
// MCP's code room lives under `<HOME>/.nornir/workspaces/default/builds`
let builds = root.join(".nornir/workspaces/default/builds");
{
let ready = Settings { driver: Some(driver.clone()), host: None, toolchain: None, search: vec![] }.probe().unwrap_or_else(|r| panic!("the built driver must probe green: {r}"));
let room = CodeRoom::new("ws", &builds);
let members = [member("shop", &shop), member("web", &web)];
let report = room.consume(&DriverDumpers(ready), &[], &Request { members: &members, ..Default::default() }, &mut |_| {}).unwrap_or_else(|r| panic!("consume refused: {r}"));
let got: Vec<_> = report.members.iter().map(|m| (m.member.as_str(), m.status, m.error.clone())).collect();
assert_eq!(got, vec![("shop", MemberStatus::CaughtUp, None), ("web", MemberStatus::CaughtUp, None)]);
// the lake is single-process: let go before the child opens it
room.close();
}
let mut mcp = Mcp::spawn(&root, &config, &driver);
// ---- clean trees: nothing pending, nothing asked of the driver
let report = mcp.doctor().unwrap_or_else(|e| panic!("clean trees must not refuse: {e}"));
assert!(report["semantic_blast"].as_object().unwrap().is_empty(), "{}", report["semantic_blast"]);
assert!(report["dirty"].as_array().unwrap().iter().all(|d| d["dirty"] == false), "{}", report["dirty"]);
// ---- an uncommitted change of a signature `web` calls: a CHANGED symbol
// with web's site, by file and line
write(&shop.join("src/lib.rs"), &SHOP_LIB.replace("cart: u32) -> u32", "cart: u64) -> u64"));
let report = mcp.doctor().unwrap_or_else(|e| panic!("a tree that compiles must not refuse: {e}"));
let blast = &report["semantic_blast"]["shop"];
assert_eq!((blast["source"].as_str(), blast["target_repo"].as_str()), (Some("code_room"), Some("shop")), "{blast}");
assert_eq!(blast["affected_crates"], json!(["web"]), "{blast}");
let impacts = blast["impacts"].as_array().unwrap();
assert_eq!(impacts.len(), 1, "only `checkout` changed: {blast}");
assert_eq!((impacts[0]["symbol"].as_str(), impacts[0]["change"].as_str()), (Some("shop::checkout"), Some("changed")), "{blast}");
assert_eq!(sites(&impacts[0]), vec![("web".to_string(), "web::render".to_string(), "CALLS".to_string(), "src/lib.rs".to_string(), 2)], "{blast}");
assert_eq!(blast["total_call_sites"], 1, "{blast}");
// ---- `refund` deleted as well: a REMOVED symbol that `web::undo` still
// calls (what the link gate fails on), next to the changed one
write(&shop.join("src/lib.rs"), &SHOP_LIB.replace("cart: u32) -> u32", "cart: u64) -> u64").replace("\npub fn refund() {}\n", ""));
let report = mcp.doctor().unwrap_or_else(|e| panic!("a tree that compiles must not refuse: {e}"));
let impacts = report["semantic_blast"]["shop"]["impacts"].as_array().unwrap().clone();
let by_symbol = |name: &str| impacts.iter().find(|i| i["symbol"] == name).unwrap_or_else(|| panic!("no impact for {name}: {impacts:?}"));
assert_eq!(impacts.len(), 2, "{impacts:?}");
assert_eq!(by_symbol("shop::refund")["change"], "removed");
assert_eq!(sites(by_symbol("shop::refund")), vec![("web".to_string(), "web::undo".to_string(), "CALLS".to_string(), "src/lib.rs".to_string(), 6)]);
assert_eq!(by_symbol("shop::checkout")["change"], "changed");
// ---- a tree that does not compile: no report, the reason by name
write(&shop.join("src/lib.rs"), "pub fn checkout(cart: u32) -> u32 {\n cart +\n}\n");
let said = mcp.doctor().expect_err("a tree that does not compile must fail closed");
assert!(said.contains("`shop`") && said.contains("does not compile") && said.contains("fail closed"), "{said}");
// ---- back to the committed text: still a dirty tree? no — nothing pending again
write(&shop.join("src/lib.rs"), SHOP_LIB);
let report = mcp.doctor().unwrap_or_else(|e| panic!("the restored tree must not refuse: {e}"));
assert!(report["semantic_blast"].as_object().unwrap().is_empty(), "{}", report["semantic_blast"]);
}