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 an inherent `as_str()`, [`serde::Serialize`],
13/// [`serde::Deserialize`], [`std::fmt::Display`], `From<&str>` and
14/// `From<String>` for a string-backed enum with a catch-all `Other(String)`
15/// variant.
16///
17/// # Requirements
18///
19/// The enum **must** have a variant `Other(String)` that stores the raw wire string
20/// for any value not listed in the known-value mapping. The macro generates match
21/// arms only for the listed variants; the `Other` catch-all is generated automatically.
22///
23/// # Usage
24///
25/// ```ignore
26/// impl_string_enum!(MyEnum, "a my-enum value",
27/// "wire-string-1" => Variant1,
28/// "wire-string-2" => Variant2,
29/// );
30/// ```
31///
32/// This emits:
33/// - `pub fn as_str(&self) -> &str` — the single wire mapping: each listed
34/// variant yields its wire string, `Other(v)` yields the inner string.
35/// Do not hand-write a second copy; `Serialize` and `Display` call this.
36/// - `impl Serialize` — serialises the `as_str()` value.
37/// - `impl Deserialize` — deserialises a JSON string using a `Visitor`; unknown values
38/// become `Other(v.to_owned())`.
39/// - `impl Display` — formats the `as_str()` value, identical to `Serialize`.
40/// - `impl From<&str>` and `impl From<String>` — the same mapping as
41/// `Deserialize`, so a listed wire string always builds its typed variant
42/// and never `Other`: `MyEnum::from("wire-string-1") == MyEnum::Variant1`.
43///
44/// # Forged `Other` values
45///
46/// `Other(String)` is a public variant, so a caller can write
47/// `MyEnum::Other("wire-string-1".into())`. That value compares unequal to
48/// `MyEnum::Variant1`, yet serialises to the same wire string and
49/// deserialises back as `MyEnum::Variant1`. Build values from a string with
50/// `MyEnum::from(..)`, which never yields `Other` for a listed wire string,
51/// and construct `Other` directly only for a string the enum does not list.
52#[macro_export]
53macro_rules! impl_string_enum {
54 ($ty:ident, $expecting:literal, $( $s:literal => $variant:ident ),+ $(,)?) => {
55 impl $ty {
56 /// The wire string for this value: the listed string for a
57 /// known variant, the stored string for `Other`.
58 pub fn as_str(&self) -> &str {
59 match self {
60 $( $ty::$variant => $s, )+
61 $ty::Other(v) => v.as_str(),
62 }
63 }
64 }
65 impl ::serde::Serialize for $ty {
66 fn serialize<S: ::serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
67 s.serialize_str(self.as_str())
68 }
69 }
70 impl<'de> ::serde::Deserialize<'de> for $ty {
71 fn deserialize<D: ::serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
72 struct Visitor;
73 impl ::serde::de::Visitor<'_> for Visitor {
74 type Value = $ty;
75 fn expecting(
76 &self,
77 f: &mut ::std::fmt::Formatter<'_>,
78 ) -> ::std::fmt::Result {
79 write!(f, $expecting)
80 }
81 fn visit_str<E: ::serde::de::Error>(self, v: &str) -> Result<$ty, E> {
82 Ok($ty::from(v))
83 }
84 }
85 d.deserialize_str(Visitor)
86 }
87 }
88 impl ::std::convert::From<&str> for $ty {
89 fn from(v: &str) -> Self {
90 match v {
91 $( $s => $ty::$variant, )+
92 _ => $ty::Other(v.to_owned()),
93 }
94 }
95 }
96 impl ::std::convert::From<::std::string::String> for $ty {
97 fn from(v: ::std::string::String) -> Self {
98 match v.as_str() {
99 $( $s => $ty::$variant, )+
100 _ => $ty::Other(v),
101 }
102 }
103 }
104 impl ::std::fmt::Display for $ty {
105 fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
106 f.write_str(self.as_str())
107 }
108 }
109 };
110}
111
112#[cfg(test)]
113mod tests {
114 // Test enum exercising the catch-all `Other(String)` pattern. The test
115 // oracle is hand-written JSON (independent of the code under test): we
116 // assert the macro emits the exact wire bytes a JMAP client would receive
117 // and that an unknown wire value round-trips losslessly via `Other`.
118 #[derive(Debug, PartialEq)]
119 enum TestKind {
120 Foo,
121 Bar,
122 Other(String),
123 }
124
125 impl_string_enum!(
126 TestKind,
127 "a test kind string",
128 "foo" => Foo,
129 "bar" => Bar,
130 );
131
132 #[test]
133 fn known_variant_serializes_to_wire_string() {
134 // Hand-written oracle: the JSON form of TestKind::Foo is the literal
135 // string "foo" (3 bytes plus surrounding quotes = 5 bytes total).
136 assert_eq!(serde_json::to_string(&TestKind::Foo).unwrap(), r#""foo""#);
137 assert_eq!(serde_json::to_string(&TestKind::Bar).unwrap(), r#""bar""#);
138 }
139
140 #[test]
141 fn known_wire_string_deserializes_to_variant() {
142 // Inverse oracle: feeding the canonical wire bytes yields the typed variant.
143 let foo: TestKind = serde_json::from_str(r#""foo""#).unwrap();
144 let bar: TestKind = serde_json::from_str(r#""bar""#).unwrap();
145 assert_eq!(foo, TestKind::Foo);
146 assert_eq!(bar, TestKind::Bar);
147 }
148
149 #[test]
150 fn forged_other_is_unequal_but_round_trips_to_variant() {
151 // The documented caveat: `Other("foo")` is not `Foo`, serialises to
152 // the wire string "foo" and deserialises back as `Foo`.
153 let forged = TestKind::Other("foo".to_owned());
154 assert_ne!(forged, TestKind::Foo);
155 let wire = serde_json::to_string(&forged).unwrap();
156 assert_eq!(wire, r#""foo""#);
157 let back: TestKind = serde_json::from_str(&wire).unwrap();
158 assert_eq!(back, TestKind::Foo);
159 assert_eq!(TestKind::from("foo"), TestKind::Foo);
160 }
161
162 #[test]
163 fn unknown_wire_string_round_trips_via_other() {
164 // The defining JMAP property: a value the spec does not know must be
165 // preserved verbatim through deserialize -> serialize. This is what
166 // `Other(String)` exists to guarantee.
167 let original = r#""vendor-extension-value""#;
168 let parsed: TestKind = serde_json::from_str(original).unwrap();
169 assert_eq!(parsed, TestKind::Other("vendor-extension-value".to_owned()));
170 let reserialized = serde_json::to_string(&parsed).unwrap();
171 assert_eq!(reserialized, original);
172 }
173
174 #[test]
175 fn display_matches_serialize_form() {
176 // Display must emit the same wire string as Serialize. A divergence
177 // here would silently corrupt log output relative to JSON output.
178 assert_eq!(format!("{}", TestKind::Foo), "foo");
179 assert_eq!(format!("{}", TestKind::Bar), "bar");
180 assert_eq!(
181 format!("{}", TestKind::Other("custom".to_owned())),
182 "custom",
183 );
184 }
185
186 #[test]
187 fn as_str_yields_wire_string() {
188 // Hand-written oracle: the listed wire strings, and the stored
189 // string verbatim for `Other`.
190 assert_eq!(TestKind::Foo.as_str(), "foo");
191 assert_eq!(TestKind::Bar.as_str(), "bar");
192 assert_eq!(TestKind::Other("custom".to_owned()).as_str(), "custom");
193 }
194
195 #[test]
196 fn from_str_matches_deserialize() {
197 // A listed wire string builds its typed variant, never `Other`,
198 // so a value built in code compares equal to the same value
199 // decoded from the wire.
200 assert_eq!(TestKind::from("foo"), TestKind::Foo);
201 assert_eq!(TestKind::from(String::from("bar")), TestKind::Bar);
202 assert_eq!(
203 TestKind::from("vendor-extension-value"),
204 TestKind::Other("vendor-extension-value".to_owned())
205 );
206 assert_eq!(
207 TestKind::from(String::from("vendor-extension-value")),
208 serde_json::from_str::<TestKind>(r#""vendor-extension-value""#).unwrap()
209 );
210 }
211
212 #[test]
213 fn empty_string_round_trips_as_other() {
214 // Empty wire string is unknown to the registered list, so it lands in
215 // `Other("")`. The macro must not panic or treat it specially.
216 let parsed: TestKind = serde_json::from_str(r#""""#).unwrap();
217 assert_eq!(parsed, TestKind::Other(String::new()));
218 assert_eq!(serde_json::to_string(&parsed).unwrap(), r#""""#);
219 }
220
221 #[test]
222 fn deserialize_rejects_non_string_json() {
223 // Visitor only implements visit_str. Numbers, booleans, objects must
224 // fail with a serde error rather than silently coercing.
225 assert!(serde_json::from_str::<TestKind>("42").is_err());
226 assert!(serde_json::from_str::<TestKind>("true").is_err());
227 assert!(serde_json::from_str::<TestKind>("null").is_err());
228 assert!(serde_json::from_str::<TestKind>("{}").is_err());
229 }
230}