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;