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;