Skip to main content

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}