Skip to main content

rsspec/
context.rs

1//! Closure-based BDD API — Context, ItBuilder, SuiteBuilder, and `run()`.
2
3use crate::runner::{self, RunConfig, Suite, TestNode};
4use std::cell::RefCell;
5
6// ============================================================================
7// Thread-local suite builder
8// ============================================================================
9
10thread_local! {
11    static BUILDER: RefCell<Option<SuiteBuilder>> = const { RefCell::new(None) };
12}
13
14pub(crate) struct SuiteBuilder {
15    stack: Vec<GroupFrame>,
16}
17
18struct GroupFrame {
19    name: String,
20    focused: bool,
21    pending: bool,
22    labels: Vec<String>,
23    before_each: Vec<Box<dyn Fn()>>,
24    after_each: Vec<Box<dyn Fn()>>,
25    before_all: Vec<Box<dyn Fn()>>,
26    after_all: Vec<Box<dyn Fn()>>,
27    just_before_each: Vec<Box<dyn Fn()>>,
28    children: Vec<TestNode>,
29}
30
31impl GroupFrame {
32    fn root() -> Self {
33        GroupFrame {
34            name: String::new(),
35            focused: false,
36            pending: false,
37            labels: Vec::new(),
38            before_each: Vec::new(),
39            after_each: Vec::new(),
40            before_all: Vec::new(),
41            after_all: Vec::new(),
42            just_before_each: Vec::new(),
43            children: Vec::new(),
44        }
45    }
46}
47
48impl SuiteBuilder {
49    fn new() -> Self {
50        SuiteBuilder {
51            stack: vec![GroupFrame::root()],
52        }
53    }
54
55    pub(crate) fn push_group(&mut self, name: String, focused: bool, pending: bool) {
56        self.stack.push(GroupFrame {
57            name,
58            focused,
59            pending,
60            labels: Vec::new(),
61            before_each: Vec::new(),
62            after_each: Vec::new(),
63            before_all: Vec::new(),
64            after_all: Vec::new(),
65            just_before_each: Vec::new(),
66            children: Vec::new(),
67        });
68    }
69
70    pub(crate) fn pop_group(&mut self) {
71        let frame = self.stack.pop().expect("rsspec: unbalanced group push/pop");
72        let node = TestNode::Describe {
73            name: frame.name,
74            focused: frame.focused,
75            pending: frame.pending,
76            labels: frame.labels,
77            before_each: frame.before_each,
78            after_each: frame.after_each,
79            before_all: frame.before_all,
80            after_all: frame.after_all,
81            just_before_each: frame.just_before_each,
82            children: frame.children,
83        };
84        self.current_frame_mut().children.push(node);
85    }
86
87    pub(crate) fn add_node(&mut self, node: TestNode) {
88        self.current_frame_mut().children.push(node);
89    }
90
91    fn add_before_each(&mut self, hook: Box<dyn Fn()>) {
92        self.current_frame_mut().before_each.push(hook);
93    }
94
95    fn add_after_each(&mut self, hook: Box<dyn Fn()>) {
96        self.current_frame_mut().after_each.push(hook);
97    }
98
99    fn add_before_all(&mut self, hook: Box<dyn Fn()>) {
100        self.current_frame_mut().before_all.push(hook);
101    }
102
103    fn add_after_all(&mut self, hook: Box<dyn Fn()>) {
104        self.current_frame_mut().after_all.push(hook);
105    }
106
107    fn add_just_before_each(&mut self, hook: Box<dyn Fn()>) {
108        self.current_frame_mut().just_before_each.push(hook);
109    }
110
111    fn add_labels(&mut self, labels: Vec<String>) {
112        self.current_frame_mut().labels.extend(labels);
113    }
114
115    fn current_frame_mut(&mut self) -> &mut GroupFrame {
116        self.stack.last_mut().expect("rsspec: empty builder stack")
117    }
118
119    fn into_nodes(mut self) -> Vec<TestNode> {
120        assert_eq!(
121            self.stack.len(),
122            1,
123            "rsspec: unbalanced group push/pop at finalization"
124        );
125        self.stack.pop().unwrap().children
126    }
127}
128
129/// Access the thread-local builder.
130pub(crate) fn with_builder<R>(f: impl FnOnce(&mut SuiteBuilder) -> R) -> R {
131    BUILDER.with(|cell| {
132        let mut opt = cell.borrow_mut();
133        let builder = opt
134            .as_mut()
135            .expect("rsspec: Context used outside of rsspec::run()");
136        f(builder)
137    })
138}
139
140// ============================================================================
141// Context — the user-facing handle
142// ============================================================================
143
144/// A lightweight handle for defining BDD test structure.
145///
146/// All methods delegate to a thread-local builder. `Context` is `Copy` so it
147/// can be passed into nested closures without ceremony.
148///
149/// # Example
150/// ```rust,no_run
151/// rsspec::run(|ctx| {
152///     ctx.describe("Calculator", |ctx| {
153///         ctx.it("adds", || { assert_eq!(2 + 3, 5); });
154///     });
155/// });
156/// ```
157#[derive(Copy, Clone)]
158pub struct Context;
159
160impl Context {
161    // ---- Describe / Context / When -------------------------------------------
162
163    /// Define a named group of tests. Alias: [`context`](Self::context), [`when`](Self::when).
164    pub fn describe(&self, name: &str, body: impl FnOnce(Context)) {
165        self.describe_impl(name, false, false, body);
166    }
167
168    /// Focused variant of [`describe`](Self::describe). Only focused groups and their
169    /// children run; all other tests are skipped.
170    pub fn fdescribe(&self, name: &str, body: impl FnOnce(Context)) {
171        self.describe_impl(name, true, false, body);
172    }
173
174    /// Pending variant of [`describe`](Self::describe). All children are marked pending
175    /// and their bodies never execute.
176    pub fn xdescribe(&self, name: &str, body: impl FnOnce(Context)) {
177        self.describe_impl(name, false, true, body);
178    }
179
180    /// Alias for [`describe`](Self::describe).
181    pub fn context(&self, name: &str, body: impl FnOnce(Context)) {
182        self.describe(name, body);
183    }
184
185    /// Alias for [`fdescribe`](Self::fdescribe).
186    pub fn fcontext(&self, name: &str, body: impl FnOnce(Context)) {
187        self.fdescribe(name, body);
188    }
189
190    /// Alias for [`xdescribe`](Self::xdescribe).
191    pub fn xcontext(&self, name: &str, body: impl FnOnce(Context)) {
192        self.xdescribe(name, body);
193    }
194
195    /// Alias for [`describe`](Self::describe).
196    pub fn when(&self, name: &str, body: impl FnOnce(Context)) {
197        self.describe(name, body);
198    }
199
200    /// Alias for [`fdescribe`](Self::fdescribe).
201    pub fn fwhen(&self, name: &str, body: impl FnOnce(Context)) {
202        self.fdescribe(name, body);
203    }
204
205    /// Alias for [`xdescribe`](Self::xdescribe).
206    pub fn xwhen(&self, name: &str, body: impl FnOnce(Context)) {
207        self.xdescribe(name, body);
208    }
209
210    fn describe_impl(&self, name: &str, focused: bool, pending: bool, body: impl FnOnce(Context)) {
211        with_builder(|b| b.push_group(name.to_string(), focused, pending));
212        body(Context);
213        with_builder(|b| b.pop_group());
214    }
215
216    // ---- It / Specify --------------------------------------------------------
217
218    /// Define a test case. Returns an [`ItBuilder`] for optional decorators.
219    ///
220    /// ```rust,no_run
221    /// # fn main() { rsspec::run(|ctx| {
222    /// ctx.it("works", || { assert!(true); });
223    ///
224    /// ctx.it("slow test", || { /* ... */ })
225    ///     .labels(&["slow"])
226    ///     .retries(3)
227    ///     .timeout(5000);
228    /// # }); }
229    /// ```
230    pub fn it(&self, name: &str, body: impl Fn() + 'static) -> ItBuilder {
231        ItBuilder::new(name.to_string(), body, false, false)
232    }
233
234    /// Focused variant of [`it`](Self::it). Only focused tests run; others are skipped.
235    pub fn fit(&self, name: &str, body: impl Fn() + 'static) -> ItBuilder {
236        ItBuilder::new(name.to_string(), body, true, false)
237    }
238
239    /// Pending variant of [`it`](Self::it). The body is registered but never executed.
240    pub fn xit(&self, name: &str, body: impl Fn() + 'static) -> ItBuilder {
241        ItBuilder::new(name.to_string(), body, false, true)
242    }
243
244    /// Alias for [`it`](Self::it).
245    pub fn specify(&self, name: &str, body: impl Fn() + 'static) -> ItBuilder {
246        self.it(name, body)
247    }
248
249    /// Alias for [`fit`](Self::fit).
250    pub fn fspecify(&self, name: &str, body: impl Fn() + 'static) -> ItBuilder {
251        self.fit(name, body)
252    }
253
254    /// Alias for [`xit`](Self::xit).
255    pub fn xspecify(&self, name: &str, body: impl Fn() + 'static) -> ItBuilder {
256        self.xit(name, body)
257    }
258
259    // ---- Hooks ---------------------------------------------------------------
260
261    /// Register a hook that runs before every test in this scope and nested scopes.
262    /// Multiple `before_each` hooks in the same scope run in registration order.
263    pub fn before_each(&self, hook: impl Fn() + 'static) {
264        with_builder(|b| b.add_before_each(Box::new(hook)));
265    }
266
267    /// Register a hook that runs after every test in this scope and nested scopes,
268    /// even if the test panics. Multiple `after_each` hooks run inner-to-outer.
269    pub fn after_each(&self, hook: impl Fn() + 'static) {
270        with_builder(|b| b.add_after_each(Box::new(hook)));
271    }
272
273    /// Register a hook that runs once before all tests in this describe scope.
274    /// Not inherited by nested scopes. Skipped if all children are filtered out.
275    pub fn before_all(&self, hook: impl Fn() + 'static) {
276        with_builder(|b| b.add_before_all(Box::new(hook)));
277    }
278
279    /// Register a hook that runs once after all tests in this describe scope.
280    /// Not inherited by nested scopes. Runs even if `before_all` panicked.
281    pub fn after_all(&self, hook: impl Fn() + 'static) {
282        with_builder(|b| b.add_after_all(Box::new(hook)));
283    }
284
285    /// Register a hook that runs after all `before_each` hooks but immediately
286    /// before the test body. Useful for final setup that must run last.
287    pub fn just_before_each(&self, hook: impl Fn() + 'static) {
288        with_builder(|b| b.add_just_before_each(Box::new(hook)));
289    }
290
291    // ---- Labels on current describe ------------------------------------------
292
293    /// Add labels to the current describe scope. Labels accumulate across
294    /// multiple calls.
295    ///
296    /// ```rust,no_run
297    /// # fn main() { rsspec::run(|ctx| {
298    /// ctx.describe("integration tests", |ctx| {
299    ///     ctx.labels(&["integration", "slow"]);
300    ///     ctx.it("test", || { /* ... */ });
301    /// });
302    /// # }); }
303    /// ```
304    pub fn labels(&self, labels: &[&str]) {
305        let labels: Vec<String> = labels.iter().map(|s| s.to_string()).collect();
306        with_builder(|b| b.add_labels(labels));
307    }
308
309    // ---- Table-driven --------------------------------------------------------
310
311    /// Start building a table-driven test.
312    ///
313    /// ```rust,no_run
314    /// # fn main() { rsspec::run(|ctx| {
315    /// ctx.describe_table("arithmetic")
316    ///     .case("addition", (2i32, 3i32, 5i32))
317    ///     .case("subtraction", (5, 3, 2))
318    ///     .run(|(a, b, expected): &(i32, i32, i32)| {
319    ///         assert_eq!(a + b, *expected);
320    ///     });
321    /// # }); }
322    /// ```
323    pub fn describe_table(&self, name: &str) -> crate::table::TableBuilder {
324        crate::table::TableBuilder::new(name.to_string())
325    }
326
327    // ---- Ordered -------------------------------------------------------------
328
329    /// Define an ordered sequence of steps that run as a single test.
330    ///
331    /// If any step fails, subsequent steps are skipped (fail-fast).
332    ///
333    /// ```rust,no_run
334    /// # fn main() { rsspec::run(|ctx| {
335    /// ctx.ordered("workflow", |oct| {
336    ///     oct.step("step 1", || { /* ... */ });
337    ///     oct.step("step 2", || { /* ... */ });
338    /// });
339    /// # }); }
340    /// ```
341    pub fn ordered(&self, name: &str, body: impl FnOnce(&mut crate::ordered::OrderedContext)) {
342        let mut oct = crate::ordered::OrderedContext::new(name.to_string(), false);
343        body(&mut oct);
344        with_builder(|b| b.add_node(oct.into_node()));
345    }
346
347    /// Like [`ordered`](Self::ordered), but continues running steps even if one fails.
348    pub fn ordered_continue_on_failure(
349        &self,
350        name: &str,
351        body: impl FnOnce(&mut crate::ordered::OrderedContext),
352    ) {
353        let mut oct = crate::ordered::OrderedContext::new(name.to_string(), true);
354        body(&mut oct);
355        with_builder(|b| b.add_node(oct.into_node()));
356    }
357}
358
359// ============================================================================
360// Async methods (requires `tokio` feature)
361// ============================================================================
362
363#[cfg(feature = "tokio")]
364impl Context {
365    // ---- Async It / Specify ---------------------------------------------------
366
367    /// Define an async test case. Returns an [`ItBuilder`] for optional decorators.
368    ///
369    /// ```rust,ignore
370    /// ctx.async_it("fetches data", || async {
371    ///     let data = fetch().await;
372    ///     assert!(!data.is_empty());
373    /// })
374    /// .retries(3)
375    /// .timeout(5000);
376    /// ```
377    pub fn async_it<F, Fut>(&self, name: &str, body: F) -> ItBuilder
378    where
379        F: Fn() -> Fut + 'static,
380        Fut: std::future::Future<Output = ()> + 'static,
381    {
382        self.it(name, crate::async_test(body))
383    }
384
385    /// Focused variant of [`async_it`](Self::async_it).
386    pub fn async_fit<F, Fut>(&self, name: &str, body: F) -> ItBuilder
387    where
388        F: Fn() -> Fut + 'static,
389        Fut: std::future::Future<Output = ()> + 'static,
390    {
391        self.fit(name, crate::async_test(body))
392    }
393
394    /// Pending variant of [`async_it`](Self::async_it).
395    pub fn async_xit<F, Fut>(&self, name: &str, body: F) -> ItBuilder
396    where
397        F: Fn() -> Fut + 'static,
398        Fut: std::future::Future<Output = ()> + 'static,
399    {
400        self.xit(name, crate::async_test(body))
401    }
402
403    /// Alias for [`async_it`](Self::async_it).
404    pub fn async_specify<F, Fut>(&self, name: &str, body: F) -> ItBuilder
405    where
406        F: Fn() -> Fut + 'static,
407        Fut: std::future::Future<Output = ()> + 'static,
408    {
409        self.async_it(name, body)
410    }
411
412    /// Alias for [`async_fit`](Self::async_fit).
413    pub fn async_fspecify<F, Fut>(&self, name: &str, body: F) -> ItBuilder
414    where
415        F: Fn() -> Fut + 'static,
416        Fut: std::future::Future<Output = ()> + 'static,
417    {
418        self.async_fit(name, body)
419    }
420
421    /// Alias for [`async_xit`](Self::async_xit).
422    pub fn async_xspecify<F, Fut>(&self, name: &str, body: F) -> ItBuilder
423    where
424        F: Fn() -> Fut + 'static,
425        Fut: std::future::Future<Output = ()> + 'static,
426    {
427        self.async_xit(name, body)
428    }
429
430    // ---- Async Hooks ----------------------------------------------------------
431
432    /// Async variant of [`before_each`](Context::before_each).
433    /// Each invocation runs on a fresh single-threaded Tokio runtime.
434    pub fn async_before_each<F, Fut>(&self, hook: F)
435    where
436        F: Fn() -> Fut + 'static,
437        Fut: std::future::Future<Output = ()> + 'static,
438    {
439        self.before_each(crate::async_test(hook));
440    }
441
442    /// Async variant of [`after_each`](Context::after_each).
443    /// Each invocation runs on a fresh single-threaded Tokio runtime.
444    pub fn async_after_each<F, Fut>(&self, hook: F)
445    where
446        F: Fn() -> Fut + 'static,
447        Fut: std::future::Future<Output = ()> + 'static,
448    {
449        self.after_each(crate::async_test(hook));
450    }
451
452    /// Async variant of [`before_all`](Context::before_all).
453    /// Runs on a fresh single-threaded Tokio runtime.
454    pub fn async_before_all<F, Fut>(&self, hook: F)
455    where
456        F: Fn() -> Fut + 'static,
457        Fut: std::future::Future<Output = ()> + 'static,
458    {
459        self.before_all(crate::async_test(hook));
460    }
461
462    /// Async variant of [`after_all`](Context::after_all).
463    /// Runs on a fresh single-threaded Tokio runtime.
464    pub fn async_after_all<F, Fut>(&self, hook: F)
465    where
466        F: Fn() -> Fut + 'static,
467        Fut: std::future::Future<Output = ()> + 'static,
468    {
469        self.after_all(crate::async_test(hook));
470    }
471
472    /// Async variant of [`just_before_each`](Context::just_before_each).
473    /// Each invocation runs on a fresh single-threaded Tokio runtime.
474    pub fn async_just_before_each<F, Fut>(&self, hook: F)
475    where
476        F: Fn() -> Fut + 'static,
477        Fut: std::future::Future<Output = ()> + 'static,
478    {
479        self.just_before_each(crate::async_test(hook));
480    }
481}
482
483// ============================================================================
484// ItBuilder — fluent decorator API, registers test on Drop
485// ============================================================================
486
487/// Builder returned by [`Context::it`]. Supports chaining decorators and
488/// registers the test node when dropped.
489///
490/// ```rust,no_run
491/// # fn main() { rsspec::run(|ctx| {
492/// // Simple (drops immediately, registered at semicolon):
493/// ctx.it("simple", || { assert!(true); });
494///
495/// // With decorators:
496/// ctx.it("complex", || { /* ... */ })
497///     .labels(&["integration"])
498///     .retries(3)
499///     .timeout(5000);
500/// # }); }
501/// ```
502pub struct ItBuilder {
503    name: String,
504    body: Option<Box<dyn Fn()>>,
505    focused: bool,
506    pending: bool,
507    labels: Vec<String>,
508    retries: Option<u32>,
509    timeout_ms: Option<u64>,
510    must_pass_repeatedly: Option<u32>,
511}
512
513impl ItBuilder {
514    fn new(name: String, body: impl Fn() + 'static, focused: bool, pending: bool) -> Self {
515        ItBuilder {
516            name,
517            body: Some(Box::new(body)),
518            focused,
519            pending,
520            labels: Vec::new(),
521            retries: None,
522            timeout_ms: None,
523            must_pass_repeatedly: None,
524        }
525    }
526
527    /// Add labels for filtering via `RSSPEC_LABEL_FILTER`. Labels accumulate
528    /// across multiple calls.
529    pub fn labels(mut self, labels: &[&str]) -> Self {
530        self.labels.extend(labels.iter().map(|s| s.to_string()));
531        self
532    }
533
534    /// Retry the test up to `n` additional times on failure.
535    pub fn retries(mut self, n: u32) -> Self {
536        self.retries = Some(n);
537        self
538    }
539
540    /// Fail the test if it exceeds `ms` milliseconds.
541    ///
542    /// **Note:** The timeout is checked *after* the closure returns — the
543    /// closure cannot be forcibly aborted mid-execution. If your test blocks
544    /// forever (e.g. an infinite loop or deadlock), the timeout will not fire.
545    pub fn timeout(mut self, ms: u64) -> Self {
546        self.timeout_ms = Some(ms);
547        self
548    }
549
550    /// Require the test to pass `n` consecutive times.
551    pub fn must_pass_repeatedly(mut self, n: u32) -> Self {
552        self.must_pass_repeatedly = Some(n);
553        self
554    }
555}
556
557impl Drop for ItBuilder {
558    fn drop(&mut self) {
559        // If we're already panicking (e.g. a describe body panicked), don't
560        // double-panic by trying to access the builder.
561        if std::thread::panicking() {
562            return;
563        }
564        let Some(body) = self.body.take() else {
565            return;
566        };
567        let node = TestNode::It {
568            name: std::mem::take(&mut self.name),
569            focused: self.focused,
570            pending: self.pending,
571            labels: std::mem::take(&mut self.labels),
572            retries: self.retries,
573            timeout_ms: self.timeout_ms,
574            must_pass_repeatedly: self.must_pass_repeatedly,
575            test_fn: body,
576        };
577        with_builder(|b| b.add_node(node));
578    }
579}
580
581// ============================================================================
582// run() / run_inline() — entry points
583// ============================================================================
584
585/// Build the test tree from user closures.
586fn build_tree(body: impl FnOnce(Context)) -> Vec<TestNode> {
587    BUILDER.with(|cell| {
588        *cell.borrow_mut() = Some(SuiteBuilder::new());
589    });
590
591    body(Context);
592
593    BUILDER.with(|cell| {
594        cell.borrow_mut()
595            .take()
596            .expect("rsspec: builder missing after run")
597            .into_nodes()
598    })
599}
600
601/// Build and run a BDD test suite.
602///
603/// Works in both contexts:
604///
605/// - **`harness = false`** — parses CLI args for filtering/listing, calls
606///   [`std::process::exit`] on failure.
607/// - **`#[test]` functions** — auto-detected via libtest-specific CLI args;
608///   skips arg parsing and panics on failure so other tests can still run.
609///
610/// # Example
611///
612/// ```rust,no_run
613/// rsspec::run(|ctx| {
614///     ctx.describe("Calculator", |ctx| {
615///         ctx.it("adds", || { assert_eq!(2 + 3, 5); });
616///     });
617/// });
618/// ```
619pub fn run(body: impl FnOnce(Context)) {
620    let nodes = build_tree(body);
621
622    // Auto-detect: are we inside cargo test's standard harness?
623    let args: Vec<String> = std::env::args().collect();
624    let inside_harness = runner::detect_libtest_args(&args[1..]).is_some();
625
626    let config = if inside_harness {
627        RunConfig {
628            filter: None,
629            list: false,
630            include_ignored: false,
631        }
632    } else {
633        RunConfig::from_args()
634    };
635
636    let suite = Suite::new("", nodes);
637    let result = runner::run_suites(&[suite], &config);
638
639    if result.failed > 0 {
640        if inside_harness {
641            // Inside #[test]: panic so other test functions still run
642            let details = result
643                .failures
644                .iter()
645                .enumerate()
646                .map(|(i, f)| format!("  {}. {}", i + 1, f))
647                .collect::<Vec<_>>()
648                .join("\n");
649            panic!(
650                "rsspec: {} test(s) failed\n{}",
651                result.failed, details
652            );
653        } else {
654            std::process::exit(1);
655        }
656    }
657}
658
659/// Build and run a BDD test suite inline, compatible with `#[test]` functions.
660///
661/// Unlike [`run`], this does **not** parse command-line args (avoiding
662/// conflicts with `cargo test`'s own filter arguments) and **panics** on
663/// failure instead of calling `process::exit`.
664///
665/// # Example
666///
667/// ```rust,no_run
668/// #[test]
669/// fn calculator_spec() {
670///     rsspec::run_inline(|ctx| {
671///         ctx.describe("Calculator", |ctx| {
672///             ctx.it("adds", || { assert_eq!(2 + 3, 5); });
673///         });
674///     });
675/// }
676/// ```
677pub fn run_inline(body: impl FnOnce(Context)) {
678    let nodes = build_tree(body);
679    let config = RunConfig {
680        filter: None,
681        list: false,
682        include_ignored: false,
683    };
684    let suite = Suite::new("", nodes);
685    let result = runner::run_suites(&[suite], &config);
686
687    if result.failed > 0 {
688        let details = result
689            .failures
690            .iter()
691            .enumerate()
692            .map(|(i, f)| format!("  {}. {}", i + 1, f))
693            .collect::<Vec<_>>()
694            .join("\n");
695        panic!(
696            "rsspec: {} test(s) failed\n{}",
697            result.failed, details
698        );
699    }
700}