Skip to main content

hegel/
runner.rs

1use crate::antithesis::TestLocation;
2use crate::test_case::TestCase;
3
4/// Health checks that can be suppressed during test execution.
5///
6/// Health checks detect common issues with test configuration that would
7/// otherwise cause tests to run inefficiently or not at all.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum HealthCheck {
10    /// Too many test cases are being filtered out via `assume()`.
11    FilterTooMuch,
12    /// Test execution is too slow.
13    TooSlow,
14    /// Generated test cases are too large.
15    TestCasesTooLarge,
16    /// The smallest natural input is very large.
17    LargeInitialTestCase,
18}
19
20impl HealthCheck {
21    /// Returns all health check variants.
22    ///
23    /// Useful for suppressing all health checks at once:
24    ///
25    /// ```no_run
26    /// use hegel::HealthCheck;
27    ///
28    /// #[hegel::test(suppress_health_check = HealthCheck::all())]
29    /// fn my_test(tc: hegel::TestCase) {
30    ///     // ...
31    /// }
32    /// ```
33    pub const fn all() -> [HealthCheck; 4] {
34        [
35            HealthCheck::FilterTooMuch,
36            HealthCheck::TooSlow,
37            HealthCheck::TestCasesTooLarge,
38            HealthCheck::LargeInitialTestCase,
39        ]
40    }
41}
42
43/// Controls which phases of the test lifecycle are executed.
44///
45/// By default, all phases run. Use [`Settings::phases`] to restrict which
46/// phases execute — for example, passing only `[Phase::Generate]` disables
47/// shrinking, which is useful when you only need to find a counterexample
48/// quickly and don't need the minimal one.
49///
50/// Corresponds to a subset of `hypothesis.Phase` (the `explain` phase is not
51/// yet supported in hegel-rust).
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum Phase {
54    /// Run explicit test cases added via `#[hegel::explicit_test_case]`.
55    Explicit,
56    /// Replay examples from the failure database.
57    Reuse,
58    /// Generate new random examples.
59    Generate,
60    /// Use targeting to guide generation toward interesting areas.
61    Target,
62    /// Shrink failing examples to a minimal counterexample.
63    Shrink,
64}
65
66/// Controls the test execution mode.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Mode {
69    /// Run a full test (multiple test cases with shrinking). This is the default.
70    TestRun,
71    /// Run a single test case with no shrinking or replay. Useful for
72    /// Antithesis workloads and other contexts where you want pure data
73    /// generation without property-testing overhead.
74    SingleTestCase,
75}
76
77/// Selects the source of randomness the engine draws from.
78///
79/// Mirrors Hypothesis's `backend` setting (specifically `backend="hypothesis"`
80/// vs `backend="hypothesis-urandom"`).
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum Backend {
83    /// The default: generate from a seeded pseudo-random generator. Runs are
84    /// reproducible from [`Settings::seed`] and shrinking/replay work as usual.
85    Default,
86    /// Read fresh entropy from `/dev/urandom` on every draw, instead of
87    /// expanding a single PRNG seed.
88    ///
89    /// This exists for running under [Antithesis](https://antithesis.com/),
90    /// whose fuzzer controls the bytes returned by `/dev/urandom`. Sourcing
91    /// every choice from the OS random device hands the fuzzer control over
92    /// the entire test case (rather than just the PRNG seed), so it can steer
93    /// and reproduce generation directly. When running inside Antithesis this
94    /// backend is selected automatically unless you set one explicitly.
95    ///
96    /// The generation algorithm is otherwise unchanged — only the random
97    /// source differs. On platforms without `/dev/urandom` (Windows) it falls
98    /// back to an OS-seeded PRNG. You almost certainly don't want this backend
99    /// unless you are running under Antithesis.
100    Urandom,
101}
102
103/// Controls how much output Hegel produces during test runs.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Verbosity {
106    /// Suppress all output.
107    Quiet,
108    /// Default output level.
109    Normal,
110    /// Show more detail about the test run.
111    Verbose,
112    /// Show protocol-level debug information.
113    Debug,
114}
115
116/// Configuration for a Hegel test run.
117///
118/// Use builder methods to customize, then pass to [`Hegel::settings`] or
119/// the `settings` parameter of `#[hegel::test]`.
120///
121/// In CI environments (detected automatically), the database is disabled
122/// and tests are derandomized by default.
123#[derive(Debug, Clone)]
124pub struct Settings {
125    pub(crate) mode: Mode,
126    pub(crate) test_cases: u64,
127    pub(crate) verbosity: Verbosity,
128    pub(crate) seed: Option<u64>,
129    pub(crate) derandomize: bool,
130    pub(crate) database: Database,
131    pub(crate) suppress_health_check: Vec<HealthCheck>,
132    pub(crate) phases: Vec<Phase>,
133    pub(crate) report_multiple_failures: bool,
134    pub(crate) print_blob: bool,
135    /// The randomness backend, or `None` to let it be chosen automatically
136    /// (urandom under Antithesis, the default PRNG otherwise). An explicit
137    /// [`Settings::backend`] always wins over the automatic choice.
138    pub(crate) backend: Option<Backend>,
139}
140
141impl Settings {
142    /// Create settings with defaults. Detects CI environments automatically.
143    pub fn new() -> Self {
144        let in_ci = is_in_ci();
145        Self {
146            mode: Mode::TestRun,
147            test_cases: 100,
148            verbosity: Verbosity::Normal,
149            seed: None,
150            derandomize: in_ci,
151            database: if in_ci {
152                Database::Disabled
153            } else {
154                Database::Unset // nocov
155            },
156            suppress_health_check: Vec::new(),
157            phases: vec![
158                Phase::Explicit,
159                Phase::Reuse,
160                Phase::Generate,
161                Phase::Target,
162                Phase::Shrink,
163            ],
164            report_multiple_failures: true,
165            print_blob: false,
166            backend: None,
167        }
168    }
169
170    /// Set the execution mode. Defaults to [`Mode::TestRun`].
171    pub fn mode(mut self, mode: Mode) -> Self {
172        self.mode = mode;
173        self
174    }
175
176    /// Select the randomness backend.
177    ///
178    /// By default the backend is chosen automatically: [`Backend::Urandom`]
179    /// when running inside Antithesis, and [`Backend::Default`] otherwise.
180    /// Calling this pins the choice, overriding the automatic detection.
181    pub fn backend(mut self, backend: Backend) -> Self {
182        self.backend = Some(backend);
183        self
184    }
185
186    /// Set the number of test cases to run (default: 100).
187    pub fn test_cases(mut self, n: u64) -> Self {
188        self.test_cases = n;
189        self
190    }
191
192    /// Set the verbosity level.
193    pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
194        self.verbosity = verbosity;
195        self
196    }
197
198    /// Set a fixed seed for reproducibility, or `None` for random.
199    pub fn seed(mut self, seed: Option<u64>) -> Self {
200        self.seed = seed;
201        self
202    }
203
204    /// When true, use a fixed seed derived from the test name. Enabled by default in CI.
205    pub fn derandomize(mut self, derandomize: bool) -> Self {
206        self.derandomize = derandomize;
207        self
208    }
209
210    /// Set the database path for storing failing examples, or `None` to disable.
211    pub fn database(mut self, database: Option<String>) -> Self {
212        self.database = match database {
213            None => Database::Disabled,
214            Some(path) => Database::Path(path),
215        };
216        self
217    }
218
219    /// Set which test lifecycle phases to run.
220    ///
221    /// Defaults to all phases: `[Phase::Explicit, Phase::Reuse, Phase::Generate, Phase::Target, Phase::Shrink]`.
222    ///
223    /// Example — skip shrinking (useful when you only need a witness, not a
224    /// minimal counterexample):
225    ///
226    /// ```no_run
227    /// use hegel::{Phase, Settings};
228    ///
229    /// let s = Settings::new().phases([Phase::Reuse, Phase::Generate]);
230    /// ```
231    pub fn phases(mut self, phases: impl IntoIterator<Item = Phase>) -> Self {
232        self.phases = phases.into_iter().collect();
233        self
234    }
235
236    /// Print a copy-pasteable `#[hegel::reproduce_failure("…")]` line for the
237    /// counterexample when a test fails. Defaults to `false`.
238    ///
239    /// The reproduce blob is always *attached* to the failure. This setting only controls whether it is printed to
240    /// the failure output. Has effect only on the native backend.
241    pub fn print_blob(mut self, print_blob: bool) -> Self {
242        self.print_blob = print_blob;
243        self
244    }
245
246    /// Suppress one or more health checks so they do not cause test failure.
247    ///
248    /// Health checks detect common issues like excessive filtering or slow
249    /// tests. Use this to suppress specific checks when they are expected.
250    /// Replaces any previously configured suppressions, like [`Settings::phases`].
251    ///
252    /// # Example
253    ///
254    /// ```no_run
255    /// use hegel::{HealthCheck, Verbosity};
256    /// use hegel::generators as gs;
257    ///
258    /// #[hegel::test(suppress_health_check = [HealthCheck::FilterTooMuch, HealthCheck::TooSlow])]
259    /// fn my_test(tc: hegel::TestCase) {
260    ///     let n: i32 = tc.draw(gs::integers());
261    ///     tc.assume(n > 0);
262    /// }
263    /// ```
264    pub fn suppress_health_check(mut self, checks: impl IntoIterator<Item = HealthCheck>) -> Self {
265        self.suppress_health_check = checks.into_iter().collect();
266        self
267    }
268
269    /// Returns `true` if the given phase is enabled in these settings.
270    pub fn has_phase(&self, phase: Phase) -> bool {
271        self.phases.contains(&phase)
272    }
273
274    /// Control whether multi-bug runs report every distinct failing example
275    /// or collapse to just the first one.
276    ///
277    /// When `true` (the default), each distinct origin Hegel finds is surfaced
278    /// as its own diagnostic, and the final panic message reports the count of
279    /// distinct failures.  Setting this to `false` makes Hegel collapse a
280    /// multi-bug run to one example — useful when you have a flaky predicate
281    /// that triggers several superficially-distinct failures whose root cause
282    /// is the same, and the extra reports are just noise.
283    ///
284    /// Maps to Hypothesis's `report_multiple_bugs` setting.
285    pub fn report_multiple_failures(mut self, report_multiple_failures: bool) -> Self {
286        self.report_multiple_failures = report_multiple_failures;
287        self
288    }
289}
290
291impl Default for Settings {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub(crate) enum Database {
299    Unset,
300    Disabled,
301    Path(String),
302}
303
304#[doc(hidden)]
305pub fn hegel<F>(test_fn: F)
306where
307    F: FnMut(TestCase),
308{
309    Hegel::new(test_fn).run();
310}
311
312fn is_in_ci() -> bool {
313    const CI_VARS: &[(&str, Option<&str>)] = &[
314        ("CI", None),
315        ("TF_BUILD", Some("true")),
316        ("BUILDKITE", Some("true")),
317        ("CIRCLECI", Some("true")),
318        ("CIRRUS_CI", Some("true")),
319        ("CODEBUILD_BUILD_ID", None),
320        ("GITHUB_ACTIONS", Some("true")),
321        ("GITLAB_CI", None),
322        ("HEROKU_TEST_RUN_ID", None),
323        ("TEAMCITY_VERSION", None),
324        ("bamboo.buildKey", None),
325    ];
326
327    CI_VARS.iter().any(|(key, value)| match value {
328        None => std::env::var_os(key).is_some(),
329        Some(expected) => std::env::var(key).ok().as_deref() == Some(expected),
330    })
331}
332
333#[doc(hidden)]
334pub struct Hegel<F> {
335    test_fn: F,
336    database_key: Option<String>,
337    test_location: Option<TestLocation>,
338    settings: Settings,
339    reproduce_failure: Option<String>,
340}
341
342impl<F> Hegel<F>
343where
344    F: FnMut(TestCase),
345{
346    /// Create a new test builder with default settings.
347    pub fn new(test_fn: F) -> Self {
348        Self {
349            test_fn,
350            database_key: None,
351            settings: Settings::new(),
352            test_location: None,
353            reproduce_failure: None,
354        }
355    }
356
357    /// Override the default settings.
358    pub fn settings(mut self, settings: Settings) -> Self {
359        self.settings = settings;
360        self
361    }
362
363    #[doc(hidden)]
364    pub fn __database_key(mut self, key: String) -> Self {
365        self.database_key = Some(key);
366        self
367    }
368
369    #[doc(hidden)]
370    pub fn test_location(mut self, location: TestLocation) -> Self {
371        self.test_location = Some(location);
372        self
373    }
374
375    /// Replay a single failing example from a base64 failure blob instead of
376    /// generating fresh test cases.
377    ///
378    /// A failure blob encodes the choice sequence of a counterexample.
379    /// Enable [`print_blob`](Settings::print_blob) to have a native failure
380    /// print one. When set, [`run`](Self::run) decodes it and runs exactly
381    /// that one example — bypassing generation and shrinking — so you can
382    /// reproduce a CI failure locally and deterministically.
383    ///
384    /// First-wins: if a blob is already set, further calls are ignored.
385    /// Stacked `#[hegel::reproduce_failure]` attributes lower to repeated
386    /// calls here, so only the first attribute replays; the rest are
387    /// bookkeeping to be deleted one by one as the failures are fixed.
388    pub fn reproduce_failure(mut self, blob: impl Into<String>) -> Self {
389        if self.reproduce_failure.is_none() {
390            self.reproduce_failure = Some(blob.into());
391        }
392        self
393    }
394
395    /// Run the property-based tests.
396    ///
397    /// Panics if any test case fails.
398    pub fn run(self) {
399        if let Some(blob) = self.reproduce_failure {
400            crate::run_lifecycle::drive_blob_replay(
401                self.test_fn,
402                &self.settings,
403                self.database_key.as_deref(),
404                &blob,
405                self.test_location.as_ref(),
406            );
407            return;
408        }
409
410        crate::run_lifecycle::drive(
411            self.test_fn,
412            &self.settings,
413            self.database_key.as_deref(),
414            self.test_location.as_ref(),
415        );
416    }
417}
418
419#[cfg(test)]
420#[path = "../tests/embedded/runner_tests.rs"]
421mod tests;