Replace proptest with hegeltest for property-based tests
Hegel (hegel.dev) uses the Hypothesis protocol, which provides
better shrinking and distribution than proptest's built-in
engine. The imperative tc.draw() style also removes the need for
strategy combinators and mapping closures.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/.gitignore b/.gitignore
index 8c932d5..ef5ee1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
/target
Cargo.lock
/mutants.out*
+.hegel
diff --git a/Cargo.toml b/Cargo.toml
index 50073a9..970ace9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,6 @@ serde = { version = "1", features = ["derive"] }
thiserror = "2"
[dev-dependencies]
+hegeltest = "0.2"
indoc = "2"
-proptest = "1"
serde = { version = "1", features = ["derive"] }
diff --git a/tests/property.rs b/tests/property.rs
index 212f6cc..f94acc1 100644
--- a/tests/property.rs
+++ b/tests/property.rs
@@ -1,18 +1,26 @@
-use proptest::prelude::*;
+use hegel::TestCase;
+use hegel::generators::{
+ Generator, booleans, floats, from_regex, integers, optional, sampled_from, vecs,
+};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
// ── Helpers ──────────────────────────────────────────────────────────────
-/// Strategy for strings that are safe to roundtrip through KDL.
+/// Generator for strings that are safe to roundtrip through KDL.
/// Excludes characters that would break KDL parsing.
-fn kdl_safe_string() -> impl Strategy<Value = String> {
- "[a-zA-Z0-9 _.,:;!?@#%^&*()+=/<>\\[\\]{}|~'-]{0,64}"
+fn kdl_safe_string() -> impl Generator<String> {
+ from_regex("[a-zA-Z0-9 _.,:;!?@#%^&*()+=/<>\\[\\]{}|~'-]{0,64}").fullmatch(true)
}
-/// Strategy for f64 values that can roundtrip (no NaN/infinity).
-fn finite_f64() -> impl Strategy<Value = f64> {
- prop::num::f64::ANY.prop_filter("finite floats only", |f| f.is_finite())
+/// Generator for f64 values that can roundtrip (no NaN/infinity).
+fn finite_f64() -> impl Generator<f64> {
+ floats::<f64>().allow_nan(false).allow_infinity(false)
+}
+
+/// Generator for valid KDL node-name identifiers (non-empty, starts with a letter).
+fn kdl_identifier() -> impl Generator<String> {
+ from_regex("[a-zA-Z][a-zA-Z0-9_-]{0,15}").fullmatch(true)
}
// ── Flat struct roundtrip ────────────────────────────────────────────────
@@ -25,24 +33,17 @@ struct FlatStruct {
ratio: f64,
}
-fn flat_struct_strategy() -> impl Strategy<Value = FlatStruct> {
- (kdl_safe_string(), any::<i32>(), any::<bool>(), finite_f64()).prop_map(
- |(name, count, enabled, ratio)| FlatStruct {
- name,
- count,
- enabled,
- ratio,
- },
- )
-}
-
-proptest! {
- #[test]
- fn flat_struct_roundtrip(val in flat_struct_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: FlatStruct = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn flat_struct_roundtrip(tc: TestCase) {
+ let val = FlatStruct {
+ name: tc.draw(kdl_safe_string()),
+ count: tc.draw(integers()),
+ enabled: tc.draw(booleans()),
+ ratio: tc.draw(finite_f64()),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: FlatStruct = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Nested struct roundtrip ──────────────────────────────────────────────
@@ -59,20 +60,18 @@ struct Outer {
inner: Inner,
}
-fn outer_strategy() -> impl Strategy<Value = Outer> {
- (kdl_safe_string(), kdl_safe_string(), any::<u16>()).prop_map(|(label, host, port)| Outer {
- label,
- inner: Inner { host, port },
- })
-}
-
-proptest! {
- #[test]
- fn nested_struct_roundtrip(val in outer_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: Outer = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn nested_struct_roundtrip(tc: TestCase) {
+ let val = Outer {
+ label: tc.draw(kdl_safe_string()),
+ inner: Inner {
+ host: tc.draw(kdl_safe_string()),
+ port: tc.draw(integers()),
+ },
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: Outer = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Vec of primitives roundtrip ──────────────────────────────────────────
@@ -83,21 +82,15 @@ struct WithVecStrings {
tags: Vec<String>,
}
-fn with_vec_strings_strategy() -> impl Strategy<Value = WithVecStrings> {
- (
- kdl_safe_string(),
- prop::collection::vec(kdl_safe_string(), 0..10),
- )
- .prop_map(|(label, tags)| WithVecStrings { label, tags })
-}
-
-proptest! {
- #[test]
- fn vec_strings_roundtrip(val in with_vec_strings_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithVecStrings = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn vec_strings_roundtrip(tc: TestCase) {
+ let val = WithVecStrings {
+ label: tc.draw(kdl_safe_string()),
+ tags: tc.draw(vecs(kdl_safe_string()).max_size(10)),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithVecStrings = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -106,21 +99,15 @@ struct WithVecInts {
numbers: Vec<i64>,
}
-fn with_vec_ints_strategy() -> impl Strategy<Value = WithVecInts> {
- (
- kdl_safe_string(),
- prop::collection::vec(any::<i64>(), 0..10),
- )
- .prop_map(|(label, numbers)| WithVecInts { label, numbers })
-}
-
-proptest! {
- #[test]
- fn vec_ints_roundtrip(val in with_vec_ints_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithVecInts = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn vec_ints_roundtrip(tc: TestCase) {
+ let val = WithVecInts {
+ label: tc.draw(kdl_safe_string()),
+ numbers: tc.draw(vecs(integers::<i64>()).max_size(10)),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithVecInts = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Vec of structs roundtrip ─────────────────────────────────────────────
@@ -137,24 +124,23 @@ struct WithItems {
item: Vec<Item>,
}
-fn with_items_strategy() -> impl Strategy<Value = WithItems> {
- (
- kdl_safe_string(),
- prop::collection::vec(
- (kdl_safe_string(), any::<i32>()).prop_map(|(name, value)| Item { name, value }),
- 2..5,
- ),
- )
- .prop_map(|(title, item)| WithItems { title, item })
-}
-
-proptest! {
- #[test]
- fn vec_structs_roundtrip(val in with_items_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithItems = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
+#[hegel::test]
+fn vec_structs_roundtrip(tc: TestCase) {
+ let count = tc.draw(integers::<usize>().min_value(2).max_value(4));
+ let mut items = Vec::new();
+ for _ in 0..count {
+ items.push(Item {
+ name: tc.draw(kdl_safe_string()),
+ value: tc.draw(integers()),
+ });
}
+ let val = WithItems {
+ title: tc.draw(kdl_safe_string()),
+ item: items,
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithItems = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Option fields roundtrip ──────────────────────────────────────────────
@@ -170,28 +156,17 @@ struct WithOptions {
maybe_bool: Option<bool>,
}
-fn with_options_strategy() -> impl Strategy<Value = WithOptions> {
- (
- kdl_safe_string(),
- prop::option::of(kdl_safe_string()),
- prop::option::of(any::<i64>()),
- prop::option::of(any::<bool>()),
- )
- .prop_map(|(required, maybe_str, maybe_num, maybe_bool)| WithOptions {
- required,
- maybe_str,
- maybe_num,
- maybe_bool,
- })
-}
-
-proptest! {
- #[test]
- fn option_fields_roundtrip(val in with_options_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithOptions = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn option_fields_roundtrip(tc: TestCase) {
+ let val = WithOptions {
+ required: tc.draw(kdl_safe_string()),
+ maybe_str: tc.draw(optional(kdl_safe_string())),
+ maybe_num: tc.draw(optional(integers::<i64>())),
+ maybe_bool: tc.draw(optional(booleans())),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithOptions = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Enum roundtrip ───────────────────────────────────────────────────────
@@ -209,21 +184,15 @@ struct WithEnum {
color: Color,
}
-fn color_strategy() -> impl Strategy<Value = Color> {
- prop_oneof![Just(Color::Red), Just(Color::Green), Just(Color::Blue),]
-}
-
-fn with_enum_strategy() -> impl Strategy<Value = WithEnum> {
- (kdl_safe_string(), color_strategy()).prop_map(|(label, color)| WithEnum { label, color })
-}
-
-proptest! {
- #[test]
- fn unit_enum_roundtrip(val in with_enum_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithEnum = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn unit_enum_roundtrip(tc: TestCase) {
+ let val = WithEnum {
+ label: tc.draw(kdl_safe_string()),
+ color: tc.draw(sampled_from(vec![Color::Red, Color::Green, Color::Blue])),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithEnum = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Complex enum roundtrip ───────────────────────────────────────────────
@@ -241,25 +210,26 @@ struct WithShape {
shape: Shape,
}
-fn shape_strategy() -> impl Strategy<Value = Shape> {
- prop_oneof![
- any::<i32>().prop_map(|radius| Shape::Circle { radius }),
- (any::<i32>(), any::<i32>()).prop_map(|(width, height)| Shape::Rectangle { width, height }),
- Just(Shape::Point),
- ]
-}
-
-fn with_shape_strategy() -> impl Strategy<Value = WithShape> {
- (kdl_safe_string(), shape_strategy()).prop_map(|(name, shape)| WithShape { name, shape })
-}
-
-proptest! {
- #[test]
- fn complex_enum_roundtrip(val in with_shape_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithShape = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn complex_enum_roundtrip(tc: TestCase) {
+ let variant = tc.draw(integers::<u8>().min_value(0).max_value(2));
+ let shape = match variant {
+ 0 => Shape::Circle {
+ radius: tc.draw(integers()),
+ },
+ 1 => Shape::Rectangle {
+ width: tc.draw(integers()),
+ height: tc.draw(integers()),
+ },
+ _ => Shape::Point,
+ };
+ let val = WithShape {
+ name: tc.draw(kdl_safe_string()),
+ shape,
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithShape = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── BTreeMap roundtrip ───────────────────────────────────────────────────
@@ -270,26 +240,20 @@ struct WithMap {
metadata: BTreeMap<String, String>,
}
-/// Strategy for map keys that are valid KDL node names (non-empty identifiers).
-fn kdl_identifier() -> impl Strategy<Value = String> {
- "[a-zA-Z][a-zA-Z0-9_-]{0,15}"
-}
-
-fn with_map_strategy() -> impl Strategy<Value = WithMap> {
- (
- kdl_safe_string(),
- prop::collection::btree_map(kdl_identifier(), kdl_safe_string(), 0..5),
- )
- .prop_map(|(title, metadata)| WithMap { title, metadata })
-}
-
-proptest! {
- #[test]
- fn btreemap_roundtrip(val in with_map_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithMap = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
+#[hegel::test]
+fn btreemap_roundtrip(tc: TestCase) {
+ let count = tc.draw(integers::<usize>().min_value(0).max_value(4));
+ let mut metadata = BTreeMap::new();
+ for _ in 0..count {
+ metadata.insert(tc.draw(kdl_identifier()), tc.draw(kdl_safe_string()));
}
+ let val = WithMap {
+ title: tc.draw(kdl_safe_string()),
+ metadata,
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithMap = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Integer types roundtrip ──────────────────────────────────────────────
@@ -306,23 +270,21 @@ struct IntegerTypes {
h: u64,
}
-proptest! {
- #[test]
- fn integer_types_roundtrip(
- a in any::<i8>(),
- b in any::<i16>(),
- c in any::<i32>(),
- d in any::<i64>(),
- e in any::<u8>(),
- f in any::<u16>(),
- g in any::<u32>(),
- h in any::<u64>(),
- ) {
- let val = IntegerTypes { a, b, c, d, e, f, g, h };
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: IntegerTypes = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn integer_types_roundtrip(tc: TestCase) {
+ let val = IntegerTypes {
+ a: tc.draw(integers()),
+ b: tc.draw(integers()),
+ c: tc.draw(integers()),
+ d: tc.draw(integers()),
+ e: tc.draw(integers()),
+ f: tc.draw(integers()),
+ g: tc.draw(integers()),
+ h: tc.draw(integers()),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: IntegerTypes = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Bool roundtrip ───────────────────────────────────────────────────────
@@ -334,14 +296,16 @@ struct Flags {
c: bool,
}
-proptest! {
- #[test]
- fn bool_roundtrip(a in any::<bool>(), b in any::<bool>(), c in any::<bool>()) {
- let val = Flags { a, b, c };
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: Flags = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn bool_roundtrip(tc: TestCase) {
+ let val = Flags {
+ a: tc.draw(booleans()),
+ b: tc.draw(booleans()),
+ c: tc.draw(booleans()),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: Flags = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Tuple roundtrip ──────────────────────────────────────────────────────
@@ -352,14 +316,15 @@ struct WithTuple {
pair: (i32, i32),
}
-proptest! {
- #[test]
- fn tuple_roundtrip(label in kdl_safe_string(), a in any::<i32>(), b in any::<i32>()) {
- let val = WithTuple { label, pair: (a, b) };
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithTuple = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn tuple_roundtrip(tc: TestCase) {
+ let val = WithTuple {
+ label: tc.draw(kdl_safe_string()),
+ pair: (tc.draw(integers()), tc.draw(integers())),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithTuple = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Deeply nested roundtrip ──────────────────────────────────────────────
@@ -381,24 +346,20 @@ struct Level1 {
level2: Level2,
}
-proptest! {
- #[test]
- fn deeply_nested_roundtrip(
- name in kdl_safe_string(),
- tag in kdl_safe_string(),
- value in any::<i32>(),
- ) {
- let val = Level1 {
- name,
- level2: Level2 {
- tag,
- level3: Level3 { value },
+#[hegel::test]
+fn deeply_nested_roundtrip(tc: TestCase) {
+ let val = Level1 {
+ name: tc.draw(kdl_safe_string()),
+ level2: Level2 {
+ tag: tc.draw(kdl_safe_string()),
+ level3: Level3 {
+ value: tc.draw(integers()),
},
- };
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: Level1 = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+ },
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: Level1 = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Serialize then deserialize never panics ──────────────────────────────
@@ -415,43 +376,34 @@ struct KitchenSink {
opt: Option<i32>,
}
-fn kitchen_sink_strategy() -> impl Strategy<Value = KitchenSink> {
- (
- kdl_safe_string(),
- any::<i64>(),
- any::<bool>(),
- finite_f64(),
- prop::collection::vec(kdl_safe_string(), 0..5),
- prop::option::of(any::<i32>()),
- )
- .prop_map(|(s, i, b, f, tags, opt)| KitchenSink {
- s,
- i,
- b,
- f,
- tags,
- opt,
- })
-}
-
-proptest! {
- #[test]
- fn kitchen_sink_roundtrip(val in kitchen_sink_strategy()) {
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: KitchenSink = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn kitchen_sink_roundtrip(tc: TestCase) {
+ let val = KitchenSink {
+ s: tc.draw(kdl_safe_string()),
+ i: tc.draw(integers()),
+ b: tc.draw(booleans()),
+ f: tc.draw(finite_f64()),
+ tags: tc.draw(vecs(kdl_safe_string()).max_size(5)),
+ opt: tc.draw(optional(integers::<i32>())),
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: KitchenSink = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Pretty printing roundtrip ────────────────────────────────────────────
-proptest! {
- #[test]
- fn pretty_print_roundtrip(val in flat_struct_strategy()) {
- let serialized = serde_kdl2::to_string_pretty(&val).unwrap();
- let deserialized: FlatStruct = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn pretty_print_roundtrip(tc: TestCase) {
+ let val = FlatStruct {
+ name: tc.draw(kdl_safe_string()),
+ count: tc.draw(integers()),
+ enabled: tc.draw(booleans()),
+ ratio: tc.draw(finite_f64()),
+ };
+ let serialized = serde_kdl2::to_string_pretty(&val).unwrap();
+ let deserialized: FlatStruct = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}
// ── Newtype enum variant roundtrip ───────────────────────────────────────
@@ -468,19 +420,19 @@ struct WithNewtype {
wrapped: Wrapper,
}
-fn wrapper_strategy() -> impl Strategy<Value = Wrapper> {
- prop_oneof![
- kdl_safe_string().prop_map(Wrapper::Text),
- any::<i64>().prop_map(Wrapper::Number),
- ]
-}
-
-proptest! {
- #[test]
- fn newtype_enum_roundtrip(label in kdl_safe_string(), wrapped in wrapper_strategy()) {
- let val = WithNewtype { label, wrapped };
- let serialized = serde_kdl2::to_string(&val).unwrap();
- let deserialized: WithNewtype = serde_kdl2::from_str(&serialized).unwrap();
- prop_assert_eq!(val, deserialized);
- }
+#[hegel::test]
+fn newtype_enum_roundtrip(tc: TestCase) {
+ let variant = tc.draw(booleans());
+ let wrapped = if variant {
+ Wrapper::Text(tc.draw(kdl_safe_string()))
+ } else {
+ Wrapper::Number(tc.draw(integers()))
+ };
+ let val = WithNewtype {
+ label: tc.draw(kdl_safe_string()),
+ wrapped,
+ };
+ let serialized = serde_kdl2::to_string(&val).unwrap();
+ let deserialized: WithNewtype = serde_kdl2::from_str(&serialized).unwrap();
+ assert_eq!(val, deserialized);
}