fix: Escape control characters in serialized KDL strings
Hegel property tests with generators::text() found that strings
containing control characters produce invalid KDL — the kdl crate
v6.5 only escapes \n, \r, \t, \b, \f, \\, and ". All other C0/C1
controls, DEL, and Unicode line separators were written verbatim,
breaking roundtrips.

Added string_entry() that sets a properly escaped value_repr on
KdlEntry when the string contains characters the kdl crate misses.

Also strengthened property tests: replaced narrow ASCII regex with
full Unicode text(), removed artificial max_size() bounds, and used
unique keys for map generation.

Assisted-by: Claude Opus 4.6 via pi
change owopwwplvrvkqvmywurvrmxtuoluyrwl
commit 80838960daafce9322e866e1b7d4ed7d6477835b
author Alpha Chen <alpha@kejadlen.dev>
date
parent stztxqwk
diff --git a/.pi/pinch-lock.json b/.pi/pinch-lock.json
new file mode 100644
index 0000000..6f4991b
--- /dev/null
+++ b/.pi/pinch-lock.json
@@ -0,0 +1,6 @@
+{
+  "hegel-skill": {
+    "commit": "07c6cc8cad9ca5ee1b98d0016239e60562afb713",
+    "ref": "main"
+  }
+}
diff --git a/.pi/pinch.json b/.pi/pinch.json
new file mode 100644
index 0000000..8a78c99
--- /dev/null
+++ b/.pi/pinch.json
@@ -0,0 +1,7 @@
+{
+  "hegel-skill": {
+    "repo": "https://github.com/hegeldev/hegel-skill",
+    "ref": "main",
+    "plugins": [{"name": "hegel-skill", "path": "."}]
+  }
+}
diff --git a/src/ser.rs b/src/ser.rs
index bab87e3..ecc85f9 100644
--- a/src/ser.rs
+++ b/src/ser.rs
@@ -12,11 +12,80 @@
 //! - **Enum (newtype)** → Child node: `VariantName value`.
 //! - **Enum (struct)** → Child node with children: `VariantName { field val }`.
 
-use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue};
+use kdl::{KdlDocument, KdlEntry, KdlEntryFormat, KdlNode, KdlValue};
 use serde::ser::{self, Serialize};
 
 use crate::error::{Error, Result};
 
+/// Returns true if `c` is a character that the `kdl` crate (v6.5) writes
+/// verbatim but the KDL v2 spec forbids in unescaped form inside quoted
+/// strings.
+///
+/// The kdl crate escapes: `\\`, `"`, `\n` (0x0A), `\r` (0x0D), `\t`
+/// (0x09), `\b` (0x08), `\f` (0x0C). All other C0 controls, DEL, C1
+/// controls, and Unicode newline characters are written raw.
+fn is_unescaped_by_kdl(c: char) -> bool {
+    let cp = c as u32;
+    matches!(
+        cp,
+        // C0 controls not handled by the kdl crate.
+        0x00..=0x07 | 0x0B | 0x0E..=0x1F
+        // DEL.
+        | 0x7F
+        // C1 controls (includes NEL at 0x85).
+        | 0x80..=0x9F
+        // Unicode line/paragraph separators treated as newlines by KDL.
+        | 0x2028..=0x2029
+    )
+}
+
+/// Build a `KdlEntry` for a string value, escaping control characters that
+/// the `kdl` crate's `Display` doesn't handle.
+///
+/// The KDL v2 spec forbids unescaped control characters (U+0000–U+0008,
+/// U+000E–U+001F, U+007F, and others) inside quoted strings. The `kdl`
+/// crate (v6.5) only escapes `\n`, `\r`, `\t`, `\b`, `\f`, `\\`, and `"`,
+/// writing all other characters verbatim. This produces invalid KDL for
+/// strings containing other control characters.
+///
+/// We work around this by setting `value_repr` on the entry to a properly
+/// escaped quoted string.
+fn string_entry(s: &str) -> KdlEntry {
+    let needs_escape = s.chars().any(is_unescaped_by_kdl);
+
+    let mut entry = KdlEntry::new(KdlValue::String(s.to_string()));
+
+    if needs_escape {
+        let mut repr = String::with_capacity(s.len() + 2);
+        repr.push('"');
+        for c in s.chars() {
+            match c {
+                '\\' => repr.push_str("\\\\"),
+                '"' => repr.push_str("\\\""),
+                '\n' => repr.push_str("\\n"),
+                '\r' => repr.push_str("\\r"),
+                '\t' => repr.push_str("\\t"),
+                '\u{08}' => repr.push_str("\\b"),
+                '\u{0C}' => repr.push_str("\\f"),
+                c if is_unescaped_by_kdl(c) => {
+                    repr.push_str(&format!("\\u{{{:x}}}", c as u32));
+                }
+                c => repr.push(c),
+            }
+        }
+        repr.push('"');
+        entry.set_format(KdlEntryFormat {
+            value_repr: repr,
+            leading: " ".into(),
+            // Prevent autoformat() from clearing the escaped repr.
+            autoformat_keep: true,
+            ..Default::default()
+        });
+    }
+
+    entry
+}
+
 /// Serialize a value to a KDL string.
 pub fn to_string<T: Serialize>(value: &T) -> Result<String> {
     let doc = to_doc(value)?;
@@ -70,7 +139,7 @@ impl Value {
             Value::Bool(b) => Some(KdlValue::Bool(*b)),
             Value::Integer(i) => Some(KdlValue::Integer(*i)),
             Value::Float(f) => Some(KdlValue::Float(*f)),
-            Value::String(s) => Some(KdlValue::String(s.clone())),
+            Value::String(_) => unreachable!("strings use string_entry, not to_kdl_value"),
             _ => unreachable!("to_kdl_value is only called on primitive values"),
         }
     }
@@ -123,7 +192,7 @@ fn value_to_nodes(name: &str, value: Value) -> Result<Vec<KdlNode>> {
         }
         Value::String(s) => {
             let mut node = KdlNode::new(name);
-            node.push(KdlEntry::new(KdlValue::String(s)));
+            node.push(string_entry(&s));
             Ok(vec![node])
         }
 
@@ -145,8 +214,13 @@ fn value_to_nodes(name: &str, value: Value) -> Result<Vec<KdlNode>> {
                 // Single node, multiple arguments
                 let mut node = KdlNode::new(name);
                 for elem in &elements {
-                    if let Some(kv) = elem.to_kdl_value() {
-                        node.push(KdlEntry::new(kv));
+                    match elem {
+                        Value::String(s) => node.push(string_entry(s)),
+                        _ => {
+                            if let Some(kv) = elem.to_kdl_value() {
+                                node.push(KdlEntry::new(kv));
+                            }
+                        }
                     }
                 }
                 Ok(vec![node])
diff --git a/tests/property.rs b/tests/property.rs
index f94acc1..17cc080 100644
--- a/tests/property.rs
+++ b/tests/property.rs
@@ -1,19 +1,17 @@
 use hegel::TestCase;
 use hegel::generators::{
-    Generator, booleans, floats, from_regex, integers, optional, sampled_from, vecs,
+    Generator, booleans, floats, from_regex, integers, optional, sampled_from, text, vecs,
 };
 use serde::{Deserialize, Serialize};
 use std::collections::BTreeMap;
 
 // ── Helpers ──────────────────────────────────────────────────────────────
 
-/// Generator for strings that are safe to roundtrip through KDL.
-/// Excludes characters that would break KDL parsing.
-fn kdl_safe_string() -> impl Generator<String> {
-    from_regex("[a-zA-Z0-9 _.,:;!?@#%^&*()+=/<>\\[\\]{}|~'-]{0,64}").fullmatch(true)
-}
-
-/// Generator for f64 values that can roundtrip (no NaN/infinity).
+/// Generator for f64 values that can roundtrip through KDL.
+///
+/// NaN is excluded because `NaN != NaN`, so roundtrip equality assertions
+/// always fail. Infinity is excluded because KDL has no infinity literal —
+/// the serializer would need to encode it as a string, which changes the type.
 fn finite_f64() -> impl Generator<f64> {
     floats::<f64>().allow_nan(false).allow_infinity(false)
 }
@@ -36,7 +34,7 @@ struct FlatStruct {
 #[hegel::test]
 fn flat_struct_roundtrip(tc: TestCase) {
     let val = FlatStruct {
-        name: tc.draw(kdl_safe_string()),
+        name: tc.draw(text()),
         count: tc.draw(integers()),
         enabled: tc.draw(booleans()),
         ratio: tc.draw(finite_f64()),
@@ -63,9 +61,9 @@ struct Outer {
 #[hegel::test]
 fn nested_struct_roundtrip(tc: TestCase) {
     let val = Outer {
-        label: tc.draw(kdl_safe_string()),
+        label: tc.draw(text()),
         inner: Inner {
-            host: tc.draw(kdl_safe_string()),
+            host: tc.draw(text()),
             port: tc.draw(integers()),
         },
     };
@@ -85,8 +83,8 @@ struct WithVecStrings {
 #[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)),
+        label: tc.draw(text()),
+        tags: tc.draw(vecs(text())),
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();
     let deserialized: WithVecStrings = serde_kdl2::from_str(&serialized).unwrap();
@@ -102,8 +100,8 @@ struct WithVecInts {
 #[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)),
+        label: tc.draw(text()),
+        numbers: tc.draw(vecs(integers::<i64>())),
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();
     let deserialized: WithVecInts = serde_kdl2::from_str(&serialized).unwrap();
@@ -126,16 +124,16 @@ struct WithItems {
 
 #[hegel::test]
 fn vec_structs_roundtrip(tc: TestCase) {
-    let count = tc.draw(integers::<usize>().min_value(2).max_value(4));
+    let count = tc.draw(integers::<usize>().max_value(10));
     let mut items = Vec::new();
     for _ in 0..count {
         items.push(Item {
-            name: tc.draw(kdl_safe_string()),
+            name: tc.draw(text()),
             value: tc.draw(integers()),
         });
     }
     let val = WithItems {
-        title: tc.draw(kdl_safe_string()),
+        title: tc.draw(text()),
         item: items,
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();
@@ -159,8 +157,8 @@ struct WithOptions {
 #[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())),
+        required: tc.draw(text()),
+        maybe_str: tc.draw(optional(text())),
         maybe_num: tc.draw(optional(integers::<i64>())),
         maybe_bool: tc.draw(optional(booleans())),
     };
@@ -187,7 +185,7 @@ struct WithEnum {
 #[hegel::test]
 fn unit_enum_roundtrip(tc: TestCase) {
     let val = WithEnum {
-        label: tc.draw(kdl_safe_string()),
+        label: tc.draw(text()),
         color: tc.draw(sampled_from(vec![Color::Red, Color::Green, Color::Blue])),
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();
@@ -224,7 +222,7 @@ fn complex_enum_roundtrip(tc: TestCase) {
         _ => Shape::Point,
     };
     let val = WithShape {
-        name: tc.draw(kdl_safe_string()),
+        name: tc.draw(text()),
         shape,
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();
@@ -242,13 +240,13 @@ struct WithMap {
 
 #[hegel::test]
 fn btreemap_roundtrip(tc: TestCase) {
-    let count = tc.draw(integers::<usize>().min_value(0).max_value(4));
+    let keys = tc.draw(vecs(kdl_identifier()).unique(true));
     let mut metadata = BTreeMap::new();
-    for _ in 0..count {
-        metadata.insert(tc.draw(kdl_identifier()), tc.draw(kdl_safe_string()));
+    for key in keys {
+        metadata.insert(key, tc.draw(text()));
     }
     let val = WithMap {
-        title: tc.draw(kdl_safe_string()),
+        title: tc.draw(text()),
         metadata,
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();
@@ -319,7 +317,7 @@ struct WithTuple {
 #[hegel::test]
 fn tuple_roundtrip(tc: TestCase) {
     let val = WithTuple {
-        label: tc.draw(kdl_safe_string()),
+        label: tc.draw(text()),
         pair: (tc.draw(integers()), tc.draw(integers())),
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();
@@ -349,9 +347,9 @@ struct Level1 {
 #[hegel::test]
 fn deeply_nested_roundtrip(tc: TestCase) {
     let val = Level1 {
-        name: tc.draw(kdl_safe_string()),
+        name: tc.draw(text()),
         level2: Level2 {
-            tag: tc.draw(kdl_safe_string()),
+            tag: tc.draw(text()),
             level3: Level3 {
                 value: tc.draw(integers()),
             },
@@ -379,11 +377,11 @@ struct KitchenSink {
 #[hegel::test]
 fn kitchen_sink_roundtrip(tc: TestCase) {
     let val = KitchenSink {
-        s: tc.draw(kdl_safe_string()),
+        s: tc.draw(text()),
         i: tc.draw(integers()),
         b: tc.draw(booleans()),
         f: tc.draw(finite_f64()),
-        tags: tc.draw(vecs(kdl_safe_string()).max_size(5)),
+        tags: tc.draw(vecs(text())),
         opt: tc.draw(optional(integers::<i32>())),
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();
@@ -396,7 +394,7 @@ fn kitchen_sink_roundtrip(tc: TestCase) {
 #[hegel::test]
 fn pretty_print_roundtrip(tc: TestCase) {
     let val = FlatStruct {
-        name: tc.draw(kdl_safe_string()),
+        name: tc.draw(text()),
         count: tc.draw(integers()),
         enabled: tc.draw(booleans()),
         ratio: tc.draw(finite_f64()),
@@ -424,12 +422,12 @@ struct WithNewtype {
 fn newtype_enum_roundtrip(tc: TestCase) {
     let variant = tc.draw(booleans());
     let wrapped = if variant {
-        Wrapper::Text(tc.draw(kdl_safe_string()))
+        Wrapper::Text(tc.draw(text()))
     } else {
         Wrapper::Number(tc.draw(integers()))
     };
     let val = WithNewtype {
-        label: tc.draw(kdl_safe_string()),
+        label: tc.draw(text()),
         wrapped,
     };
     let serialized = serde_kdl2::to_string(&val).unwrap();