Fix serialization of () and unit structs: emit #null instead of omitting
Previously, serialize_none, serialize_unit, and serialize_unit_struct all
produced the same Value::Null, which was then skipped by the struct/map
serializers. This meant () and unit struct fields silently vanished and
failed to roundtrip.

Add a Value::None variant for Option::None (field omitted) distinct from
Value::Null (emits #null). Only Value::None is skipped during
serialization.
change rzrsynqkxysrrnnskrsvswpkloonrzkn
commit 97f7872352cb2bfdc5715f66a1463bef1f4d2da4
author Alpha Chen <alpha@kejadlen.dev>
date
parent zsokzlko
diff --git a/src/ser.rs b/src/ser.rs
index 62c1763..df5e3d1 100644
--- a/src/ser.rs
+++ b/src/ser.rs
@@ -44,6 +44,9 @@ pub fn to_doc<T: Serialize>(value: &T) -> Result<KdlDocument> {
 /// tree structure. We serialize into this first, then convert to KDL.
 #[derive(Debug, Clone)]
 enum Value {
+    /// Represents `Option::None` — the field should be omitted entirely.
+    None,
+    /// Represents `()` and unit structs — serializes as `#null`.
     Null,
     Bool(bool),
     Integer(i128),
@@ -57,12 +60,18 @@ impl Value {
     fn is_primitive(&self) -> bool {
         matches!(
             self,
-            Value::Null | Value::Bool(_) | Value::Integer(_) | Value::Float(_) | Value::String(_)
+            Value::None
+                | Value::Null
+                | Value::Bool(_)
+                | Value::Integer(_)
+                | Value::Float(_)
+                | Value::String(_)
         )
     }
 
     fn to_kdl_value(&self) -> Option<KdlValue> {
         match self {
+            Value::None => Some(KdlValue::Null),
             Value::Null => Some(KdlValue::Null),
             Value::Bool(b) => Some(KdlValue::Bool(*b)),
             Value::Integer(i) => Some(KdlValue::Integer(*i)),
@@ -96,9 +105,9 @@ fn value_to_doc(value: Value) -> Result<KdlDocument> {
 /// Convert a key-value pair to one or more KDL nodes.
 fn value_to_nodes(name: &str, value: Value) -> Result<Vec<KdlNode>> {
     match value {
-        // Null reaches value_to_nodes through the mixed-sequence `-`
+        // None/Null reach value_to_nodes through the mixed-sequence `-`
         // children path (e.g., Vec<Option<Vec<T>>> with a None element).
-        Value::Null => {
+        Value::None | Value::Null => {
             let mut node = KdlNode::new(name);
             node.push(KdlEntry::new(KdlValue::Null));
             Ok(vec![node])
@@ -285,7 +294,7 @@ impl ser::Serializer for ValueSerializer {
     }
 
     fn serialize_none(self) -> Result<Value> {
-        Ok(Value::Null)
+        Ok(Value::None)
     }
 
     fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<Value> {
@@ -481,8 +490,8 @@ impl ser::SerializeMap for SerializeMap {
             .take()
             .ok_or_else(|| Error::Message("serialize_value called before serialize_key".into()))?;
         let val = value.serialize(ValueSerializer)?;
-        // Skip None/Null values (they represent absent optional fields)
-        if !matches!(val, Value::Null) {
+        // Skip None values (they represent absent optional fields)
+        if !matches!(val, Value::None) {
             self.entries.push((key, val));
         }
         Ok(())
@@ -503,8 +512,8 @@ impl ser::SerializeStruct for SerializeMap {
         value: &T,
     ) -> Result<()> {
         let val = value.serialize(ValueSerializer)?;
-        // Skip None/Null values for struct fields (Option::None)
-        if !matches!(val, Value::Null) {
+        // Skip None values for struct fields (Option::None)
+        if !matches!(val, Value::None) {
             self.entries.push((key.to_string(), val));
         }
         Ok(())
@@ -530,7 +539,7 @@ impl ser::SerializeStructVariant for SerializeStructVariant {
         value: &T,
     ) -> Result<()> {
         let val = value.serialize(ValueSerializer)?;
-        if !matches!(val, Value::Null) {
+        if !matches!(val, Value::None) {
             self.entries.push((key.to_string(), val));
         }
         Ok(())
diff --git a/tests/integration.rs b/tests/integration.rs
index 32a74aa..fdd4422 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -814,29 +814,38 @@ fn serialize_bytes() {
 }
 
 #[test]
-fn serialize_unit_value() {
+fn roundtrip_unit_field() {
     #[derive(Debug, Serialize, Deserialize, PartialEq)]
     struct S {
         label: String,
-        #[allow(dead_code)]
         marker: (),
     }
-    let output = serde_kdl2::to_string(&S {
+    let val = S {
         label: String::from("test"),
         marker: (),
-    })
-    .unwrap();
+    };
+    let output = serde_kdl2::to_string(&val).unwrap();
     assert!(output.contains("label"));
+    assert!(output.contains("marker"));
+    let rt: S = serde_kdl2::from_str(&output).unwrap();
+    assert_eq!(val, rt);
 }
 
 #[test]
-fn serialize_explicit_null_value() {
-    #[derive(Serialize)]
+fn roundtrip_unit_struct_field() {
+    #[derive(Debug, Serialize, Deserialize, PartialEq)]
     struct S {
+        label: String,
         marker: UnitStruct,
     }
-    let output = serde_kdl2::to_string(&S { marker: UnitStruct }).unwrap();
-    assert!(!output.contains("marker"));
+    let val = S {
+        label: String::from("test"),
+        marker: UnitStruct,
+    };
+    let output = serde_kdl2::to_string(&val).unwrap();
+    assert!(output.contains("marker"));
+    let rt: S = serde_kdl2::from_str(&output).unwrap();
+    assert_eq!(val, rt);
 }
 
 #[test]