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;