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
change stztxqwkuqvvpssxrszzwvwmsvtrxvnx
commit 1dcd51de31295e06a4296799c4b79f0cba11400c
author Alpha Chen <alpha@kejadlen.dev>
date
parent ooqxpkxw
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);
 }