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}