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

nordisk/nornir

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(&reg).unwrap();
        for i in 0..spec.ext_crates {
            let name = format!("extdep-{i:03}");
            write_crate(&reg.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
}