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 [`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}