Skip to main content

jmap_contacts_types/
lib.rs

1//! RFC 9610 + RFC 9553 data types for JMAP Contacts.
2//!
3//! Provides [`AddressBook`], [`AddressBookRights`], [`ContactCard`],
4//! [`ContactCardFilterCondition`], [`ContactCardComparator`],
5//! [`ContactsCapability`], and [`ContactsAccountCapability`].
6//!
7//! This crate is types-only: no method handlers, no async, no network I/O.
8//! It sits between `jmap-types` (shared JMAP base primitives) and the
9//! server/client crates that consume these types.
10//!
11//! All types implement [`serde::Serialize`] and [`serde::Deserialize`] with
12//! the camelCase field names required by the JMAP wire format.
13//!
14//! ## JSContact sub-object types
15//!
16//! The RFC 9553 JSContact sub-object types ([`Name`], [`EmailAddress`],
17//! [`Phone`], [`Address`], etc.) live in the dedicated
18//! `jmap-jscontact-types` crate and are re-exported here for caller
19//! ergonomics. The [`ContactCard`] struct keeps its 22 sub-object fields
20//! typed as `Option<serde_json::Value>` as the wire-format anchor — typed
21//! access is opt-in via [`serde_json::from_value`] on a chosen field.
22//!
23//! ```rust
24//! use jmap_contacts_types::{ContactCard, Name};
25//!
26//! let card_json = serde_json::json!({
27//!     "name": {
28//!         "components": [
29//!             { "kind": "given", "value": "Vincent" },
30//!             { "kind": "surname", "value": "van Gogh" }
31//!         ],
32//!         "isOrdered": true
33//!     }
34//! });
35//! let card: ContactCard = serde_json::from_value(card_json).unwrap();
36//! let name: Name = serde_json::from_value(card.name.unwrap()).unwrap();
37//! assert_eq!(name.components.unwrap()[0].value, "Vincent");
38//! ```
39//!
40//! # Logging safety
41//!
42//! This crate derives plain [`Debug`] on all PII-bearing types
43//! ([`ContactCard`], [`AddressBook`], and the 30+ re-exported RFC 9553
44//! JSContact sub-types: [`Name`], [`EmailAddress`], [`Phone`],
45//! [`Address`], [`Note`], [`Anniversary`], [`PersonalInfo`],
46//! [`CryptoKey`], etc.). Consumers using `tracing` or `log` macros
47//! that interpolate values via `?` (debug) or `%` (display) emit the
48//! full record contents to the configured sink. This includes phone
49//! numbers, email addresses, postal addresses, names, notes, and
50//! CryptoKey URIs — all of which are typically regulated as
51//! personally-identifying information under GDPR Article 5, CCPA
52//! §1798.100, and similar privacy frameworks.
53//!
54//! Consumers SHOULD wrap PII-bearing values in a redacting newtype
55//! before logging, or use a structured-logging field-allowlist:
56//!
57//! ```ignore
58//! // BAD — leaks every name, phone, email, address into the log sink.
59//! tracing::debug!(card = ?contact_card, "fetched contact");
60//!
61//! // GOOD — emits only the opaque id and surfaces nothing else.
62//! tracing::debug!(card_id = %contact_card.id.as_deref().unwrap_or_default(), "fetched contact");
63//! ```
64//!
65//! The workspace has a Debug-redaction precedent in `jmap-base-client`
66//! for credential-bearing types ([`BearerAuth`], [`BasicAuth`],
67//! `Session`, `AccountInfo`) but **not** for PII-bearing types. The
68//! decision whether to propagate that precedent workspace-wide is
69//! tracked by a separate workspace-policy bead; this crate's current
70//! posture is plain `Debug` plus this safety contract. JMAP-glx8.26.
71//!
72//! [`BearerAuth`]: https://docs.rs/jmap-base-client
73//! [`BasicAuth`]: https://docs.rs/jmap-base-client
74//!
75//! # Example
76//!
77//! ```rust
78//! use jmap_contacts_types::AddressBook;
79//!
80//! let json = r#"{
81//!     "id": "ab1",
82//!     "name": "Personal",
83//!     "sortOrder": 0,
84//!     "isDefault": true,
85//!     "isSubscribed": true,
86//!     "shareWith": null,
87//!     "myRights": {
88//!         "mayRead": true,
89//!         "mayWrite": true,
90//!         "mayShare": true,
91//!         "mayDelete": false
92//!     }
93//! }"#;
94//!
95//! let ab: AddressBook = serde_json::from_str(json).unwrap();
96//! assert_eq!(ab.name, "Personal");
97//! ```
98
99#![forbid(unsafe_code)]
100
101pub mod addressbook;
102pub mod backend;
103pub mod capability;
104pub mod card;
105pub mod collision;
106
107/// Module alias re-exporting [`jmap_jscontact_types`].
108///
109/// Provides nested access to JSContact sub-object types as
110/// `jmap_contacts_types::jscontact::Name`, mirroring the
111/// `jmap_calendars_types::jscalendar::*` pattern. New code should prefer
112/// the top-level re-exports (e.g. `jmap_contacts_types::Name`) or the
113/// direct path `jmap_jscontact_types::Name`.
114pub use jmap_jscontact_types as jscontact;
115
116pub use addressbook::{AddressBook, AddressBookRights};
117pub use backend::{AddressBookProperty, ContactCardProperty};
118pub use capability::{ContactsAccountCapability, ContactsCapability, JMAP_CONTACTS_URI};
119pub use card::{ContactCard, ContactCardComparator, ContactCardFilter, ContactCardFilterCondition};
120pub use collision::CollisionError;
121
122/// Constant identifiers for [`ContactCardComparator::property`] sort keys
123/// (re-export of [`card::prop`]).
124pub use card::prop as comparator_prop;
125
126/// Generic filter algebra from `jmap-types::query` (RFC 8620 §5.5).
127///
128/// Re-exported here so callers of `jmap-contacts-types` do not need a
129/// direct dependency on `jmap-types`. Mirrors the canonical
130/// [`jmap_mail_types::query`] re-exports from the workspace canonical
131/// extension-types template.
132///
133/// [`jmap_mail_types::query`]: https://docs.rs/jmap-mail-types/latest/jmap_mail_types/query/index.html
134pub use jmap_types::query::{Filter, FilterOperator, Operator};
135
136// ── JSContact sub-object re-exports (RFC 9553) ───────────────────────────────
137//
138// Mirrors the jmap-calendars-types re-export pattern. Top-level access to
139// every public RFC 9553 typed sub-object so callers can write
140// `jmap_contacts_types::Name` symmetrically with the wire field name on
141// `ContactCard`.
142pub use jmap_jscontact_types::{
143    Address, AddressComponent, Anniversary, AnniversaryDate, Author, Calendar, CryptoKey,
144    Directory, EmailAddress, LanguagePref, Link, Media, Name, NameComponent, Nickname, Note,
145    OnlineService, OrgUnit, Organization, PartialDate, PersonalInfo, Phone, Pronouns, Relation,
146    SchedulingAddress, SpeakToAs, Timestamp, Title,
147};
148
149#[cfg(test)]
150mod tests {
151    use std::collections::HashMap;
152
153    use jmap_types::Id;
154    use serde_json::json;
155
156    use super::*;
157
158    // ── AddressBookRights ────────────────────────────────────────────────
159
160    /// AddressBookRights::default() MUST produce all-false.
161    /// Oracle: spec §2 — four boolean fields, default-false is the safe value.
162    #[test]
163    fn address_book_rights_default_is_all_false() {
164        let r = AddressBookRights::default();
165        assert!(!r.may_read, "may_read default should be false");
166        assert!(!r.may_write, "may_write default should be false");
167        assert!(!r.may_share, "may_share default should be false");
168        assert!(!r.may_delete, "may_delete default should be false");
169    }
170
171    /// Deserialize AddressBookRights from spec §4.1 example JSON.
172    /// Oracle: literal JSON from RFC 9610 §4.1.
173    #[test]
174    fn address_book_rights_deserialize() {
175        let json = r#"{
176            "mayRead": true,
177            "mayWrite": false,
178            "mayShare": false,
179            "mayDelete": false
180        }"#;
181        let r: AddressBookRights = serde_json::from_str(json).unwrap();
182        assert!(r.may_read);
183        assert!(!r.may_write);
184        assert!(!r.may_share);
185        assert!(!r.may_delete);
186    }
187
188    /// AddressBookRights round-trip: serialize then deserialize must be
189    /// identity.
190    #[test]
191    fn address_book_rights_round_trip() {
192        let original = AddressBookRights {
193            may_read: true,
194            may_write: true,
195            may_share: false,
196            may_delete: false,
197            extra: serde_json::Map::new(),
198        };
199        let json = serde_json::to_string(&original).unwrap();
200        let decoded: AddressBookRights = serde_json::from_str(&json).unwrap();
201        assert_eq!(original, decoded);
202    }
203
204    // ── AddressBook ──────────────────────────────────────────────────────
205
206    /// Deserialize an AddressBook from the spec §4.1 example response.
207    /// Oracle: literal JSON from RFC 9610 §4.1.
208    #[test]
209    fn address_book_deserialize_spec_example() {
210        // Taken verbatim from RFC 9610 §4.1.
211        let json = r#"{
212            "id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
213            "name": "Personal",
214            "description": null,
215            "sortOrder": 0,
216            "isDefault": true,
217            "isSubscribed": true,
218            "shareWith": {
219                "3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
220                    "mayRead": true,
221                    "mayWrite": false,
222                    "mayShare": false,
223                    "mayDelete": false
224                }
225            },
226            "myRights": {
227                "mayRead": true,
228                "mayWrite": true,
229                "mayShare": true,
230                "mayDelete": false
231            }
232        }"#;
233        let ab: AddressBook = serde_json::from_str(json).unwrap();
234        assert_eq!(ab.name, "Personal");
235        assert!(ab.is_default);
236        assert!(ab.is_subscribed);
237        assert_eq!(ab.sort_order, 0);
238        assert!(ab.description.is_none());
239
240        let share_with = ab.share_with.unwrap();
241        let principal_id: Id = Id::from("3f1502e0-63fe-4335-9ff3-e739c188f5dd");
242        let shared_rights = &share_with[&principal_id];
243        assert!(shared_rights.may_read);
244        assert!(!shared_rights.may_write);
245
246        assert!(ab.my_rights.may_read);
247        assert!(ab.my_rights.may_write);
248        assert!(ab.my_rights.may_share);
249        assert!(!ab.my_rights.may_delete);
250    }
251
252    /// Deserialize the second AddressBook from §4.1 (shareWith: null).
253    /// Oracle: literal JSON from RFC 9610 §4.1.
254    #[test]
255    fn address_book_share_with_null() {
256        let json = r#"{
257            "id": "cd40089d-35f9-4fd7-980b-ba3a9f1d74fe",
258            "name": "Autosaved",
259            "description": null,
260            "sortOrder": 1,
261            "isDefault": false,
262            "isSubscribed": true,
263            "shareWith": null,
264            "myRights": {
265                "mayRead": true,
266                "mayWrite": true,
267                "mayShare": true,
268                "mayDelete": false
269            }
270        }"#;
271        let ab: AddressBook = serde_json::from_str(json).unwrap();
272        assert_eq!(ab.name, "Autosaved");
273        assert!(ab.share_with.is_none());
274    }
275
276    /// AddressBook with share_with: None serializes as "shareWith": null, not absent.
277    /// Oracle: RFC 9610 §2 type `Id[AddressBookRights]|null`; §4.1 second
278    /// AddressBook example shows `"shareWith": null` explicitly on the wire.
279    #[test]
280    fn address_book_share_with_null_serializes() {
281        // Build an AddressBook that has share_with: None (the null case).
282        let json = r#"{
283            "id": "ab-null-sw",
284            "name": "Private",
285            "sortOrder": 0,
286            "isDefault": false,
287            "isSubscribed": true,
288            "shareWith": null,
289            "myRights": {
290                "mayRead": true,
291                "mayWrite": true,
292                "mayShare": false,
293                "mayDelete": false
294            }
295        }"#;
296        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
297        assert!(ab.share_with.is_none());
298
299        let serialized = serde_json::to_value(&ab).expect("serialize");
300        let obj = serialized.as_object().expect("object");
301        assert!(
302            obj.contains_key("shareWith"),
303            "shareWith must be present in wire JSON (required-nullable)"
304        );
305        assert!(
306            obj["shareWith"].is_null(),
307            "shareWith must serialize as null when None"
308        );
309    }
310
311    /// AddressBook with share_with: Some(map) round-trips correctly.
312    /// Oracle: RFC 9610 §4.1 first AddressBook example with populated shareWith map.
313    #[test]
314    fn address_book_share_with_some_serializes() {
315        // From RFC 9610 §4.1 first AddressBook.
316        let json = r#"{
317            "id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
318            "name": "Personal",
319            "description": null,
320            "sortOrder": 0,
321            "isDefault": true,
322            "isSubscribed": true,
323            "shareWith": {
324                "3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
325                    "mayRead": true,
326                    "mayWrite": false,
327                    "mayShare": false,
328                    "mayDelete": false
329                }
330            },
331            "myRights": {
332                "mayRead": true,
333                "mayWrite": true,
334                "mayShare": true,
335                "mayDelete": false
336            }
337        }"#;
338        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
339        assert!(ab.share_with.is_some());
340
341        let serialized = serde_json::to_value(&ab).expect("serialize");
342        let obj = serialized.as_object().expect("object");
343        assert!(
344            obj.contains_key("shareWith"),
345            "shareWith must be present when set"
346        );
347        assert!(
348            !obj["shareWith"].is_null(),
349            "shareWith must not be null when populated"
350        );
351        // Round-trip: deserialize the serialized form, verify equality.
352        let back: AddressBook = serde_json::from_value(serialized).expect("round-trip deserialize");
353        assert_eq!(ab.share_with, back.share_with);
354    }
355
356    /// AddressBook round-trip: description Some vs None.
357    #[test]
358    fn address_book_round_trip_with_description() {
359        // Construct via JSON (avoids #[non_exhaustive] struct literal restriction).
360        let original_json = json!({
361            "id": "ab1",
362            "name": "Work",
363            "description": "Work contacts",
364            "sortOrder": 5,
365            "isDefault": false,
366            "isSubscribed": true,
367            "myRights": {
368                "mayRead": true,
369                "mayWrite": false,
370                "mayShare": false,
371                "mayDelete": false
372            }
373        });
374        let ab: AddressBook = serde_json::from_value(original_json).unwrap();
375        assert_eq!(ab.description.as_deref(), Some("Work contacts"));
376        let rt: AddressBook = serde_json::from_str(&serde_json::to_string(&ab).unwrap()).unwrap();
377        assert_eq!(rt.description.as_deref(), Some("Work contacts"));
378    }
379
380    /// description: null → None in Rust → "description": null on the wire.
381    ///
382    /// Oracle: RFC 9553 + RFC 9610 §4.1 — both example
383    /// AddressBook objects show `"description": null` explicitly.  The field
384    /// must be present as null, never absent.
385    #[test]
386    fn address_book_description_null_serializes() {
387        let json = r#"{
388            "id": "AB1",
389            "name": "Personal",
390            "description": null,
391            "sortOrder": 0,
392            "isDefault": false,
393            "isSubscribed": false,
394            "shareWith": null,
395            "myRights": {
396                "mayRead": true,
397                "mayWrite": true,
398                "mayShare": false,
399                "mayDelete": false
400            }
401        }"#;
402        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
403        assert!(ab.description.is_none(), "description should be None");
404
405        let out = serde_json::to_value(&ab).expect("serialize");
406        assert!(
407            out.as_object().expect("object").contains_key("description"),
408            "description key must be present in wire JSON"
409        );
410        assert!(
411            out["description"].is_null(),
412            "description must serialize as null, not be absent"
413        );
414    }
415
416    // ── ContactCard ──────────────────────────────────────────────────────
417
418    /// Deserialize the ContactCard from the spec §4.1 example.
419    /// Oracle: literal JSON from RFC 9610 §4.1.
420    #[test]
421    fn contact_card_deserialize_spec_example() {
422        // From RFC 9610 §4.1 response.
423        let json = r#"{
424            "id": "3",
425            "addressBookIds": {
426                "062adcfa-105d-455c-bc60-6db68b69c3f3": true
427            },
428            "name": {
429                "components": [
430                    { "kind": "given", "value": "Joe" },
431                    { "kind": "surname", "value": "Bloggs" }
432                ],
433                "isOrdered": true
434            },
435            "emails": {
436                "0": {
437                    "contexts": {
438                        "private": true
439                    },
440                    "address": "joe.bloggs@example.com"
441                }
442            }
443        }"#;
444        let card: ContactCard = serde_json::from_str(json).unwrap();
445
446        let id = card.id.as_ref().unwrap();
447        assert_eq!(id.as_ref(), "3");
448
449        let ab_ids = card.address_book_ids.as_ref().unwrap();
450        let ab_key: Id = Id::from("062adcfa-105d-455c-bc60-6db68b69c3f3");
451        assert!(ab_ids[&ab_key]);
452
453        // name is a serde_json::Value
454        let name = card.name.as_ref().unwrap();
455        assert_eq!(name["isOrdered"], serde_json::Value::Bool(true));
456        assert_eq!(name["components"][0]["kind"], "given");
457        assert_eq!(name["components"][0]["value"], "Joe");
458
459        // emails is a serde_json::Value
460        let emails = card.emails.as_ref().unwrap();
461        assert_eq!(emails["0"]["address"], "joe.bloggs@example.com");
462    }
463
464    /// ContactCard with kind="group" and members map.
465    /// Oracle: hand-written from RFC 9553 §2.1.6 example.
466    #[test]
467    fn contact_card_group_with_members() {
468        let json = r#"{
469            "version": "1.0",
470            "uid": "urn:uuid:ab4310aa-fa43-11e9-8f0b-362b9e155667",
471            "kind": "group",
472            "name": { "full": "The Doe family" },
473            "members": {
474                "urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af": true,
475                "urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519": true
476            }
477        }"#;
478        let card: ContactCard = serde_json::from_str(json).unwrap();
479        assert_eq!(card.kind.as_deref(), Some("group"));
480        let members = card.members.as_ref().unwrap();
481        assert!(members["urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af"]);
482        assert_eq!(members.len(), 2);
483    }
484
485    /// ContactCard with no optional fields: only id and addressBookIds.
486    #[test]
487    fn contact_card_minimal_fields() {
488        let json = r#"{
489            "id": "minimal1",
490            "addressBookIds": { "ab1": true }
491        }"#;
492        let card: ContactCard = serde_json::from_str(json).unwrap();
493        assert!(card.version.is_none());
494        assert!(card.uid.is_none());
495        assert!(card.kind.is_none());
496        assert!(card.emails.is_none());
497        assert!(card.phones.is_none());
498        assert!(card.addresses.is_none());
499    }
500
501    /// ContactCard skip_serializing_if omits None fields from output.
502    #[test]
503    fn contact_card_serializes_only_present_fields() {
504        // Build from JSON to bypass #[non_exhaustive] struct literal restriction.
505        let card: ContactCard = serde_json::from_value(json!({
506            "id": "x1",
507            "addressBookIds": { "ab1": true },
508            "version": "1.0",
509            "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
510        }))
511        .unwrap();
512
513        let serialized = serde_json::to_value(&card).unwrap();
514        // Only present fields should appear.
515        assert!(serialized.get("id").is_some());
516        assert!(serialized.get("version").is_some());
517        assert!(serialized.get("uid").is_some());
518        // Absent fields must NOT appear.
519        assert!(serialized.get("emails").is_none());
520        assert!(serialized.get("phones").is_none());
521        assert!(serialized.get("addresses").is_none());
522        assert!(serialized.get("kind").is_none());
523    }
524
525    /// `localizations` round-trips as a `HashMap<String, PatchObject>` and
526    /// is byte-identical on the wire (proving `#[serde(transparent)]` on
527    /// `PatchObject` introduces no wrapper key).
528    ///
529    /// Oracle: hand-written JSON modelled on RFC 9553 §2.7.1 localizations
530    /// (BCP 47 language tag keys → PatchObject values).
531    #[test]
532    fn contact_card_localizations_patch_object_transparent() {
533        let json = r#"{"localizations":{"de":{"name/full":"Joe Bloggs (DE)"}}}"#;
534        let card: ContactCard = serde_json::from_str(json).expect("deserialize");
535
536        let locs = card.localizations.as_ref().expect("localizations present");
537        let de = locs.get("de").expect("de locale present");
538        assert_eq!(
539            de.as_map().get("name/full").and_then(|v| v.as_str()),
540            Some("Joe Bloggs (DE)"),
541        );
542
543        let serialized = serde_json::to_string(&card).expect("serialize");
544        assert_eq!(serialized, json);
545    }
546
547    // ── ContactCardFilterCondition ───────────────────────────────────────
548
549    /// Verify that slash-keyed filter fields serialize with the correct wire
550    /// names.
551    /// Oracle: RFC 9610 §3.3.1 field list.
552    #[test]
553    fn filter_condition_slash_keys_serialize_correctly() {
554        let filter: ContactCardFilterCondition = serde_json::from_value(json!({
555            "name/given": "John",
556            "name/surname": "Smith",
557            "name/surname2": "Doe"
558        }))
559        .unwrap();
560
561        assert_eq!(filter.name_given.as_deref(), Some("John"));
562        assert_eq!(filter.name_surname.as_deref(), Some("Smith"));
563        assert_eq!(filter.name_surname2.as_deref(), Some("Doe"));
564
565        let serialized = serde_json::to_value(&filter).unwrap();
566        assert_eq!(serialized["name/given"], "John");
567        assert_eq!(serialized["name/surname"], "Smith");
568        assert_eq!(serialized["name/surname2"], "Doe");
569    }
570
571    /// All filter fields deserialize from spec §3.3.1 field names.
572    /// Oracle: field names taken directly from RFC 9610 §3.3.1.
573    #[test]
574    fn filter_condition_all_fields() {
575        let json = r#"{
576            "inAddressBook": "ab1",
577            "uid": "urn:uuid:abc123",
578            "hasMember": "urn:uuid:def456",
579            "kind": "individual",
580            "createdBefore": "2024-01-01T00:00:00Z",
581            "createdAfter": "2020-01-01T00:00:00Z",
582            "updatedBefore": "2024-06-01T00:00:00Z",
583            "updatedAfter": "2021-06-01T00:00:00Z",
584            "text": "John",
585            "name": "Doe",
586            "name/given": "Jane",
587            "name/surname": "Smith",
588            "name/surname2": "Gomez",
589            "nickname": "Johnny",
590            "organization": "ACME",
591            "email": "john@example.com",
592            "phone": "+1-555-1234",
593            "onlineService": "twitter",
594            "address": "Main St",
595            "note": "VIP"
596        }"#;
597        let f: ContactCardFilterCondition = serde_json::from_str(json).unwrap();
598        assert_eq!(
599            f.in_address_book.as_ref().map(|id| id.as_ref()),
600            Some("ab1")
601        );
602        assert_eq!(f.uid.as_deref(), Some("urn:uuid:abc123"));
603        assert_eq!(f.has_member.as_deref(), Some("urn:uuid:def456"));
604        assert_eq!(f.kind.as_deref(), Some("individual"));
605        assert_eq!(
606            f.created_before.as_ref().map(AsRef::as_ref),
607            Some("2024-01-01T00:00:00Z")
608        );
609        assert_eq!(
610            f.created_after.as_ref().map(AsRef::as_ref),
611            Some("2020-01-01T00:00:00Z")
612        );
613        assert_eq!(
614            f.updated_before.as_ref().map(AsRef::as_ref),
615            Some("2024-06-01T00:00:00Z")
616        );
617        assert_eq!(
618            f.updated_after.as_ref().map(AsRef::as_ref),
619            Some("2021-06-01T00:00:00Z")
620        );
621        assert_eq!(f.text.as_deref(), Some("John"));
622        assert_eq!(f.name.as_deref(), Some("Doe"));
623        assert_eq!(f.name_given.as_deref(), Some("Jane"));
624        assert_eq!(f.name_surname.as_deref(), Some("Smith"));
625        assert_eq!(f.name_surname2.as_deref(), Some("Gomez"));
626        assert_eq!(f.nickname.as_deref(), Some("Johnny"));
627        assert_eq!(f.organization.as_deref(), Some("ACME"));
628        assert_eq!(f.email.as_deref(), Some("john@example.com"));
629        assert_eq!(f.phone.as_deref(), Some("+1-555-1234"));
630        assert_eq!(f.online_service.as_deref(), Some("twitter"));
631        assert_eq!(f.address.as_deref(), Some("Main St"));
632        assert_eq!(f.note.as_deref(), Some("VIP"));
633    }
634
635    /// Empty filter condition serializes to `{}`.
636    #[test]
637    fn filter_condition_empty_serializes_to_empty_object() {
638        let f = ContactCardFilterCondition::default();
639        let v = serde_json::to_value(&f).unwrap();
640        assert_eq!(v, json!({}));
641    }
642
643    // ── Capability ───────────────────────────────────────────────────────
644
645    /// ContactsCapability serializes to `{}`.
646    /// Oracle: RFC 9610 §1.4.1 — session-level capability is an empty object.
647    #[test]
648    fn contacts_capability_is_empty_object() {
649        let cap = ContactsCapability::default();
650        let v = serde_json::to_value(&cap).unwrap();
651        assert_eq!(v, json!({}));
652    }
653
654    /// ContactsAccountCapability deserializes correctly.
655    /// Oracle: RFC 9610 §1.4.1 field list.
656    #[test]
657    fn contacts_account_capability_deserialize() {
658        let json = r#"{
659            "maxAddressBooksPerCard": 5,
660            "mayCreateAddressBook": true
661        }"#;
662        let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
663        assert_eq!(cap.max_address_books_per_card, Some(5));
664        assert!(cap.may_create_address_book);
665    }
666
667    /// ContactsAccountCapability with null maxAddressBooksPerCard.
668    /// Oracle: RFC 9610 §1.4.1 — "null for no limit".
669    #[test]
670    fn contacts_account_capability_null_max() {
671        let json = r#"{
672            "maxAddressBooksPerCard": null,
673            "mayCreateAddressBook": false
674        }"#;
675        let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
676        assert!(cap.max_address_books_per_card.is_none());
677        assert!(!cap.may_create_address_book);
678    }
679
680    /// URI constant has the correct value.
681    #[test]
682    fn jmap_contacts_uri_value() {
683        assert_eq!(JMAP_CONTACTS_URI, "urn:ietf:params:jmap:contacts");
684    }
685
686    // ── ContactCardComparator ────────────────────────────────────────────
687
688    /// is_ascending defaults to true when absent from JSON.
689    /// Oracle: JMAP base protocol default comparator behavior.
690    #[test]
691    fn comparator_is_ascending_defaults_to_true() {
692        let json = r#"{ "property": "created" }"#;
693        let c: ContactCardComparator = serde_json::from_str(json).unwrap();
694        assert!(c.is_ascending);
695        assert!(c.collation.is_none());
696    }
697
698    /// Comparator round-trip with all fields.
699    #[test]
700    fn comparator_round_trip() {
701        let json = r#"{ "property": "name/given", "isAscending": false, "collation": "i;unicode-casemap" }"#;
702        let c: ContactCardComparator = serde_json::from_str(json).unwrap();
703        assert_eq!(c.property, "name/given");
704        assert!(!c.is_ascending);
705        assert_eq!(c.collation.as_deref(), Some("i;unicode-casemap"));
706        let rt: ContactCardComparator =
707            serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
708        assert_eq!(rt.property, "name/given");
709        assert!(!rt.is_ascending);
710    }
711
712    // ── ContactCard sloppy-field → jmap-jscontact-types typed round-trip ──
713    //
714    // RFC 9610 §3 defers all RFC 9553 sub-object shapes to the JSContact
715    // spec. The sloppy `Option<serde_json::Value>` fields on `ContactCard`
716    // are the wire-format anchor; consumers obtain typed views via
717    // `serde_json::from_value` into the corresponding type from
718    // `jmap-jscontact-types`. The tests below cover each sloppy field
719    // against RFC 9553 example JSON, mirroring the per-field acceptance
720    // criterion in bd:JMAP-sehw.2.
721    //
722    // Oracle: hand-typed JSON taken from RFC 9553 worked examples.
723
724    /// Deserialize the sloppy `name` field through `Name`.
725    /// Oracle: RFC 9553 Figure 16.
726    #[test]
727    fn sloppy_field_name_roundtrips_through_jscontact_name() {
728        let card_json = json!({
729            "name": {
730                "components": [
731                    { "kind": "given", "value": "Vincent" },
732                    { "kind": "surname", "value": "van Gogh" }
733                ],
734                "isOrdered": true
735            }
736        });
737        let card: ContactCard = serde_json::from_value(card_json).unwrap();
738        let name: Name = serde_json::from_value(card.name.clone().unwrap()).unwrap();
739        let back = serde_json::to_value(&name).unwrap();
740        assert_eq!(back, card.name.unwrap());
741    }
742
743    /// Deserialize the sloppy `nicknames` field through `HashMap<String, Nickname>`.
744    /// Oracle: RFC 9553 Figure 21.
745    #[test]
746    fn sloppy_field_nicknames_roundtrips_through_jscontact_nickname_map() {
747        let card_json = json!({
748            "nicknames": {
749                "k391": { "name": "Johnny" }
750            }
751        });
752        let card: ContactCard = serde_json::from_value(card_json).unwrap();
753        let nicks: HashMap<String, Nickname> =
754            serde_json::from_value(card.nicknames.clone().unwrap()).unwrap();
755        assert_eq!(nicks["k391"].name, "Johnny");
756        let back = serde_json::to_value(&nicks).unwrap();
757        assert_eq!(back, card.nicknames.unwrap());
758    }
759
760    /// Deserialize the sloppy `organizations` field through `HashMap<String, Organization>`.
761    /// Oracle: RFC 9553 Figure 22.
762    #[test]
763    fn sloppy_field_organizations_roundtrips_through_jscontact_organization_map() {
764        let card_json = json!({
765            "organizations": {
766                "o1": {
767                    "name": "ABC, Inc.",
768                    "units": [
769                        { "name": "North American Division" },
770                        { "name": "Marketing" }
771                    ],
772                    "sortAs": "ABC"
773                }
774            }
775        });
776        let card: ContactCard = serde_json::from_value(card_json).unwrap();
777        let orgs: HashMap<String, Organization> =
778            serde_json::from_value(card.organizations.clone().unwrap()).unwrap();
779        assert_eq!(orgs["o1"].name.as_deref(), Some("ABC, Inc."));
780        let back = serde_json::to_value(&orgs).unwrap();
781        assert_eq!(back, card.organizations.unwrap());
782    }
783
784    /// Deserialize the sloppy `speakToAs` field through `SpeakToAs`.
785    /// Oracle: RFC 9553 Figure 23.
786    #[test]
787    fn sloppy_field_speak_to_as_roundtrips_through_jscontact_speak_to_as() {
788        let card_json = json!({
789            "speakToAs": {
790                "grammaticalGender": "neuter",
791                "pronouns": {
792                    "k19": { "pronouns": "they/them", "pref": 2 }
793                }
794            }
795        });
796        let card: ContactCard = serde_json::from_value(card_json).unwrap();
797        let speak: SpeakToAs = serde_json::from_value(card.speak_to_as.clone().unwrap()).unwrap();
798        let back = serde_json::to_value(&speak).unwrap();
799        assert_eq!(back, card.speak_to_as.unwrap());
800    }
801
802    /// Deserialize the sloppy `titles` field through `HashMap<String, Title>`.
803    /// Oracle: RFC 9553 Figure 24.
804    #[test]
805    fn sloppy_field_titles_roundtrips_through_jscontact_title_map() {
806        let card_json = json!({
807            "titles": {
808                "le9": { "kind": "title", "name": "Research Scientist" }
809            }
810        });
811        let card: ContactCard = serde_json::from_value(card_json).unwrap();
812        let titles: HashMap<String, Title> =
813            serde_json::from_value(card.titles.clone().unwrap()).unwrap();
814        assert_eq!(titles["le9"].name, "Research Scientist");
815        let back = serde_json::to_value(&titles).unwrap();
816        assert_eq!(back, card.titles.unwrap());
817    }
818
819    /// Deserialize the sloppy `emails` field through `HashMap<String, EmailAddress>`.
820    /// Oracle: RFC 9553 Figure 25.
821    #[test]
822    fn sloppy_field_emails_roundtrips_through_jscontact_email_address_map() {
823        let card_json = json!({
824            "emails": {
825                "e1": { "contexts": { "work": true }, "address": "jqpublic@xyz.example.com" }
826            }
827        });
828        let card: ContactCard = serde_json::from_value(card_json).unwrap();
829        let emails: HashMap<String, EmailAddress> =
830            serde_json::from_value(card.emails.clone().unwrap()).unwrap();
831        assert_eq!(emails["e1"].address, "jqpublic@xyz.example.com");
832        let back = serde_json::to_value(&emails).unwrap();
833        assert_eq!(back, card.emails.unwrap());
834    }
835
836    /// Deserialize the sloppy `onlineServices` field through `HashMap<String, OnlineService>`.
837    /// Oracle: RFC 9553 Figure 26.
838    #[test]
839    fn sloppy_field_online_services_roundtrips_through_jscontact_online_service_map() {
840        let card_json = json!({
841            "onlineServices": {
842                "x1": { "uri": "xmpp:alice@example.com" }
843            }
844        });
845        let card: ContactCard = serde_json::from_value(card_json).unwrap();
846        let svcs: HashMap<String, OnlineService> =
847            serde_json::from_value(card.online_services.clone().unwrap()).unwrap();
848        let back = serde_json::to_value(&svcs).unwrap();
849        assert_eq!(back, card.online_services.unwrap());
850    }
851
852    /// Deserialize the sloppy `phones` field through `HashMap<String, Phone>`.
853    /// Oracle: RFC 9553 Figure 27.
854    #[test]
855    fn sloppy_field_phones_roundtrips_through_jscontact_phone_map() {
856        let card_json = json!({
857            "phones": {
858                "tel0": {
859                    "contexts": { "private": true },
860                    "features": { "voice": true },
861                    "number": "tel:+1-555-555-5555;ext=5555",
862                    "pref": 1
863                }
864            }
865        });
866        let card: ContactCard = serde_json::from_value(card_json).unwrap();
867        let phones: HashMap<String, Phone> =
868            serde_json::from_value(card.phones.clone().unwrap()).unwrap();
869        assert_eq!(phones["tel0"].number, "tel:+1-555-555-5555;ext=5555");
870        let back = serde_json::to_value(&phones).unwrap();
871        assert_eq!(back, card.phones.unwrap());
872    }
873
874    /// Deserialize the sloppy `preferredLanguages` field through `HashMap<String, LanguagePref>`.
875    /// Oracle: RFC 9553 Figure 28.
876    #[test]
877    fn sloppy_field_preferred_languages_roundtrips_through_jscontact_language_pref_map() {
878        let card_json = json!({
879            "preferredLanguages": {
880                "l1": { "language": "en", "contexts": { "work": true }, "pref": 1 }
881            }
882        });
883        let card: ContactCard = serde_json::from_value(card_json).unwrap();
884        let langs: HashMap<String, LanguagePref> =
885            serde_json::from_value(card.preferred_languages.clone().unwrap()).unwrap();
886        assert_eq!(langs["l1"].language, "en");
887        let back = serde_json::to_value(&langs).unwrap();
888        assert_eq!(back, card.preferred_languages.unwrap());
889    }
890
891    /// Deserialize the sloppy `calendars` field through `HashMap<String, Calendar>`.
892    /// Oracle: RFC 9553 Figure 29.
893    #[test]
894    fn sloppy_field_calendars_roundtrips_through_jscontact_calendar_map() {
895        let card_json = json!({
896            "calendars": {
897                "calA": { "kind": "calendar", "uri": "webcal://calendar.example.com/calA.ics" }
898            }
899        });
900        let card: ContactCard = serde_json::from_value(card_json).unwrap();
901        let cals: HashMap<String, Calendar> =
902            serde_json::from_value(card.calendars.clone().unwrap()).unwrap();
903        let back = serde_json::to_value(&cals).unwrap();
904        assert_eq!(back, card.calendars.unwrap());
905    }
906
907    /// Deserialize the sloppy `schedulingAddresses` field through `HashMap<String, SchedulingAddress>`.
908    /// Oracle: RFC 9553 Figure 30.
909    #[test]
910    fn sloppy_field_scheduling_addresses_roundtrips_through_jscontact_scheduling_address_map() {
911        let card_json = json!({
912            "schedulingAddresses": {
913                "sched1": { "uri": "mailto:janedoe@example.com" }
914            }
915        });
916        let card: ContactCard = serde_json::from_value(card_json).unwrap();
917        let scheds: HashMap<String, SchedulingAddress> =
918            serde_json::from_value(card.scheduling_addresses.clone().unwrap()).unwrap();
919        assert_eq!(scheds["sched1"].uri, "mailto:janedoe@example.com");
920        let back = serde_json::to_value(&scheds).unwrap();
921        assert_eq!(back, card.scheduling_addresses.unwrap());
922    }
923
924    /// Deserialize the sloppy `addresses` field through `HashMap<String, Address>`.
925    /// Oracle: RFC 9553 Figure 31.
926    #[test]
927    fn sloppy_field_addresses_roundtrips_through_jscontact_address_map() {
928        let card_json = json!({
929            "addresses": {
930                "k23": {
931                    "contexts": { "work": true },
932                    "components": [
933                        { "kind": "number", "value": "54321" },
934                        { "kind": "name", "value": "Oak St" }
935                    ],
936                    "countryCode": "US",
937                    "isOrdered": true
938                }
939            }
940        });
941        let card: ContactCard = serde_json::from_value(card_json).unwrap();
942        let addrs: HashMap<String, Address> =
943            serde_json::from_value(card.addresses.clone().unwrap()).unwrap();
944        assert_eq!(addrs["k23"].country_code.as_deref(), Some("US"));
945        let back = serde_json::to_value(&addrs).unwrap();
946        assert_eq!(back, card.addresses.unwrap());
947    }
948
949    /// Deserialize the sloppy `cryptoKeys` field through `HashMap<String, CryptoKey>`.
950    /// Oracle: RFC 9553 Figure 34.
951    #[test]
952    fn sloppy_field_crypto_keys_roundtrips_through_jscontact_crypto_key_map() {
953        let card_json = json!({
954            "cryptoKeys": {
955                "mykey1": { "uri": "https://www.example.com/keys/jdoe.cer" }
956            }
957        });
958        let card: ContactCard = serde_json::from_value(card_json).unwrap();
959        let keys: HashMap<String, CryptoKey> =
960            serde_json::from_value(card.crypto_keys.clone().unwrap()).unwrap();
961        let back = serde_json::to_value(&keys).unwrap();
962        assert_eq!(back, card.crypto_keys.unwrap());
963    }
964
965    /// Deserialize the sloppy `directories` field through `HashMap<String, Directory>`.
966    /// Oracle: RFC 9553 Figure 36.
967    #[test]
968    fn sloppy_field_directories_roundtrips_through_jscontact_directory_map() {
969        let card_json = json!({
970            "directories": {
971                "dir1": {
972                    "kind": "entry",
973                    "uri": "https://dir.example.com/addrbook/jdoe/Jean%20Dupont.vcf"
974                }
975            }
976        });
977        let card: ContactCard = serde_json::from_value(card_json).unwrap();
978        let dirs: HashMap<String, Directory> =
979            serde_json::from_value(card.directories.clone().unwrap()).unwrap();
980        assert_eq!(dirs["dir1"].kind.as_deref(), Some("entry"));
981        let back = serde_json::to_value(&dirs).unwrap();
982        assert_eq!(back, card.directories.unwrap());
983    }
984
985    /// Deserialize the sloppy `links` field through `HashMap<String, Link>`.
986    /// Oracle: RFC 9553 Figure 37.
987    #[test]
988    fn sloppy_field_links_roundtrips_through_jscontact_link_map() {
989        let card_json = json!({
990            "links": {
991                "link3": { "kind": "contact", "uri": "mailto:contact@example.com", "pref": 1 }
992            }
993        });
994        let card: ContactCard = serde_json::from_value(card_json).unwrap();
995        let links: HashMap<String, Link> =
996            serde_json::from_value(card.links.clone().unwrap()).unwrap();
997        let back = serde_json::to_value(&links).unwrap();
998        assert_eq!(back, card.links.unwrap());
999    }
1000
1001    /// Deserialize the sloppy `media` field through `HashMap<String, Media>`.
1002    /// Oracle: RFC 9553 Figure 38.
1003    #[test]
1004    fn sloppy_field_media_roundtrips_through_jscontact_media_map() {
1005        let card_json = json!({
1006            "media": {
1007                "res47": { "kind": "logo", "uri": "https://www.example.com/pub/logos/abccorp.jpg" }
1008            }
1009        });
1010        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1011        let media: HashMap<String, Media> =
1012            serde_json::from_value(card.media.clone().unwrap()).unwrap();
1013        assert_eq!(media["res47"].kind.as_deref(), Some("logo"));
1014        let back = serde_json::to_value(&media).unwrap();
1015        assert_eq!(back, card.media.unwrap());
1016    }
1017
1018    /// Deserialize the sloppy `anniversaries` field through `HashMap<String, Anniversary>`.
1019    /// Oracle: RFC 9553 Figure 41.
1020    #[test]
1021    fn sloppy_field_anniversaries_roundtrips_through_jscontact_anniversary_map() {
1022        let card_json = json!({
1023            "anniversaries": {
1024                "k8": {
1025                    "kind": "birth",
1026                    "date": { "year": 1953, "month": 4, "day": 15 }
1027                }
1028            }
1029        });
1030        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1031        let annivs: HashMap<String, Anniversary> =
1032            serde_json::from_value(card.anniversaries.clone().unwrap()).unwrap();
1033        assert_eq!(annivs["k8"].kind, "birth");
1034        let back = serde_json::to_value(&annivs).unwrap();
1035        assert_eq!(back, card.anniversaries.unwrap());
1036    }
1037
1038    /// Deserialize the sloppy `notes` field through `HashMap<String, Note>`.
1039    /// Oracle: RFC 9553 Figure 43.
1040    #[test]
1041    fn sloppy_field_notes_roundtrips_through_jscontact_note_map() {
1042        let card_json = json!({
1043            "notes": {
1044                "n1": {
1045                    "note": "Open office hours are 1600 to 1715 EST, Mon-Fri",
1046                    "created": "2022-11-23T15:01:32Z",
1047                    "author": { "name": "John" }
1048                }
1049            }
1050        });
1051        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1052        let notes: HashMap<String, Note> =
1053            serde_json::from_value(card.notes.clone().unwrap()).unwrap();
1054        assert_eq!(
1055            notes["n1"].author.as_ref().unwrap().name.as_deref(),
1056            Some("John")
1057        );
1058        let back = serde_json::to_value(&notes).unwrap();
1059        assert_eq!(back, card.notes.unwrap());
1060    }
1061
1062    /// Deserialize the sloppy `personalInfo` field through `HashMap<String, PersonalInfo>`.
1063    /// Oracle: RFC 9553 Figure 44.
1064    #[test]
1065    fn sloppy_field_personal_info_roundtrips_through_jscontact_personal_info_map() {
1066        let card_json = json!({
1067            "personalInfo": {
1068                "pi2": { "kind": "expertise", "value": "chemistry", "level": "high" }
1069            }
1070        });
1071        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1072        let info: HashMap<String, PersonalInfo> =
1073            serde_json::from_value(card.personal_info.clone().unwrap()).unwrap();
1074        assert_eq!(info["pi2"].kind, "expertise");
1075        let back = serde_json::to_value(&info).unwrap();
1076        assert_eq!(back, card.personal_info.unwrap());
1077    }
1078
1079    /// Deserialize the sloppy `relatedTo` field through `HashMap<String, Relation>`.
1080    /// Oracle: RFC 9553 Figure 13.
1081    #[test]
1082    fn sloppy_field_related_to_roundtrips_through_jscontact_relation_map() {
1083        let card_json = json!({
1084            "relatedTo": {
1085                "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": {
1086                    "relation": { "friend": true }
1087                }
1088            }
1089        });
1090        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1091        let rels: HashMap<String, Relation> =
1092            serde_json::from_value(card.related_to.clone().unwrap()).unwrap();
1093        let back = serde_json::to_value(&rels).unwrap();
1094        assert_eq!(back, card.related_to.unwrap());
1095    }
1096
1097    /// Typed `keywords` field (RFC 9553 §2.8.2 `String[Boolean]`) round-trips.
1098    /// Oracle: RFC 9553 Figure 42.
1099    ///
1100    /// RFC 9553 §2.8.2 declares `keywords` as `String[Boolean]` — there is
1101    /// no JSContact object type for keywords, only a tag→true map. The
1102    /// field is typed as `Option<HashMap<String, bool>>` to match the
1103    /// sibling `members` and `address_book_ids` fields and the spec's
1104    /// closed shape (JMAP-glx8.3).
1105    #[test]
1106    fn keywords_roundtrips_as_string_bool_map() {
1107        let card_json = json!({
1108            "keywords": {
1109                "internet": true,
1110                "IETF": true
1111            }
1112        });
1113        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1114        let kws = card.keywords.as_ref().expect("keywords present");
1115        assert!(kws["internet"]);
1116        assert!(kws["IETF"]);
1117        assert_eq!(kws.len(), 2);
1118    }
1119
1120    /// Verify the `jscontact` module alias resolves to the same crate as
1121    /// the top-level re-exports.
1122    ///
1123    /// Type identity is asserted at **compile time** by the const fn
1124    /// pointers below: if `jmap_jscontact_types::Name`,
1125    /// `crate::jscontact::Name`, and the top-level `Name` re-export ever
1126    /// resolved to distinct types, these `const _ASSERT_*` items would
1127    /// fail to typecheck and the build would break — no runtime call
1128    /// needed. JMAP-glx8.21.
1129    #[test]
1130    fn jscontact_module_alias_is_jmap_jscontact_types() {
1131        const _ASSERT_ALIASED_SAME_AS_DIRECT: fn(
1132            jmap_jscontact_types::Name,
1133        ) -> crate::jscontact::Name = |x| x;
1134        const _ASSERT_TOP_LEVEL_SAME_AS_DIRECT: fn(jmap_jscontact_types::Name) -> Name = |x| x;
1135
1136        // Smoke-test that all three paths actually deserialize the same
1137        // wire JSON; a regression in re-export visibility would show up
1138        // as a build error here, not a runtime panic.
1139        let v = json!({ "full": "Vincent van Gogh" });
1140        let _direct: jmap_jscontact_types::Name = serde_json::from_value(v.clone()).unwrap();
1141        let _aliased: crate::jscontact::Name = serde_json::from_value(v.clone()).unwrap();
1142        let _top_level: Name = serde_json::from_value(v).unwrap();
1143    }
1144
1145    // ── Extras-preservation policy tests (JMAP-lbdy.5) ───────────────────
1146
1147    /// `AddressBookRights.extra` captures vendor fields and preserves them.
1148    #[test]
1149    fn address_book_rights_preserves_vendor_extras() {
1150        let raw = json!({
1151            "mayRead": true,
1152            "mayWrite": false,
1153            "mayShare": false,
1154            "mayDelete": false,
1155            "acmeCorpMayMerge": true
1156        });
1157        let r: AddressBookRights = serde_json::from_value(raw).unwrap();
1158        assert_eq!(
1159            r.extra.get("acmeCorpMayMerge").and_then(|v| v.as_bool()),
1160            Some(true)
1161        );
1162        let back = serde_json::to_value(&r).unwrap();
1163        assert_eq!(back["acmeCorpMayMerge"], true);
1164    }
1165
1166    /// `AddressBook.extra` captures vendor fields and preserves them.
1167    #[test]
1168    fn address_book_preserves_vendor_extras() {
1169        let raw = json!({
1170            "id": "ab1",
1171            "name": "Personal",
1172            "description": null,
1173            "sortOrder": 0,
1174            "isDefault": true,
1175            "isSubscribed": true,
1176            "shareWith": null,
1177            "myRights": {
1178                "mayRead": true, "mayWrite": true,
1179                "mayShare": false, "mayDelete": false
1180            },
1181            "acmeCorpRetentionDays": 365
1182        });
1183        let ab: AddressBook = serde_json::from_value(raw).unwrap();
1184        assert_eq!(
1185            ab.extra
1186                .get("acmeCorpRetentionDays")
1187                .and_then(|v| v.as_u64()),
1188            Some(365)
1189        );
1190        let back = serde_json::to_value(&ab).unwrap();
1191        assert_eq!(back["acmeCorpRetentionDays"], 365);
1192    }
1193
1194    /// `ContactCard.extra` captures vendor fields and preserves them.
1195    #[test]
1196    fn contact_card_preserves_vendor_extras() {
1197        let raw = json!({
1198            "uid": "card-1",
1199            "version": "1.0",
1200            "acmeCorpExternalId": "ldap-42"
1201        });
1202        let c: ContactCard = serde_json::from_value(raw).unwrap();
1203        assert_eq!(
1204            c.extra.get("acmeCorpExternalId").and_then(|v| v.as_str()),
1205            Some("ldap-42")
1206        );
1207        let back = serde_json::to_value(&c).unwrap();
1208        assert_eq!(back["acmeCorpExternalId"], "ldap-42");
1209    }
1210
1211    // ── MUST-be-true map values (JMAP-glx8.11) ───────────────────────────
1212    //
1213    // `addressBookIds`, `members`, and `keywords` are JMAP `Id[Boolean]` /
1214    // `String[Boolean]` sets where the spec requires each value MUST be
1215    // `true` (RFC 9610 §3, RFC 9553 §2.1.6, RFC 9553 §2.8.2). The type
1216    // shape `HashMap<_, bool>` cannot enforce that — caller / server is
1217    // responsible for rejecting `false`. The tests below pin the
1218    // deserialise-cleanly contract so handler-layer validators know what
1219    // they will see on the wire, matching the canonical `Email.mailbox_ids`
1220    // precedent in `jmap-mail-types`.
1221
1222    /// `addressBookIds` with `false` deserialises cleanly; the type does
1223    /// not enforce MUST-be-true. Oracle: hand-built JSON.
1224    #[test]
1225    fn contact_card_address_book_ids_accepts_false_deserialise() {
1226        let raw = json!({
1227            "id": "card-1",
1228            "addressBookIds": { "ab1": false }
1229        });
1230        let c: ContactCard = serde_json::from_value(raw).unwrap();
1231        let ids = c.address_book_ids.as_ref().unwrap();
1232        let ab1: Id = Id::from("ab1");
1233        // false is preserved on round-trip — handler layer rejects, not the type.
1234        assert_eq!(ids.get(&ab1).copied(), Some(false));
1235    }
1236
1237    /// `members` with `false` deserialises cleanly; the type does not
1238    /// enforce MUST-be-true. Oracle: hand-built JSON.
1239    #[test]
1240    fn contact_card_members_accepts_false_deserialise() {
1241        let raw = json!({
1242            "id": "card-1",
1243            "kind": "group",
1244            "members": { "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": false }
1245        });
1246        let c: ContactCard = serde_json::from_value(raw).unwrap();
1247        let mems = c.members.as_ref().unwrap();
1248        assert_eq!(
1249            mems.get("urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6")
1250                .copied(),
1251            Some(false),
1252        );
1253    }
1254
1255    /// `keywords` with `false` deserialises cleanly; the type does not
1256    /// enforce MUST-be-true. Oracle: hand-built JSON.
1257    #[test]
1258    fn contact_card_keywords_accepts_false_deserialise() {
1259        let raw = json!({
1260            "id": "card-1",
1261            "keywords": { "IETF": false }
1262        });
1263        let c: ContactCard = serde_json::from_value(raw).unwrap();
1264        let kws = c.keywords.as_ref().unwrap();
1265        assert_eq!(kws.get("IETF").copied(), Some(false));
1266    }
1267
1268    // ── Negative deserialize at the wire boundary (JMAP-glx8.23) ─────────
1269    //
1270    // The crate sits at the JMAP-wire deserialize boundary. Downstream
1271    // consumers (jmap-contacts-server, third-party kits) need a documented
1272    // contract for what malformed / oversized / spec-violating input does
1273    // when it reaches `serde_json::from_str` on these types. The tests
1274    // below pin that contract: the type-crate either accepts cleanly
1275    // (handler layer rejects later) or returns `Err`. No test asserts a
1276    // handler-layer behavior; those concerns live in `jmap-contacts-server`
1277    // (bd:JMAP-yfpq).
1278    //
1279    // Oracle: hand-built JSON whose shape is independent of the code
1280    // under test, per workspace test-integrity rule.
1281
1282    /// Malformed JSON returns `Err` from `serde_json::from_str` — does not
1283    /// panic, does not silently parse a partial value. Oracle: three
1284    /// hand-built malformed inputs.
1285    #[test]
1286    fn malformed_json_returns_error() {
1287        // Truncated object — closing brace missing.
1288        let truncated = r#"{"id": "card-1""#;
1289        assert!(serde_json::from_str::<ContactCard>(truncated).is_err());
1290
1291        // Mismatched braces — closes with `]` instead of `}`.
1292        let mismatched = r#"{"id": "card-1"]"#;
1293        assert!(serde_json::from_str::<ContactCard>(mismatched).is_err());
1294
1295        // Unterminated string literal.
1296        let unterminated = r#"{"id": "card-1}"#;
1297        assert!(serde_json::from_str::<ContactCard>(unterminated).is_err());
1298    }
1299
1300    /// Oversized `name` value on `AddressBook` deserialises cleanly: the
1301    /// type accepts up to `String::MAX_LEN` regardless of the 255-octet
1302    /// upper bound declared by RFC 9610 §2. The handler layer
1303    /// (`jmap-contacts-server`, bd:JMAP-yfpq) is responsible for
1304    /// rejecting names that exceed the spec bound with
1305    /// `invalidProperties` per RFC 8620 §5.3. Oracle: hand-built JSON
1306    /// with a 1000-byte string.
1307    #[test]
1308    fn oversized_name_deserialises_to_typed_field() {
1309        let big_name = "x".repeat(1000);
1310        let raw = json!({
1311            "id": "ab-big-name",
1312            "name": big_name,
1313            "description": null,
1314            "sortOrder": 0,
1315            "isDefault": false,
1316            "isSubscribed": true,
1317            "shareWith": null,
1318            "myRights": {
1319                "mayRead": true, "mayWrite": false,
1320                "mayShare": false, "mayDelete": false
1321            }
1322        });
1323        let ab: AddressBook = serde_json::from_value(raw).unwrap();
1324        assert_eq!(ab.name.len(), 1000);
1325    }
1326
1327    /// `sortOrder` values in `[2^31, 2^32)` deserialise cleanly into the
1328    /// typed `u32` field, even though RFC 9610 §2 declares the spec range
1329    /// `[0, 2^31)`. The handler layer is responsible for rejecting values
1330    /// outside the spec range. This test documents the spec-range vs
1331    /// type-range gap. Oracle: hand-built JSON with `sortOrder` =
1332    /// 2_147_483_648 (one past the spec ceiling, well within `u32::MAX`).
1333    #[test]
1334    fn sort_order_overflow_outside_spec_range_accepts() {
1335        let raw = json!({
1336            "id": "ab-overflow",
1337            "name": "Overflow",
1338            "description": null,
1339            "sortOrder": 2_147_483_648u64,
1340            "isDefault": false,
1341            "isSubscribed": true,
1342            "shareWith": null,
1343            "myRights": {
1344                "mayRead": true, "mayWrite": false,
1345                "mayShare": false, "mayDelete": false
1346            }
1347        });
1348        let ab: AddressBook = serde_json::from_value(raw).unwrap();
1349        assert_eq!(ab.sort_order, 2_147_483_648);
1350    }
1351
1352    /// A Sloppy-Value field (`ContactCard.name`, typed as
1353    /// `Option<serde_json::Value>`) accepts any JSON shape, including
1354    /// non-object values like a bare string. The handler layer is
1355    /// responsible for rejecting non-object wire shapes per RFC 9553 §2.2.
1356    /// Typed access via `serde_json::from_value::<Name>(...)` returns
1357    /// `Err` when the wire shape is not an object. Oracle: hand-built
1358    /// JSON with `name` as a string instead of an object.
1359    #[test]
1360    fn sloppy_field_accepts_non_object_value() {
1361        let raw = json!({
1362            "id": "card-1",
1363            "name": "vincent"
1364        });
1365        let c: ContactCard = serde_json::from_value(raw).unwrap();
1366        let name_value = c.name.as_ref().unwrap();
1367        assert!(name_value.is_string(), "sloppy field preserved the string");
1368
1369        // Typed access through jmap-jscontact-types fails cleanly — does
1370        // not panic — when the wire shape is not the expected object.
1371        let typed = serde_json::from_value::<Name>(name_value.clone());
1372        assert!(typed.is_err(), "Name expects an object, got a string");
1373    }
1374
1375    /// A deeply nested but bounded sloppy-field value deserialises
1376    /// cleanly: serde_json's default recursion limit is 128 levels of
1377    /// nesting, and 100-deep nesting fits comfortably under that. Oracle:
1378    /// hand-built 100-level nested JSON object inside the `name` field.
1379    #[test]
1380    fn deeply_nested_sloppy_field_within_serde_json_limit_succeeds() {
1381        // Build {"id":"c","name":{"x":{"x":...{"x":1}...}}} with 100
1382        // levels of "x" nesting inside name.
1383        let mut nested = String::from("1");
1384        for _ in 0..100 {
1385            nested = format!("{{\"x\":{nested}}}");
1386        }
1387        let payload = format!("{{\"id\":\"c\",\"name\":{nested}}}");
1388        let c: ContactCard = serde_json::from_str(&payload)
1389            .expect("100-deep nesting must be within serde_json's default 128 recursion limit");
1390        assert!(c.name.is_some());
1391    }
1392
1393    /// A sloppy-field value nested past serde_json's default 128-level
1394    /// recursion limit returns `Err`. Oracle: hand-built 200-level
1395    /// nested JSON object inside the `name` field. This documents that
1396    /// the type-crate inherits serde_json's stack-overflow defense; it
1397    /// does NOT make a memory-budget claim — see JMAP-glx8.24 for the
1398    /// memory-and-CPU contract.
1399    #[test]
1400    fn deeply_nested_sloppy_field_beyond_serde_json_limit_errors() {
1401        let mut nested = String::from("1");
1402        for _ in 0..200 {
1403            nested = format!("{{\"x\":{nested}}}");
1404        }
1405        let payload = format!("{{\"id\":\"c\",\"name\":{nested}}}");
1406        let result: Result<ContactCard, _> = serde_json::from_str(&payload);
1407        assert!(
1408            result.is_err(),
1409            "200-deep nesting must exceed serde_json's default 128 recursion limit"
1410        );
1411    }
1412
1413    /// JSON object with a duplicate **typed** field key: `serde_derive`'s
1414    /// generated visitor REJECTS the duplicate with a "duplicate field"
1415    /// error. RFC 8259 §4 leaves duplicate-key handling
1416    /// implementation-defined; this crate's typed-struct contract is
1417    /// strict-reject. Cross-implementation behavior (Go, Python, Java)
1418    /// varies — see the extras-collision contract on
1419    /// [`ContactCard::extra`] (card.rs:247-267). Oracle: hand-built
1420    /// JSON with two `"uid"` keys.
1421    #[test]
1422    fn duplicate_typed_field_keys_rejected() {
1423        let raw = r#"{"id":"card-1","uid":"first","uid":"second"}"#;
1424        let err = serde_json::from_str::<ContactCard>(raw)
1425            .expect_err("typed field duplicate must be rejected");
1426        let msg = err.to_string();
1427        assert!(
1428            msg.contains("duplicate field"),
1429            "expected duplicate-field error, got: {msg}"
1430        );
1431    }
1432
1433    /// JSON object with a duplicate **vendor-extra** key: the
1434    /// `#[serde(flatten)]` `extra` field is backed by
1435    /// `serde_json::Map<String, Value>`, which last-wins on duplicate
1436    /// keys (matching the untyped `serde_json::Value` path). This
1437    /// documents the asymmetry — typed fields reject duplicates,
1438    /// vendor extras absorb the last value silently. Cross-reference:
1439    /// [`ContactCard::extra`] collision contract. Oracle: hand-built
1440    /// JSON with two `"acmeCorpExternalId"` keys.
1441    #[test]
1442    fn duplicate_vendor_extra_keys_last_wins() {
1443        let raw = r#"{"id":"card-1","acmeCorpExternalId":"first","acmeCorpExternalId":"second"}"#;
1444        let c: ContactCard = serde_json::from_str(raw)
1445            .expect("vendor-extra duplicates fold into extra map, last-wins");
1446        assert_eq!(
1447            c.extra.get("acmeCorpExternalId").and_then(|v| v.as_str()),
1448            Some("second"),
1449            "extra map last-wins on duplicate keys"
1450        );
1451    }
1452
1453    // ── validate_extras (JMAP-glx8.25) ───────────────────────────────────
1454    //
1455    // The `validate_extras()` method on each affected type is a defensive
1456    // pre-serialize check that detects keys in `extra` that shadow typed
1457    // wire-format fields. The tests below pin the contract: clean extras
1458    // pass, every typed wire-field name collides when programmatically
1459    // inserted into `extra`, and multi-collision reports list all keys.
1460
1461    /// `ContactCard::validate_extras` returns `Ok(())` when `extra` is
1462    /// empty or contains only non-colliding vendor keys.
1463    #[test]
1464    fn contact_card_validate_extras_ok_on_clean() {
1465        let c = ContactCard::default();
1466        assert!(c.validate_extras().is_ok(), "empty extras must pass");
1467
1468        let mut c = ContactCard::default();
1469        c.extra.insert("acmeCorpFoo".into(), json!("bar"));
1470        assert!(c.validate_extras().is_ok(), "vendor-only key must pass");
1471    }
1472
1473    /// `ContactCard::validate_extras` returns `Err` listing every
1474    /// typed wire-format field name when programmatically inserted
1475    /// into `extra`. Oracle: the spec'd list of camelCase field names
1476    /// from RFC 9610 §3 + RFC 9553 §2 + workspace extras-preservation
1477    /// policy.
1478    #[test]
1479    fn contact_card_validate_extras_detects_every_typed_field_collision() {
1480        // Every typed wire-format field name on ContactCard. Kept here
1481        // independent of the const in card.rs as the test oracle.
1482        let typed_fields = [
1483            "id",
1484            "addressBookIds",
1485            "version",
1486            "created",
1487            "kind",
1488            "language",
1489            "members",
1490            "prodId",
1491            "relatedTo",
1492            "uid",
1493            "updated",
1494            "name",
1495            "nicknames",
1496            "organizations",
1497            "speakToAs",
1498            "titles",
1499            "emails",
1500            "onlineServices",
1501            "phones",
1502            "preferredLanguages",
1503            "calendars",
1504            "schedulingAddresses",
1505            "addresses",
1506            "cryptoKeys",
1507            "directories",
1508            "links",
1509            "media",
1510            "localizations",
1511            "anniversaries",
1512            "keywords",
1513            "notes",
1514            "personalInfo",
1515        ];
1516        for field in typed_fields {
1517            let mut c = ContactCard::default();
1518            c.extra.insert(field.into(), json!("collision"));
1519            let err = c
1520                .validate_extras()
1521                .expect_err(&format!("{field} collision must be detected"));
1522            assert_eq!(err.keys(), &[field.to_string()]);
1523        }
1524    }
1525
1526    /// `ContactCard::validate_extras` reports multiple collisions in
1527    /// alphabetical order in a single `Err`.
1528    #[test]
1529    fn contact_card_validate_extras_reports_multiple_collisions() {
1530        let mut c = ContactCard::default();
1531        c.extra.insert("uid".into(), json!("a"));
1532        c.extra.insert("id".into(), json!("b"));
1533        c.extra.insert("acmeCorpFoo".into(), json!("c"));
1534        let err = c.validate_extras().expect_err("multi-collision must error");
1535        assert_eq!(err.keys(), &["id".to_string(), "uid".to_string()]);
1536        let msg = err.to_string();
1537        assert!(msg.contains("id, uid"), "got: {msg}");
1538    }
1539
1540    /// `AddressBook::validate_extras` returns `Ok(())` on clean extras
1541    /// and `Err` on every typed wire-format field collision.
1542    #[test]
1543    fn address_book_validate_extras_detects_every_typed_field_collision() {
1544        let typed_fields = [
1545            "id",
1546            "name",
1547            "description",
1548            "sortOrder",
1549            "isDefault",
1550            "isSubscribed",
1551            "shareWith",
1552            "myRights",
1553        ];
1554        // Build a minimal AddressBook (without using the literal field
1555        // names that would interfere with the test).
1556        let template = json!({
1557            "id": "ab-test",
1558            "name": "Test",
1559            "description": null,
1560            "sortOrder": 0,
1561            "isDefault": false,
1562            "isSubscribed": true,
1563            "shareWith": null,
1564            "myRights": {
1565                "mayRead": true, "mayWrite": false,
1566                "mayShare": false, "mayDelete": false
1567            }
1568        });
1569        let mut ab: AddressBook = serde_json::from_value(template).unwrap();
1570        assert!(ab.validate_extras().is_ok(), "clean extras pass");
1571
1572        for field in typed_fields {
1573            ab.extra.clear();
1574            ab.extra.insert(field.into(), json!("collision"));
1575            let err = ab
1576                .validate_extras()
1577                .expect_err(&format!("{field} collision must be detected"));
1578            assert_eq!(err.keys(), &[field.to_string()]);
1579        }
1580    }
1581
1582    /// `AddressBookRights::validate_extras` returns `Ok(())` on clean
1583    /// extras and `Err` on every typed wire-format field collision.
1584    #[test]
1585    fn address_book_rights_validate_extras_detects_every_typed_field_collision() {
1586        let typed_fields = ["mayRead", "mayWrite", "mayShare", "mayDelete"];
1587        let mut r = AddressBookRights::default();
1588        assert!(r.validate_extras().is_ok(), "clean extras pass");
1589
1590        for field in typed_fields {
1591            r.extra.clear();
1592            r.extra.insert(field.into(), json!(true));
1593            let err = r
1594                .validate_extras()
1595                .expect_err(&format!("{field} collision must be detected"));
1596            assert_eq!(err.keys(), &[field.to_string()]);
1597        }
1598    }
1599}