Imported from hegeldev/hegel-rust (
AGENTS.md). Install upstream withnpx skills add hegeldev/hegel-rust. Copyright stays with the author.
AGENTS.md
This file provides guidance to Claude Code (claude.ai/code), Codex 5.5, and other coding agents when working with code in this repository. The legacy .claude path is a symlink to .agents, and .claude/CLAUDE.md resolves back to this file so Claude and Codex share the same instructions.
Overview
This repository is the Rust implementation of Hegel, a universal property-based testing framework. It contains two crates: the native engine (a port of Hypothesis's conjecture engine) lives in the hegel-c workspace member and is built as the libhegel C-ABI library, and the hegeltest frontend (the root crate) drives that engine through the same C ABI every other language binding uses. Everything runs in-process — there is no external server or Python dependency.
Build & Test Commands
just check # run full CI checks (lint + tests + all-features tests)
just test # run tests
just lint # run clippy, rustfmt --check, and the repo lint scripts
just format # format
just docs # build and open docs
just check-coverage # check coverage (requires cargo-llvm-cov + llvm-tools-preview)
just c-test # hegel-c smoke tests + example C programs
just miri # fast core of the suite under Miri
cargo test test_name # run a single test
MSRV is 1.86 (enforced in CI and Cargo.toml). If you bump it, also bump ci.yml, hegel-macros/Cargo.toml, and hegel-c/Cargo.toml.
Workspace Structure
hegeltest (root crate) — the Rust frontend
src/lib.rs— Public API surface:hegel(), theHegelbuilder,TestCase, theGeneratortrait, and the proc-macro re-exports (#[hegel::test],#[hegel::main],#[derive(DefaultGenerator)],#[composite],#[state_machine],#[reproduce_failure], …)src/ffi.rs— The libhegel C-ABI boundary: the only module that touches the rawhegel_*functions; the rest of the frontend works against its safe wrappers (SettingsHandle,RunHandle,CTestCase,RunResult)src/ffi/sys.rs— Where thehegel_*functions come from, in two interchangeable forms: by default mirroredrepr(C)types (sys/types.rs) plus a runtime loader (sys/loader.rs) that dlopens thelibhegel_ccdylibbuild.rsbuilt, sohegeltest-cand its dependencies stay out of consumers' cargo graphs; with the publicstatic-enginefeature, a re-export of thehegel_crlib (self-contained binaries, visible engine dependencies — also what Miri and the coverage workspace pass use). One macro,for_each_hegel_fn!insys/fns.rs, is the single list of functions, generated fromhegel-c/src/lib.rsbyscripts/gen-ffi-list.py(just c-headerrefreshes it,just check-ffi-listchecks it); embedded drift tests compile-assert it and the mirrors againsthegel_cunderstatic-enginesrc/run_lifecycle.rs— Cross-cutting per-test-case lifecycle: panic hook,catch_unwindwrapping, translating panics intoTestCaseResult, and the final re-raisesrc/backend.rs— The result types the lifecycle speaks (TestCaseResult,Failure)src/test_case.rs—TestCase(the handle test bodies draw from) and its thread-local state, and theCollectionhelpersrc/runner.rs—Hegelbuilder plusSettings,HealthCheck,Phase,Backend,Verbositysrc/cli.rs— CLI argument parsing for standalone#[hegel::main]binariessrc/generators/— All first-party generator implementations (theGeneratortrait lives ingenerators.rs)src/extras/— Feature-gated third-party integrations (chrono,jiff,serde_json,rand)src/stateful.rs— Stateful (model-based) testing via#[state_machine]src/explicit_test_case.rs— Explicit test-case support (#[explicit_test_case])src/control.rs— Control-flow unwind payloads (AssumeFailed,StopTest) and their handlinghegel-macros/— Proc-macro crate (sub-crate with its ownCargo.toml)
hegel-c — the engine, built as libhegel
src/lib.rs— The exportedhegel_*C functions: settings, run lifecycle, test-case handles (including clones), draws, spans, collections, pools, state machines, targeting, results/failures. The checked-in headerhegel-c/include/hegel.his generated from this file by cbindgen (just c-header)src/backend.rs— TheDataSourcetrait the engine implements and the C ABI drivessrc/native/— The engine proper:core/(choice sequence, test-case state, shrink ordering),draws/(the typed draw implementations: float specs, string generators, regex, internet, date/time/uuid/ip),shrinker/,test_runner.rs(owns a run: database replay, generation, targeting, shrinking, final replay), plus the failure database, data tree / novel-prefix generation, RNG, regex generation (re/), interval sets + Unicode tables, and blob encodingsrc/embed.rs— Low-level embedding entry point for driving the engine natively from Rustsrc/antithesis.rs— The Antithesis integration: detection fromANTITHESIS_OUTPUT_DIR(selects theworkloadprofile) and reporting each run's verdict tosdk.jsonlfor tests whose settings carry a location (hegel_settings_set_test_location, which the frontend's macros supply)benches/— Microbenchmarks of engine internals, exposed through the internal__benchfeature- Released as
libhegel-<goos>-<goarch>.<ext>assets on eachlibhegel-v<version>-tagged GitHub release (hegel-rust's own releases are the plainv<version>tags and get no GitHub release); the source is published to crates.io ashegeltest-c— the crate the frontend'sbuild.rsbuilds the shared library from (and links directly understatic-engine)
Feature Flags (root crate)
rand,chrono,jiff,serde_json,serde_json_raw_value: gate the correspondingextras::generator modulesstatic-engine: links the engine in as an rlib instead of loading thelibhegel_cshared library at runtime; seesrc/ffi/sys.rsabove
The __ prefix marks a feature as internal. The coverage run enables every root-crate feature except internal ones, computed from cargo metadata by public_features() in scripts/check-coverage.py, so a new feature needs no coverage wiring.
Architecture
How It Works
The engine owns a test run. hegel_run_start starts an engine worker; the frontend pulls test cases off it with hegel_next_test_case, runs the user's test body against each test-case handle, and reports each outcome back with hegel_mark_complete. Database replay, generation, targeting, shrinking, and the final replay all happen inside the engine (hegel-c/src/native/test_runner.rs); the frontend's run_lifecycle::drive wires the pull loop to the user's test function, catches panics, and reads the aggregate result — including reproduce blobs — off the finished run.
The C ABI
The frontend and engine communicate exclusively through the hegel_* functions wrapped by src/ffi.rs. Draws are typed: hegel_generate_integer / hegel_generate_integer_big, hegel_generate_float, hegel_generate_boolean, hegel_generate_bytes, hegel_generate_string (through an opaque hegel_string_generator_t handle built by typed constructors for text/regex/email/url/domain), and structured hegel_generate_date/_time/_datetime/_uuid/_ipv4/_ipv6. Alongside the draws: hegel_start_span/hegel_stop_span group related draws for the shrinker, hegel_new_collection/hegel_collection_more/hegel_collection_reject drive dynamically-sized collections, hegel_target records targeting scores, and hegel_mark_complete reports the outcome (VALID, INVALID, OVERRUN, or INTERESTING). Failed calls report diagnostics on an explicit HegelContext handle rather than thread-local state.
Generator Trait
Generators implement Generator<T> (src/generators/generators.rs) with one required method, do_draw(&self, tc: &TestCase) -> T, and one provided method, label(&self) -> u64. Leaf generators call the typed draw methods on TestCase (generate_integer_i64, generate_float, generate_string, …), which wrap the C ABI; composite generators (tuples, collections, one_of, map/filter/flat_map) compose other generators' do_draw inside a span labelled with self.label(). String-shaped generators cache a validated ffi::StringGenerator handle in a OnceLock so the alphabet/pattern work happens once, not per draw.
Span System
Spans (start_span/stop_span) group related generation calls so the shrinker can shrink effectively. A span's label is an opaque u64 identifying the generator that opened it, and nothing more: the engine treats same-label spans as coming from the same generator (candidates for swapping, duplicating and reordering) and never interprets a label. Labels are hashed from names with generators::label_from_name (FNV-1a 64, const fn) and folded together with generators::combine_labels; every generator reports its own through Generator::label, which defaults to a hash of the type name and which combinators override to combine a label of their own kind (hegel.vec, hegel.one_of, …) with their components' labels, so vecs(integers()) and vecs(text()) differ. The engine exposes the same two functions over the C ABI as hegel_label_from_name / hegel_label_combine (hegel-c/src/native/labels.rs) and labels its own spans around every draw with names like hegel.integer (hegel-c/src/native/draws/mod.rs): same-label spans are what the engine's mutation machinery duplicates to propose repeated values, so leaf draws each get a kind-specific span. There is no enumeration of labels anywhere; do not add one.
Collections
Engine-managed collections use the new_collection/collection_more/collection_reject calls: the engine decides how many elements to produce and the client pulls them one at a time. The Collection struct in src/test_case.rs handles dynamic sizing via the more() protocol with lazy initialization.
Key Patterns
Adding a New Generator
Follow the skills: new-generator for the generator itself (struct, builder methods, Generator impl, wiring, rustdoc, required tests), new-default-generator to wire up gs::default::<T>() / #[derive(DefaultGenerator)] support, and add-library-support for a whole third-party crate integration under src/extras/.
Derive Macro
#[derive(DefaultGenerator)] (in hegel-macros/) creates a <Type>Generator struct with:
new(): UsesDefaultGeneratorfor all fields<field>(gen): Builder methods to customize field generators
For enums, it also creates <Enum><Variant>Generator for each data variant. Implementation is split across struct_gen.rs, enum_gen.rs, and utils.rs.
Testing Conventions
- All tests go in
tests/, never inline in source files. Tests that don't need access to private functions go directly intests/as integration tests. Tests that need access to private functions go intests/embedded/, mirroring thesrc/directory structure. This applies to both crates (e.g.src/ffi.rs→tests/embedded/ffi_tests.rs;hegel-c/src/native/core/state.rs→hegel-c/tests/embedded/native/state_tests.rs). Embedded tests are included as child modules of their source file via#[cfg(test)] #[path = "..."] mod tests;, which gives them access to private items throughuse super::*. This keeps test code out of source files while preserving access to internals that Rust would otherwise forbid. - Always import generators as
use hegel::generators as gs;(oruse hegel::generators::{self as gs, Generator};when theGeneratortrait is needed). Usegs::in all generator calls, e.g.gs::booleans(),gs::integers::<i32>(). This applies to code inside string literals (e.g.TempRustProjectsnippets) as well. - When a test needs a throwaway generator, prefer
gs::booleans()as the simplest option (unless the test needs a larger value space). - In test code, prefer
.unwrap()over.expect("static message"). A static expect message rarely adds information beyond what the panic already provides (error type + source location). Only use.expect()when the message includes a formatted value that aids debugging (e.g.,.expect(&format!("failed to open {}", path))). - When a return value isn't used, don't bind it to
_— just call the function as a bare statement. Only uselet _ =when needed to suppress a#[must_use]warning.
Code Coverage
This project enforces 100% line coverage for new code. You may not add // nocov annotations without explicit human permission. See the coverage skill for full details on the coverage philosophy, ratchet mechanism, and how to make code testable.
CRITICAL: You MUST NOT increase the numbers in .github/coverage-ratchet.json without first asking for and then receiving explicit human permission to do so.
Comments
Do not write comments other than nocov and other comments that have specific functions. Before committing, check if you are about to stage comments you have written, and if you are then delete them before committing.