Skip to main content

jmap_chat_client/
types.rs

1//! JMAP Chat client-side auxiliary types.
2//!
3//! This module contains types used in client-facing APIs that are not part of
4//! the wire-format types defined in `jmap-chat-types`.
5
6use jmap_types::impl_string_enum;
7use serde::Serialize;
8
9// ---------------------------------------------------------------------------
10// ContactPresenceFilter
11// ---------------------------------------------------------------------------
12
13/// Presence filter for `ChatContact/query` operations.
14///
15/// Mirrors [`jmap_chat_types::Presence`] but omits `Other`, which has no
16/// defined filter semantics and must never be sent to the server.
17///
18/// Use [`TryFrom<jmap_chat_types::Presence>`] to convert a deserialized
19/// presence value into a filter value (fails if `Other`).
20#[non_exhaustive]
21#[derive(Debug, Clone, PartialEq, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ContactPresenceFilter {
24    /// Filter to contacts currently online.
25    Online,
26    /// Filter to contacts marked away.
27    Away,
28    /// Filter to contacts marked busy.
29    Busy,
30    /// Filter to contacts marked invisible.
31    Invisible,
32    /// Filter to contacts currently offline.
33    Offline,
34}
35
36impl TryFrom<jmap_chat_types::Presence> for ContactPresenceFilter {
37    /// Conversion fails when `p` is [`jmap_chat_types::Presence::Other`].
38    /// The failed value is returned in the `Err` so callers can recover
39    /// the original wire string (typically for logging or selective
40    /// fallback) rather than dropping it to a unit error.
41    type Error = jmap_chat_types::Presence;
42
43    fn try_from(p: jmap_chat_types::Presence) -> Result<Self, Self::Error> {
44        match p {
45            jmap_chat_types::Presence::Online => Ok(ContactPresenceFilter::Online),
46            jmap_chat_types::Presence::Away => Ok(ContactPresenceFilter::Away),
47            jmap_chat_types::Presence::Busy => Ok(ContactPresenceFilter::Busy),
48            jmap_chat_types::Presence::Invisible => Ok(ContactPresenceFilter::Invisible),
49            jmap_chat_types::Presence::Offline => Ok(ContactPresenceFilter::Offline),
50            other => Err(other),
51        }
52    }
53}
54
55// ---------------------------------------------------------------------------
56// QuotaScope
57// ---------------------------------------------------------------------------
58
59/// RFC 9425 §3.1 Scope — the set of accounts the quota limit applies to.
60///
61/// Wire strings: `"account"`, `"domain"`, `"global"`.
62/// `Other(String)` preserves any unrecognized value for lossless round-trip.
63#[non_exhaustive]
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum QuotaScope {
66    /// Quota applies to this account only.
67    Account,
68    /// Quota applies to all accounts sharing this domain.
69    Domain,
70    /// Quota applies to all accounts on the server.
71    Global,
72    /// Catch-all for any unrecognized wire value from a future spec version.
73    /// The original wire value is preserved for lossless round-trip.
74    ///
75    /// # Forging caveat
76    ///
77    /// `Other(String)` is `pub`, so callers can construct
78    /// `QuotaScope::Other("account".into())`. The custom serde impl
79    /// emits the wrapped string verbatim on serialize and normalises
80    /// canonical wire strings to their typed variant on deserialize.
81    /// Consequences:
82    /// * `QuotaScope::Other("account".into()) != QuotaScope::Account`
83    ///   on PartialEq, but both serialize to `"account"`.
84    /// * `Other("account")` -> `"account"` -> `Account` is a lossy
85    ///   round-trip (the variant changes shape).
86    ///
87    /// Reserve `Other(s)` for genuinely unrecognised wire strings.
88    /// Comparing wire-string equality across two values requires
89    /// matching on `as_str()`, not on `PartialEq`.
90    Other(String),
91}
92
93impl QuotaScope {
94    /// The canonical wire string for this quota scope.
95    pub fn as_str(&self) -> &str {
96        match self {
97            Self::Account => "account",
98            Self::Domain => "domain",
99            Self::Global => "global",
100            Self::Other(s) => s.as_str(),
101        }
102    }
103}
104
105impl_string_enum!(QuotaScope, "a QuotaScope wire string",
106    "account" => Account,
107    "domain"  => Domain,
108    "global"  => Global,
109);
110
111// ---------------------------------------------------------------------------
112// QuotaResourceType
113// ---------------------------------------------------------------------------
114
115/// RFC 9425 §3.2 ResourceType — the unit of measure for a quota.
116///
117/// Wire strings: `"count"`, `"octets"`.
118/// `Other(String)` preserves any unrecognized value for lossless round-trip.
119#[non_exhaustive]
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum QuotaResourceType {
122    /// Quota measured in number of data-type objects.
123    Count,
124    /// Quota measured in size (octets / bytes).
125    Octets,
126    /// Catch-all for any unrecognized wire value from a future spec version.
127    /// The original wire value is preserved for lossless round-trip.
128    ///
129    /// **Forging caveat**: see [`QuotaScope::Other`] for the full
130    /// discussion. Constructing
131    /// `QuotaResourceType::Other("count".into())` produces a value
132    /// that is unequal to `QuotaResourceType::Count` on `PartialEq`
133    /// but serialises to the same wire string and round-trips back
134    /// to `QuotaResourceType::Count`. Reserve `Other(s)` for
135    /// genuinely unrecognised wire strings; compare wire equality
136    /// via `as_str()`, not `PartialEq`.
137    Other(String),
138}
139
140impl QuotaResourceType {
141    /// The canonical wire string for this resource type.
142    pub fn as_str(&self) -> &str {
143        match self {
144            Self::Count => "count",
145            Self::Octets => "octets",
146            Self::Other(s) => s.as_str(),
147        }
148    }
149}
150
151impl_string_enum!(QuotaResourceType, "a QuotaResourceType wire string",
152    "count"  => Count,
153    "octets" => Octets,
154);
155
156// ---------------------------------------------------------------------------
157// ChatMemberRole
158// ---------------------------------------------------------------------------
159
160/// Role of a participant in a group Chat.
161///
162/// The spec defines two well-known values: `"admin"` and `"member"`.
163/// `Other(String)` preserves any unrecognized value for lossless round-trip.
164///
165/// Wire strings: `"admin"`, `"member"`.
166#[non_exhaustive]
167#[derive(Debug, Clone, PartialEq)]
168pub enum ChatMemberRole {
169    /// Group or channel administrator with management permissions.
170    Admin,
171    /// Regular member.
172    Member,
173    /// Catch-all for any unrecognized wire value from a future spec version.
174    ///
175    /// **Forging caveat**: see [`QuotaScope::Other`] for the full
176    /// discussion. Constructing `ChatMemberRole::Other("admin".into())`
177    /// produces a value that is unequal to `ChatMemberRole::Admin` on
178    /// `PartialEq` but serialises to the same wire string `"admin"`
179    /// and round-trips back to `ChatMemberRole::Admin`. Reserve
180    /// `Other(s)` for genuinely unrecognised wire strings; compare
181    /// wire equality via `as_str()`, not `PartialEq`.
182    Other(String),
183}
184
185impl ChatMemberRole {
186    /// The canonical wire string for this role.
187    pub fn as_str(&self) -> &str {
188        match self {
189            Self::Admin => "admin",
190            Self::Member => "member",
191            Self::Other(s) => s.as_str(),
192        }
193    }
194}
195
196impl_string_enum!(ChatMemberRole, "a ChatMemberRole wire string",
197    "admin"  => Admin,
198    "member" => Member,
199);
200
201// ---------------------------------------------------------------------------
202// Wire-enum round-trip preservation tests
203// ---------------------------------------------------------------------------
204//
205// Per workspace AGENTS.md "Extras-preservation policy" for in-scope result
206// enums: each enum that carries an `Other(String)` catch-all MUST have a
207// test asserting an unknown wire string deserialises into `Other(s)` and
208// round-trips back to the same wire string.
209//
210// jmap_types::impl_string_enum!'s own test module exercises the macro
211// logic; these tests exercise the per-enum (wire-string, variant)
212// mapping registered by each invocation in this file. Independent
213// oracles: each test uses a hand-chosen wire string that is provably
214// outside the registered set for that enum.
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    /// QuotaScope: unknown wire string round-trips via Other(s).
221    /// Oracle: `"siteCustom-tier-A"` is not in RFC 9425 §3.1
222    /// `{account, domain, global}`.
223    #[test]
224    fn quota_scope_unknown_round_trips_via_other() {
225        let raw = r#""siteCustom-tier-A""#;
226        let parsed: QuotaScope = serde_json::from_str(raw).expect("must deserialize");
227        assert_eq!(parsed, QuotaScope::Other("siteCustom-tier-A".to_owned()));
228        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
229    }
230
231    /// QuotaResourceType: unknown wire string round-trips via Other(s).
232    /// Oracle: `"vendorUnit-decibels"` is not in RFC 9425 §3.2
233    /// `{count, octets}`.
234    #[test]
235    fn quota_resource_type_unknown_round_trips_via_other() {
236        let raw = r#""vendorUnit-decibels""#;
237        let parsed: QuotaResourceType = serde_json::from_str(raw).expect("must deserialize");
238        assert_eq!(
239            parsed,
240            QuotaResourceType::Other("vendorUnit-decibels".to_owned())
241        );
242        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
243    }
244
245    /// ChatMemberRole: unknown wire string round-trips via Other(s).
246    /// Oracle: `"moderator"` is not in draft-atwood-jmap-chat-00 §Chat
247    /// roles `{admin, member}` — it is the canonical vendor-extension
248    /// example the chat smoke tests use.
249    #[test]
250    fn chat_member_role_unknown_round_trips_via_other() {
251        let raw = r#""moderator""#;
252        let parsed: ChatMemberRole = serde_json::from_str(raw).expect("must deserialize");
253        assert_eq!(parsed, ChatMemberRole::Other("moderator".to_owned()));
254        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
255    }
256
257    /// QuotaScope canonical variants round-trip correctly to/from their
258    /// registered wire strings.
259    #[test]
260    fn quota_scope_canonical_variants_round_trip() {
261        let cases: &[(&str, QuotaScope)] = &[
262            (r#""account""#, QuotaScope::Account),
263            (r#""domain""#, QuotaScope::Domain),
264            (r#""global""#, QuotaScope::Global),
265        ];
266        for (raw, expected) in cases {
267            let parsed: QuotaScope = serde_json::from_str(raw).expect("must deserialize");
268            assert_eq!(
269                &parsed, expected,
270                "wire {raw} must deserialise to {expected:?}"
271            );
272            assert_eq!(
273                serde_json::to_string(&parsed).unwrap(),
274                *raw,
275                "wire {raw} must round-trip"
276            );
277        }
278    }
279}