Skip to main content

rustdv_runner/
rustdv_runner.rs

1//! # rustdv-runner
2//!
3//! The regression manager (design-doc D2.4, §4.5): port of cocotb's
4//! `RegressionManager`. Owns the test registry (populated at link time by
5//! `#[rustdv::test]`), runs tests sequentially, applies timeouts, scores
6//! panics/errors vs. expectations, prints the summary table, and provides
7//! the simulator entry point.
8//!
9//! **Bootstrap deviation from D3.2 (STATUS.md):** instead of exporting
10//! cocotb's libpygpi entry symbols, the testbench cdylib is loaded as a
11//! plain **VPI module** (`vvp -M<dir> -m<name>`): `rustdv::vpi_bootstrap!()`
12//! exports `vlog_startup_routines`, whose startup routine registers a
13//! cbStartOfSimulation callback that kicks off the regression.
14
15use std::cell::RefCell;
16use std::future::Future;
17use std::pin::Pin;
18use std::rc::Rc;
19
20use linkme::distributed_slice;
21use rustdv_gpi as gpi;
22use rustdv_sim::combinators::{Either, first2};
23use rustdv_sim::handle::top_module;
24use rustdv_sim::log;
25use rustdv_sim::time::{SimDuration, sim_time_ns};
26use rustdv_sim::triggers::Timer;
27
28// ===========================================================================
29// Public test-facing types
30// ===========================================================================
31
32/// Test failure value — defined in `rustdv-methodology` since step 4, because
33/// `Component::run` returns it and the UVM crate sits below this one
34/// (D46/D47). Re-exported so `::rustdv::TestError` is unchanged.
35pub use rustdv_methodology::TestError;
36
37/// Handed to each test. Since step 4 this is the one universal context
38/// (D47): the old `TestCtx` and `RunCtx` merged into `RustdvCtx`, which
39/// lives in `rustdv-methodology` beside the `Component` trait that receives it.
40pub use rustdv_methodology::RustdvCtx;
41
42type TestFn = fn(RustdvCtx) -> Pin<Box<dyn Future<Output = Result<(), TestError>>>>;
43
44/// One registered test (design-doc §6.1: the cocotb `Test` option set).
45pub struct TestRegistration {
46    pub name: &'static str,
47    pub module: &'static str,
48    pub file: &'static str,
49    pub line: u32,
50    pub run: TestFn,
51    /// (time, unit), e.g. (100, "us").
52    pub timeout: Option<(u64, &'static str)>,
53    pub skip: bool,
54    pub expect_fail: bool,
55    /// Pass only if the test fails with this cause (D68). Strictly stronger
56    /// than `expect_fail`, which accepts any failure at all.
57    pub expect_error: Option<&'static str>,
58}
59
60// ===========================================================================
61// Link-time registry (design-doc §0.5/§6.1), implemented as a linkme
62// distributed slice so the platform-specific linker work stays encapsulated.
63// ===========================================================================
64
65/// All test registrations contributed by `#[rustdv::test]`.
66///
67/// Public only so the attribute macro can name it from a downstream crate.
68#[doc(hidden)]
69#[distributed_slice]
70pub static TEST_REGISTRATIONS: [TestRegistration];
71
72/// All registered tests, in (file, line) order.
73pub fn collect_tests() -> Vec<&'static TestRegistration> {
74    let mut out: Vec<&'static TestRegistration> = TEST_REGISTRATIONS.iter().collect();
75    out.sort_by_key(|r| (r.file, r.line));
76    out
77}
78
79// ===========================================================================
80// Regression execution (§4.5)
81// ===========================================================================
82
83#[derive(Clone, Debug, PartialEq, Eq)]
84enum Outcome {
85    Pass,
86    /// `kind` is the machine-readable cause, when the failure had one, so
87    /// `expect_error` can insist a test failed for the *right* reason.
88    Fail {
89        msg: String,
90        kind: Option<&'static str>,
91    },
92    Skip,
93}
94
95fn fail(msg: impl Into<String>) -> Outcome {
96    Outcome::Fail {
97        msg: msg.into(),
98        kind: None,
99    }
100}
101
102struct TestResult {
103    name: &'static str,
104    outcome: Outcome,
105    sim_ns: f64,
106}
107
108thread_local! {
109    /// "A panic in any task fails the current test" (cocotb:
110    /// TestManager._task_done_callback).
111    static CURRENT_FAILURE: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
112}
113
114fn take_background_failure() -> Option<String> {
115    CURRENT_FAILURE.with(|f| f.borrow_mut().take())
116}
117
118fn seed_from_env() -> u64 {
119    std::env::var("RUSTDV_RANDOM_SEED")
120        .ok()
121        .and_then(|s| s.parse().ok())
122        .unwrap_or_else(|| {
123            std::time::SystemTime::now()
124                .duration_since(std::time::UNIX_EPOCH)
125                .map(|d| d.as_secs())
126                .unwrap_or(1)
127        })
128}
129
130async fn run_one(reg: &'static TestRegistration, seed: u64) -> Outcome {
131    // The simulator's phase outlives the test that put it there (D108). A test
132    // ending inside ReadOnly — `read_only().await` as its last act, which is
133    // exactly how a test that checks a settled value ends — hands the ReadOnly
134    // region straight to whatever the executor runs next, and what it runs next
135    // is this test, in the same drain of the same callback. Its first write
136    // would then be illegal for a reason that has nothing to do with it. Get
137    // out of the region first, which costs one precision step when it happens
138    // at all and nothing when the predecessor ended anywhere else.
139    rustdv_sim::phase::leave_read_only().await;
140
141    // Each test starts clean — pyuvm's run_test does the same, so a test never
142    // inherits the previous test's BFM (with its half-drained queues) or its
143    // logging configuration. D16's rule; the ConfigDb clear is what carries it
144    // for the BFM now that the BFM lives there rather than in a singleton
145    // (D101).
146    rustdv_methodology::ConfigDb::clear();
147    log::reset_config();
148
149    let dut = match top_module() {
150        Ok(d) => d,
151        Err(e) => return fail(format!("no DUT: {e}")),
152    };
153    // D49: the root path is the test's registered name, so `ctx.info(..)`
154    // logs `[random_test]` where UVM logs `uvm_test_top`.
155    let ctx = RustdvCtx::new(reg.name, dut, seed);
156
157    let ex = rustdv_sim::executor::current();
158    let watermark = ex.watermark();
159
160    // UVM's end-of-test consensus: the body finishes, then the test waits
161    // for every outstanding objection. The clone shares the registry, and
162    // folding the wait into the same future keeps it under the timeout.
163    // A test that never objected is not made to wait — that is the Part II
164    // front door (D46), and pyuvm's "you never objected" warning would
165    // otherwise fire on every cocotb-shaped test.
166    let body = {
167        let watcher = ctx.clone();
168        let fut = (reg.run)(ctx);
169        async move {
170            let result = fut.await;
171            if watcher.objections().ever_raised() {
172                watcher.all_objections_dropped().await;
173            }
174            result
175        }
176    };
177
178    let handle = ex.spawn_named(body, Some(reg.name));
179
180    // handle.await → Result<Result<(), TestError>, TaskError>
181    let raw = match reg.timeout {
182        Some((n, unit)) => {
183            let d = SimDuration::from_unit(n, unit);
184            match first2(handle, Timer::new(d)).await {
185                Either::First(r) => Some(r),
186                Either::Second(()) => None, // timeout
187            }
188        }
189        None => Some(handle.await),
190    };
191
192    // Kill surviving tasks spawned during the test (§4.5).
193    ex.cancel_after(watermark);
194
195    let mut outcome = match raw {
196        None => fail(format!(
197            "timeout after {}{}",
198            reg.timeout.unwrap().0,
199            reg.timeout.unwrap().1
200        )),
201        Some(Err(e)) => fail(format!("test task: {e}")),
202        Some(Ok(Err(e))) => Outcome::Fail {
203            msg: e.to_string(),
204            kind: e.kind(),
205        },
206        Some(Ok(Ok(()))) => Outcome::Pass,
207    };
208
209    // A panic in a child task fails the test even if the body passed.
210    if let Some(bg) = take_background_failure()
211        && outcome == Outcome::Pass
212    {
213        outcome = fail(bg);
214    }
215
216    if let Some(expected) = reg.expect_error {
217        outcome = match outcome {
218            Outcome::Pass => fail(format!("expected error '{expected}' but test passed")),
219            Outcome::Fail { msg, kind } if kind == Some(expected) => {
220                let _ = msg;
221                Outcome::Pass
222            }
223            Outcome::Fail { msg, kind } => fail(format!(
224                "expected error '{expected}', got {}: {msg}",
225                kind.unwrap_or("an unclassified failure")
226            )),
227            s => s,
228        };
229    } else if reg.expect_fail {
230        outcome = match outcome {
231            Outcome::Pass => fail("expected failure but test passed"),
232            Outcome::Fail { .. } => Outcome::Pass,
233            s => s,
234        };
235    }
236    outcome
237}
238
239/// `RUSTDV_TESTCASE` — run only the tests whose names contain one of these
240/// comma-separated substrings (cocotb's `TESTCASE`, widened from exact names
241/// to substrings so a naming prefix selects a group).
242///
243/// Matching is case-insensitive because the two test forms spell their names
244/// differently: `#[rustdv::test]` on a function registers `conc_two_runs`,
245/// and on a struct it registers the type name, `ConcTwoRuns`. One filter
246/// should select a group whichever form its members happen to take.
247///
248/// A filter matching nothing is an **error**, not an empty pass: a typo in a
249/// regression entry would otherwise look like a green run of zero tests.
250fn apply_testcase_filter(
251    tests: Vec<&'static TestRegistration>,
252) -> Result<Vec<&'static TestRegistration>, String> {
253    let Ok(raw) = std::env::var("RUSTDV_TESTCASE") else {
254        return Ok(tests);
255    };
256    let pats: Vec<String> = raw
257        .split(',')
258        .map(|s| s.trim().to_ascii_lowercase())
259        .filter(|s| !s.is_empty())
260        .collect();
261    if pats.is_empty() {
262        return Ok(tests);
263    }
264    let kept: Vec<_> = tests
265        .into_iter()
266        .filter(|t| {
267            let name = t.name.to_ascii_lowercase();
268            pats.iter().any(|p| name.contains(p))
269        })
270        .collect();
271    if kept.is_empty() {
272        return Err(format!("RUSTDV_TESTCASE={raw} matched no test"));
273    }
274    Ok(kept)
275}
276
277async fn regression() {
278    let tests = match apply_testcase_filter(collect_tests()) {
279        Ok(t) => t,
280        Err(e) => {
281            log::error(&e);
282            println!("REGRESSION: FAIL");
283            gpi::finish();
284            return;
285        }
286    };
287    let seed = seed_from_env();
288    log::info(&format!(
289        "rustdv: found {} test(s), RUSTDV_RANDOM_SEED={seed}",
290        tests.len()
291    ));
292
293    let mut results: Vec<TestResult> = Vec::new();
294    let total = tests.len();
295
296    for (i, reg) in tests.iter().enumerate() {
297        if reg.skip {
298            log::info(&format!("skipping {} ({}/{})", reg.name, i + 1, total));
299            results.push(TestResult {
300                name: reg.name,
301                outcome: Outcome::Skip,
302                sim_ns: 0.0,
303            });
304            continue;
305        }
306        log::info(&format!(
307            "running {} ({}/{})  [{}:{}]",
308            reg.name,
309            i + 1,
310            total,
311            reg.file,
312            reg.line
313        ));
314        let t0 = sim_time_ns();
315        let outcome = run_one(reg, seed.wrapping_add(i as u64)).await;
316        let dt = sim_time_ns() - t0;
317        match &outcome {
318            Outcome::Pass => log::info(&format!("{} PASSED", reg.name)),
319            Outcome::Fail { msg, .. } => log::error(&format!("{} FAILED: {msg}", reg.name)),
320            Outcome::Skip => {}
321        }
322        results.push(TestResult {
323            name: reg.name,
324            outcome,
325            sim_ns: dt,
326        });
327    }
328
329    print_summary(&results);
330    write_xunit(&results);
331
332    let failed = results
333        .iter()
334        .any(|r| matches!(r.outcome, Outcome::Fail { .. }));
335    println!("REGRESSION: {}", if failed { "FAIL" } else { "PASS" });
336    gpi::finish();
337}
338
339fn print_summary(results: &[TestResult]) {
340    // Port of cocotb's summary table shape (regression.py _log_test_summary).
341    println!("{}", "*".repeat(78));
342    println!(
343        "** {:<40} {:>8} {:>14}      **",
344        "TEST", "STATUS", "SIM TIME (ns)"
345    );
346    println!("{}", "*".repeat(78));
347    for r in results {
348        let status = match &r.outcome {
349            Outcome::Pass => "PASS",
350            Outcome::Fail { .. } => "FAIL",
351            Outcome::Skip => "SKIP",
352        };
353        println!("** {:<40} {:>8} {:>14.2}      **", r.name, status, r.sim_ns);
354    }
355    println!("{}", "*".repeat(78));
356}
357
358/// xUnit XML (cocotb: _xunit_reporter.py) — written only if
359/// RUSTDV_RESULTS_XML names a path.
360fn write_xunit(results: &[TestResult]) {
361    let Ok(path) = std::env::var("RUSTDV_RESULTS_XML") else {
362        return;
363    };
364    let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
365    let failures = results
366        .iter()
367        .filter(|r| matches!(r.outcome, Outcome::Fail { .. }))
368        .count();
369    let skipped = results
370        .iter()
371        .filter(|r| matches!(r.outcome, Outcome::Skip))
372        .count();
373    xml.push_str(&format!(
374        "<testsuites>\n<testsuite name=\"rustdv\" tests=\"{}\" failures=\"{}\" skipped=\"{}\">\n",
375        results.len(),
376        failures,
377        skipped
378    ));
379    for r in results {
380        xml.push_str(&format!(
381            "  <testcase name=\"{}\" time=\"{:.2}\"",
382            r.name, r.sim_ns
383        ));
384        match &r.outcome {
385            Outcome::Pass => xml.push_str("/>\n"),
386            Outcome::Skip => xml.push_str("><skipped/></testcase>\n"),
387            Outcome::Fail { msg: m, .. } => xml.push_str(&format!(
388                "><failure message=\"{}\"/></testcase>\n",
389                m.replace('"', "'").replace('<', "(").replace('>', ")")
390            )),
391        }
392    }
393    xml.push_str("</testsuite>\n</testsuites>\n");
394    if let Err(e) = std::fs::write(&path, xml) {
395        log::warning(&format!("could not write {path}: {e}"));
396    }
397}
398
399// ===========================================================================
400// Bootstrap (§3.2, deviated: VPI-module loading — see crate docs)
401// ===========================================================================
402
403/// Called from `vlog_startup_routines` at VPI module load time (before
404/// elaboration). Registers the start-of-simulation hook; everything else
405/// happens from simulator callbacks.
406pub fn vpi_startup() {
407    let cb = gpi::register_start_of_simulation(Box::new(|| {
408        on_start_of_simulation();
409    }));
410    cb.forget();
411}
412
413fn on_start_of_simulation() {
414    let ex = rustdv_sim::init();
415
416    // Route panics — from tasks and from raw GPI callbacks — into
417    // "fail the current test".
418    let flag = CURRENT_FAILURE.with(|f| f.clone());
419    ex.set_failure_sink(Box::new(move |msg| {
420        let mut slot = flag.borrow_mut();
421        if slot.is_none() {
422            *slot = Some(msg.to_string());
423        }
424    }));
425    let flag2 = CURRENT_FAILURE.with(|f| f.clone());
426    gpi::set_panic_sink(Box::new(move |msg| {
427        let mut slot = flag2.borrow_mut();
428        if slot.is_none() {
429            *slot = Some(format!("panic in simulator callback: {msg}"));
430        }
431    }));
432
433    ex.spawn_named(regression(), Some("rustdv_regression"));
434    ex.run_until_idle();
435}