jmap_types/string_enum.rs
1//! Shared macro for string-backed enums with a catch-all `Other(String)` variant.
2//!
3//! This module is the single canonical home for the [`impl_string_enum!`] macro
4//! used across the `jmap-*-types` crate family. JMAP wire-protocol enums often
5//! allow vendor-specific extensions (per RFC 8620 ยง1.6 and the various JMAP
6//! extension specs), so each enum carries an `Other(String)` variant that
7//! preserves unknown values for round-trip fidelity.
8//!
9//! Previously every `jmap-*-types` crate carried a byte-identical copy of this
10//! macro; this module consolidates them. See bd issue JMAP-wk77.
11
12/// Implement [`serde::Serialize`], [`serde::Deserialize`], and [`std::fmt::Display`]
13/// for a string-backed enum with a catch-all `Other(String)` variant.
14///
15/// # Requirements
16///
17/// The enum **must** have a variant `Other(String)` that stores the raw wire string
18/// for any value not listed in the known-value mapping. The macro generates match
19/// arms only for the listed variants; the `Other` catch-all is generated automatically.
20///
21/// # Usage
22///
23/// ```ignore
24/// impl_string_enum!(MyEnum, "a my-enum value",
25/// "wire-string-1" => Variant1,
26/// "wire-string-2" => Variant2,
27/// );
28/// ```
29///
30/// This emits:
31/// - `impl Serialize` โ serialises each listed variant to its wire string; `Other(v)`
32/// serialises to the inner string.
33/// - `impl Deserialize` โ deserialises a JSON string using a `Visitor`; unknown values
34/// become `Other(v.to_owned())`.
35/// - `impl Display` โ formats using the same mapping as `Serialize`.
36#[macro_export]
37macro_rules! impl_string_enum {
38 ($ty:ident, $expecting:literal, $( $s:literal => $variant:ident ),+ $(,)?) => {
39 impl ::serde::Serialize for $ty {
40 fn serialize<S: ::serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
41 s.serialize_str(match self {
42 $( $ty::$variant => $s, )+
43 $ty::Other(v) => v.as_str(),
44 })
45 }
46 }
47 impl<'de> ::serde::Deserialize<'de> for $ty {
48 fn deserialize<D: ::serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
49 struct Visitor;
50 impl ::serde::de::Visitor<'_> for Visitor {
51 type Value = $ty;
52 fn expecting(
53 &self,
54 f: &mut ::std::fmt::Formatter<'_>,
55 ) -> ::std::fmt::Result {
56 write!(f, $expecting)
57 }
58 fn visit_str<E: ::serde::de::Error>(self, v: &str) -> Result<$ty, E> {
59 Ok(match v {
60 $( $s => $ty::$variant, )+
61 _ => $ty::Other(v.to_owned()),
62 })
63 }
64 }
65 d.deserialize_str(Visitor)
66 }
67 }
68 impl ::std::fmt::Display for $ty {
69 fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
70 f.write_str(match self {
71 $( $ty::$variant => $s, )+
72 $ty::Other(v) => v.as_str(),
73 })
74 }
75 }
76 };
77}
78
79#[cfg(test)]
80mod tests {
81 // Test enum exercising the catch-all `Other(String)` pattern. The test
82 // oracle is hand-written JSON (independent of the code under test): we
83 // assert the macro emits the exact wire bytes a JMAP client would receive
84 // and that an unknown wire value round-trips losslessly via `Other`.
85 #[derive(Debug, PartialEq)]
86 enum TestKind {
87 Foo,
88 Bar,
89 Other(String),
90 }
91
92 impl_string_enum!(
93 TestKind,
94 "a test kind string",
95 "foo" => Foo,
96 "bar" => Bar,
97 );
98
99 #[test]
100 fn known_variant_serializes_to_wire_string() {
101 // Hand-written oracle: the JSON form of TestKind::Foo is the literal
102 // string "foo" (3 bytes plus surrounding quotes = 5 bytes total).
103 assert_eq!(serde_json::to_string(&TestKind::Foo).unwrap(), r#""foo""#);
104 assert_eq!(serde_json::to_string(&TestKind::Bar).unwrap(), r#""bar""#);
105 }
106
107 #[test]
108 fn known_wire_string_deserializes_to_variant() {
109 // Inverse oracle: feeding the canonical wire bytes yields the typed variant.
110 let foo: TestKind = serde_json::from_str(r#""foo""#).unwrap();
111 let bar: TestKind = serde_json::from_str(r#""bar""#).unwrap();
112 assert_eq!(foo, TestKind::Foo);
113 assert_eq!(bar, TestKind::Bar);
114 }
115
116 #[test]
117 fn unknown_wire_string_round_trips_via_other() {
118 // The defining JMAP property: a value the spec does not know must be
119 // preserved verbatim through deserialize -> serialize. This is what
120 // `Other(String)` exists to guarantee.
121 let original = r#""vendor-extension-value""#;
122 let parsed: TestKind = serde_json::from_str(original).unwrap();
123 assert_eq!(parsed, TestKind::Other("vendor-extension-value".to_owned()));
124 let reserialized = serde_json::to_string(&parsed).unwrap();
125 assert_eq!(reserialized, original);
126 }
127
128 #[test]
129 fn display_matches_serialize_form() {
130 // Display must emit the same wire string as Serialize. A divergence
131 // here would silently corrupt log output relative to JSON output.
132 assert_eq!(format!("{}", TestKind::Foo), "foo");
133 assert_eq!(format!("{}", TestKind::Bar), "bar");
134 assert_eq!(
135 format!("{}", TestKind::Other("custom".to_owned())),
136 "custom",
137 );
138 }
139
140 #[test]
141 fn empty_string_round_trips_as_other() {
142 // Empty wire string is unknown to the registered list, so it lands in
143 // `Other("")`. The macro must not panic or treat it specially.
144 let parsed: TestKind = serde_json::from_str(r#""""#).unwrap();
145 assert_eq!(parsed, TestKind::Other(String::new()));
146 assert_eq!(serde_json::to_string(&parsed).unwrap(), r#""""#);
147 }
148
149 #[test]
150 fn deserialize_rejects_non_string_json() {
151 // Visitor only implements visit_str. Numbers, booleans, objects must
152 // fail with a serde error rather than silently coercing.
153 assert!(serde_json::from_str::<TestKind>("42").is_err());
154 assert!(serde_json::from_str::<TestKind>("true").is_err());
155 assert!(serde_json::from_str::<TestKind>("null").is_err());
156 assert!(serde_json::from_str::<TestKind>("{}").is_err());
157 }
158}