Skip to main content

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}