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}