Skip to main content

hegel/
runner.rs

1use crate::antithesis::TestLocation;
2use crate::test_case::TestCase;
3
4// ─── Public types ───────────────────────────────────────────────────────────
5
6/// Health checks that can be suppressed during test execution.
7///
8/// Health checks detect common issues with test configuration that would
9/// otherwise cause tests to run inefficiently or not at all.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum HealthCheck {
12    /// Too many test cases are being filtered out via `assume()`.
13    FilterTooMuch,
14    /// Test execution is too slow.
15    TooSlow,
16    /// Generated test cases are too large.
17    TestCasesTooLarge,
18    /// The smallest natural input is very large.
19    LargeInitialTestCase,
20}
21
22impl HealthCheck {
23    /// Returns all health check variants.
24    ///
25    /// Useful for suppressing all health checks at once:
26    ///
27    /// ```no_run
28    /// use hegel::HealthCheck;
29    ///
30    /// #[hegel::test(suppress_health_check = HealthCheck::all())]
31    /// fn my_test(tc: hegel::TestCase) {
32    ///     // ...
33    /// }
34    /// ```
35    pub const fn all() -> [HealthCheck; 4] {
36        [
37            HealthCheck::FilterTooMuch,
38            HealthCheck::TooSlow,
39            HealthCheck::TestCasesTooLarge,
40            HealthCheck::LargeInitialTestCase,
41        ]
42    }
43}
44
45/// Controls which phases of the test lifecycle are executed.
46///
47/// By default, all phases run. Use [`Settings::phases`] to restrict which
48/// phases execute — for example, passing only `[Phase::Generate]` disables
49/// shrinking, which is useful when you only need to find a counterexample
50/// quickly and don't need the minimal one.
51///
52/// Corresponds to a subset of `hypothesis.Phase` (the `explain` phase is not
53/// yet supported in hegel-rust).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
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/// Controls the test execution mode.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Mode {
71    /// Run a full test (multiple test cases with shrinking). This is the default.
72    TestRun,
73    /// Run a single test case with no shrinking or replay. Useful for
74    /// Antithesis workloads and other contexts where you want pure data
75    /// generation without property-testing overhead.
76    SingleTestCase,
77}
78
79/// Controls how much output Hegel produces during test runs.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Verbosity {
82    /// Suppress all output.
83    Quiet,
84    /// Default output level.
85    Normal,
86    /// Show more detail about the test run.
87    Verbose,
88    /// Show protocol-level debug information.
89    Debug,
90}
91
92/// Configuration for a Hegel test run.
93///
94/// Use builder methods to customize, then pass to [`Hegel::settings`] or
95/// the `settings` parameter of `#[hegel::test]`.
96///
97/// In CI environments (detected automatically), the database is disabled
98/// and tests are derandomized by default.
99#[derive(Debug, Clone)]
100pub struct Settings {
101    pub(crate) mode: Mode,
102    pub(crate) test_cases: u64,
103    pub(crate) verbosity: Verbosity,
104    pub(crate) seed: Option<u64>,
105    pub(crate) derandomize: bool,
106    pub(crate) database: Database,
107    pub(crate) suppress_health_check: Vec<HealthCheck>,
108    pub(crate) phases: Vec<Phase>,
109    pub(crate) report_multiple_failures: bool,
110}
111
112impl Settings {
113    /// Create settings with defaults. Detects CI environments automatically.
114    pub fn new() -> Self {
115        let in_ci = is_in_ci();
116        Self {
117            mode: Mode::TestRun,
118            test_cases: 100,
119            verbosity: Verbosity::Normal,
120            seed: None,
121            derandomize: in_ci,
122            database: if in_ci {
123                Database::Disabled
124            } else {
125                Database::Unset // nocov
126            },
127            suppress_health_check: Vec::new(),
128            phases: vec![
129                Phase::Explicit,
130                Phase::Reuse,
131                Phase::Generate,
132                Phase::Target,
133                Phase::Shrink,
134            ],
135            report_multiple_failures: true,
136        }
137    }
138
139    /// Set the execution mode. Defaults to [`Mode::TestRun`].
140    pub fn mode(mut self, mode: Mode) -> Self {
141        self.mode = mode;
142        self
143    }
144
145    /// Set the number of test cases to run (default: 100).
146    pub fn test_cases(mut self, n: u64) -> Self {
147        self.test_cases = n;
148        self
149    }
150
151    /// Set the verbosity level.
152    pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
153        self.verbosity = verbosity;
154        self
155    }
156
157    /// Set a fixed seed for reproducibility, or `None` for random.
158    pub fn seed(mut self, seed: Option<u64>) -> Self {
159        self.seed = seed;
160        self
161    }
162
163    /// When true, use a fixed seed derived from the test name. Enabled by default in CI.
164    pub fn derandomize(mut self, derandomize: bool) -> Self {
165        self.derandomize = derandomize;
166        self
167    }
168
169    /// Set the database path for storing failing examples, or `None` to disable.
170    pub fn database(mut self, database: Option<String>) -> Self {
171        self.database = match database {
172            None => Database::Disabled,
173            Some(path) => Database::Path(path),
174        };
175        self
176    }
177
178    /// Set which test lifecycle phases to run.
179    ///
180    /// Defaults to all phases: `[Phase::Explicit, Phase::Reuse, Phase::Generate, Phase::Target, Phase::Shrink]`.
181    ///
182    /// Example — skip shrinking (useful when you only need a witness, not a
183    /// minimal counterexample):
184    ///
185    /// ```no_run
186    /// use hegel::{Phase, Settings};
187    ///
188    /// let s = Settings::new().phases([Phase::Reuse, Phase::Generate]);
189    /// ```
190    pub fn phases(mut self, phases: impl IntoIterator<Item = Phase>) -> Self {
191        self.phases = phases.into_iter().collect();
192        self
193    }
194
195    /// Suppress one or more health checks so they do not cause test failure.
196    ///
197    /// Health checks detect common issues like excessive filtering or slow
198    /// tests. Use this to suppress specific checks when they are expected.
199    ///
200    /// # Example
201    ///
202    /// ```no_run
203    /// use hegel::{HealthCheck, Verbosity};
204    /// use hegel::generators as gs;
205    ///
206    /// #[hegel::test(suppress_health_check = [HealthCheck::FilterTooMuch, HealthCheck::TooSlow])]
207    /// fn my_test(tc: hegel::TestCase) {
208    ///     let n: i32 = tc.draw(gs::integers());
209    ///     tc.assume(n > 0);
210    /// }
211    /// ```
212    pub fn suppress_health_check(mut self, checks: impl IntoIterator<Item = HealthCheck>) -> Self {
213        self.suppress_health_check.extend(checks);
214        self
215    }
216
217    /// Returns `true` if the given phase is enabled in these settings.
218    pub fn has_phase(&self, phase: Phase) -> bool {
219        self.phases.contains(&phase)
220    }
221
222    /// Control whether multi-bug runs report every distinct failing example
223    /// or collapse to just the first one.
224    ///
225    /// When `true` (the default), each distinct origin Hegel finds is surfaced
226    /// as its own diagnostic, and the final panic message reports the count of
227    /// distinct failures.  Setting this to `false` makes Hegel collapse a
228    /// multi-bug run to one example — useful when you have a flaky predicate
229    /// that triggers several superficially-distinct failures whose root cause
230    /// is the same, and the extra reports are just noise.
231    ///
232    /// Maps to Hypothesis's `report_multiple_bugs` setting.
233    pub fn report_multiple_failures(mut self, report_multiple_failures: bool) -> Self {
234        self.report_multiple_failures = report_multiple_failures;
235        self
236    }
237}
238
239impl Default for Settings {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub(crate) enum Database {
247    Unset,
248    Disabled,
249    Path(String),
250}
251
252// ─── Hegel test builder ─────────────────────────────────────────────────────
253
254// internal use only
255#[doc(hidden)]
256pub fn hegel<F>(test_fn: F)
257where
258    F: FnMut(TestCase),
259{
260    Hegel::new(test_fn).run();
261}
262
263fn is_in_ci() -> bool {
264    const CI_VARS: &[(&str, Option<&str>)] = &[
265        ("CI", None),
266        ("TF_BUILD", Some("true")),
267        ("BUILDKITE", Some("true")),
268        ("CIRCLECI", Some("true")),
269        ("CIRRUS_CI", Some("true")),
270        ("CODEBUILD_BUILD_ID", None),
271        ("GITHUB_ACTIONS", Some("true")),
272        ("GITLAB_CI", None),
273        ("HEROKU_TEST_RUN_ID", None),
274        ("TEAMCITY_VERSION", None),
275        ("bamboo.buildKey", None),
276    ];
277
278    CI_VARS.iter().any(|(key, value)| match value {
279        None => std::env::var_os(key).is_some(),
280        Some(expected) => std::env::var(key).ok().as_deref() == Some(expected),
281    })
282}
283
284// internal use only
285#[doc(hidden)]
286pub struct Hegel<F> {
287    test_fn: F,
288    database_key: Option<String>,
289    test_location: Option<TestLocation>,
290    settings: Settings,
291}
292
293impl<F> Hegel<F>
294where
295    F: FnMut(TestCase),
296{
297    /// Create a new test builder with default settings.
298    pub fn new(test_fn: F) -> Self {
299        Self {
300            test_fn,
301            database_key: None,
302            settings: Settings::new(),
303            test_location: None,
304        }
305    }
306
307    /// Override the default settings.
308    pub fn settings(mut self, settings: Settings) -> Self {
309        self.settings = settings;
310        self
311    }
312
313    #[doc(hidden)]
314    pub fn __database_key(mut self, key: String) -> Self {
315        self.database_key = Some(key);
316        self
317    }
318
319    #[doc(hidden)]
320    pub fn test_location(mut self, location: TestLocation) -> Self {
321        self.test_location = Some(location);
322        self
323    }
324
325    /// Run the property-based tests.
326    ///
327    /// Panics if any test case fails.
328    pub fn run(self) {
329        #[cfg(feature = "native")]
330        let runner = crate::native::test_runner::NativeTestRunner;
331        #[cfg(not(feature = "native"))]
332        let runner = crate::server::session::ServerTestRunner;
333
334        crate::run_lifecycle::drive(
335            runner,
336            self.test_fn,
337            &self.settings,
338            self.database_key.as_deref(),
339            self.test_location.as_ref(),
340        );
341    }
342}
343
344#[cfg(test)]
345#[path = "../tests/embedded/runner_tests.rs"]
346mod tests;