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