jmap_jscontact_types/lib.rs
1//! JSContact (RFC 9553) typed sub-types for the jmap-* crate family.
2//!
3//! Normative reference: RFC 9553 (JSContact).
4//!
5//! These are sub-object types that have no JMAP identity of their own.
6//! They are embedded within `ContactCard` (from `jmap-contacts-types`).
7//!
8//! ## Crate family position
9//!
10//! ```text
11//! (no JMAP dep)
12//! └── jmap-jscontact-types ← this crate (RFC 9553 typed sub-types)
13//! └── jmap-contacts-types (consumes via path-dep + re-export)
14//! ```
15//!
16//! ## Design: optional fields and `Option<...>`
17//!
18//! RFC 9553 marks most fields optional, and JMAP `properties` arguments
19//! permit partial responses. Every optional field is `Option<...>` with
20//! `#[serde(skip_serializing_if = "Option::is_none")]` so partial inputs
21//! round-trip unchanged.
22//!
23//! Mandatory fields per the RFC are normally kept as bare types (not
24//! `Option`) to express the requirement at the type level — callers
25//! building a fresh sub-object must populate them. The exception is
26//! the `Resource`-derived types ([`Calendar`], [`CryptoKey`],
27//! [`Directory`], [`Link`], [`Media`]), whose RFC-mandatory `kind` and
28//! `uri` fields are modelled as `Option` to permit partial-response
29//! deserialization (a JMAP client requesting `properties: ["kind"]` on
30//! a `Calendar` legitimately receives a JSON object with no `uri` and
31//! must round-trip it unchanged).
32//!
33//! The trade-off: callers can construct, e.g.,
34//! `Calendar { kind: None, uri: None, ... }` and serialize a wire
35//! object that no spec-conformant peer can validate. The type system
36//! does not catch this; the kit's posture is "types model the wire
37//! shape; semantic validity is the consumer's job" (see the kit-vs-jig
38//! section in the workspace `AGENTS.md`). Callers building a fresh
39//! value for emission MUST populate the mandatory-on-wire fields
40//! themselves before serializing.
41//!
42//! Mandatory-on-wire fields modelled as `Option` (8 sites across 5
43//! types), gathered into one place so each consumer does not have to
44//! re-derive them from the per-field rustdoc:
45//!
46//! | Struct | Mandatory field | RFC section |
47//! |---|---|---|
48//! | [`Calendar`] | `kind` | RFC 9553 §2.4.1 |
49//! | [`Calendar`] | `uri` | RFC 9553 §1.4.4 |
50//! | [`CryptoKey`] | `uri` | RFC 9553 §1.4.4 |
51//! | [`Directory`] | `kind` | RFC 9553 §2.6.2 |
52//! | [`Directory`] | `uri` | RFC 9553 §1.4.4 |
53//! | [`Link`] | `uri` | RFC 9553 §1.4.4 |
54//! | [`Media`] | `kind` | RFC 9553 §2.6.4 |
55//! | [`Media`] | `uri` | RFC 9553 §1.4.4 |
56//!
57//! ## Design: cross-field invariants are not type-enforced
58//!
59//! Six structs carry a cross-field "at least one of X, Y must be set"
60//! constraint at the rustdoc level that the Rust type system does not
61//! enforce. The kit's posture is "types model the wire shape; semantic
62//! validity is the consumer's job" (see the kit-vs-jig section in the
63//! workspace `AGENTS.md`). Encoding these constraints in the type system
64//! would diverge from that posture and force the partial-response
65//! `Option` modelling into a corner.
66//!
67//! Callers building a fresh value for emission MUST validate the
68//! constraint themselves before serializing. The constraints, gathered
69//! into one place so each consumer does not have to re-derive them
70//! from the per-struct rustdoc:
71//!
72//! | Struct | Constraint |
73//! |---|---|
74//! | [`Name`] | at least one of `components` or `full` |
75//! | [`Organization`] | at least one of `name` or `units` |
76//! | [`SpeakToAs`] | at least one of `grammatical_gender` or `pronouns` |
77//! | [`OnlineService`] | at least one of `uri` or `user` |
78//! | [`Address`] | at least one of `components`, `coordinates`, `country_code`, `full`, or `time_zone` |
79//! | [`Author`] | at least one property other than `@type` |
80//!
81//! Deserialize does not reject inputs that violate these constraints —
82//! the kit accepts partial-response inputs that legitimately omit
83//! fields. The constraint applies only to emitting fresh values. See
84//! `bd:JMAP-sgrr.30`.
85//!
86//! ## Design: numeric-range bounds are not type-enforced
87//!
88//! RFC 9553 specifies bounded ranges for several numeric fields, but
89//! the kit models them as bare `u32` (or `Option<u32>`) without
90//! type-level guarantees. Deserialize accepts out-of-range values
91//! without complaint; callers building a fresh value MUST verify the
92//! bound themselves before serializing. The kit's posture — types
93//! model the wire shape, semantic validity is the consumer's job —
94//! takes precedence over the `NonZeroU32` / `BoundedU8` patterns that
95//! would push validation into the type system but require breaking
96//! API changes to introduce later.
97//!
98//! Affected fields, gathered into one place:
99//!
100//! | Field(s) | RFC bound | RFC section |
101//! |---|---|---|
102//! | `.pref` on [`Nickname`], [`Pronouns`], [`EmailAddress`], [`OnlineService`], [`Phone`], [`LanguagePref`], [`Calendar`], [`SchedulingAddress`], [`Address`], [`CryptoKey`], [`Directory`], [`Link`], [`Media`] (13 sites) | `1..=100`, lower = more preferred | RFC 9553 §1.5.3 |
103//! | [`Directory::list_as`] | `> 0` | RFC 9553 §2.6.2 |
104//! | [`PersonalInfo::list_as`] | `> 0` | RFC 9553 §2.8.4 |
105//! | [`PartialDate::month`] | `1..=12` | RFC 9553 §2.8.1 |
106//! | [`PartialDate::day`] | `1..=31` (with month-specific cap) | RFC 9553 §2.8.1 |
107//!
108//! Newtypes like `Pref(NonZeroU8)` capped at 100 or
109//! `Option<NonZeroU32>` for `list_as` would express the contract at
110//! the type level, but adopting them is a public-API break the kit
111//! is not taking; see `bd:JMAP-sgrr.14`, `bd:JMAP-sgrr.15`, and
112//! `bd:JMAP-sgrr.22`.
113//!
114//! ## Design: `@type` discriminator
115//!
116//! Every RFC 9553 sub-object has an `@type` discriminator on the wire.
117//! The Rust field is named `at_type: Option<String>` and renamed to
118//! `"@type"` via serde attributes, with `default` and
119//! `skip_serializing_if = "Option::is_none"`. The field is modelled as
120//! `Option<String>` (not bare `String`) because RFC 9553 §1.3.4 permits
121//! omitting `@type` whenever the type is implied by context — most
122//! notably when the value is in a `defaultType` position (see
123//! [`Anniversary::date`] / [`AnniversaryDate`] for the worked example).
124//! The value type is `String` (not an enum) to preserve forward-
125//! compatibility with new sub-object types.
126//!
127//! ## Design: `Resource`-derived types
128//!
129//! RFC 9553 §1.4.4 defines the abstract `Resource` common fields
130//! (`@type`, `kind`, `uri`, `mediaType`, `contexts`, `pref`, `label`).
131//! Five concrete types extend `Resource`:
132//! [`Calendar`], [`CryptoKey`], [`Directory`], [`Link`], [`Media`].
133//! Each embeds the common fields directly because the RFC defines the
134//! inheritance for documentation only — the wire format is a flat
135//! object per concrete type.
136
137#![forbid(unsafe_code)]
138
139use std::collections::HashMap;
140
141use serde::{Deserialize, Serialize};
142
143// ── Common helpers ────────────────────────────────────────────────────────────
144
145// ── Name and NameComponent (RFC 9553 §2.2.1) ──────────────────────────────────
146
147/// The name of the entity represented by a Card (RFC 9553 §2.2.1).
148///
149/// At least one of [`components`](Self::components) or [`full`](Self::full)
150/// must be set per the RFC; this is not enforced at the type level.
151#[non_exhaustive]
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct Name {
155 /// Object type discriminator; SHOULD be `"Name"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
156 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
157 pub at_type: Option<String>,
158
159 /// The components making up this name.
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub components: Option<Vec<NameComponent>>,
162
163 /// Whether the components are ordered (default `false`).
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub is_ordered: Option<bool>,
166
167 /// The default separator to insert between component values when
168 /// concatenating them; only valid when `is_ordered` is `true`.
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub default_separator: Option<String>,
171
172 /// The full name representation of the Name. Must be set if
173 /// `components` is not set.
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub full: Option<String>,
176
177 /// Sort-as overrides: `kind` → verbatim string to compare.
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub sort_as: Option<HashMap<String, String>>,
180
181 /// The script used by the `phonetic` property on components.
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub phonetic_script: Option<String>,
184
185 /// The phonetic system used by the `phonetic` property on components.
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub phonetic_system: Option<String>,
188
189 /// Catch-all for vendor / site / private extension fields not covered
190 /// by the typed fields above. Preserves unknown fields across
191 /// deserialize/serialize round-trip per workspace extras-preservation
192 /// policy (see workspace AGENTS.md).
193 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
194 pub extra: serde_json::Map<String, serde_json::Value>,
195}
196
197/// A single component of a [`Name`] (RFC 9553 §2.2.1.2).
198#[non_exhaustive]
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct NameComponent {
202 /// Object type discriminator; SHOULD be `"NameComponent"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
203 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
204 pub at_type: Option<String>,
205
206 /// The component value (e.g. `"Vincent"`).
207 pub value: String,
208
209 /// The kind of name component: `"title"`, `"given"`, `"given2"`,
210 /// `"surname"`, `"surname2"`, `"credential"`, `"generation"`, or
211 /// `"separator"`.
212 pub kind: String,
213
214 /// Phonetic pronunciation of the component. If set, the parent
215 /// [`Name`] must set at least one of `phonetic_script` / `phonetic_system`.
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub phonetic: Option<String>,
218
219 /// Catch-all for vendor / site / private extension fields not covered
220 /// by the typed fields above. Preserves unknown fields across
221 /// deserialize/serialize round-trip per workspace extras-preservation
222 /// policy (see workspace AGENTS.md).
223 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
224 pub extra: serde_json::Map<String, serde_json::Value>,
225}
226
227// ── Nickname (RFC 9553 §2.2.2) ────────────────────────────────────────────────
228
229/// A nickname for the entity represented by a Card (RFC 9553 §2.2.2).
230#[non_exhaustive]
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct Nickname {
234 /// Object type discriminator; SHOULD be `"Nickname"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
235 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
236 pub at_type: Option<String>,
237
238 /// The nickname.
239 pub name: String,
240
241 /// Contexts in which to use the nickname (key → `true`).
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub contexts: Option<HashMap<String, bool>>,
244
245 /// Preference order in 1..=100 (lower = more preferred).
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub pref: Option<u32>,
248
249 /// Catch-all for vendor / site / private extension fields not covered
250 /// by the typed fields above. Preserves unknown fields across
251 /// deserialize/serialize round-trip per workspace extras-preservation
252 /// policy (see workspace AGENTS.md).
253 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
254 pub extra: serde_json::Map<String, serde_json::Value>,
255}
256
257// ── Organization and OrgUnit (RFC 9553 §2.2.3) ────────────────────────────────
258
259/// A company or organization name associated with a Card (RFC 9553 §2.2.3).
260///
261/// At least one of [`name`](Self::name) or [`units`](Self::units) must be
262/// set per the RFC; this is not enforced at the type level.
263#[non_exhaustive]
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct Organization {
267 /// Object type discriminator; SHOULD be `"Organization"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
268 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
269 pub at_type: Option<String>,
270
271 /// The name of the organization.
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub name: Option<String>,
274
275 /// Organizational units, ordered descending by hierarchy. If set,
276 /// must contain at least one entry.
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub units: Option<Vec<OrgUnit>>,
279
280 /// The verbatim string for lexicographic sort by name.
281 #[serde(skip_serializing_if = "Option::is_none")]
282 pub sort_as: Option<String>,
283
284 /// Contexts in which association applies (key → `true`).
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub contexts: Option<HashMap<String, bool>>,
287
288 /// Catch-all for vendor / site / private extension fields not covered
289 /// by the typed fields above. Preserves unknown fields across
290 /// deserialize/serialize round-trip per workspace extras-preservation
291 /// policy (see workspace AGENTS.md).
292 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
293 pub extra: serde_json::Map<String, serde_json::Value>,
294}
295
296/// An organizational unit within an [`Organization`] (RFC 9553 §2.2.3).
297#[non_exhaustive]
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct OrgUnit {
301 /// Object type discriminator; SHOULD be `"OrgUnit"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
302 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
303 pub at_type: Option<String>,
304
305 /// The name of the unit.
306 pub name: String,
307
308 /// The verbatim string for lexicographic sort within this level.
309 #[serde(skip_serializing_if = "Option::is_none")]
310 pub sort_as: Option<String>,
311
312 /// Catch-all for vendor / site / private extension fields not covered
313 /// by the typed fields above. Preserves unknown fields across
314 /// deserialize/serialize round-trip per workspace extras-preservation
315 /// policy (see workspace AGENTS.md).
316 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
317 pub extra: serde_json::Map<String, serde_json::Value>,
318}
319
320// ── SpeakToAs and Pronouns (RFC 9553 §2.2.4) ──────────────────────────────────
321
322/// How to address or refer to the entity represented by a Card
323/// (RFC 9553 §2.2.4).
324///
325/// At least one of [`grammatical_gender`](Self::grammatical_gender) or
326/// [`pronouns`](Self::pronouns) must be set per the RFC.
327#[non_exhaustive]
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct SpeakToAs {
331 /// Object type discriminator; SHOULD be `"SpeakToAs"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
332 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
333 pub at_type: Option<String>,
334
335 /// Grammatical gender to use in salutations: `"animate"`, `"common"`,
336 /// `"feminine"`, `"inanimate"`, `"masculine"`, or `"neuter"`.
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub grammatical_gender: Option<String>,
339
340 /// Map of pronoun Id (per RFC 9553 §1.4.1) → [`Pronouns`] object.
341 /// Keys are bare `String` per the workspace policy that JSContact
342 /// `Id` references on the wire are modelled as `String`; validation
343 /// of the character set (`A-Z`, `a-z`, `0-9`, `-`, `_`, length
344 /// 1–255) is the caller's responsibility.
345 #[serde(skip_serializing_if = "Option::is_none")]
346 pub pronouns: Option<HashMap<String, Pronouns>>,
347
348 /// Catch-all for vendor / site / private extension fields not covered
349 /// by the typed fields above. Preserves unknown fields across
350 /// deserialize/serialize round-trip per workspace extras-preservation
351 /// policy (see workspace AGENTS.md).
352 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
353 pub extra: serde_json::Map<String, serde_json::Value>,
354}
355
356/// A pronouns entry (RFC 9553 §2.2.4).
357#[non_exhaustive]
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct Pronouns {
361 /// Object type discriminator; SHOULD be `"Pronouns"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
362 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
363 pub at_type: Option<String>,
364
365 /// The pronouns (free-form, e.g. `"she/her"`, `"they/them/theirs"`).
366 pub pronouns: String,
367
368 /// Contexts in which to use these pronouns (key → `true`).
369 #[serde(skip_serializing_if = "Option::is_none")]
370 pub contexts: Option<HashMap<String, bool>>,
371
372 /// Preference order in 1..=100 (lower = more preferred).
373 #[serde(skip_serializing_if = "Option::is_none")]
374 pub pref: Option<u32>,
375
376 /// Catch-all for vendor / site / private extension fields not covered
377 /// by the typed fields above. Preserves unknown fields across
378 /// deserialize/serialize round-trip per workspace extras-preservation
379 /// policy (see workspace AGENTS.md).
380 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
381 pub extra: serde_json::Map<String, serde_json::Value>,
382}
383
384// ── Title (RFC 9553 §2.2.5) ───────────────────────────────────────────────────
385
386/// A job title or functional position (RFC 9553 §2.2.5).
387#[non_exhaustive]
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(rename_all = "camelCase")]
390pub struct Title {
391 /// Object type discriminator; SHOULD be `"Title"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
392 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
393 pub at_type: Option<String>,
394
395 /// The title or role name.
396 pub name: String,
397
398 /// `"title"` (default) or `"role"`.
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub kind: Option<String>,
401
402 /// The JSContact `Id` (per RFC 9553 §1.4.1) of the organization in
403 /// which this title is held. Modelled as `String`; validation of
404 /// the character set is the caller's responsibility.
405 #[serde(skip_serializing_if = "Option::is_none")]
406 pub organization_id: Option<String>,
407
408 /// Catch-all for vendor / site / private extension fields not covered
409 /// by the typed fields above. Preserves unknown fields across
410 /// deserialize/serialize round-trip per workspace extras-preservation
411 /// policy (see workspace AGENTS.md).
412 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
413 pub extra: serde_json::Map<String, serde_json::Value>,
414}
415
416// ── EmailAddress (RFC 9553 §2.3.1) ────────────────────────────────────────────
417
418/// An email address (RFC 9553 §2.3.1).
419///
420/// Distinct from the JMAP Mail RFC 8621 §2 binding type
421/// [`jmap_mail_types::EmailAddress`](https://docs.rs/jmap-mail-types):
422/// that type carries an RFC 5322 mailbox (`name` + `email`) and appears
423/// in `Email.from` / `Email.to` etc., whereas this `EmailAddress` is a
424/// JSContact sub-object embedded in a `ContactCard.emails` map.
425#[non_exhaustive]
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(rename_all = "camelCase")]
428pub struct EmailAddress {
429 /// Object type discriminator; SHOULD be `"EmailAddress"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
430 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
431 pub at_type: Option<String>,
432
433 /// The email address. Must be an RFC 5322 addr-spec.
434 pub address: String,
435
436 /// Contexts in which to use the address (key → `true`).
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub contexts: Option<HashMap<String, bool>>,
439
440 /// Preference order in 1..=100 (lower = more preferred).
441 #[serde(skip_serializing_if = "Option::is_none")]
442 pub pref: Option<u32>,
443
444 /// Custom label for the value.
445 #[serde(skip_serializing_if = "Option::is_none")]
446 pub label: Option<String>,
447
448 /// Catch-all for vendor / site / private extension fields not covered
449 /// by the typed fields above. Preserves unknown fields across
450 /// deserialize/serialize round-trip per workspace extras-preservation
451 /// policy (see workspace AGENTS.md).
452 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
453 pub extra: serde_json::Map<String, serde_json::Value>,
454}
455
456// ── OnlineService (RFC 9553 §2.3.2) ───────────────────────────────────────────
457
458/// An online service (messaging service, social media, etc.) (RFC 9553 §2.3.2).
459///
460/// At least one of [`uri`](Self::uri) or [`user`](Self::user) must be set
461/// per the RFC; this is not enforced at the type level.
462#[non_exhaustive]
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(rename_all = "camelCase")]
465pub struct OnlineService {
466 /// Object type discriminator; SHOULD be `"OnlineService"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
467 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
468 pub at_type: Option<String>,
469
470 /// Name of the online service or protocol (e.g. `"GitHub"`, `"Mastodon"`).
471 #[serde(skip_serializing_if = "Option::is_none")]
472 pub service: Option<String>,
473
474 /// Identifier for the entity at this service. Must be a URI (RFC 3986).
475 #[serde(skip_serializing_if = "Option::is_none")]
476 pub uri: Option<String>,
477
478 /// Username at the service.
479 #[serde(skip_serializing_if = "Option::is_none")]
480 pub user: Option<String>,
481
482 /// Contexts in which to use the service (key → `true`).
483 #[serde(skip_serializing_if = "Option::is_none")]
484 pub contexts: Option<HashMap<String, bool>>,
485
486 /// Preference order in 1..=100 (lower = more preferred).
487 #[serde(skip_serializing_if = "Option::is_none")]
488 pub pref: Option<u32>,
489
490 /// Custom label for the value.
491 #[serde(skip_serializing_if = "Option::is_none")]
492 pub label: Option<String>,
493
494 /// Catch-all for vendor / site / private extension fields not covered
495 /// by the typed fields above. Preserves unknown fields across
496 /// deserialize/serialize round-trip per workspace extras-preservation
497 /// policy (see workspace AGENTS.md).
498 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
499 pub extra: serde_json::Map<String, serde_json::Value>,
500}
501
502// ── Phone (RFC 9553 §2.3.3) ───────────────────────────────────────────────────
503
504/// A phone number (RFC 9553 §2.3.3).
505#[non_exhaustive]
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507#[serde(rename_all = "camelCase")]
508pub struct Phone {
509 /// Object type discriminator; SHOULD be `"Phone"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
510 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
511 pub at_type: Option<String>,
512
513 /// The phone number, either as a URI (typically `tel:` or `sip:`) or
514 /// as free text.
515 pub number: String,
516
517 /// Feature flags (key → `true`): `"mobile"`, `"voice"`, `"text"`,
518 /// `"video"`, `"main-number"`, `"textphone"`, `"fax"`, `"pager"`.
519 #[serde(skip_serializing_if = "Option::is_none")]
520 pub features: Option<HashMap<String, bool>>,
521
522 /// Contexts in which to use the number (key → `true`).
523 #[serde(skip_serializing_if = "Option::is_none")]
524 pub contexts: Option<HashMap<String, bool>>,
525
526 /// Preference order in 1..=100 (lower = more preferred).
527 #[serde(skip_serializing_if = "Option::is_none")]
528 pub pref: Option<u32>,
529
530 /// Custom label for the value.
531 #[serde(skip_serializing_if = "Option::is_none")]
532 pub label: Option<String>,
533
534 /// Catch-all for vendor / site / private extension fields not covered
535 /// by the typed fields above. Preserves unknown fields across
536 /// deserialize/serialize round-trip per workspace extras-preservation
537 /// policy (see workspace AGENTS.md).
538 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
539 pub extra: serde_json::Map<String, serde_json::Value>,
540}
541
542// ── LanguagePref (RFC 9553 §2.3.4) ────────────────────────────────────────────
543
544/// A preferred language (RFC 9553 §2.3.4).
545#[non_exhaustive]
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547#[serde(rename_all = "camelCase")]
548pub struct LanguagePref {
549 /// Object type discriminator; SHOULD be `"LanguagePref"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
550 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
551 pub at_type: Option<String>,
552
553 /// BCP 47 language tag.
554 pub language: String,
555
556 /// Contexts in which to use the language (key → `true`).
557 #[serde(skip_serializing_if = "Option::is_none")]
558 pub contexts: Option<HashMap<String, bool>>,
559
560 /// Preference order in 1..=100 (lower = more preferred).
561 #[serde(skip_serializing_if = "Option::is_none")]
562 pub pref: Option<u32>,
563
564 /// Catch-all for vendor / site / private extension fields not covered
565 /// by the typed fields above. Preserves unknown fields across
566 /// deserialize/serialize round-trip per workspace extras-preservation
567 /// policy (see workspace AGENTS.md).
568 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
569 pub extra: serde_json::Map<String, serde_json::Value>,
570}
571
572// ── Calendar (RFC 9553 §2.4.1; extends Resource §1.4.4) ───────────────────────
573
574/// A calendaring resource (RFC 9553 §2.4.1).
575///
576/// Extends the abstract [Resource](crate#design-resource-derived-types)
577/// type with a mandatory `kind` value of either `"calendar"` or `"freeBusy"`.
578///
579/// `kind` and `uri` are mandatory on the wire but modelled as `Option`
580/// to permit partial-response deserialization; callers building a fresh
581/// `Calendar` MUST populate both fields. See the crate-level
582/// [Design: optional fields and `Option<...>`](crate#design-optional-fields-and-option)
583/// section.
584///
585/// Distinct from the JMAP Calendars binding object
586/// [`jmap_calendars_types::Calendar`](https://docs.rs/jmap-calendars-types):
587/// that type is a top-level JMAP wire object with `id`, `name`,
588/// `myRights`, `shareWith`, etc., whereas this `Calendar` is a JSContact
589/// resource sub-object embedded in a `ContactCard`. The two wire shapes
590/// are unrelated.
591#[non_exhaustive]
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
593#[serde(rename_all = "camelCase")]
594pub struct Calendar {
595 /// Object type discriminator; SHOULD be `"Calendar"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
596 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
597 pub at_type: Option<String>,
598
599 /// `"calendar"` or `"freeBusy"`. Mandatory per RFC 9553 §2.4.1, but
600 /// modelled as `Option` to permit partial-response deserialization.
601 #[serde(skip_serializing_if = "Option::is_none")]
602 pub kind: Option<String>,
603
604 /// The resource URI (RFC 3986). Mandatory on the wire per
605 /// RFC 9553 §1.4.4, but modelled as `Option` to permit
606 /// partial-response deserialization.
607 #[serde(skip_serializing_if = "Option::is_none")]
608 pub uri: Option<String>,
609
610 /// IANA media type of the resource.
611 #[serde(skip_serializing_if = "Option::is_none")]
612 pub media_type: Option<String>,
613
614 /// Contexts in which to use the resource (key → `true`).
615 #[serde(skip_serializing_if = "Option::is_none")]
616 pub contexts: Option<HashMap<String, bool>>,
617
618 /// Preference order in 1..=100 (lower = more preferred).
619 #[serde(skip_serializing_if = "Option::is_none")]
620 pub pref: Option<u32>,
621
622 /// Custom label for the value.
623 #[serde(skip_serializing_if = "Option::is_none")]
624 pub label: Option<String>,
625
626 /// Catch-all for vendor / site / private extension fields not covered
627 /// by the typed fields above. Preserves unknown fields across
628 /// deserialize/serialize round-trip per workspace extras-preservation
629 /// policy (see workspace AGENTS.md).
630 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
631 pub extra: serde_json::Map<String, serde_json::Value>,
632}
633
634// ── SchedulingAddress (RFC 9553 §2.4.2) ───────────────────────────────────────
635
636/// An iTIP scheduling address (RFC 9553 §2.4.2).
637#[non_exhaustive]
638#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
639#[serde(rename_all = "camelCase")]
640pub struct SchedulingAddress {
641 /// Object type discriminator; SHOULD be `"SchedulingAddress"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
642 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
643 pub at_type: Option<String>,
644
645 /// The scheduling URI (RFC 3986).
646 pub uri: String,
647
648 /// Contexts in which to use the scheduling address (key → `true`).
649 #[serde(skip_serializing_if = "Option::is_none")]
650 pub contexts: Option<HashMap<String, bool>>,
651
652 /// Preference order in 1..=100 (lower = more preferred).
653 #[serde(skip_serializing_if = "Option::is_none")]
654 pub pref: Option<u32>,
655
656 /// Custom label for the value.
657 #[serde(skip_serializing_if = "Option::is_none")]
658 pub label: Option<String>,
659
660 /// Catch-all for vendor / site / private extension fields not covered
661 /// by the typed fields above. Preserves unknown fields across
662 /// deserialize/serialize round-trip per workspace extras-preservation
663 /// policy (see workspace AGENTS.md).
664 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
665 pub extra: serde_json::Map<String, serde_json::Value>,
666}
667
668// ── Address and AddressComponent (RFC 9553 §2.5.1) ────────────────────────────
669
670/// A postal or geographic address (RFC 9553 §2.5.1).
671///
672/// At least one of `components`, `coordinates`, `country_code`, `full`,
673/// or `time_zone` must be set per the RFC; this is not enforced at the
674/// type level.
675///
676/// Distinct from the JMAP Mail RFC 8621 §3.2 submission-address type
677/// [`jmap_mail_types::Address`](https://docs.rs/jmap-mail-types):
678/// that type is an RFC 5321 SMTP envelope address with `email` and
679/// `parameters`, whereas this `Address` is a JSContact postal-address
680/// sub-object embedded in a `ContactCard.addresses` map.
681#[non_exhaustive]
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683#[serde(rename_all = "camelCase")]
684pub struct Address {
685 /// Object type discriminator; SHOULD be `"Address"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
686 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
687 pub at_type: Option<String>,
688
689 /// The components making up this address.
690 #[serde(skip_serializing_if = "Option::is_none")]
691 pub components: Option<Vec<AddressComponent>>,
692
693 /// Whether components are ordered (default `false`).
694 #[serde(skip_serializing_if = "Option::is_none")]
695 pub is_ordered: Option<bool>,
696
697 /// ISO 3166-1 Alpha-2 country code.
698 #[serde(skip_serializing_if = "Option::is_none")]
699 pub country_code: Option<String>,
700
701 /// `geo:` URI for the address (RFC 5870).
702 #[serde(skip_serializing_if = "Option::is_none")]
703 pub coordinates: Option<String>,
704
705 /// IANA time zone name for the address.
706 #[serde(skip_serializing_if = "Option::is_none")]
707 pub time_zone: Option<String>,
708
709 /// Contexts (key → `true`); extra keys beyond common contexts are
710 /// `"billing"` and `"delivery"`.
711 #[serde(skip_serializing_if = "Option::is_none")]
712 pub contexts: Option<HashMap<String, bool>>,
713
714 /// The full address as a single string.
715 #[serde(skip_serializing_if = "Option::is_none")]
716 pub full: Option<String>,
717
718 /// Default separator between component values when concatenating.
719 #[serde(skip_serializing_if = "Option::is_none")]
720 pub default_separator: Option<String>,
721
722 /// Preference order in 1..=100 (lower = more preferred).
723 #[serde(skip_serializing_if = "Option::is_none")]
724 pub pref: Option<u32>,
725
726 /// Phonetic script for component phonetics.
727 #[serde(skip_serializing_if = "Option::is_none")]
728 pub phonetic_script: Option<String>,
729
730 /// Phonetic system for component phonetics.
731 #[serde(skip_serializing_if = "Option::is_none")]
732 pub phonetic_system: Option<String>,
733
734 /// Catch-all for vendor / site / private extension fields not covered
735 /// by the typed fields above. Preserves unknown fields across
736 /// deserialize/serialize round-trip per workspace extras-preservation
737 /// policy (see workspace AGENTS.md).
738 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
739 pub extra: serde_json::Map<String, serde_json::Value>,
740}
741
742/// A single component of an [`Address`] (RFC 9553 §2.5.1.2).
743///
744/// Enumerated `kind` values include `"room"`, `"apartment"`, `"floor"`,
745/// `"building"`, `"number"`, `"name"`, `"block"`, `"subdistrict"`,
746/// `"district"`, `"locality"`, `"region"`, `"postcode"`, `"country"`,
747/// `"direction"`, `"landmark"`, `"postOfficeBox"`, `"separator"`.
748#[non_exhaustive]
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
750#[serde(rename_all = "camelCase")]
751pub struct AddressComponent {
752 /// Object type discriminator; SHOULD be `"AddressComponent"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
753 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
754 pub at_type: Option<String>,
755
756 /// The component value.
757 pub value: String,
758
759 /// The kind of address component (see type-level doc for enumerated values).
760 pub kind: String,
761
762 /// Phonetic pronunciation. If set, parent [`Address`] must set at
763 /// least one of `phonetic_script` / `phonetic_system`.
764 #[serde(skip_serializing_if = "Option::is_none")]
765 pub phonetic: Option<String>,
766
767 /// Catch-all for vendor / site / private extension fields not covered
768 /// by the typed fields above. Preserves unknown fields across
769 /// deserialize/serialize round-trip per workspace extras-preservation
770 /// policy (see workspace AGENTS.md).
771 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
772 pub extra: serde_json::Map<String, serde_json::Value>,
773}
774
775// ── CryptoKey (RFC 9553 §2.6.1; extends Resource §1.4.4) ──────────────────────
776
777/// A cryptographic key or certificate associated with a Card
778/// (RFC 9553 §2.6.1). Extends the abstract Resource type.
779///
780/// `uri` is mandatory on the wire but modelled as `Option` to permit
781/// partial-response deserialization; callers building a fresh
782/// `CryptoKey` MUST populate it. See the crate-level
783/// [Design: optional fields and `Option<...>`](crate#design-optional-fields-and-option)
784/// section.
785#[non_exhaustive]
786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
787#[serde(rename_all = "camelCase")]
788pub struct CryptoKey {
789 /// Object type discriminator; SHOULD be `"CryptoKey"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
790 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
791 pub at_type: Option<String>,
792
793 /// Kind of resource (optional for CryptoKey per RFC 9553 §2.6.1).
794 #[serde(skip_serializing_if = "Option::is_none")]
795 pub kind: Option<String>,
796
797 /// The resource URI (RFC 3986). Mandatory per RFC 9553 §1.4.4 but
798 /// modelled as `Option` for partial-response deserialization.
799 #[serde(skip_serializing_if = "Option::is_none")]
800 pub uri: Option<String>,
801
802 /// IANA media type of the resource.
803 #[serde(skip_serializing_if = "Option::is_none")]
804 pub media_type: Option<String>,
805
806 /// Contexts in which to use the resource (key → `true`).
807 #[serde(skip_serializing_if = "Option::is_none")]
808 pub contexts: Option<HashMap<String, bool>>,
809
810 /// Preference order in 1..=100 (lower = more preferred).
811 #[serde(skip_serializing_if = "Option::is_none")]
812 pub pref: Option<u32>,
813
814 /// Custom label for the value.
815 #[serde(skip_serializing_if = "Option::is_none")]
816 pub label: Option<String>,
817
818 /// Catch-all for vendor / site / private extension fields not covered
819 /// by the typed fields above. Preserves unknown fields across
820 /// deserialize/serialize round-trip per workspace extras-preservation
821 /// policy (see workspace AGENTS.md).
822 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
823 pub extra: serde_json::Map<String, serde_json::Value>,
824}
825
826// ── Directory (RFC 9553 §2.6.2; extends Resource §1.4.4) ──────────────────────
827
828/// A directory service associated with a Card (RFC 9553 §2.6.2).
829///
830/// Extends the abstract Resource type with a mandatory `kind` value of
831/// either `"directory"` or `"entry"`, and an extra `list_as` ordering hint.
832///
833/// `kind` and `uri` are mandatory on the wire but modelled as `Option`
834/// to permit partial-response deserialization; callers building a fresh
835/// `Directory` MUST populate both fields. See the crate-level
836/// [Design: optional fields and `Option<...>`](crate#design-optional-fields-and-option)
837/// section.
838#[non_exhaustive]
839#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
840#[serde(rename_all = "camelCase")]
841pub struct Directory {
842 /// Object type discriminator; SHOULD be `"Directory"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
843 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
844 pub at_type: Option<String>,
845
846 /// `"directory"` or `"entry"`. Mandatory per RFC 9553 §2.6.2,
847 /// modelled as `Option` for partial-response deserialization.
848 #[serde(skip_serializing_if = "Option::is_none")]
849 pub kind: Option<String>,
850
851 /// The resource URI (RFC 3986). Mandatory per RFC 9553 §1.4.4 but
852 /// modelled as `Option` for partial-response deserialization.
853 #[serde(skip_serializing_if = "Option::is_none")]
854 pub uri: Option<String>,
855
856 /// IANA media type of the resource.
857 #[serde(skip_serializing_if = "Option::is_none")]
858 pub media_type: Option<String>,
859
860 /// Contexts in which to use the resource (key → `true`).
861 #[serde(skip_serializing_if = "Option::is_none")]
862 pub contexts: Option<HashMap<String, bool>>,
863
864 /// Preference order in 1..=100 (lower = more preferred).
865 #[serde(skip_serializing_if = "Option::is_none")]
866 pub pref: Option<u32>,
867
868 /// Custom label for the value.
869 #[serde(skip_serializing_if = "Option::is_none")]
870 pub label: Option<String>,
871
872 /// Position in the list of Directory objects of the same `kind`
873 /// (RFC 9553 §2.6.2). Must be > 0 when set.
874 #[serde(skip_serializing_if = "Option::is_none")]
875 pub list_as: Option<u32>,
876
877 /// Catch-all for vendor / site / private extension fields not covered
878 /// by the typed fields above. Preserves unknown fields across
879 /// deserialize/serialize round-trip per workspace extras-preservation
880 /// policy (see workspace AGENTS.md).
881 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
882 pub extra: serde_json::Map<String, serde_json::Value>,
883}
884
885// ── Link (RFC 9553 §2.6.3; extends Resource §1.4.4) ───────────────────────────
886
887/// A generic resource link associated with a Card (RFC 9553 §2.6.3).
888///
889/// Extends the abstract Resource type. The `kind` value is optional;
890/// when set, the only enumerated value is `"contact"`.
891///
892/// `uri` is mandatory on the wire but modelled as `Option` to permit
893/// partial-response deserialization; callers building a fresh `Link`
894/// MUST populate it. See the crate-level
895/// [Design: optional fields and `Option<...>`](crate#design-optional-fields-and-option)
896/// section.
897///
898/// Distinct from JSCalendar's `Link` type ([`jmap_jscalendar_types::Link`](https://docs.rs/jmap-jscalendar-types));
899/// the two are unrelated wire-format types.
900#[non_exhaustive]
901#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
902#[serde(rename_all = "camelCase")]
903pub struct Link {
904 /// Object type discriminator; SHOULD be `"Link"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
905 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
906 pub at_type: Option<String>,
907
908 /// Optional `"contact"` discriminator.
909 #[serde(skip_serializing_if = "Option::is_none")]
910 pub kind: Option<String>,
911
912 /// The resource URI (RFC 3986). Mandatory per RFC 9553 §1.4.4 but
913 /// modelled as `Option` for partial-response deserialization.
914 #[serde(skip_serializing_if = "Option::is_none")]
915 pub uri: Option<String>,
916
917 /// IANA media type of the resource.
918 #[serde(skip_serializing_if = "Option::is_none")]
919 pub media_type: Option<String>,
920
921 /// Contexts in which to use the resource (key → `true`).
922 #[serde(skip_serializing_if = "Option::is_none")]
923 pub contexts: Option<HashMap<String, bool>>,
924
925 /// Preference order in 1..=100 (lower = more preferred).
926 #[serde(skip_serializing_if = "Option::is_none")]
927 pub pref: Option<u32>,
928
929 /// Custom label for the value.
930 #[serde(skip_serializing_if = "Option::is_none")]
931 pub label: Option<String>,
932
933 /// Catch-all for vendor / site / private extension fields not covered
934 /// by the typed fields above. Preserves unknown fields across
935 /// deserialize/serialize round-trip per workspace extras-preservation
936 /// policy (see workspace AGENTS.md).
937 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
938 pub extra: serde_json::Map<String, serde_json::Value>,
939}
940
941// ── Media (RFC 9553 §2.6.4; extends Resource §1.4.4) ──────────────────────────
942
943/// A media resource associated with a Card (RFC 9553 §2.6.4).
944///
945/// Extends the abstract Resource type with a mandatory `kind` value of
946/// `"photo"`, `"sound"`, or `"logo"`.
947///
948/// `kind` and `uri` are mandatory on the wire but modelled as `Option`
949/// to permit partial-response deserialization; callers building a fresh
950/// `Media` MUST populate both fields. See the crate-level
951/// [Design: optional fields and `Option<...>`](crate#design-optional-fields-and-option)
952/// section.
953#[non_exhaustive]
954#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
955#[serde(rename_all = "camelCase")]
956pub struct Media {
957 /// Object type discriminator; SHOULD be `"Media"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
958 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
959 pub at_type: Option<String>,
960
961 /// `"photo"`, `"sound"`, or `"logo"`. Mandatory per RFC 9553 §2.6.4,
962 /// modelled as `Option` for partial-response deserialization.
963 #[serde(skip_serializing_if = "Option::is_none")]
964 pub kind: Option<String>,
965
966 /// The resource URI (RFC 3986). Mandatory per RFC 9553 §1.4.4 but
967 /// modelled as `Option` for partial-response deserialization.
968 #[serde(skip_serializing_if = "Option::is_none")]
969 pub uri: Option<String>,
970
971 /// IANA media type of the resource.
972 #[serde(skip_serializing_if = "Option::is_none")]
973 pub media_type: Option<String>,
974
975 /// Contexts in which to use the resource (key → `true`).
976 #[serde(skip_serializing_if = "Option::is_none")]
977 pub contexts: Option<HashMap<String, bool>>,
978
979 /// Preference order in 1..=100 (lower = more preferred).
980 #[serde(skip_serializing_if = "Option::is_none")]
981 pub pref: Option<u32>,
982
983 /// Custom label for the value.
984 #[serde(skip_serializing_if = "Option::is_none")]
985 pub label: Option<String>,
986
987 /// Catch-all for vendor / site / private extension fields not covered
988 /// by the typed fields above. Preserves unknown fields across
989 /// deserialize/serialize round-trip per workspace extras-preservation
990 /// policy (see workspace AGENTS.md).
991 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
992 pub extra: serde_json::Map<String, serde_json::Value>,
993}
994
995// ── Anniversary, PartialDate, Timestamp (RFC 9553 §2.8.1) ─────────────────────
996
997/// A complete or partial Gregorian calendar date (RFC 9553 §2.8.1).
998///
999/// Used by [`Anniversary`]. Any of `year`, `month`, `day` may be absent,
1000/// representing a partial date; `month` requires either `year` or `day`,
1001/// and `day` requires `month`.
1002#[non_exhaustive]
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1004#[serde(rename_all = "camelCase")]
1005pub struct PartialDate {
1006 /// Object type discriminator; SHOULD be `"PartialDate"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
1007 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
1008 pub at_type: Option<String>,
1009
1010 /// Calendar year.
1011 #[serde(skip_serializing_if = "Option::is_none")]
1012 pub year: Option<u32>,
1013
1014 /// Calendar month, 1..=12. The type does NOT range-check; callers
1015 /// MUST verify the value is in `1..=12` before treating the
1016 /// `PartialDate` as RFC 9553-conformant. Deserialize accepts
1017 /// out-of-range values (e.g. `13`) without complaint.
1018 #[serde(skip_serializing_if = "Option::is_none")]
1019 pub month: Option<u32>,
1020
1021 /// Calendar day of month, 1..=31 (with month-specific cap of
1022 /// 28/29/30/31). The type does NOT range-check; callers MUST
1023 /// verify the value is in `1..=31` and within the month-specific
1024 /// cap before treating the `PartialDate` as RFC 9553-conformant.
1025 /// Deserialize accepts out-of-range values (e.g. `32`) without
1026 /// complaint.
1027 #[serde(skip_serializing_if = "Option::is_none")]
1028 pub day: Option<u32>,
1029
1030 /// Calendar system (lowercase CLDR name or vendor-specific value).
1031 #[serde(skip_serializing_if = "Option::is_none")]
1032 pub calendar_scale: Option<String>,
1033
1034 /// Catch-all for vendor / site / private extension fields not covered
1035 /// by the typed fields above. Preserves unknown fields across
1036 /// deserialize/serialize round-trip per workspace extras-preservation
1037 /// policy (see workspace AGENTS.md).
1038 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1039 pub extra: serde_json::Map<String, serde_json::Value>,
1040}
1041
1042/// A UTC point in time (RFC 9553 §2.8.1).
1043///
1044/// Used by [`Anniversary`] as one of the two alternative `date` value
1045/// shapes (the other being [`PartialDate`]).
1046#[non_exhaustive]
1047#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1048#[serde(rename_all = "camelCase")]
1049pub struct Timestamp {
1050 /// Object type discriminator; required to be `"Timestamp"` on the
1051 /// wire when the value is used as an `Anniversary.date` (because the
1052 /// default-type for that field is `PartialDate`; explicit `@type`
1053 /// is what selects this variant).
1054 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
1055 pub at_type: Option<String>,
1056
1057 /// The UTC date-time (RFC 9553 §1.4.5 `UTCDateTime`): an RFC 3339
1058 /// date-time string with the time-offset always `"Z"`, e.g.
1059 /// `"2022-05-22T03:30:00Z"`.
1060 ///
1061 /// Stored as bare `String` so deserialize accepts any peer-emitted
1062 /// value losslessly. The format is NOT validated at construction or
1063 /// deserialize time; callers that need the parsed value should pipe
1064 /// it through `chrono::DateTime::parse_from_rfc3339` or
1065 /// `time::OffsetDateTime::parse` themselves (per the workspace
1066 /// convention used by `jmap_types::UTCDate`, which carries the same
1067 /// parse-on-demand contract).
1068 pub utc: String,
1069
1070 /// Catch-all for vendor / site / private extension fields not covered
1071 /// by the typed fields above. Preserves unknown fields across
1072 /// deserialize/serialize round-trip per workspace extras-preservation
1073 /// policy (see workspace AGENTS.md).
1074 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1075 pub extra: serde_json::Map<String, serde_json::Value>,
1076}
1077
1078/// The date value of an [`Anniversary`] — either a [`PartialDate`] or a
1079/// [`Timestamp`] (RFC 9553 §2.8.1).
1080///
1081/// Wire selection follows JSContact's `@type` discriminator rules
1082/// (RFC 9553 §1.3.4): for the default type ([`PartialDate`]), the
1083/// `@type` discriminator may be omitted; for [`Timestamp`], it must be
1084/// `"Timestamp"`.
1085///
1086/// An [`Unknown`](Self::Unknown) variant preserves any other shape as
1087/// raw JSON for round-trip fidelity.
1088///
1089/// Serde is implemented manually because `#[serde(tag = "@type", other)]`
1090/// with tuple variants is not supported by the derive macro.
1091///
1092/// # Deserialize dispatch contract
1093///
1094/// - `@type` absent (no field at all) and `@type: "PartialDate"` both
1095/// route to the [`PartialDate`](Self::PartialDate) variant. Per RFC
1096/// 9553 §1.3.4 the wire forms are interchangeable for the default
1097/// type; this enum treats them as a single variant.
1098/// - `@type: "Timestamp"` routes to the [`Timestamp`](Self::Timestamp)
1099/// variant.
1100/// - Any other `@type` string value (including the literal empty
1101/// string, which RFC 9553 does not define) routes to
1102/// [`Unknown`](Self::Unknown) with the original `serde_json::Value`
1103/// preserved for round-trip.
1104///
1105/// # Error context
1106///
1107/// Errors parsing the inner `PartialDate` or `Timestamp` body surface
1108/// as `serde::de::Error::custom` strings. They do not include the
1109/// path within a parent [`Anniversary`]; callers debugging a deeply-
1110/// nested `ContactCard` (from the consumer `jmap-contacts-types`
1111/// crate) should wrap the parse and add context at the call site.
1112#[non_exhaustive]
1113#[derive(Debug, Clone, PartialEq, Eq)]
1114pub enum AnniversaryDate {
1115 /// A partial Gregorian date (the default per RFC 9553 §2.8.1).
1116 PartialDate(PartialDate),
1117 /// An absolute UTC timestamp.
1118 Timestamp(Timestamp),
1119 /// Any other shape; preserved opaquely.
1120 ///
1121 /// Carries the raw `serde_json::Value` so a future-spec `@type`
1122 /// variant a JSContact extension server may emit round-trips
1123 /// losslessly through this crate, even though the kit cannot
1124 /// dispatch on it. Matches the workspace extras-preservation
1125 /// posture (see workspace `AGENTS.md`).
1126 ///
1127 /// **Do not remove this variant** to "simplify" the enum into
1128 /// `{PartialDate, Timestamp}` only — that would force a
1129 /// deserialize error on any spec-conformant input carrying a
1130 /// future `@type` value, silently losing data. The variant exists
1131 /// specifically to prevent that bug. See `bd:JMAP-sgrr.10`.
1132 ///
1133 /// # Construction precondition (Rust-side callers)
1134 ///
1135 /// Lossless round-trip through this variant requires that the
1136 /// wrapped [`serde_json::Value`] is **either**:
1137 ///
1138 /// 1. not a JSON object (any scalar, array, or `null`), **or**
1139 /// 2. a JSON object whose `@type` field is set to a string value
1140 /// *outside* the set the deserializer dispatches on —
1141 /// currently `{"PartialDate", "Timestamp"}` plus implicit
1142 /// `@type` absence (which routes to `PartialDate` per
1143 /// RFC 9553 §2.8.1's `defaultType` rule).
1144 ///
1145 /// Wrapping a `Value` that is an object **with** `@type` set to
1146 /// `"PartialDate"` or `"Timestamp"`, **or** an object with no
1147 /// `@type` at all (a bare `PartialDate` shape), will not survive
1148 /// a serialize → deserialize round trip as `Unknown`: the
1149 /// deserializer will re-dispatch on the recognised `@type` (or on
1150 /// `defaultType = PartialDate` if `@type` is absent) and produce
1151 /// the corresponding typed variant. Variant identity is lost,
1152 /// though the underlying field data is preserved through the
1153 /// typed shape.
1154 ///
1155 /// This is intentional: the dispatch contract documented above is
1156 /// driven entirely by `@type`; the variant is for shapes the
1157 /// dispatcher does not recognise. No spec-conformant RFC 9553
1158 /// emitter produces wire input that triggers this asymmetry — the
1159 /// concern is composing callers who hand-construct `Unknown(v)`
1160 /// without first checking `v`. See `bd:JMAP-sgrr.28`.
1161 Unknown(serde_json::Value),
1162}
1163
1164impl Serialize for AnniversaryDate {
1165 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1166 match self {
1167 AnniversaryDate::PartialDate(d) => d.serialize(s),
1168 // When emitting a `Timestamp` in `AnniversaryDate` position the
1169 // `@type` discriminator is what distinguishes the variant from
1170 // the default `PartialDate`; if the caller left `at_type` as
1171 // `None` we re-stamp it on the way out so the wire output is
1172 // unambiguous and survives a serialize→deserialize round trip.
1173 // A caller-provided `at_type` (even an odd value) is preserved
1174 // verbatim for forward-compat / faithful echo. See bd:JMAP-sgrr.29.
1175 AnniversaryDate::Timestamp(t) if t.at_type.is_none() => {
1176 let mut stamped = t.clone();
1177 stamped.at_type = Some("Timestamp".to_owned());
1178 stamped.serialize(s)
1179 }
1180 AnniversaryDate::Timestamp(t) => t.serialize(s),
1181 AnniversaryDate::Unknown(v) => v.serialize(s),
1182 }
1183 }
1184}
1185
1186impl<'de> Deserialize<'de> for AnniversaryDate {
1187 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1188 // Deserialize into an intermediate Value, then dispatch on @type.
1189 let v = serde_json::Value::deserialize(d)?;
1190 // Non-object Values (scalar, array, null) cannot be a PartialDate
1191 // or Timestamp — both are JSON objects with @type. Route them to
1192 // Unknown so any AnniversaryDate constructed in Rust as
1193 // Unknown(non-object) survives a serialize→deserialize round
1194 // trip. RFC 9553 itself never emits a non-object date, so this
1195 // branch is unreachable from spec-conformant wire input.
1196 if !v.is_object() {
1197 return Ok(AnniversaryDate::Unknown(v));
1198 }
1199 // Match on Option<&str> so absent @type (None) and any concrete
1200 // @type value (Some(_)) are dispatched without conflating absent
1201 // with a literal empty string. RFC 9553 §1.3.4 makes @type
1202 // omissible in defaultType positions but does not define an
1203 // empty-string @type, so empty strings are routed to Unknown
1204 // along with any other unrecognised @type value.
1205 match v.get("@type").and_then(|t| t.as_str()) {
1206 // RFC 9553 §2.8.1: PartialDate is the default type when @type
1207 // is absent (or explicitly set to "PartialDate").
1208 None | Some("PartialDate") => {
1209 let d: PartialDate = serde_json::from_value(v).map_err(serde::de::Error::custom)?;
1210 Ok(AnniversaryDate::PartialDate(d))
1211 }
1212 Some("Timestamp") => {
1213 let t: Timestamp = serde_json::from_value(v).map_err(serde::de::Error::custom)?;
1214 Ok(AnniversaryDate::Timestamp(t))
1215 }
1216 Some(_) => Ok(AnniversaryDate::Unknown(v)),
1217 }
1218 }
1219}
1220
1221/// A memorable date or event (RFC 9553 §2.8.1).
1222#[non_exhaustive]
1223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1224#[serde(rename_all = "camelCase")]
1225pub struct Anniversary {
1226 /// Object type discriminator; SHOULD be `"Anniversary"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
1227 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
1228 pub at_type: Option<String>,
1229
1230 /// `"birth"`, `"death"`, or `"wedding"`.
1231 pub kind: String,
1232
1233 /// The date of the anniversary — either a [`PartialDate`] or a
1234 /// [`Timestamp`].
1235 pub date: AnniversaryDate,
1236
1237 /// An associated address (e.g. place of birth or death).
1238 #[serde(skip_serializing_if = "Option::is_none")]
1239 pub place: Option<Address>,
1240
1241 /// Catch-all for vendor / site / private extension fields not covered
1242 /// by the typed fields above. Preserves unknown fields across
1243 /// deserialize/serialize round-trip per workspace extras-preservation
1244 /// policy (see workspace AGENTS.md).
1245 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1246 pub extra: serde_json::Map<String, serde_json::Value>,
1247}
1248
1249// ── Note and Author (RFC 9553 §2.8.3) ─────────────────────────────────────────
1250
1251/// A free-text note associated with a Card (RFC 9553 §2.8.3).
1252#[non_exhaustive]
1253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1254#[serde(rename_all = "camelCase")]
1255pub struct Note {
1256 /// Object type discriminator; SHOULD be `"Note"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
1257 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
1258 pub at_type: Option<String>,
1259
1260 /// The free-text value of this note.
1261 pub note: String,
1262
1263 /// UTC date-time when the note was created (RFC 9553 §1.4.5
1264 /// `UTCDateTime`): an RFC 3339 date-time string with the time-offset
1265 /// always `"Z"`, e.g. `"2022-05-22T03:30:00Z"`.
1266 ///
1267 /// Stored as bare `String` and not validated; callers that need the
1268 /// parsed value should use `chrono::DateTime::parse_from_rfc3339` or
1269 /// `time::OffsetDateTime::parse`. Same contract as [`Timestamp::utc`].
1270 #[serde(skip_serializing_if = "Option::is_none")]
1271 pub created: Option<String>,
1272
1273 /// The author of this note.
1274 #[serde(skip_serializing_if = "Option::is_none")]
1275 pub author: Option<Author>,
1276
1277 /// Catch-all for vendor / site / private extension fields not covered
1278 /// by the typed fields above. Preserves unknown fields across
1279 /// deserialize/serialize round-trip per workspace extras-preservation
1280 /// policy (see workspace AGENTS.md).
1281 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1282 pub extra: serde_json::Map<String, serde_json::Value>,
1283}
1284
1285/// The author of a [`Note`] (RFC 9553 §2.8.3).
1286///
1287/// At least one property other than `@type` must be set per the RFC;
1288/// this is not enforced at the type level.
1289#[non_exhaustive]
1290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1291#[serde(rename_all = "camelCase")]
1292pub struct Author {
1293 /// Object type discriminator; SHOULD be `"Author"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
1294 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
1295 pub at_type: Option<String>,
1296
1297 /// Name of the author.
1298 #[serde(skip_serializing_if = "Option::is_none")]
1299 pub name: Option<String>,
1300
1301 /// URI that identifies the author.
1302 #[serde(skip_serializing_if = "Option::is_none")]
1303 pub uri: Option<String>,
1304
1305 /// Catch-all for vendor / site / private extension fields not covered
1306 /// by the typed fields above. Preserves unknown fields across
1307 /// deserialize/serialize round-trip per workspace extras-preservation
1308 /// policy (see workspace AGENTS.md).
1309 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1310 pub extra: serde_json::Map<String, serde_json::Value>,
1311}
1312
1313// ── PersonalInfo (RFC 9553 §2.8.4) ────────────────────────────────────────────
1314
1315/// Personal information such as an expertise, hobby, or interest
1316/// (RFC 9553 §2.8.4).
1317#[non_exhaustive]
1318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1319#[serde(rename_all = "camelCase")]
1320pub struct PersonalInfo {
1321 /// Object type discriminator; SHOULD be `"PersonalInfo"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
1322 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
1323 pub at_type: Option<String>,
1324
1325 /// `"expertise"`, `"hobby"`, or `"interest"`.
1326 pub kind: String,
1327
1328 /// The actual information value.
1329 pub value: String,
1330
1331 /// Level of engagement: `"high"`, `"medium"`, or `"low"`.
1332 #[serde(skip_serializing_if = "Option::is_none")]
1333 pub level: Option<String>,
1334
1335 /// Position in the list of PersonalInfo entries of the same kind.
1336 /// Must be > 0 when set.
1337 #[serde(skip_serializing_if = "Option::is_none")]
1338 pub list_as: Option<u32>,
1339
1340 /// Custom label.
1341 #[serde(skip_serializing_if = "Option::is_none")]
1342 pub label: Option<String>,
1343
1344 /// Catch-all for vendor / site / private extension fields not covered
1345 /// by the typed fields above. Preserves unknown fields across
1346 /// deserialize/serialize round-trip per workspace extras-preservation
1347 /// policy (see workspace AGENTS.md).
1348 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1349 pub extra: serde_json::Map<String, serde_json::Value>,
1350}
1351
1352// ── Relation (RFC 9553 §2.1.8) ────────────────────────────────────────────────
1353
1354/// A relationship to another Card (RFC 9553 §2.1.8).
1355///
1356/// This is the value type for the `relatedTo` property on a `ContactCard`.
1357/// Each map key is the `uid` of the related Card; each value is a
1358/// `Relation` object describing the relationship.
1359///
1360/// Distinct from the JSCalendar RFC 8984 §1.4.10 type
1361/// [`jmap_jscalendar_types::Relation`](https://docs.rs/jmap-jscalendar-types):
1362/// that type relates JSCalendar entries (events, tasks) via UID and
1363/// has a different `relation` enumeration; this `Relation` relates
1364/// JSContact Cards.
1365#[non_exhaustive]
1366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1367#[serde(rename_all = "camelCase")]
1368pub struct Relation {
1369 /// Object type discriminator; SHOULD be `"Relation"` when present per RFC 9553 §1.3.4 (may be omitted in defaultType positions).
1370 #[serde(rename = "@type", default, skip_serializing_if = "Option::is_none")]
1371 pub at_type: Option<String>,
1372
1373 /// Set of relation types (key → `true`). Initial enumerated values:
1374 /// `"acquaintance"`, `"agent"`, `"child"`, `"co-resident"`,
1375 /// `"co-worker"`, `"colleague"`, `"contact"`, `"crush"`, `"date"`,
1376 /// `"emergency"`, `"friend"`, `"kin"`, `"me"`, `"met"`, `"muse"`,
1377 /// `"neighbor"`, `"parent"`, `"sibling"`, `"spouse"`, `"sweetheart"`.
1378 #[serde(skip_serializing_if = "Option::is_none")]
1379 pub relation: Option<HashMap<String, bool>>,
1380
1381 /// Catch-all for vendor / site / private extension fields not covered
1382 /// by the typed fields above. Preserves unknown fields across
1383 /// deserialize/serialize round-trip per workspace extras-preservation
1384 /// policy (see workspace AGENTS.md).
1385 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1386 pub extra: serde_json::Map<String, serde_json::Value>,
1387}
1388
1389// ─────────────────────────────────────────────────────────────────────────────
1390// Tests
1391// ─────────────────────────────────────────────────────────────────────────────
1392
1393#[cfg(test)]
1394mod tests {
1395 //! Round-trip tests using RFC 9553 example JSON as the oracle.
1396 //!
1397 //! Each test loads a hand-typed JSON fixture taken verbatim from a
1398 //! figure in RFC 9553, parses it into the typed struct, re-serializes,
1399 //! and checks the round-trip preserves the data. The RFC is the
1400 //! oracle — expected values are never derived from the code under
1401 //! test.
1402 //!
1403 //! ## Do not generate fixtures programmatically
1404 //!
1405 //! It is tempting to reduce repetition by replacing the hand-typed
1406 //! JSON literals with something like
1407 //! `serde_json::to_value(Name::default())`. Do not. A fixture
1408 //! generated from the code under test is not an independent oracle —
1409 //! it would only verify that `to_value(from_value(x)) == to_value(x)`,
1410 //! which is a tautology. The figure-numbered fixtures verify that
1411 //! the typed shape matches the wire shape the RFC says the wire
1412 //! shape is, which is the only check that catches a misnamed serde
1413 //! attribute or a renamed field.
1414 //!
1415 //! When RFC 9553 errata land, the figure-of-record citations in
1416 //! each test name (`figure_16`, `figure_19`, etc.) are the audit
1417 //! trail: re-check the erratum against the corresponding figure
1418 //! before changing the fixture. See workspace `AGENTS.md` "Test
1419 //! Integrity" and this crate's `PLAN.md` §"Round-trip test policy".
1420
1421 use super::*;
1422 use serde_json::json;
1423
1424 fn assert_roundtrip<T>(value: serde_json::Value)
1425 where
1426 T: serde::de::DeserializeOwned + Serialize,
1427 {
1428 let typed: T = serde_json::from_value(value.clone())
1429 .unwrap_or_else(|e| panic!("deserialize failed: {e}\ninput: {value}"));
1430 let back = serde_json::to_value(&typed).unwrap_or_else(|e| panic!("serialize failed: {e}"));
1431 assert_eq!(back, value, "round-trip mismatch");
1432 }
1433
1434 // ── Name + NameComponent (RFC 9553 Figure 16 / §2.2.1) ────────────────
1435
1436 #[test]
1437 fn name_roundtrip_figure_16() {
1438 // RFC 9553 Figure 16: "Vincent van Gogh"
1439 let v = json!({
1440 "components": [
1441 { "kind": "given", "value": "Vincent" },
1442 { "kind": "surname", "value": "van Gogh" }
1443 ],
1444 "isOrdered": true
1445 });
1446 assert_roundtrip::<Name>(v);
1447 }
1448
1449 #[test]
1450 fn name_roundtrip_figure_19() {
1451 // RFC 9553 Figure 19: sortAs example
1452 let v = json!({
1453 "components": [
1454 { "kind": "given", "value": "Robert" },
1455 { "kind": "given2", "value": "Pau" },
1456 { "kind": "surname", "value": "Shou Chang" }
1457 ],
1458 "sortAs": {
1459 "surname": "Pau Shou Chang",
1460 "given": "Robert"
1461 },
1462 "isOrdered": true
1463 });
1464 assert_roundtrip::<Name>(v);
1465 }
1466
1467 // ── Nickname (RFC 9553 Figure 21 / §2.2.2) ────────────────────────────
1468
1469 #[test]
1470 fn nickname_roundtrip_figure_21() {
1471 let v = json!({
1472 "name": "Johnny"
1473 });
1474 assert_roundtrip::<Nickname>(v);
1475 }
1476
1477 // ── Organization + OrgUnit (RFC 9553 Figure 22 / §2.2.3) ──────────────
1478
1479 #[test]
1480 fn organization_roundtrip_figure_22() {
1481 let v = json!({
1482 "name": "ABC, Inc.",
1483 "units": [
1484 { "name": "North American Division" },
1485 { "name": "Marketing" }
1486 ],
1487 "sortAs": "ABC"
1488 });
1489 assert_roundtrip::<Organization>(v);
1490 }
1491
1492 // ── SpeakToAs + Pronouns (RFC 9553 Figure 23 / §2.2.4) ────────────────
1493
1494 #[test]
1495 fn speak_to_as_roundtrip_figure_23() {
1496 let v = json!({
1497 "grammaticalGender": "neuter",
1498 "pronouns": {
1499 "k19": {
1500 "pronouns": "they/them",
1501 "pref": 2
1502 },
1503 "k32": {
1504 "pronouns": "xe/xir",
1505 "pref": 1
1506 }
1507 }
1508 });
1509 assert_roundtrip::<SpeakToAs>(v);
1510 }
1511
1512 // ── Title (RFC 9553 Figure 24 / §2.2.5) ───────────────────────────────
1513
1514 #[test]
1515 fn title_roundtrip_figure_24_title() {
1516 let v = json!({
1517 "kind": "title",
1518 "name": "Research Scientist"
1519 });
1520 assert_roundtrip::<Title>(v);
1521 }
1522
1523 #[test]
1524 fn title_roundtrip_figure_24_role() {
1525 let v = json!({
1526 "kind": "role",
1527 "name": "Project Leader",
1528 "organizationId": "o2"
1529 });
1530 assert_roundtrip::<Title>(v);
1531 }
1532
1533 // ── EmailAddress (RFC 9553 Figure 25 / §2.3.1) ────────────────────────
1534
1535 #[test]
1536 fn email_address_roundtrip_figure_25_work() {
1537 let v = json!({
1538 "contexts": { "work": true },
1539 "address": "jqpublic@xyz.example.com"
1540 });
1541 assert_roundtrip::<EmailAddress>(v);
1542 }
1543
1544 #[test]
1545 fn email_address_roundtrip_figure_25_pref() {
1546 let v = json!({
1547 "address": "jane_doe@example.com",
1548 "pref": 1
1549 });
1550 assert_roundtrip::<EmailAddress>(v);
1551 }
1552
1553 // ── OnlineService (RFC 9553 Figure 26 / §2.3.2) ───────────────────────
1554
1555 #[test]
1556 fn online_service_roundtrip_figure_26() {
1557 let v = json!({
1558 "service": "Mastodon",
1559 "user": "@alice@example2.com",
1560 "uri": "https://example2.com/@alice"
1561 });
1562 assert_roundtrip::<OnlineService>(v);
1563 }
1564
1565 // ── Phone (RFC 9553 Figure 27 / §2.3.3) ───────────────────────────────
1566
1567 #[test]
1568 fn phone_roundtrip_figure_27() {
1569 let v = json!({
1570 "contexts": { "private": true },
1571 "features": { "voice": true },
1572 "number": "tel:+1-555-555-5555;ext=5555",
1573 "pref": 1
1574 });
1575 assert_roundtrip::<Phone>(v);
1576 }
1577
1578 // ── LanguagePref (RFC 9553 Figure 28 / §2.3.4) ────────────────────────
1579
1580 #[test]
1581 fn language_pref_roundtrip_figure_28() {
1582 let v = json!({
1583 "language": "en",
1584 "contexts": { "work": true },
1585 "pref": 1
1586 });
1587 assert_roundtrip::<LanguagePref>(v);
1588 }
1589
1590 // ── Calendar (RFC 9553 Figure 29 / §2.4.1) ────────────────────────────
1591
1592 #[test]
1593 fn calendar_roundtrip_figure_29_calendar() {
1594 let v = json!({
1595 "kind": "calendar",
1596 "uri": "webcal://calendar.example.com/calA.ics"
1597 });
1598 assert_roundtrip::<Calendar>(v);
1599 }
1600
1601 #[test]
1602 fn calendar_roundtrip_figure_29_freebusy() {
1603 let v = json!({
1604 "kind": "freeBusy",
1605 "uri": "https://calendar.example.com/busy/project-a"
1606 });
1607 assert_roundtrip::<Calendar>(v);
1608 }
1609
1610 // ── SchedulingAddress (RFC 9553 Figure 30 / §2.4.2) ───────────────────
1611
1612 #[test]
1613 fn scheduling_address_roundtrip_figure_30() {
1614 let v = json!({
1615 "uri": "mailto:janedoe@example.com"
1616 });
1617 assert_roundtrip::<SchedulingAddress>(v);
1618 }
1619
1620 // ── Address (RFC 9553 Figure 31 / §2.5.1) ─────────────────────────────
1621
1622 #[test]
1623 fn address_roundtrip_figure_31() {
1624 let v = json!({
1625 "contexts": { "work": true },
1626 "components": [
1627 { "kind": "number", "value": "54321" },
1628 { "kind": "separator", "value": " " },
1629 { "kind": "name", "value": "Oak St" },
1630 { "kind": "locality", "value": "Reston" },
1631 { "kind": "region", "value": "VA" },
1632 { "kind": "separator", "value": " " },
1633 { "kind": "postcode", "value": "20190" },
1634 { "kind": "country", "value": "USA" }
1635 ],
1636 "countryCode": "US",
1637 "defaultSeparator": ", ",
1638 "isOrdered": true
1639 });
1640 assert_roundtrip::<Address>(v);
1641 }
1642
1643 // ── CryptoKey (RFC 9553 Figure 34 / §2.6.1) ───────────────────────────
1644
1645 #[test]
1646 fn crypto_key_roundtrip_figure_34() {
1647 let v = json!({
1648 "uri": "https://www.example.com/keys/jdoe.cer"
1649 });
1650 assert_roundtrip::<CryptoKey>(v);
1651 }
1652
1653 // ── Directory (RFC 9553 Figure 36 / §2.6.2) ───────────────────────────
1654
1655 #[test]
1656 fn directory_roundtrip_figure_36_entry() {
1657 let v = json!({
1658 "kind": "entry",
1659 "uri": "https://dir.example.com/addrbook/jdoe/Jean%20Dupont.vcf"
1660 });
1661 assert_roundtrip::<Directory>(v);
1662 }
1663
1664 #[test]
1665 fn directory_roundtrip_figure_36_directory() {
1666 let v = json!({
1667 "kind": "directory",
1668 "uri": "ldap://ldap.example/o=Example%20Tech,ou=Engineering",
1669 "pref": 1
1670 });
1671 assert_roundtrip::<Directory>(v);
1672 }
1673
1674 // ── Link (RFC 9553 Figure 37 / §2.6.3) ────────────────────────────────
1675
1676 #[test]
1677 fn link_roundtrip_figure_37() {
1678 let v = json!({
1679 "kind": "contact",
1680 "uri": "mailto:contact@example.com",
1681 "pref": 1
1682 });
1683 assert_roundtrip::<Link>(v);
1684 }
1685
1686 // ── Media (RFC 9553 Figure 38 / §2.6.4) ───────────────────────────────
1687
1688 #[test]
1689 fn media_roundtrip_figure_38_sound() {
1690 let v = json!({
1691 "kind": "sound",
1692 "uri": "CID:JOHNQ.part8.19960229T080000.xyzMail@example.com"
1693 });
1694 assert_roundtrip::<Media>(v);
1695 }
1696
1697 #[test]
1698 fn media_roundtrip_figure_38_logo() {
1699 let v = json!({
1700 "kind": "logo",
1701 "uri": "https://www.example.com/pub/logos/abccorp.jpg"
1702 });
1703 assert_roundtrip::<Media>(v);
1704 }
1705
1706 // ── Anniversary + PartialDate + Timestamp (RFC 9553 Figure 41 / §2.8.1) ──
1707
1708 #[test]
1709 fn anniversary_roundtrip_figure_41_partial_date() {
1710 // Figure 41, k8 entry. PartialDate is the default; @type omitted.
1711 let v = json!({
1712 "kind": "birth",
1713 "date": {
1714 "year": 1953,
1715 "month": 4,
1716 "day": 15
1717 }
1718 });
1719 assert_roundtrip::<Anniversary>(v);
1720 }
1721
1722 #[test]
1723 fn anniversary_roundtrip_figure_41_timestamp() {
1724 // Figure 41, k9 entry. Timestamp requires explicit @type.
1725 let v = json!({
1726 "kind": "death",
1727 "date": {
1728 "@type": "Timestamp",
1729 "utc": "2019-10-15T23:10:00Z"
1730 },
1731 "place": {
1732 "full": "4445 Tree Street\nNew England, ND 58647\nUSA"
1733 }
1734 });
1735 assert_roundtrip::<Anniversary>(v);
1736 }
1737
1738 #[test]
1739 fn anniversary_date_unknown_preserves_opaque() {
1740 // A future-spec @type value the crate doesn't recognise must be
1741 // preserved opaquely so a Card object containing it round-trips.
1742 let v = json!({
1743 "kind": "birth",
1744 "date": {
1745 "@type": "FuturisticDateShape",
1746 "stardate": 41153.7
1747 }
1748 });
1749 let anniv: Anniversary = serde_json::from_value(v.clone()).unwrap();
1750 assert!(matches!(anniv.date, AnniversaryDate::Unknown(_)));
1751 let back = serde_json::to_value(&anniv).unwrap();
1752 assert_eq!(back, v);
1753 }
1754
1755 #[test]
1756 fn anniversary_date_unknown_non_object_round_trips() {
1757 // Regression test for bd:JMAP-sgrr.9: a non-object Value wrapped
1758 // in Unknown must survive serialize→deserialize.
1759 //
1760 // Independent oracle: each case is a hand-built JSON literal
1761 // (scalar string, array, null) chosen because no real RFC 9553
1762 // emitter would produce it on the wire. The test verifies the
1763 // round-trip invariant for Rust-constructable values; the wire
1764 // never exercises this branch.
1765 for value in [
1766 serde_json::Value::String("opaque-scalar".into()),
1767 serde_json::json!([1, 2, 3]),
1768 serde_json::Value::Null,
1769 ] {
1770 let original = AnniversaryDate::Unknown(value.clone());
1771 let on_wire = serde_json::to_value(&original).unwrap();
1772 assert_eq!(on_wire, value, "serialize forwards verbatim");
1773 let back: AnniversaryDate = serde_json::from_value(on_wire).unwrap();
1774 assert!(
1775 matches!(back, AnniversaryDate::Unknown(_)),
1776 "non-object Value must deserialize back to Unknown, got: {back:?}",
1777 );
1778 // Extract the inner Value and verify it equals the original.
1779 let AnniversaryDate::Unknown(inner) = back else {
1780 unreachable!("matches! above already guards this")
1781 };
1782 assert_eq!(inner, value, "Unknown payload preserved across round trip");
1783 }
1784 }
1785
1786 #[test]
1787 fn anniversary_date_unknown_object_with_recognised_at_type_loses_variant_identity() {
1788 // Pin test for bd:JMAP-sgrr.28.
1789 //
1790 // The Unknown variant's round-trip contract holds only when
1791 // the wrapped Value's @type is OUTSIDE the dispatcher's
1792 // recognised set ({"PartialDate", "Timestamp"}) or the Value
1793 // is not an object. When a caller wraps a Value whose @type
1794 // IS recognised — or an object with no @type at all (which
1795 // routes to PartialDate by RFC 9553 §2.8.1's defaultType
1796 // rule) — variant identity is lost across serialize →
1797 // deserialize: the deserializer re-dispatches on the @type
1798 // and produces the typed variant.
1799 //
1800 // This is documented behaviour, not a bug. The test pins it
1801 // so a future contributor does not accidentally "fix" the
1802 // dispatch contract and break the documented preservation
1803 // posture for genuinely-unknown shapes.
1804 //
1805 // Independent oracle: hand-built JSON literals chosen to
1806 // exercise each recognised dispatch path.
1807 let cases: &[(&str, serde_json::Value, fn(&AnniversaryDate) -> bool)] = &[
1808 (
1809 "object with @type = PartialDate",
1810 serde_json::json!({"@type": "PartialDate", "year": 2000}),
1811 |d| matches!(d, AnniversaryDate::PartialDate(_)),
1812 ),
1813 (
1814 "object with @type = Timestamp",
1815 serde_json::json!({"@type": "Timestamp", "utc": "2000-01-01T00:00:00Z"}),
1816 |d| matches!(d, AnniversaryDate::Timestamp(_)),
1817 ),
1818 (
1819 "object with no @type (defaultType = PartialDate)",
1820 serde_json::json!({"year": 2000}),
1821 |d| matches!(d, AnniversaryDate::PartialDate(_)),
1822 ),
1823 ];
1824 for (label, value, expected) in cases {
1825 let original = AnniversaryDate::Unknown(value.clone());
1826 let wire = serde_json::to_value(&original).expect("serialize");
1827 assert_eq!(&wire, value, "{label}: serialize forwards verbatim");
1828 let back: AnniversaryDate = serde_json::from_value(wire).expect("deserialize");
1829 assert!(
1830 expected(&back),
1831 "{label}: contract — recognised @type re-dispatches off Unknown, got {back:?}",
1832 );
1833 }
1834 }
1835
1836 #[test]
1837 fn anniversary_date_timestamp_at_type_none_round_trips_as_timestamp() {
1838 // Regression test for bd:JMAP-sgrr.29.
1839 //
1840 // A Rust-side caller can construct
1841 // `AnniversaryDate::Timestamp(Timestamp { at_type: None, .. })`
1842 // because `Timestamp::at_type` is `Option<String>` (a Timestamp
1843 // standing alone outside an Anniversary may omit `@type` per
1844 // workspace `@type` convention). When that value is emitted in
1845 // `AnniversaryDate` position the Serialize impl re-stamps
1846 // `@type = "Timestamp"` so the wire output is unambiguous and
1847 // deserializes back to the `Timestamp` variant rather than
1848 // silently routing to the default `PartialDate`.
1849 //
1850 // Independent oracle: hand-built `Timestamp` with `at_type:
1851 // None` (the construction path the bug report describes).
1852 let original = AnniversaryDate::Timestamp(Timestamp {
1853 at_type: None,
1854 utc: "2022-05-22T03:30:00Z".to_owned(),
1855 extra: serde_json::Map::new(),
1856 });
1857 let wire = serde_json::to_value(&original).expect("serialize");
1858 // Wire must carry the @type discriminator so a peer can dispatch.
1859 assert_eq!(
1860 wire.get("@type").and_then(|t| t.as_str()),
1861 Some("Timestamp"),
1862 "Serialize must re-stamp @type when caller left it None: {wire}",
1863 );
1864 let back: AnniversaryDate = serde_json::from_value(wire).expect("deserialize");
1865 match back {
1866 AnniversaryDate::Timestamp(t) => {
1867 assert_eq!(t.utc, "2022-05-22T03:30:00Z");
1868 assert!(t.extra.is_empty(), "no stray flatten-extras");
1869 }
1870 other => panic!("variant identity lost: expected Timestamp, got {other:?}"),
1871 }
1872 }
1873
1874 #[test]
1875 fn anniversary_date_timestamp_preserves_caller_at_type() {
1876 // Sibling of `anniversary_date_timestamp_at_type_none_round_trips_as_timestamp`.
1877 // When the caller DID set `at_type` we must preserve it verbatim
1878 // — the re-stamp logic only applies on the `None` path. This
1879 // guards against an over-eager fix that would overwrite caller
1880 // input.
1881 let original = AnniversaryDate::Timestamp(Timestamp {
1882 at_type: Some("Timestamp".to_owned()),
1883 utc: "2022-05-22T03:30:00Z".to_owned(),
1884 extra: serde_json::Map::new(),
1885 });
1886 let wire = serde_json::to_value(&original).expect("serialize");
1887 assert_eq!(
1888 wire.get("@type").and_then(|t| t.as_str()),
1889 Some("Timestamp"),
1890 );
1891 let back: AnniversaryDate = serde_json::from_value(wire).expect("deserialize");
1892 assert!(matches!(back, AnniversaryDate::Timestamp(_)));
1893 }
1894
1895 // ── Note + Author (RFC 9553 Figure 43 / §2.8.3) ───────────────────────
1896
1897 #[test]
1898 fn note_roundtrip_figure_43() {
1899 let v = json!({
1900 "note": "Open office hours are 1600 to 1715 EST, Mon-Fri",
1901 "created": "2022-11-23T15:01:32Z",
1902 "author": {
1903 "name": "John"
1904 }
1905 });
1906 assert_roundtrip::<Note>(v);
1907 }
1908
1909 // ── PersonalInfo (RFC 9553 Figure 44 / §2.8.4) ────────────────────────
1910
1911 #[test]
1912 fn personal_info_roundtrip_figure_44_expertise() {
1913 let v = json!({
1914 "kind": "expertise",
1915 "value": "chemistry",
1916 "level": "high"
1917 });
1918 assert_roundtrip::<PersonalInfo>(v);
1919 }
1920
1921 #[test]
1922 fn personal_info_roundtrip_figure_44_hobby() {
1923 let v = json!({
1924 "kind": "hobby",
1925 "value": "reading",
1926 "level": "high"
1927 });
1928 assert_roundtrip::<PersonalInfo>(v);
1929 }
1930
1931 // ── Relation (RFC 9553 Figure 13 / §2.1.8) ────────────────────────────
1932
1933 #[test]
1934 fn relation_roundtrip_figure_13_friend() {
1935 let v = json!({
1936 "relation": { "friend": true }
1937 });
1938 assert_roundtrip::<Relation>(v);
1939 }
1940
1941 #[test]
1942 fn relation_roundtrip_figure_13_empty() {
1943 let v = json!({
1944 "relation": {}
1945 });
1946 assert_roundtrip::<Relation>(v);
1947 }
1948
1949 // ── Extras-preservation policy tests (JMAP-lbdy.5, JMAP-lbdy.12) ─────
1950 //
1951 // One round-trip preservation test per migrated type. Each asserts
1952 // that an unknown vendor / site / private-extension field survives
1953 // deserialize/serialize unchanged.
1954 //
1955 // The four formerly Hash-derived types (NameComponent,
1956 // AddressComponent, PartialDate, Timestamp) had their Hash derive
1957 // dropped under JMAP-lbdy.12 option A so the extras-preservation
1958 // policy applies to them uniformly.
1959
1960 /// Generic helper: assert a vendor field round-trips through the
1961 /// given type's `extra` field.
1962 ///
1963 /// Asserts full object equality after the round-trip — not just the
1964 /// vendor key. The stronger assertion catches subtle bugs where a
1965 /// typed field unexpectedly lands in `extra` (or a vendor field
1966 /// unexpectedly captures a typed field's value) due to a serde
1967 /// `flatten` + `rename` interaction. See `bd:JMAP-sgrr.8`.
1968 fn assert_extras_roundtrip<T>(
1969 mut raw: serde_json::Value,
1970 vendor_key: &str,
1971 vendor_val: serde_json::Value,
1972 ) where
1973 T: serde::de::DeserializeOwned + Serialize,
1974 {
1975 raw[vendor_key] = vendor_val;
1976 let de: T = serde_json::from_value(raw.clone()).unwrap();
1977 let back = serde_json::to_value(&de).unwrap();
1978 assert_eq!(
1979 back, raw,
1980 "full round-trip must preserve typed fields AND the vendor extra"
1981 );
1982 }
1983
1984 #[test]
1985 fn name_preserves_vendor_extras() {
1986 assert_extras_roundtrip::<Name>(
1987 json!({"full": "Alice"}),
1988 "acmeCorpNameSource",
1989 json!("hr"),
1990 );
1991 }
1992
1993 // ── @type-vs-extras serde-interaction probe (bd:JMAP-sgrr.6) ──────────
1994 //
1995 // Every wire-format struct combines `#[serde(rename = "@type", ...)]`
1996 // on `at_type` with `#[serde(flatten)]` on `extra`. The intended
1997 // behavior is that an `@type` field on the wire populates `at_type`
1998 // and `extra` stays empty; the pathological alternative is that
1999 // flatten captures `@type` into `extra` and `at_type` stays None.
2000 // Workspace policy reasons from serde docs that the explicit rename
2001 // takes priority over flatten, but no test verified the behaviour
2002 // empirically. These probes lock it in so a future serde release
2003 // that subtly changes the interaction would fail loudly here rather
2004 // than silently break every JSContact emitter.
2005
2006 #[test]
2007 fn name_at_type_populates_at_type_not_extras() {
2008 // Probe 1: bare @type. Independent oracle: a hand-built JSON
2009 // literal with @type set; assert the typed field captures it
2010 // and extras stays empty; assert full round-trip equality.
2011 let wire = json!({"@type": "Name", "full": "Alice"});
2012 let de: Name = serde_json::from_value(wire.clone()).unwrap();
2013 assert_eq!(
2014 de.at_type.as_deref(),
2015 Some("Name"),
2016 "@type must populate at_type, not flow into extras"
2017 );
2018 assert!(
2019 de.extra.is_empty(),
2020 "@type must NOT leak into extras; found: {:?}",
2021 de.extra
2022 );
2023 let back = serde_json::to_value(&de).unwrap();
2024 assert_eq!(back, wire, "round-trip must preserve @type on the wire");
2025 }
2026
2027 #[test]
2028 fn name_at_type_and_vendor_extras_coexist_separately() {
2029 // Probe 2: @type AND a vendor extra. Independent oracle: a
2030 // hand-built JSON literal carrying both; assert at_type captures
2031 // @type, extras captures the vendor key only, and full
2032 // round-trip preserves the wire shape byte-for-byte.
2033 let wire = json!({
2034 "@type": "Name",
2035 "full": "Alice",
2036 "acmeCorpNameSource": "hr",
2037 });
2038 let de: Name = serde_json::from_value(wire.clone()).unwrap();
2039 assert_eq!(de.at_type.as_deref(), Some("Name"));
2040 assert_eq!(
2041 de.extra.get("acmeCorpNameSource"),
2042 Some(&json!("hr")),
2043 "vendor key must land in extras"
2044 );
2045 assert!(
2046 !de.extra.contains_key("@type"),
2047 "@type must NOT also appear in extras; found: {:?}",
2048 de.extra
2049 );
2050 let back = serde_json::to_value(&de).unwrap();
2051 assert_eq!(back, wire);
2052 }
2053
2054 #[test]
2055 fn nickname_preserves_vendor_extras() {
2056 assert_extras_roundtrip::<Nickname>(
2057 json!({"name": "Al"}),
2058 "acmeCorpScope",
2059 json!("internal"),
2060 );
2061 }
2062
2063 #[test]
2064 fn organization_preserves_vendor_extras() {
2065 assert_extras_roundtrip::<Organization>(
2066 json!({"name": "Acme"}),
2067 "acmeCorpDept",
2068 json!("eng"),
2069 );
2070 }
2071
2072 #[test]
2073 fn org_unit_preserves_vendor_extras() {
2074 assert_extras_roundtrip::<OrgUnit>(
2075 json!({"name": "Platform"}),
2076 "acmeCorpCostCenter",
2077 json!("cc-42"),
2078 );
2079 }
2080
2081 #[test]
2082 fn speak_to_as_preserves_vendor_extras() {
2083 assert_extras_roundtrip::<SpeakToAs>(
2084 json!({"grammaticalGender": "feminine"}),
2085 "acmeCorpFormality",
2086 json!("informal"),
2087 );
2088 }
2089
2090 #[test]
2091 fn pronouns_preserves_vendor_extras() {
2092 assert_extras_roundtrip::<Pronouns>(
2093 json!({"pronouns": "she/her"}),
2094 "acmeCorpAccessibilityHint",
2095 json!("screen-reader"),
2096 );
2097 }
2098
2099 #[test]
2100 fn title_preserves_vendor_extras() {
2101 assert_extras_roundtrip::<Title>(json!({"name": "Engineer"}), "acmeCorpLevel", json!(5));
2102 }
2103
2104 #[test]
2105 fn email_address_preserves_vendor_extras() {
2106 assert_extras_roundtrip::<EmailAddress>(
2107 json!({"address": "alice@example.com"}),
2108 "acmeCorpVerified",
2109 json!(true),
2110 );
2111 }
2112
2113 #[test]
2114 fn online_service_preserves_vendor_extras() {
2115 assert_extras_roundtrip::<OnlineService>(
2116 json!({"service": "GitHub", "uri": "https://github.com/alice"}),
2117 "acmeCorpScore",
2118 json!(0.95),
2119 );
2120 }
2121
2122 #[test]
2123 fn phone_preserves_vendor_extras() {
2124 assert_extras_roundtrip::<Phone>(
2125 json!({"number": "tel:+1-555-0100"}),
2126 "acmeCorpRegion",
2127 json!("us"),
2128 );
2129 }
2130
2131 #[test]
2132 fn language_pref_preserves_vendor_extras() {
2133 assert_extras_roundtrip::<LanguagePref>(
2134 json!({"language": "en"}),
2135 "acmeCorpProficiency",
2136 json!("native"),
2137 );
2138 }
2139
2140 #[test]
2141 fn calendar_preserves_vendor_extras() {
2142 assert_extras_roundtrip::<Calendar>(
2143 json!({"kind": "calendar", "uri": "https://cal/example"}),
2144 "acmeCorpAccessLevel",
2145 json!("read-only"),
2146 );
2147 }
2148
2149 #[test]
2150 fn scheduling_address_preserves_vendor_extras() {
2151 assert_extras_roundtrip::<SchedulingAddress>(
2152 json!({"uri": "mailto:alice@example.com"}),
2153 "acmeCorpReplyHint",
2154 json!("auto"),
2155 );
2156 }
2157
2158 #[test]
2159 fn address_preserves_vendor_extras() {
2160 assert_extras_roundtrip::<Address>(
2161 json!({"full": "123 Main St"}),
2162 "acmeCorpGeocoded",
2163 json!(true),
2164 );
2165 }
2166
2167 #[test]
2168 fn crypto_key_preserves_vendor_extras() {
2169 assert_extras_roundtrip::<CryptoKey>(
2170 json!({"uri": "https://example.com/key.pem"}),
2171 "acmeCorpKeyAlgorithm",
2172 json!("rsa-2048"),
2173 );
2174 }
2175
2176 #[test]
2177 fn directory_preserves_vendor_extras() {
2178 assert_extras_roundtrip::<Directory>(
2179 json!({"kind": "directory", "uri": "ldap://example.com"}),
2180 "acmeCorpDirectoryNamespace",
2181 json!("internal"),
2182 );
2183 }
2184
2185 #[test]
2186 fn link_preserves_vendor_extras() {
2187 assert_extras_roundtrip::<Link>(
2188 json!({"uri": "https://example.com"}),
2189 "acmeCorpLinkRel",
2190 json!("homepage"),
2191 );
2192 }
2193
2194 #[test]
2195 fn media_preserves_vendor_extras() {
2196 assert_extras_roundtrip::<Media>(
2197 json!({"kind": "photo", "uri": "https://example.com/photo.jpg"}),
2198 "acmeCorpThumbnailUri",
2199 json!("https://example.com/photo.thumb.jpg"),
2200 );
2201 }
2202
2203 #[test]
2204 fn anniversary_preserves_vendor_extras() {
2205 assert_extras_roundtrip::<Anniversary>(
2206 json!({"kind": "birth", "date": {"year": 2000, "month": 1, "day": 1}}),
2207 "acmeCorpReminderDays",
2208 json!(7),
2209 );
2210 }
2211
2212 #[test]
2213 fn note_preserves_vendor_extras() {
2214 assert_extras_roundtrip::<Note>(
2215 json!({"note": "important"}),
2216 "acmeCorpClassification",
2217 json!("internal"),
2218 );
2219 }
2220
2221 #[test]
2222 fn author_preserves_vendor_extras() {
2223 assert_extras_roundtrip::<Author>(
2224 json!({"name": "Alice"}),
2225 "acmeCorpAuthorRole",
2226 json!("manager"),
2227 );
2228 }
2229
2230 #[test]
2231 fn personal_info_preserves_vendor_extras() {
2232 assert_extras_roundtrip::<PersonalInfo>(
2233 json!({"kind": "hobby", "value": "skiing"}),
2234 "acmeCorpInterestRank",
2235 json!(3),
2236 );
2237 }
2238
2239 #[test]
2240 fn relation_preserves_vendor_extras() {
2241 assert_extras_roundtrip::<Relation>(
2242 json!({"relation": {"friend": true}}),
2243 "acmeCorpRelationStrength",
2244 json!("close"),
2245 );
2246 }
2247
2248 // ── JMAP-lbdy.12: formerly Hash-derived types ─────────────────────────
2249
2250 #[test]
2251 fn name_component_preserves_vendor_extras() {
2252 assert_extras_roundtrip::<NameComponent>(
2253 json!({"value": "Vincent", "kind": "given"}),
2254 "acmeCorpComponentSource",
2255 json!("hr"),
2256 );
2257 }
2258
2259 #[test]
2260 fn address_component_preserves_vendor_extras() {
2261 assert_extras_roundtrip::<AddressComponent>(
2262 json!({"value": "123", "kind": "number"}),
2263 "acmeCorpVerified",
2264 json!(true),
2265 );
2266 }
2267
2268 #[test]
2269 fn partial_date_preserves_vendor_extras() {
2270 assert_extras_roundtrip::<PartialDate>(
2271 json!({"year": 2000, "month": 1, "day": 1}),
2272 "acmeCorpDateSource",
2273 json!("self-reported"),
2274 );
2275 }
2276
2277 #[test]
2278 fn timestamp_preserves_vendor_extras() {
2279 assert_extras_roundtrip::<Timestamp>(
2280 json!({"@type": "Timestamp", "utc": "2022-05-22T03:30:00Z"}),
2281 "acmeCorpTimezone",
2282 json!("UTC"),
2283 );
2284 }
2285}