Skip to main content

noyalib/with/
singleton_map_optional.rs

1//! Serialize optional enums as single-entry maps.
2//!
3//! This module provides `serialize` and `deserialize` functions for use with
4//! serde's `#[serde(with = "...")]` attribute to serialize `Option<T>` fields
5//! where the inner type should use singleton map representation.
6//!
7//! # Examples
8//!
9//! ```rust
10//! use noyalib::with::singleton_map_optional;
11//!
12//! #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
13//! enum Status {
14//!     Active,
15//!     Pending { reason: String },
16//! }
17//!
18//! #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
19//! struct Task {
20//!     name: String,
21//!     #[serde(
22//!         with = "singleton_map_optional",
23//!         skip_serializing_if = "Option::is_none",
24//!         default
25//!     )]
26//!     status: Option<Status>,
27//! }
28//!
29//! let task = Task {
30//!     name: "example".to_string(),
31//!     status: Some(Status::Pending {
32//!         reason: "waiting".to_string(),
33//!     }),
34//! };
35//!
36//! let yaml = noyalib::to_string(&task).unwrap();
37//! let parsed: Task = noyalib::from_str(&yaml).unwrap();
38//! assert_eq!(parsed, task);
39//! ```
40
41// SPDX-License-Identifier: MIT OR Apache-2.0
42// Copyright (c) 2026 Noyalib. All rights reserved.
43
44/// Serialize an optional value as a singleton map.
45///
46/// For `Some(value)`, this serializes the value using singleton map format.
47/// For `None`, this serializes as null.
48///
49/// # Examples
50///
51/// ```rust
52/// use noyalib::with::singleton_map_optional;
53///
54/// #[derive(Debug, serde::Serialize, serde::Deserialize)]
55/// enum Action {
56///     Start,
57///     Stop,
58/// }
59///
60/// #[derive(Debug, serde::Serialize, serde::Deserialize)]
61/// struct Command {
62///     #[serde(
63///         with = "singleton_map_optional",
64///         skip_serializing_if = "Option::is_none",
65///         default
66///     )]
67///     action: Option<Action>,
68/// }
69/// ```
70pub fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
71where
72    T: serde_core::Serialize,
73    S: serde_core::Serializer,
74{
75    match value {
76        Some(inner) => {
77            use serde_core::ser::SerializeMap as _;
78
79            // Serialize to Value to inspect structure
80            let yaml_value = crate::to_value(inner).map_err(serde_core::ser::Error::custom)?;
81
82            match yaml_value {
83                crate::Value::Mapping(map) => {
84                    // Already a map (struct variant), serialize directly
85                    let mut ser_map = serializer.serialize_map(Some(map.len()))?;
86                    for (k, v) in map {
87                        ser_map.serialize_entry(&k, &v)?;
88                    }
89                    ser_map.end()
90                }
91                crate::Value::String(s) => {
92                    // Unit variant - serialize as { VariantName: null }
93                    let mut ser_map = serializer.serialize_map(Some(1))?;
94                    ser_map.serialize_entry(&s, &())?;
95                    ser_map.end()
96                }
97                other => {
98                    // Fallback: serialize as-is
99                    serde_core::Serialize::serialize(&other, serializer)
100                }
101            }
102        }
103        None => serializer.serialize_none(),
104    }
105}
106
107/// Deserialize an optional value from a singleton map.
108///
109/// This is the counterpart to [`serialize`], deserializing from the
110/// singleton map format back to `Option<T>`.
111///
112/// # Examples
113///
114/// ```
115/// use noyalib::with::singleton_map_optional;
116///
117/// #[derive(serde::Deserialize, serde::Serialize, PartialEq, Debug)]
118/// enum Status { Active }
119///
120/// #[derive(serde::Deserialize, Debug)]
121/// struct Doc {
122///     #[serde(with = "singleton_map_optional", default)]
123///     s: Option<Status>,
124/// }
125///
126/// let d: Doc = noyalib::from_str("s: ~\n").unwrap();
127/// assert!(d.s.is_none());
128/// ```
129pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
130where
131    T: serde_core::de::DeserializeOwned + 'static,
132    D: serde_core::Deserializer<'de>,
133{
134    use serde_core::Deserialize as _;
135    // Deserialize as Option<Value> first
136    let opt_value: Option<crate::Value> = Option::deserialize(deserializer)?;
137
138    match opt_value {
139        Some(value) => {
140            let inner: T = crate::from_value(&value).map_err(serde_core::de::Error::custom)?;
141            Ok(Some(inner))
142        }
143        None => Ok(None),
144    }
145}
146
147#[cfg(test)]
148mod tests {
149
150    #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
151    enum TestEnum {
152        Unit,
153        Struct { value: String },
154    }
155
156    #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
157    struct Container {
158        name: String,
159        #[serde(
160            with = "crate::with::singleton_map_optional",
161            skip_serializing_if = "Option::is_none",
162            default
163        )]
164        item: Option<TestEnum>,
165    }
166
167    #[test]
168    fn test_singleton_map_optional_some() {
169        let container = Container {
170            name: "test".to_string(),
171            item: Some(TestEnum::Struct {
172                value: "hello".to_string(),
173            }),
174        };
175
176        let yaml = crate::to_string(&container).unwrap();
177        assert!(yaml.contains("Struct"));
178        assert!(yaml.contains("value: hello"));
179
180        let parsed: Container = crate::from_str(&yaml).unwrap();
181        assert_eq!(parsed, container);
182    }
183
184    #[test]
185    fn test_singleton_map_optional_none() {
186        let container = Container {
187            name: "test".to_string(),
188            item: None,
189        };
190
191        let yaml = crate::to_string(&container).unwrap();
192        // With skip_serializing_if, the field should not appear
193        assert!(!yaml.contains("item"));
194
195        let parsed: Container = crate::from_str(&yaml).unwrap();
196        assert_eq!(parsed, container);
197    }
198
199    #[test]
200    fn test_singleton_map_optional_unit_variant() {
201        let container = Container {
202            name: "test".to_string(),
203            item: Some(TestEnum::Unit),
204        };
205
206        let yaml = crate::to_string(&container).unwrap();
207        assert!(yaml.contains("Unit"));
208
209        let parsed: Container = crate::from_str(&yaml).unwrap();
210        assert_eq!(parsed, container);
211    }
212
213    #[test]
214    fn test_singleton_map_optional_fallback() {
215        // Test the fallback path when value is a Sequence
216        #[derive(Debug, serde::Serialize)]
217        struct SeqContainer {
218            #[serde(with = "crate::with::singleton_map_optional")]
219            items: Option<Vec<i32>>,
220        }
221
222        let container = SeqContainer {
223            items: Some(vec![1, 2, 3]),
224        };
225
226        let yaml = crate::to_string(&container).unwrap();
227        assert!(yaml.contains('1'));
228    }
229
230    #[test]
231    fn test_singleton_map_optional_serialize_none() {
232        // Test serializing None without skip_serializing_if
233        #[derive(Debug, serde::Serialize)]
234        struct NoneContainer {
235            name: String,
236            #[serde(with = "crate::with::singleton_map_optional")]
237            item: Option<TestEnum>,
238        }
239
240        let container = NoneContainer {
241            name: "test".to_string(),
242            item: None,
243        };
244
245        let yaml = crate::to_string(&container).unwrap();
246        // Should serialize None as null
247        assert!(yaml.contains("name: test"));
248    }
249
250    #[test]
251    fn test_singleton_map_optional_deserialize_none() {
252        // Test deserializing null as None
253        #[derive(Debug, serde::Deserialize, PartialEq)]
254        struct NoneContainer {
255            name: String,
256            #[serde(with = "crate::with::singleton_map_optional", default)]
257            item: Option<TestEnum>,
258        }
259
260        let yaml = "name: test\nitem: null\n";
261        let parsed: NoneContainer = crate::from_str(yaml).unwrap();
262        assert_eq!(parsed.name, "test");
263        assert!(parsed.item.is_none());
264    }
265}