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

nordisk/edda

crates/nornir-testmatrix/src/model.rs

//! The **pure** test-matrix model: rows, summaries, selectors, status tags, and
//! the human/JSON renderers. Zero heavy deps — `std` only (no chrono, no uuid):
//! a tiny RFC3339 formatter + a getrandom-free UUIDv4 keep the crate lean.
//!
//! `nornir` re-exports every item here verbatim (`nornir::warehouse::test_results::*`),
//! and implements the Iceberg writer/reader on top of [`TestResultRow`].

use serde::{Deserialize, Serialize};

/// Canonical status tags for a test/aspect case.
///
/// `STALLED` is the watchdog's verdict for a subprocess that went silent past
/// the stall threshold — recorded red so a hung test is *visible*. `SKIP` is the
/// multi-aspect engine's verdict for an aspect whose tool isn't on PATH
/// (clippy/audit/llvm-cov/hack/fmt may be absent): **neither green nor red** —
/// amber/neutral — so a missing tool never fails the matrix.
pub mod status {
    pub const PASS: &str = "pass";
    pub const FAIL: &str = "fail";
    pub const IGNORED: &str = "ignored";
    pub const STALLED: &str = "stalled";
    /// An aspect whose tool wasn't installed — neutral, never red, never green.
    pub const SKIP: &str = "skip";
    /// A test that the runner **discovered** (via `cargo nextest list` /
    /// `cargo test -- --list`) but has **not yet run** in the latest run. A row
    /// with this status seeds the matrix so an existing-but-unrun test shows as
    /// **NotRun** (blank cell) — never green, never red, and never neutral/skip.
    /// It is overridden by a real `pass`/`fail`/`ignored` row once the test runs.
    pub const LISTED: &str = "listed";

    /// Is this status a red (failing) verdict? `fail` + `stalled` are red;
    /// `pass`, `ignored`, `skip`, and `listed` are not.
    pub fn is_red(s: &str) -> bool {
        s == FAIL || s == STALLED
    }

    /// Is this status the discovery-only `listed` verdict (test exists but has
    /// not run yet → renders as NotRun)? Distinct from `skip` (neutral) so the
    /// grid can blank the cell instead of counting it.
    pub fn is_listed(s: &str) -> bool {
        s == LISTED
    }

    /// Is this status a clean green (counts toward a passing run)? Only `pass`.
    /// `ignored` + `skip` are neutral (neither green nor red).
    pub fn is_green(s: &str) -> bool {
        s == PASS
    }

    /// Is this status neutral (neither green nor red): `ignored` or `skip`.
    pub fn is_neutral(s: &str) -> bool {
        s == IGNORED || s == SKIP
    }
}

/// The default aspect tag for rows that predate the multi-aspect engine (a plain
/// `cargo test` matrix row). Iceberg rows written before the `aspect` column
/// existed read back with this value.
pub const ASPECT_UNIT: &str = "unit";

/// One row of the `test_results` table — a single test case (or aspect result)
/// in a run.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TestResultRow {
    /// The test-run identity (groups a run's rows). One per `nornir test` invocation.
    pub run_id: String,
    /// The workspace repo the tests ran in (partition key).
    pub repo: String,
    /// The crate / test target the case lives in (e.g. `nornir`, `roundtrip`).
    pub suite: String,
    /// The test function name (`module::path::test_fn`), or the aspect's synthetic
    /// case name (e.g. `clippy`, `coverage`).
    pub test_name: String,
    /// `pass` | `fail` | `ignored` | `stalled` | `skip` (see [`status`]).
    pub status: String,
    /// Wall-clock duration of the case, milliseconds. `0.0` when the runner gave none.
    pub duration_ms: f64,
    /// Run time, microseconds since the Unix epoch (UTC). Shared across a run's rows.
    pub ts_micros: i64,
    /// Failure detail / stall note / skip reason (`""` = none, never null).
    pub message: String,
    /// Which **aspect** produced this row: `unit` (default, plain test), `build`,
    /// `doctest`, `clippy`, `fmt`, `audit`, `bench-smoke`, `coverage`,
    /// `feature-powerset`, `msrv`, `examples`. Defaulted to [`ASPECT_UNIT`] so
    /// existing call sites + pre-aspect Iceberg rows stay valid.
    #[serde(default = "default_aspect")]
    pub aspect: String,
    /// A numeric metric the aspect measured: coverage % (coverage), warning count
    /// (clippy), advisory count (audit feeds the SBOM), etc. `0.0` when N/A.
    /// Defaulted to `0.0` so existing call sites + pre-aspect rows stay valid.
    #[serde(default)]
    pub metric: f64,
}

fn default_aspect() -> String {
    ASPECT_UNIT.to_string()
}

impl TestResultRow {
    /// The `(ts_micros, run_id, suite, test_name)` stable sort key. nornir's
    /// Iceberg reader sorts query results by this (Iceberg gives no scan order).
    pub fn key(&self) -> (i64, String, String, String) {
        (self.ts_micros, self.run_id.clone(), self.suite.clone(), self.test_name.clone())
    }

    /// Build a row with `aspect = unit`, `metric = 0.0` — the back-compat
    /// constructor for plain test cases (so existing nornir call sites that built
    /// the struct field-by-field keep their shape via the public fields, while
    /// this is the convenient path).
    #[allow(clippy::too_many_arguments)]
    pub fn unit(
        run_id: impl Into<String>,
        repo: impl Into<String>,
        suite: impl Into<String>,
        test_name: impl Into<String>,
        status: impl Into<String>,
        duration_ms: f64,
        ts_micros: i64,
        message: impl Into<String>,
    ) -> Self {
        Self {
            run_id: run_id.into(),
            repo: repo.into(),
            suite: suite.into(),
            test_name: test_name.into(),
            status: status.into(),
            duration_ms,
            ts_micros,
            message: message.into(),
            aspect: ASPECT_UNIT.to_string(),
            metric: 0.0,
        }
    }
}

/// Which test results to read.
#[derive(Debug, Clone)]
pub enum TestSelector {
    /// Exactly one run (its `run_id`).
    Run(String),
    /// Every result whose `repo` matches — across all that repo's runs.
    Repo(String),
    /// Everything in the table.
    All,
}

// ─── test inventory (list phase) ─────────────────────────────────────────

/// Parse `cargo nextest list --message-format json` output into the fully
/// qualified `suite::test_name` strings of every discovered `#[test]`.
///
/// nextest's list JSON is one object with a `rust-suites` map; each suite has a
/// `testcases` map keyed by the test path. We join `<suite>::<case>` so the name
/// matches what the run phase records (`suite` + `test_name`). Robust to extra
/// fields and to the binary having no tests. PURE — fed canned JSON by tests.
pub fn parse_nextest_list(json: &str) -> Vec<String> {
    let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else {
        return Vec::new();
    };
    let mut out = Vec::new();
    let suites = v.get("rust-suites").and_then(|s| s.as_object());
    if let Some(suites) = suites {
        for (suite_name, suite) in suites {
            // Prefer the suite's `binary-id` (e.g. `nornir::bin`) when present,
            // else the map key.
            let suite_label = suite
                .get("binary-id")
                .and_then(|b| b.as_str())
                .unwrap_or(suite_name.as_str());
            if let Some(cases) = suite.get("testcases").and_then(|c| c.as_object()) {
                for case_name in cases.keys() {
                    out.push(format!("{suite_label}::{case_name}"));
                }
            }
        }
    }
    out.sort();
    out.dedup();
    out
}

/// Parse the plain `cargo test -- --list` grammar (the libtest fallback when
/// nextest is absent) into discovered test names. libtest prints one line per
/// item: `module::path::test_fn: test` (and `...: benchmark` for benches, which
/// we drop). PURE — fed canned output by tests.
pub fn parse_cargo_test_list(output: &str) -> Vec<String> {
    let mut out = Vec::new();
    for line in output.lines() {
        let l = line.trim();
        // `name: test` — keep tests; skip `: benchmark` and summary lines.
        if let Some(name) = l.strip_suffix(": test") {
            let name = name.trim();
            if !name.is_empty() {
                out.push(name.to_string());
            }
        }
    }
    out.sort();
    out.dedup();
    out
}

/// Turn discovered test names (from [`parse_nextest_list`] /
/// [`parse_cargo_test_list`]) into `listed`-status [`TestResultRow`]s for one run.
///
/// Each name is split on the **last** `::` into `(suite, test_name)` so the row's
/// `suite` matches the run phase; names without `::` keep the whole string as the
/// test name and default the suite to `repo`. All rows share `run_id`, `repo`,
/// `ts_micros`, `aspect = unit`, and `status = listed`. This is the inventory
/// seed: a `listed` row with no later `pass`/`fail` row in the run = **NotRun**.
pub fn listed_rows(
    names: &[String],
    run_id: &str,
    repo: &str,
    ts_micros: i64,
) -> Vec<TestResultRow> {
    names
        .iter()
        .map(|full| {
            let (suite, test_name) = match full.rsplit_once("::") {
                Some((s, n)) if !s.is_empty() => (s.to_string(), n.to_string()),
                _ => (repo.to_string(), full.clone()),
            };
            TestResultRow {
                run_id: run_id.to_string(),
                repo: repo.to_string(),
                suite,
                test_name,
                status: status::LISTED.to_string(),
                duration_ms: 0.0,
                ts_micros,
                message: String::new(),
                aspect: ASPECT_UNIT.to_string(),
                metric: 0.0,
            }
        })
        .collect()
}

// ─── rendering: JSON + human view ────────────────────────────────────────

/// Serialize rows to a stable JSON array (one object per case). Pure string
/// assembly so the renderer stays dependency-light. Carries the new `aspect` +
/// `metric` fields.
pub fn rows_to_json(rows: &[TestResultRow]) -> String {
    fn esc(s: &str) -> String {
        let mut o = String::with_capacity(s.len() + 2);
        for c in s.chars() {
            match c {
                '"' => o.push_str("\\\""),
                '\\' => o.push_str("\\\\"),
                '\n' => o.push_str("\\n"),
                '\t' => o.push_str("\\t"),
                '\r' => o.push_str("\\r"),
                c => o.push(c),
            }
        }
        o
    }
    let mut s = String::from("[\n");
    for (i, r) in rows.iter().enumerate() {
        let ts_rfc = rfc3339_from_micros(r.ts_micros);
        s.push_str(&format!(
            "  {{\"run_id\": \"{}\", \"repo\": \"{}\", \"suite\": \"{}\", \
             \"test_name\": \"{}\", \"status\": \"{}\", \"duration_ms\": {:.3}, \
             \"ts\": \"{}\", \"message\": \"{}\", \"aspect\": \"{}\", \"metric\": {:.3}}}{}\n",
            esc(&r.run_id),
            esc(&r.repo),
            esc(&r.suite),
            esc(&r.test_name),
            esc(&r.status),
            r.duration_ms,
            esc(&ts_rfc),
            esc(&r.message),
            esc(&r.aspect),
            r.metric,
            if i + 1 < rows.len() { "," } else { "" },
        ));
    }
    s.push(']');
    s
}

/// A per-run summary: pass / fail / ignored / stalled / skipped counts for one
/// `run_id`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunSummary {
    pub run_id: String,
    pub repo: String,
    pub ts_micros: i64,
    pub passed: usize,
    pub failed: usize,
    pub ignored: usize,
    pub stalled: usize,
    pub skipped: usize,
    /// Discovered-but-not-run cases (`listed` status). Counted separately so a
    /// listed-only run reads as "N tests known, 0 run", never as red/green.
    pub listed: usize,
}

impl RunSummary {
    /// Total cases recorded in this run.
    pub fn total(&self) -> usize {
        self.passed + self.failed + self.ignored + self.stalled + self.skipped + self.listed
    }
    /// True iff no case is red (no `fail` and no `stalled`). Skips don't count red.
    pub fn green(&self) -> bool {
        self.failed == 0 && self.stalled == 0
    }
}

/// Roll rows up into one [`RunSummary`] per `run_id`, **newest run first**.
pub fn summarize_runs(rows: &[TestResultRow]) -> Vec<RunSummary> {
    use std::collections::BTreeMap;
    let mut by_run: BTreeMap<String, RunSummary> = BTreeMap::new();
    for r in rows {
        let s = by_run.entry(r.run_id.clone()).or_insert_with(|| RunSummary {
            run_id: r.run_id.clone(),
            repo: r.repo.clone(),
            ts_micros: r.ts_micros,
            ..Default::default()
        });
        s.ts_micros = s.ts_micros.max(r.ts_micros);
        match r.status.as_str() {
            status::PASS => s.passed += 1,
            status::FAIL => s.failed += 1,
            status::IGNORED => s.ignored += 1,
            status::STALLED => s.stalled += 1,
            status::SKIP => s.skipped += 1,
            // `listed` = discovered-but-not-run: counted as not-run, never red.
            status::LISTED => s.listed += 1,
            _ => s.failed += 1, // unknown status counts as red, never silently dropped
        }
    }
    let mut out: Vec<RunSummary> = by_run.into_values().collect();
    out.sort_by(|a, b| b.ts_micros.cmp(&a.ts_micros));
    out
}

/// Serialize a slice of [`RunSummary`] (newest-first) to a pretty JSON array,
/// folding in the derived `green` verdict + `total` count on each row so a
/// consumer never has to recompute them. The canonical wire shape for
/// `nornir test history <repo> --json`'s per-run view and the `test_history`
/// MCP tool (CLI⇄MCP parity). Input order is preserved verbatim.
pub fn runs_to_json(summaries: &[RunSummary]) -> String {
    let arr: Vec<serde_json::Value> = summaries
        .iter()
        .map(|s| {
            serde_json::json!({
                "run_id": s.run_id,
                "repo": s.repo,
                "ts_micros": s.ts_micros,
                "passed": s.passed,
                "failed": s.failed,
                "ignored": s.ignored,
                "stalled": s.stalled,
                "skipped": s.skipped,
                "listed": s.listed,
                "total": s.total(),
                "green": s.green(),
            })
        })
        .collect();
    serde_json::to_string_pretty(&serde_json::Value::Array(arr))
        .unwrap_or_else(|_| "[]".to_string())
}

/// Human-readable matrix: each run's pass/fail counts (newest first), then the
/// red cases under it. The CLI `nornir test history` view.
pub fn render_matrix(rows: &[TestResultRow]) -> String {
    use std::collections::BTreeMap;
    if rows.is_empty() {
        return "(no test runs recorded)\n".to_string();
    }
    let summaries = summarize_runs(rows);
    let mut by_run: BTreeMap<String, Vec<&TestResultRow>> = BTreeMap::new();
    for r in rows {
        by_run.entry(r.run_id.clone()).or_default().push(r);
    }
    let mut out = String::new();
    for s in &summaries {
        let mark = if s.green() { "✓" } else { "✗" };
        let ts = rfc3339_from_micros(s.ts_micros);
        out.push_str(&format!(
            "{mark} {repo}  run {run}  [{ts}]\n     {p} passed · {f} failed · {ig} ignored · {st} stalled · {sk} skipped  ({tot} total)\n",
            repo = s.repo,
            run = short_run(&s.run_id),
            p = s.passed,
            f = s.failed,
            ig = s.ignored,
            st = s.stalled,
            sk = s.skipped,
            tot = s.total(),
        ));
        if let Some(cases) = by_run.get(&s.run_id) {
            for c in cases.iter().filter(|c| status::is_red(&c.status)) {
                let detail = if c.message.is_empty() { String::new() } else { format!(" — {}", c.message) };
                out.push_str(&format!(
                    "       ✗ [{}] {}::{} {}{}\n",
                    c.aspect, c.suite, c.test_name, c.status, detail
                ));
            }
        }
        out.push('\n');
    }
    out
}

/// Abbreviate a run id for compact display (first 8 chars of a UUID, else whole).
pub fn short_run(run_id: &str) -> String {
    if run_id.len() > 12 {
        format!("{}…", &run_id[..8])
    } else {
        run_id.to_string()
    }
}

/// A fresh run id (UUIDv4 string). Std-only: seeds 16 bytes from a couple of
/// non-deterministic sources (time + a heap address + a thread-local counter)
/// and formats them per RFC 4122 v4 — good enough to group a run's rows; no
/// `uuid`/`getrandom` dep pulled into a leaf repo.
pub fn new_run_id() -> String {
    let mut b = seed_16();
    // Set version (4) and variant (RFC 4122) bits.
    b[6] = (b[6] & 0x0f) | 0x40;
    b[8] = (b[8] & 0x3f) | 0x80;
    format!(
        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
        b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
        b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15],
    )
}

// ─── std-only helpers (no uuid / chrono) ────────────────────────────────

/// 16 pseudo-random bytes mixed from nanosecond time, a per-call counter, and a
/// stack address via a SplitMix64 hash. Not cryptographic — only used to make
/// run ids collide-resistant within a process.
fn seed_16() -> [u8; 16] {
    use std::sync::atomic::{AtomicU64, Ordering};
    use std::time::{SystemTime, UNIX_EPOCH};
    static CTR: AtomicU64 = AtomicU64::new(0);
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0);
    let ctr = CTR.fetch_add(1, Ordering::Relaxed);
    let stack = &nanos as *const _ as u64;
    let mut state = nanos ^ ctr.rotate_left(17) ^ stack.rotate_left(31);
    let mut out = [0u8; 16];
    for chunk in out.chunks_mut(8) {
        // SplitMix64.
        state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = state;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        z ^= z >> 31;
        let bytes = z.to_le_bytes();
        chunk.copy_from_slice(&bytes[..chunk.len()]);
    }
    out
}

/// Format a UTC timestamp (microseconds since the Unix epoch) as RFC 3339
/// (`YYYY-MM-DDTHH:MM:SS.ssssss+00:00`). Std-only civil-date math (no chrono).
pub fn rfc3339_from_micros(ts_micros: i64) -> String {
    let secs = ts_micros.div_euclid(1_000_000);
    let micros = ts_micros.rem_euclid(1_000_000);
    let days = secs.div_euclid(86_400);
    let secs_of_day = secs.rem_euclid(86_400);
    let (h, m, s) = (
        secs_of_day / 3600,
        (secs_of_day % 3600) / 60,
        secs_of_day % 60,
    );
    let (year, month, day) = civil_from_days(days);
    format!(
        "{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{micros:06}+00:00"
    )
}

/// Days since 1970-01-01 → (year, month, day). Howard Hinnant's civil-date
/// algorithm (public domain).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
    (if m <= 2 { y + 1 } else { y }, m, d)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn row(run: &str, repo: &str, name: &str, st: &str, aspect: &str, metric: f64, ts: i64) -> TestResultRow {
        TestResultRow {
            run_id: run.into(),
            repo: repo.into(),
            suite: repo.into(),
            test_name: name.into(),
            status: st.into(),
            duration_ms: 1.0,
            ts_micros: ts,
            message: String::new(),
            aspect: aspect.into(),
            metric,
        }
    }

    #[test]
    fn summarize_counts_every_status_including_skip() {
        let rows = vec![
            row("r", "z", "a", status::PASS, "unit", 0.0, 100),
            row("r", "z", "b", status::FAIL, "unit", 0.0, 100),
            row("r", "z", "c", status::IGNORED, "unit", 0.0, 100),
            row("r", "z", "d", status::STALLED, "unit", 0.0, 100),
            row("r", "z", "clippy", status::SKIP, "clippy", 0.0, 100),
        ];
        let s = summarize_runs(&rows);
        assert_eq!(s.len(), 1);
        assert_eq!(
            (s[0].passed, s[0].failed, s[0].ignored, s[0].stalled, s[0].skipped),
            (1, 1, 1, 1, 1)
        );
        assert_eq!(s[0].total(), 5);
        assert!(!s[0].green(), "a fail makes the run red");
    }

    #[test]
    fn skip_is_neither_red_nor_green() {
        assert!(!status::is_red(status::SKIP));
        assert!(!status::is_green(status::SKIP));
        assert!(status::is_neutral(status::SKIP));
        assert!(status::is_red(status::FAIL));
        assert!(status::is_red(status::STALLED));
        assert!(status::is_green(status::PASS));
    }

    #[test]
    fn summarize_orders_newest_first() {
        let rows = vec![
            row("old", "z", "a", status::PASS, "unit", 0.0, 100),
            row("new", "z", "b", status::PASS, "unit", 0.0, 500),
        ];
        let s = summarize_runs(&rows);
        assert_eq!(s[0].run_id, "new", "newest run id first");
        assert_eq!(s[1].run_id, "old");
    }

    #[test]
    fn runs_to_json_newest_first_with_green_and_total() {
        // Older all-green run + a newer run with one failure → the summary JSON
        // must lead with the newer (red) run and fold in green/total per row.
        let rows = vec![
            row("old", "z", "a", status::PASS, "unit", 0.0, 100),
            row("old", "z", "b", status::PASS, "unit", 0.0, 100),
            row("new", "z", "a", status::PASS, "unit", 0.0, 500),
            row("new", "z", "c", status::FAIL, "unit", 0.0, 500),
        ];
        let summaries = summarize_runs(&rows);
        let json = runs_to_json(&summaries);
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        let arr = v.as_array().unwrap();
        assert_eq!(arr.len(), 2, "one summary per run");
        // Newest (red) first.
        assert_eq!(arr[0]["run_id"], "new");
        assert_eq!(arr[0]["failed"], 1);
        assert_eq!(arr[0]["total"], 2);
        assert_eq!(arr[0]["green"], false, "newest run has a failure");
        // Older run trails and is green.
        assert_eq!(arr[1]["run_id"], "old");
        assert_eq!(arr[1]["passed"], 2);
        assert_eq!(arr[1]["green"], true);
        // RunSummary round-trips through serde (the RPC path relies on this).
        let back: RunSummary = serde_json::from_str(
            &serde_json::to_string(&summaries[0]).unwrap(),
        )
        .unwrap();
        assert_eq!(back, summaries[0]);
    }

    #[test]
    fn json_carries_aspect_and_metric() {
        let rows = vec![row("r", "z", "coverage", status::PASS, "coverage", 87.5, 100)];
        let json = rows_to_json(&rows);
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        let o = &v.as_array().unwrap()[0];
        assert_eq!(o["aspect"], "coverage");
        assert_eq!(o["metric"], 87.5);
        assert_eq!(o["status"], "pass");
    }

    #[test]
    fn render_matrix_tags_red_case_with_aspect() {
        let rows = vec![
            row("r", "z", "build", status::FAIL, "build", 0.0, 100),
            row("r", "z", "ok", status::PASS, "unit", 0.0, 100),
        ];
        let m = render_matrix(&rows);
        assert!(m.contains("[build]"), "red case is tagged with its aspect: {m}");
        assert!(m.contains("1 failed"), "summary line counts the failure");
        assert!(m.contains("skipped"), "summary line names the skip bucket");
    }

    #[test]
    fn rfc3339_is_correct_for_known_epoch() {
        // 2021-01-01T00:00:00Z = 1609459200 s.
        assert_eq!(rfc3339_from_micros(1_609_459_200_000_000), "2021-01-01T00:00:00.000000+00:00");
        // Unix epoch.
        assert_eq!(rfc3339_from_micros(0), "1970-01-01T00:00:00.000000+00:00");
        // With a sub-second component.
        assert_eq!(rfc3339_from_micros(1_609_459_200_123_456), "2021-01-01T00:00:00.123456+00:00");
    }

    #[test]
    fn new_run_id_is_uuid_v4_shaped_and_unique() {
        let a = new_run_id();
        let b = new_run_id();
        assert_ne!(a, b, "two ids differ");
        assert_eq!(a.len(), 36, "uuid string length");
        assert_eq!(a.as_bytes()[14], b'4', "version nibble is 4");
        let variant = a.as_bytes()[19];
        assert!(matches!(variant, b'8' | b'9' | b'a' | b'b'), "RFC4122 variant nibble: {variant}");
    }

    #[test]
    fn unit_constructor_defaults_aspect_and_metric() {
        let r = TestResultRow::unit("r", "z", "z", "t", status::PASS, 5.0, 100, "");
        assert_eq!(r.aspect, ASPECT_UNIT);
        assert_eq!(r.metric, 0.0);
    }

    #[test]
    fn parse_nextest_list_extracts_exact_test_names() {
        // A canned `cargo nextest list --message-format json` blob: two suites,
        // three testcases. We must recover exactly `<binary-id>::<case>` per case.
        let json = r#"{
          "rust-suites": {
            "nornir": {
              "binary-id": "nornir",
              "testcases": {
                "warehouse::tests::round_trip": {"ignored": false},
                "viz::tests::renders": {"ignored": false}
              }
            },
            "roundtrip": {
              "binary-id": "nornir::roundtrip",
              "testcases": {
                "end_to_end": {"ignored": false}
              }
            }
          }
        }"#;
        let names = parse_nextest_list(json);
        assert_eq!(
            names,
            vec![
                "nornir::roundtrip::end_to_end".to_string(),
                "nornir::viz::tests::renders".to_string(),
                "nornir::warehouse::tests::round_trip".to_string(),
            ],
            "exact discovered names, sorted: {names:?}"
        );
    }

    #[test]
    fn parse_nextest_list_handles_empty_and_garbage() {
        assert!(parse_nextest_list("not json").is_empty());
        assert!(parse_nextest_list(r#"{"rust-suites":{}}"#).is_empty());
        assert!(
            parse_nextest_list(r#"{"rust-suites":{"s":{"binary-id":"s","testcases":{}}}}"#)
                .is_empty(),
            "a suite with no testcases yields nothing"
        );
    }

    #[test]
    fn parse_cargo_test_list_keeps_tests_drops_benches() {
        let out = "\
warehouse::tests::round_trip: test
viz::tests::renders: test
bench_throughput: benchmark

2 tests, 1 benchmark
";
        let names = parse_cargo_test_list(out);
        assert_eq!(
            names,
            vec![
                "viz::tests::renders".to_string(),
                "warehouse::tests::round_trip".to_string(),
            ],
            "only `: test` lines, sorted; benchmark + summary dropped: {names:?}"
        );
    }

    #[test]
    fn listed_rows_builds_listed_status_rows_with_split_suite() {
        let names = vec![
            "nornir::warehouse::tests::round_trip".to_string(),
            "bare_name".to_string(),
        ];
        let rows = listed_rows(&names, "run1", "nornir", 4242);
        assert_eq!(rows.len(), 2);
        // qualified name → suite is everything before the last ::, test the tail.
        assert_eq!(rows[0].suite, "nornir::warehouse::tests");
        assert_eq!(rows[0].test_name, "round_trip");
        assert_eq!(rows[0].status, status::LISTED);
        assert_eq!(rows[0].run_id, "run1");
        assert_eq!(rows[0].repo, "nornir");
        assert_eq!(rows[0].ts_micros, 4242);
        assert_eq!(rows[0].aspect, ASPECT_UNIT);
        // bare name (no ::) → suite defaults to repo, whole string is the name.
        assert_eq!(rows[1].suite, "nornir");
        assert_eq!(rows[1].test_name, "bare_name");
        assert_eq!(rows[1].status, status::LISTED);
    }

    #[test]
    fn listed_status_is_not_red_green_or_neutral() {
        assert!(!status::is_red(status::LISTED));
        assert!(!status::is_green(status::LISTED));
        assert!(!status::is_neutral(status::LISTED), "listed is its own bucket, not skip");
        assert!(status::is_listed(status::LISTED));
    }

    #[test]
    fn summarize_counts_listed_separately_not_as_failed() {
        let rows = vec![
            row("r", "z", "a", status::PASS, "unit", 0.0, 100),
            row("r", "z", "b", status::LISTED, "unit", 0.0, 100),
            row("r", "z", "c", status::LISTED, "unit", 0.0, 100),
        ];
        let s = summarize_runs(&rows);
        assert_eq!(s[0].listed, 2, "listed rows counted in their own bucket");
        assert_eq!(s[0].failed, 0, "listed must NEVER count as a failure");
        assert!(s[0].green(), "a run of pass + listed is still green (no red)");
        assert_eq!(s[0].total(), 3);
    }

    #[test]
    fn deserialize_legacy_row_without_aspect_defaults() {
        // A pre-aspect JSON row (no aspect/metric) must deserialize with defaults.
        let json = r#"{"run_id":"r","repo":"z","suite":"z","test_name":"t","status":"pass","duration_ms":1.0,"ts_micros":100,"message":""}"#;
        let r: TestResultRow = serde_json::from_str(json).unwrap();
        assert_eq!(r.aspect, ASPECT_UNIT);
        assert_eq!(r.metric, 0.0);
        assert_eq!(r.status, status::PASS);
    }
}