tests/support/genworkspace.rs
//! Synthetic workspace generator for the scenario ladder (L0–L4).
//!
//! Produces a self-contained tempdir workspace of real, cargo-valid
//! crates wired by `path`+`version` dependencies, plus a
//! `nornir-workspace.toml` descriptor and a `nornir.toml` config. Each
//! "repo" is its own single-crate cargo workspace under the root, git
//! initialised + committed (purely via `gitio`, no shell).
//!
//! The point: feed [`nornir::warehouse::dep_graph::build`]
//! a graph of known shape so the dependency-Mímir probes (Q1–Q6) have a
//! ground-truth to assert against — demonstrating that Mímir, not a
//! reasoning model, computes blast radius / build order / paths.
//!
//! Crate code is intentionally compilable and carries a propagating
//! `metric()` (`SELF_VALUE + Σ dep::metric()`), so mutating a leaf
//! constant ripples into every dependent's metric — the basis for the
//! regression scenarios. A per-repo `#[test]` gates on a `.flags/test_pass`
//! file so test pass/fail is toggleable from the harness.
//!
//! Not every consumer needs this module; included via
//! `#[path = "support/genworkspace.rs"] mod genworkspace;`. Allow dead
//! code so individual test binaries that use only part of it stay warning
//! clean.
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
/// Topology of the generated graph.
#[derive(Debug, Clone, Copy)]
pub enum Shape {
/// `c0 → c1 → … → c{n-1}` (each depends on the next). L0/L1.
Chain,
/// `root → {leaf1 … leaf{n-1}}`. First fan-out. L1.
FanOut,
/// The fixed 10-repo holger-like diamond (ignores `repos`). L2.
NamedHolgerTree,
/// Seeded layered DAG of `repos` nodes (edges only point to deeper
/// layers, so it is acyclic). L3/L4.
LayeredDag,
}
/// Inputs to [`gen_workspace`].
#[derive(Debug, Clone)]
pub struct GenSpec {
pub seed: u64,
pub repos: usize,
/// Number of locked "external" crates under `locked-registry/`.
pub ext_crates: usize,
pub shape: Shape,
}
impl GenSpec {
pub fn new(shape: Shape, repos: usize) -> Self {
Self {
seed: 0xC0FFEE,
repos,
ext_crates: 0,
shape,
}
}
pub fn seed(mut self, s: u64) -> Self {
self.seed = s;
self
}
pub fn ext_crates(mut self, n: usize) -> Self {
self.ext_crates = n;
self
}
}
/// A generated workspace on disk. Drop the [`TempDir`] (kept in `_root`)
/// to clean up.
pub struct GeneratedWorkspace {
_root: TempDir,
pub root: PathBuf,
pub descriptor_path: PathBuf,
pub nornir_toml_path: PathBuf,
pub repo_names: Vec<String>,
/// Ground-truth cross-repo edges `(from, to)` — `from` depends on `to`.
pub edges: Vec<(String, String)>,
/// Ground-truth external-crate dependencies `(repo, ext_crate)`.
pub ext_edges: Vec<(String, String)>,
}
impl GeneratedWorkspace {
pub fn repo_path(&self, repo: &str) -> PathBuf {
self.root.join(repo)
}
/// Direct in-workspace dependencies of `repo` (ground truth).
pub fn expected_deps(&self, repo: &str) -> Vec<String> {
self.edges
.iter()
.filter(|(f, _)| f == repo)
.map(|(_, t)| t.clone())
.collect()
}
/// Direct in-workspace dependents of `repo` (ground truth).
pub fn expected_dependents(&self, repo: &str) -> Vec<String> {
self.edges
.iter()
.filter(|(_, t)| t == repo)
.map(|(f, _)| f.clone())
.collect()
}
/// Toggle a repo's `#[test]` gate by creating/removing its flag file.
pub fn set_test_flag(&self, repo: &str, pass: bool) {
let flag = self.repo_path(repo).join(".flags").join("test_pass");
if pass {
std::fs::create_dir_all(flag.parent().unwrap()).unwrap();
std::fs::write(&flag, b"ok").unwrap();
} else if flag.exists() {
std::fs::remove_file(&flag).unwrap();
}
}
/// Rewrite a repo's `SELF_VALUE` constant (shifts its and dependents'
/// `metric()`). Does not commit — call [`Self::commit`] to clean the
/// working tree afterwards.
pub fn set_metric_base(&self, repo: &str, value: u64) {
let lib = self.repo_path(repo).join("src").join("lib.rs");
let src = std::fs::read_to_string(&lib).unwrap();
let rewritten = rewrite_self_value(&src, value);
std::fs::write(&lib, rewritten).unwrap();
}
/// Convenience for the L2 regression scenario: bump `codec-utils`.
pub fn bump_codec_const(&self, value: u64) {
self.set_metric_base("codec-utils", value);
}
/// Commit the current working tree of `repo` (after a mutation).
pub fn commit(&self, repo: &str, message: &str) -> String {
nornir::gitio::commit_all(&self.repo_path(repo), message).unwrap()
}
}
/// Deterministic SplitMix64 PRNG — no external rng dependency.
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
}
/// Build the workspace described by `spec`.
pub fn gen_workspace(spec: &GenSpec) -> GeneratedWorkspace {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
let (repo_names, edges) = match spec.shape {
Shape::Chain => chain(spec.repos),
Shape::FanOut => fan_out(spec.repos),
Shape::NamedHolgerTree => named_holger_tree(),
Shape::LayeredDag => layered_dag(spec.repos, spec.seed),
};
// Optional locked "external" crates + which repos pin them.
let mut ext_edges: Vec<(String, String)> = Vec::new();
if spec.ext_crates > 0 {
let reg = root.join("locked-registry");
std::fs::create_dir_all(®).unwrap();
for i in 0..spec.ext_crates {
let name = format!("extdep-{i:03}");
write_crate(®.join(&name), &name, &[], &[], CrateBody::Leaf(7));
}
// Each repo pins a small, seeded subset of ext crates.
let mut rng = Rng(spec.seed ^ 0xEC0_DEED);
for repo in &repo_names {
let pins = 1 + rng.below(3);
for _ in 0..pins {
let idx = rng.below(spec.ext_crates);
let ext = format!("extdep-{idx:03}");
if !ext_edges.iter().any(|(r, e)| r == repo && e == &ext) {
ext_edges.push((repo.clone(), ext));
}
}
}
}
// Map repo → (in-workspace deps, ext deps).
let mut deps_of: BTreeMap<String, Vec<String>> =
repo_names.iter().map(|r| (r.clone(), Vec::new())).collect();
for (f, t) in &edges {
deps_of.get_mut(f).unwrap().push(t.clone());
}
let mut ext_of: BTreeMap<String, Vec<String>> =
repo_names.iter().map(|r| (r.clone(), Vec::new())).collect();
for (r, e) in &ext_edges {
ext_of.get_mut(r).unwrap().push(e.clone());
}
// Write + git-init every repo.
let mut rng = Rng(spec.seed);
for repo in &repo_names {
let dir = root.join(repo);
let deps = &deps_of[repo];
let exts = &ext_of[repo];
let base = 1 + rng.below(50) as u64;
let body = if deps.is_empty() {
CrateBody::Leaf(base)
} else {
CrateBody::Node(base)
};
write_crate(&dir, repo, deps, exts, body);
// Default: tests pass.
std::fs::create_dir_all(dir.join(".flags")).unwrap();
std::fs::write(dir.join(".flags").join("test_pass"), b"ok").unwrap();
nornir::gitio::init(&dir).unwrap();
let sha = nornir::gitio::commit_all(&dir, "initial synthetic commit").unwrap();
nornir::gitio::tag_lightweight(&dir, "v0.1.0", &sha).unwrap();
}
// Descriptor + nornir.toml.
let descriptor_path = write_descriptor(&root, &repo_names);
let nornir_toml_path = write_nornir_toml(&root, &repo_names, spec.ext_crates > 0);
GeneratedWorkspace {
_root: tmp,
root,
descriptor_path,
nornir_toml_path,
repo_names,
edges,
ext_edges,
}
}
// ── shapes ────────────────────────────────────────────────────────────
fn chain(n: usize) -> (Vec<String>, Vec<(String, String)>) {
let names: Vec<String> = (0..n).map(|i| format!("chain-{i:02}")).collect();
let edges = (0..n.saturating_sub(1))
.map(|i| (names[i].clone(), names[i + 1].clone()))
.collect();
(names, edges)
}
fn fan_out(n: usize) -> (Vec<String>, Vec<(String, String)>) {
let names: Vec<String> = (0..n).map(|i| format!("fan-{i:02}")).collect();
let edges = (1..n)
.map(|i| (names[0].clone(), names[i].clone()))
.collect();
(names, edges)
}
fn named_holger_tree() -> (Vec<String>, Vec<(String, String)>) {
let names: Vec<String> = [
"codec-utils",
"decomp-lz4",
"decomp-zstd",
"decomp-brotli",
"decomp-deflate",
"io-buf",
"snippy",
"holger",
"bench-suite",
"logging-utils",
]
.iter()
.map(|s| s.to_string())
.collect();
let mut e = Vec::new();
let decomps = [
"decomp-lz4",
"decomp-zstd",
"decomp-brotli",
"decomp-deflate",
];
for d in decomps {
e.push((d.to_string(), "codec-utils".to_string()));
e.push(("snippy".to_string(), d.to_string()));
e.push((d.to_string(), "logging-utils".to_string()));
}
e.push(("holger".to_string(), "snippy".to_string()));
e.push(("holger".to_string(), "io-buf".to_string()));
e.push(("holger".to_string(), "codec-utils".to_string()));
e.push(("bench-suite".to_string(), "holger".to_string()));
// io-buf deliberately does NOT depend on codec-utils (so it stays
// out of codec-utils' blast radius), but shares logging-utils.
e.push(("io-buf".to_string(), "logging-utils".to_string()));
(names, e)
}
fn layered_dag(n: usize, seed: u64) -> (Vec<String>, Vec<(String, String)>) {
let names: Vec<String> = (0..n).map(|i| format!("node-{i:03}")).collect();
// Assign nodes to ~sqrt(n) layers; edges only point to deeper layers.
let layers = ((n as f64).sqrt().ceil() as usize).max(2);
let layer_of: Vec<usize> = (0..n).map(|i| i * layers / n).collect();
let mut rng = Rng(seed);
let mut edges = Vec::new();
for i in 0..n {
let li = layer_of[i];
let deeper: Vec<usize> = (0..n).filter(|&j| layer_of[j] > li).collect();
if deeper.is_empty() {
continue;
}
let want = 1 + rng.below(3);
let mut picked = std::collections::BTreeSet::new();
for _ in 0..want {
picked.insert(deeper[rng.below(deeper.len())]);
}
for j in picked {
edges.push((names[i].clone(), names[j].clone()));
}
}
(names, edges)
}
// ── crate emission ──────────────────────────────────────────────────────
enum CrateBody {
/// No metric deps; `metric() == value`.
Leaf(u64),
/// `metric() == SELF_VALUE + Σ dep::metric()`.
Node(u64),
}
fn crate_ident(name: &str) -> String {
name.replace('-', "_")
}
fn write_crate(dir: &Path, name: &str, deps: &[String], exts: &[String], body: CrateBody) {
std::fs::create_dir_all(dir.join("src")).unwrap();
let mut cargo = String::new();
cargo.push_str(&format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n"
));
for d in deps {
cargo.push_str(&format!(
"{d} = {{ path = \"../{d}\", version = \"0.1.0\" }}\n"
));
}
for e in exts {
cargo.push_str(&format!(
"{e} = {{ path = \"../locked-registry/{e}\", version = \"0.1.0\" }}\n"
));
}
std::fs::write(dir.join("Cargo.toml"), cargo).unwrap();
let self_value = match body {
CrateBody::Leaf(v) | CrateBody::Node(v) => v,
};
let mut lib = String::new();
lib.push_str("//! Synthetic crate generated by genworkspace.\n");
lib.push_str(&format!("pub const SELF_VALUE: u64 = {self_value};\n\n"));
lib.push_str("/// Propagating metric: this crate's value plus every dependency's.\n");
lib.push_str("pub fn metric() -> u64 {\n SELF_VALUE");
if let CrateBody::Node(_) = body {
for d in deps {
lib.push_str(&format!("\n + {}::metric()", crate_ident(d)));
}
}
lib.push_str("\n}\n\n");
lib.push_str(
"#[cfg(test)]\nmod gen_gate {\n #[test]\n fn test_pass_flag_present() {\n\
\x20 let flag = std::path::Path::new(env!(\"CARGO_MANIFEST_DIR\"))\n\
\x20 .join(\".flags\").join(\"test_pass\");\n\
\x20 assert!(flag.exists(), \"test_pass flag missing — harness forced failure\");\n\
\x20 }\n}\n",
);
std::fs::write(dir.join("src").join("lib.rs"), lib).unwrap();
// examples/nornir-bench.rs — the harness `run_bench_example` invokes
// (`cargo run --release --example nornir-bench --quiet`). It prints a
// single `BenchRun` JSON line on stdout. `throughput` is modelled as
// `1_000_000 / metric()`, so when an upstream common lib's SELF_VALUE
// grows (it got "slower"), every dependent's metric() rises and its
// throughput DROPS — the basis for the bench-regression scenario.
//
// NB: `BenchResult.metrics` is `#[serde(flatten)]`, so the scalar sits
// directly on the result object (`{"name":..,"ops_sec":..}`), NOT under
// a nested "metrics" key. No `timestamp` is emitted, so the warehouse
// dates the run at `date` midnight; each regression check only ever has
// a single prior baseline, so that is unambiguous.
let ident = crate_ident(name);
let mut ex = String::new();
ex.push_str("//! Generated bench harness: emits one BenchRun JSON line.\n");
ex.push_str("fn main() {\n");
ex.push_str(&format!(" let m = {ident}::metric() as f64;\n"));
ex.push_str(" let ops_sec = 1_000_000.0 / m;\n");
ex.push_str(" println!(\n");
ex.push_str(
" \"{{\\\"date\\\":\\\"2024-01-01\\\",\\\"version\\\":\\\"0.1.0\\\",\\\"machine\\\":\\\"synthetic\\\",\\\"cores\\\":1,\\\"results\\\":[{{\\\"name\\\":\\\"throughput\\\",\\\"ops_sec\\\":{ops_sec}}}]}}\",\n",
);
ex.push_str(" ops_sec = ops_sec\n");
ex.push_str(" );\n");
ex.push_str("}\n");
std::fs::create_dir_all(dir.join("examples")).unwrap();
std::fs::write(dir.join("examples").join("nornir-bench.rs"), ex).unwrap();
// Keep build artifacts out of the committed tree so post-cargo runs
// don't make the worktree dirty in multi-run scenarios.
std::fs::write(dir.join(".gitignore"), "/target\n/Cargo.lock\n").unwrap();
}
fn rewrite_self_value(src: &str, value: u64) -> String {
src.lines()
.map(|l| {
if l.trim_start().starts_with("pub const SELF_VALUE") {
format!("pub const SELF_VALUE: u64 = {value};")
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
fn write_descriptor(root: &Path, repos: &[String]) -> PathBuf {
let mut toml = String::from("[workspace]\nname = \"synthetic\"\n\n");
for r in repos {
toml.push_str(&format!("[repos.{r}]\npath = \"{r}\"\n\n"));
}
let path = root.join("nornir-workspace.toml");
std::fs::write(&path, toml).unwrap();
path
}
fn write_nornir_toml(root: &Path, repos: &[String], has_locked: bool) -> PathBuf {
let mut toml = String::new();
toml.push_str("[guard]\nforbidden = [");
if has_locked {
toml.push_str("\"locked-registry\"");
}
toml.push_str("]\n\n[storage]\nkind = \"local\"\nlocal_path = \".nornir\"\n\n");
for r in repos {
toml.push_str(&format!(
"[repo.{r}]\n[repo.{r}.gates]\nno_path_patches = true\n\n"
));
}
let path = root.join("nornir.toml");
std::fs::write(&path, toml).unwrap();
path
}