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//! # Example
41//!
42//! ```rust
43//! use jmap_contacts_types::AddressBook;
44//!
45//! let json = r#"{
46//!     "id": "ab1",
47//!     "name": "Personal",
48//!     "sortOrder": 0,
49//!     "isDefault": true,
50//!     "isSubscribed": true,
51//!     "shareWith": null,
52//!     "myRights": {
53//!         "mayRead": true,
54//!         "mayWrite": true,
55//!         "mayShare": true,
56//!         "mayDelete": false
57//!     }
58//! }"#;
59//!
60//! let ab: AddressBook = serde_json::from_str(json).unwrap();
61//! assert_eq!(ab.name, "Personal");
62//! ```
63
64#![forbid(unsafe_code)]
65
66pub mod addressbook;
67pub mod backend;
68pub mod capability;
69pub mod card;
70
71/// Module alias re-exporting [`jmap_jscontact_types`].
72///
73/// Provides nested access to JSContact sub-object types as
74/// `jmap_contacts_types::jscontact::Name`, mirroring the
75/// `jmap_calendars_types::jscalendar::*` pattern. New code should prefer
76/// the top-level re-exports (e.g. `jmap_contacts_types::Name`) or the
77/// direct path `jmap_jscontact_types::Name`.
78pub use jmap_jscontact_types as jscontact;
79
80pub use addressbook::{AddressBook, AddressBookRights};
81pub use backend::{AddressBookProperty, ContactCardProperty};
82pub use capability::{ContactsAccountCapability, ContactsCapability, JMAP_CONTACTS_URI};
83pub use card::{ContactCard, ContactCardComparator, ContactCardFilterCondition};
84
85// ── JSContact sub-object re-exports (RFC 9553) ───────────────────────────────
86//
87// Mirrors the jmap-calendars-types re-export pattern. Top-level access to
88// every public RFC 9553 typed sub-object so callers can write
89// `jmap_contacts_types::Name` symmetrically with the wire field name on
90// `ContactCard`.
91pub use jmap_jscontact_types::{
92    Address, AddressComponent, Anniversary, AnniversaryDate, Author, Calendar, CryptoKey,
93    Directory, EmailAddress, JsContactId, LanguagePref, Link, Media, Name, NameComponent, Nickname,
94    Note, OnlineService, OrgUnit, Organization, PartialDate, PersonalInfo, Phone, Pronouns,
95    Relation, SchedulingAddress, SpeakToAs, Timestamp, Title,
96};
97
98#[cfg(test)]
99mod tests {
100    use std::collections::HashMap;
101
102    use jmap_types::Id;
103    use serde_json::json;
104
105    use super::*;
106
107    // ── AddressBookRights ────────────────────────────────────────────────
108
109    /// AddressBookRights::default() MUST produce all-false.
110    /// Oracle: spec §2 — four boolean fields, default-false is the safe value.
111    #[test]
112    fn address_book_rights_default_is_all_false() {
113        let r = AddressBookRights::default();
114        assert!(!r.may_read, "may_read default should be false");
115        assert!(!r.may_write, "may_write default should be false");
116        assert!(!r.may_share, "may_share default should be false");
117        assert!(!r.may_delete, "may_delete default should be false");
118    }
119
120    /// Deserialize AddressBookRights from spec §4.1 example JSON.
121    /// Oracle: literal JSON from RFC 9610 §4.1.
122    #[test]
123    fn address_book_rights_deserialize() {
124        let json = r#"{
125            "mayRead": true,
126            "mayWrite": false,
127            "mayShare": false,
128            "mayDelete": false
129        }"#;
130        let r: AddressBookRights = serde_json::from_str(json).unwrap();
131        assert!(r.may_read);
132        assert!(!r.may_write);
133        assert!(!r.may_share);
134        assert!(!r.may_delete);
135    }
136
137    /// AddressBookRights round-trip: serialize then deserialize must be
138    /// identity.
139    #[test]
140    fn address_book_rights_round_trip() {
141        let original = AddressBookRights {
142            may_read: true,
143            may_write: true,
144            may_share: false,
145            may_delete: false,
146            extra: serde_json::Map::new(),
147        };
148        let json = serde_json::to_string(&original).unwrap();
149        let decoded: AddressBookRights = serde_json::from_str(&json).unwrap();
150        assert_eq!(original, decoded);
151    }
152
153    // ── AddressBook ──────────────────────────────────────────────────────
154
155    /// Deserialize an AddressBook from the spec §4.1 example response.
156    /// Oracle: literal JSON from RFC 9610 §4.1.
157    #[test]
158    fn address_book_deserialize_spec_example() {
159        // Taken verbatim from RFC 9610 §4.1.
160        let json = r#"{
161            "id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
162            "name": "Personal",
163            "description": null,
164            "sortOrder": 0,
165            "isDefault": true,
166            "isSubscribed": true,
167            "shareWith": {
168                "3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
169                    "mayRead": true,
170                    "mayWrite": false,
171                    "mayShare": false,
172                    "mayDelete": false
173                }
174            },
175            "myRights": {
176                "mayRead": true,
177                "mayWrite": true,
178                "mayShare": true,
179                "mayDelete": false
180            }
181        }"#;
182        let ab: AddressBook = serde_json::from_str(json).unwrap();
183        assert_eq!(ab.name, "Personal");
184        assert!(ab.is_default);
185        assert!(ab.is_subscribed);
186        assert_eq!(ab.sort_order, 0);
187        assert!(ab.description.is_none());
188
189        let share_with = ab.share_with.unwrap();
190        let principal_id: Id = Id::from("3f1502e0-63fe-4335-9ff3-e739c188f5dd");
191        let shared_rights = &share_with[&principal_id];
192        assert!(shared_rights.may_read);
193        assert!(!shared_rights.may_write);
194
195        assert!(ab.my_rights.may_read);
196        assert!(ab.my_rights.may_write);
197        assert!(ab.my_rights.may_share);
198        assert!(!ab.my_rights.may_delete);
199    }
200
201    /// Deserialize the second AddressBook from §4.1 (shareWith: null).
202    /// Oracle: literal JSON from RFC 9610 §4.1.
203    #[test]
204    fn address_book_share_with_null() {
205        let json = r#"{
206            "id": "cd40089d-35f9-4fd7-980b-ba3a9f1d74fe",
207            "name": "Autosaved",
208            "description": null,
209            "sortOrder": 1,
210            "isDefault": false,
211            "isSubscribed": true,
212            "shareWith": null,
213            "myRights": {
214                "mayRead": true,
215                "mayWrite": true,
216                "mayShare": true,
217                "mayDelete": false
218            }
219        }"#;
220        let ab: AddressBook = serde_json::from_str(json).unwrap();
221        assert_eq!(ab.name, "Autosaved");
222        assert!(ab.share_with.is_none());
223    }
224
225    /// AddressBook with share_with: None serializes as "shareWith": null, not absent.
226    /// Oracle: RFC 9610 §2 type `Id[AddressBookRights]|null`; §4.1 second
227    /// AddressBook example shows `"shareWith": null` explicitly on the wire.
228    #[test]
229    fn address_book_share_with_null_serializes() {
230        // Build an AddressBook that has share_with: None (the null case).
231        let json = r#"{
232            "id": "ab-null-sw",
233            "name": "Private",
234            "sortOrder": 0,
235            "isDefault": false,
236            "isSubscribed": true,
237            "shareWith": null,
238            "myRights": {
239                "mayRead": true,
240                "mayWrite": true,
241                "mayShare": false,
242                "mayDelete": false
243            }
244        }"#;
245        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
246        assert!(ab.share_with.is_none());
247
248        let serialized = serde_json::to_value(&ab).expect("serialize");
249        let obj = serialized.as_object().expect("object");
250        assert!(
251            obj.contains_key("shareWith"),
252            "shareWith must be present in wire JSON (required-nullable)"
253        );
254        assert!(
255            obj["shareWith"].is_null(),
256            "shareWith must serialize as null when None"
257        );
258    }
259
260    /// AddressBook with share_with: Some(map) round-trips correctly.
261    /// Oracle: RFC 9610 §4.1 first AddressBook example with populated shareWith map.
262    #[test]
263    fn address_book_share_with_some_serializes() {
264        // From RFC 9610 §4.1 first AddressBook.
265        let json = r#"{
266            "id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
267            "name": "Personal",
268            "description": null,
269            "sortOrder": 0,
270            "isDefault": true,
271            "isSubscribed": true,
272            "shareWith": {
273                "3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
274                    "mayRead": true,
275                    "mayWrite": false,
276                    "mayShare": false,
277                    "mayDelete": false
278                }
279            },
280            "myRights": {
281                "mayRead": true,
282                "mayWrite": true,
283                "mayShare": true,
284                "mayDelete": false
285            }
286        }"#;
287        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
288        assert!(ab.share_with.is_some());
289
290        let serialized = serde_json::to_value(&ab).expect("serialize");
291        let obj = serialized.as_object().expect("object");
292        assert!(
293            obj.contains_key("shareWith"),
294            "shareWith must be present when set"
295        );
296        assert!(
297            !obj["shareWith"].is_null(),
298            "shareWith must not be null when populated"
299        );
300        // Round-trip: deserialize the serialized form, verify equality.
301        let back: AddressBook = serde_json::from_value(serialized).expect("round-trip deserialize");
302        assert_eq!(ab.share_with, back.share_with);
303    }
304
305    /// AddressBook round-trip: description Some vs None.
306    #[test]
307    fn address_book_round_trip_with_description() {
308        let rights = AddressBookRights {
309            may_read: true,
310            may_write: false,
311            may_share: false,
312            may_delete: false,
313            extra: serde_json::Map::new(),
314        };
315        // Construct via JSON (avoids #[non_exhaustive] struct literal restriction).
316        let original_json = json!({
317            "id": "ab1",
318            "name": "Work",
319            "description": "Work contacts",
320            "sortOrder": 5,
321            "isDefault": false,
322            "isSubscribed": true,
323            "myRights": {
324                "mayRead": true,
325                "mayWrite": false,
326                "mayShare": false,
327                "mayDelete": false
328            }
329        });
330        let ab: AddressBook = serde_json::from_value(original_json).unwrap();
331        assert_eq!(ab.description.as_deref(), Some("Work contacts"));
332        let _rt: AddressBook = serde_json::from_str(&serde_json::to_string(&ab).unwrap()).unwrap();
333        // rights field unused directly; just suppress unused-variable warning
334        let _ = rights;
335    }
336
337    /// description: null → None in Rust → "description": null on the wire.
338    ///
339    /// Oracle: RFC 9553 + RFC 9610 §4.1 — both example
340    /// AddressBook objects show `"description": null` explicitly.  The field
341    /// must be present as null, never absent.
342    #[test]
343    fn address_book_description_null_serializes() {
344        let json = r#"{
345            "id": "AB1",
346            "name": "Personal",
347            "description": null,
348            "sortOrder": 0,
349            "isDefault": false,
350            "isSubscribed": false,
351            "shareWith": null,
352            "myRights": {
353                "mayRead": true,
354                "mayWrite": true,
355                "mayShare": false,
356                "mayDelete": false
357            }
358        }"#;
359        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
360        assert!(ab.description.is_none(), "description should be None");
361
362        let out = serde_json::to_value(&ab).expect("serialize");
363        assert!(
364            out.as_object().expect("object").contains_key("description"),
365            "description key must be present in wire JSON"
366        );
367        assert!(
368            out["description"].is_null(),
369            "description must serialize as null, not be absent"
370        );
371    }
372
373    // ── ContactCard ──────────────────────────────────────────────────────
374
375    /// Deserialize the ContactCard from the spec §4.1 example.
376    /// Oracle: literal JSON from RFC 9610 §4.1.
377    #[test]
378    fn contact_card_deserialize_spec_example() {
379        // From RFC 9610 §4.1 response.
380        let json = r#"{
381            "id": "3",
382            "addressBookIds": {
383                "062adcfa-105d-455c-bc60-6db68b69c3f3": true
384            },
385            "name": {
386                "components": [
387                    { "kind": "given", "value": "Joe" },
388                    { "kind": "surname", "value": "Bloggs" }
389                ],
390                "isOrdered": true
391            },
392            "emails": {
393                "0": {
394                    "contexts": {
395                        "private": true
396                    },
397                    "address": "joe.bloggs@example.com"
398                }
399            }
400        }"#;
401        let card: ContactCard = serde_json::from_str(json).unwrap();
402
403        let id = card.id.as_ref().unwrap();
404        assert_eq!(id.as_ref(), "3");
405
406        let ab_ids = card.address_book_ids.as_ref().unwrap();
407        let ab_key: Id = Id::from("062adcfa-105d-455c-bc60-6db68b69c3f3");
408        assert!(ab_ids[&ab_key]);
409
410        // name is a serde_json::Value
411        let name = card.name.as_ref().unwrap();
412        assert_eq!(name["isOrdered"], serde_json::Value::Bool(true));
413        assert_eq!(name["components"][0]["kind"], "given");
414        assert_eq!(name["components"][0]["value"], "Joe");
415
416        // emails is a serde_json::Value
417        let emails = card.emails.as_ref().unwrap();
418        assert_eq!(emails["0"]["address"], "joe.bloggs@example.com");
419    }
420
421    /// ContactCard with kind="group" and members map.
422    /// Oracle: hand-written from RFC 9553 §2.1.6 example.
423    #[test]
424    fn contact_card_group_with_members() {
425        let json = r#"{
426            "version": "1.0",
427            "uid": "urn:uuid:ab4310aa-fa43-11e9-8f0b-362b9e155667",
428            "kind": "group",
429            "name": { "full": "The Doe family" },
430            "members": {
431                "urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af": true,
432                "urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519": true
433            }
434        }"#;
435        let card: ContactCard = serde_json::from_str(json).unwrap();
436        assert_eq!(card.kind.as_deref(), Some("group"));
437        let members = card.members.as_ref().unwrap();
438        assert!(members["urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af"]);
439        assert_eq!(members.len(), 2);
440    }
441
442    /// ContactCard with no optional fields: only id and addressBookIds.
443    #[test]
444    fn contact_card_minimal_fields() {
445        let json = r#"{
446            "id": "minimal1",
447            "addressBookIds": { "ab1": true }
448        }"#;
449        let card: ContactCard = serde_json::from_str(json).unwrap();
450        assert!(card.version.is_none());
451        assert!(card.uid.is_none());
452        assert!(card.kind.is_none());
453        assert!(card.emails.is_none());
454        assert!(card.phones.is_none());
455        assert!(card.addresses.is_none());
456    }
457
458    /// ContactCard skip_serializing_if omits None fields from output.
459    #[test]
460    fn contact_card_serializes_only_present_fields() {
461        // Build from JSON to bypass #[non_exhaustive] struct literal restriction.
462        let card: ContactCard = serde_json::from_value(json!({
463            "id": "x1",
464            "addressBookIds": { "ab1": true },
465            "version": "1.0",
466            "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
467        }))
468        .unwrap();
469
470        let serialized = serde_json::to_value(&card).unwrap();
471        // Only present fields should appear.
472        assert!(serialized.get("id").is_some());
473        assert!(serialized.get("version").is_some());
474        assert!(serialized.get("uid").is_some());
475        // Absent fields must NOT appear.
476        assert!(serialized.get("emails").is_none());
477        assert!(serialized.get("phones").is_none());
478        assert!(serialized.get("addresses").is_none());
479        assert!(serialized.get("kind").is_none());
480    }
481
482    /// `localizations` round-trips as a `HashMap<String, PatchObject>` and
483    /// is byte-identical on the wire (proving `#[serde(transparent)]` on
484    /// `PatchObject` introduces no wrapper key).
485    ///
486    /// Oracle: hand-written JSON modelled on RFC 9553 §2.7.1 localizations
487    /// (BCP 47 language tag keys → PatchObject values).
488    #[test]
489    fn contact_card_localizations_patch_object_transparent() {
490        let json = r#"{"localizations":{"de":{"name/full":"Joe Bloggs (DE)"}}}"#;
491        let card: ContactCard = serde_json::from_str(json).expect("deserialize");
492
493        let locs = card.localizations.as_ref().expect("localizations present");
494        let de = locs.get("de").expect("de locale present");
495        assert_eq!(
496            de.as_map().get("name/full").and_then(|v| v.as_str()),
497            Some("Joe Bloggs (DE)"),
498        );
499
500        let serialized = serde_json::to_string(&card).expect("serialize");
501        assert_eq!(serialized, json);
502    }
503
504    // ── ContactCardFilterCondition ───────────────────────────────────────
505
506    /// Verify that slash-keyed filter fields serialize with the correct wire
507    /// names.
508    /// Oracle: RFC 9610 §3.3.1 field list.
509    #[test]
510    fn filter_condition_slash_keys_serialize_correctly() {
511        let filter: ContactCardFilterCondition = serde_json::from_value(json!({
512            "name/given": "John",
513            "name/surname": "Smith",
514            "name/surname2": "Doe"
515        }))
516        .unwrap();
517
518        assert_eq!(filter.name_given.as_deref(), Some("John"));
519        assert_eq!(filter.name_surname.as_deref(), Some("Smith"));
520        assert_eq!(filter.name_surname2.as_deref(), Some("Doe"));
521
522        let serialized = serde_json::to_value(&filter).unwrap();
523        assert_eq!(serialized["name/given"], "John");
524        assert_eq!(serialized["name/surname"], "Smith");
525        assert_eq!(serialized["name/surname2"], "Doe");
526    }
527
528    /// All filter fields deserialize from spec §3.3.1 field names.
529    /// Oracle: field names taken directly from RFC 9610 §3.3.1.
530    #[test]
531    fn filter_condition_all_fields() {
532        let json = r#"{
533            "inAddressBook": "ab1",
534            "uid": "urn:uuid:abc123",
535            "hasMember": "urn:uuid:def456",
536            "kind": "individual",
537            "createdBefore": "2024-01-01T00:00:00Z",
538            "createdAfter": "2020-01-01T00:00:00Z",
539            "updatedBefore": "2024-06-01T00:00:00Z",
540            "updatedAfter": "2021-06-01T00:00:00Z",
541            "text": "John",
542            "name": "Doe",
543            "name/given": "Jane",
544            "name/surname": "Smith",
545            "name/surname2": "Gomez",
546            "nickname": "Johnny",
547            "organization": "ACME",
548            "email": "john@example.com",
549            "phone": "+1-555-1234",
550            "onlineService": "twitter",
551            "address": "Main St",
552            "note": "VIP"
553        }"#;
554        let f: ContactCardFilterCondition = serde_json::from_str(json).unwrap();
555        assert_eq!(
556            f.in_address_book.as_ref().map(|id| id.as_ref()),
557            Some("ab1")
558        );
559        assert_eq!(f.uid.as_deref(), Some("urn:uuid:abc123"));
560        assert_eq!(f.has_member.as_deref(), Some("urn:uuid:def456"));
561        assert_eq!(f.kind.as_deref(), Some("individual"));
562        assert_eq!(
563            f.created_before.as_ref().map(AsRef::as_ref),
564            Some("2024-01-01T00:00:00Z")
565        );
566        assert_eq!(
567            f.created_after.as_ref().map(AsRef::as_ref),
568            Some("2020-01-01T00:00:00Z")
569        );
570        assert_eq!(
571            f.updated_before.as_ref().map(AsRef::as_ref),
572            Some("2024-06-01T00:00:00Z")
573        );
574        assert_eq!(
575            f.updated_after.as_ref().map(AsRef::as_ref),
576            Some("2021-06-01T00:00:00Z")
577        );
578        assert_eq!(f.text.as_deref(), Some("John"));
579        assert_eq!(f.name.as_deref(), Some("Doe"));
580        assert_eq!(f.name_given.as_deref(), Some("Jane"));
581        assert_eq!(f.name_surname.as_deref(), Some("Smith"));
582        assert_eq!(f.name_surname2.as_deref(), Some("Gomez"));
583        assert_eq!(f.nickname.as_deref(), Some("Johnny"));
584        assert_eq!(f.organization.as_deref(), Some("ACME"));
585        assert_eq!(f.email.as_deref(), Some("john@example.com"));
586        assert_eq!(f.phone.as_deref(), Some("+1-555-1234"));
587        assert_eq!(f.online_service.as_deref(), Some("twitter"));
588        assert_eq!(f.address.as_deref(), Some("Main St"));
589        assert_eq!(f.note.as_deref(), Some("VIP"));
590    }
591
592    /// Empty filter condition serializes to `{}`.
593    #[test]
594    fn filter_condition_empty_serializes_to_empty_object() {
595        let f = ContactCardFilterCondition::default();
596        let v = serde_json::to_value(&f).unwrap();
597        assert_eq!(v, json!({}));
598    }
599
600    // ── Capability ───────────────────────────────────────────────────────
601
602    /// ContactsCapability serializes to `{}`.
603    /// Oracle: RFC 9610 §1.4.1 — session-level capability is an empty object.
604    #[test]
605    fn contacts_capability_is_empty_object() {
606        let cap = ContactsCapability::default();
607        let v = serde_json::to_value(&cap).unwrap();
608        assert_eq!(v, json!({}));
609    }
610
611    /// ContactsAccountCapability deserializes correctly.
612    /// Oracle: RFC 9610 §1.4.1 field list.
613    #[test]
614    fn contacts_account_capability_deserialize() {
615        let json = r#"{
616            "maxAddressBooksPerCard": 5,
617            "mayCreateAddressBook": true
618        }"#;
619        let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
620        assert_eq!(cap.max_address_books_per_card, Some(5));
621        assert!(cap.may_create_address_book);
622    }
623
624    /// ContactsAccountCapability with null maxAddressBooksPerCard.
625    /// Oracle: RFC 9610 §1.4.1 — "null for no limit".
626    #[test]
627    fn contacts_account_capability_null_max() {
628        let json = r#"{
629            "maxAddressBooksPerCard": null,
630            "mayCreateAddressBook": false
631        }"#;
632        let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
633        assert!(cap.max_address_books_per_card.is_none());
634        assert!(!cap.may_create_address_book);
635    }
636
637    /// URI constant has the correct value.
638    #[test]
639    fn jmap_contacts_uri_value() {
640        assert_eq!(JMAP_CONTACTS_URI, "urn:ietf:params:jmap:contacts");
641    }
642
643    // ── ContactCardComparator ────────────────────────────────────────────
644
645    /// is_ascending defaults to true when absent from JSON.
646    /// Oracle: JMAP base protocol default comparator behavior.
647    #[test]
648    fn comparator_is_ascending_defaults_to_true() {
649        let json = r#"{ "property": "created" }"#;
650        let c: ContactCardComparator = serde_json::from_str(json).unwrap();
651        assert!(c.is_ascending);
652        assert!(c.collation.is_none());
653    }
654
655    /// Comparator round-trip with all fields.
656    #[test]
657    fn comparator_round_trip() {
658        let json = r#"{ "property": "name/given", "isAscending": false, "collation": "i;unicode-casemap" }"#;
659        let c: ContactCardComparator = serde_json::from_str(json).unwrap();
660        assert_eq!(c.property, "name/given");
661        assert!(!c.is_ascending);
662        assert_eq!(c.collation.as_deref(), Some("i;unicode-casemap"));
663        let rt: ContactCardComparator =
664            serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
665        assert_eq!(rt.property, "name/given");
666        assert!(!rt.is_ascending);
667    }
668
669    // ── Suppress unused-import warnings ─────────────────────────────────
670    fn _use_imports() {
671        let _: HashMap<String, bool> = HashMap::new();
672    }
673
674    // ── ContactCard sloppy-field → jmap-jscontact-types typed round-trip ──
675    //
676    // RFC 9610 §3 defers all RFC 9553 sub-object shapes to the JSContact
677    // spec. The sloppy `Option<serde_json::Value>` fields on `ContactCard`
678    // are the wire-format anchor; consumers obtain typed views via
679    // `serde_json::from_value` into the corresponding type from
680    // `jmap-jscontact-types`. The tests below cover each sloppy field
681    // against RFC 9553 example JSON, mirroring the per-field acceptance
682    // criterion in bd:JMAP-sehw.2.
683    //
684    // Oracle: hand-typed JSON taken from RFC 9553 worked examples.
685
686    /// Deserialize the sloppy `name` field through `Name`.
687    /// Oracle: RFC 9553 Figure 16.
688    #[test]
689    fn sloppy_field_name_roundtrips_through_jscontact_name() {
690        let card_json = json!({
691            "name": {
692                "components": [
693                    { "kind": "given", "value": "Vincent" },
694                    { "kind": "surname", "value": "van Gogh" }
695                ],
696                "isOrdered": true
697            }
698        });
699        let card: ContactCard = serde_json::from_value(card_json).unwrap();
700        let name: Name = serde_json::from_value(card.name.clone().unwrap()).unwrap();
701        let back = serde_json::to_value(&name).unwrap();
702        assert_eq!(back, card.name.unwrap());
703    }
704
705    /// Deserialize the sloppy `nicknames` field through `HashMap<String, Nickname>`.
706    /// Oracle: RFC 9553 Figure 21.
707    #[test]
708    fn sloppy_field_nicknames_roundtrips_through_jscontact_nickname_map() {
709        let card_json = json!({
710            "nicknames": {
711                "k391": { "name": "Johnny" }
712            }
713        });
714        let card: ContactCard = serde_json::from_value(card_json).unwrap();
715        let nicks: HashMap<String, Nickname> =
716            serde_json::from_value(card.nicknames.clone().unwrap()).unwrap();
717        assert_eq!(nicks["k391"].name, "Johnny");
718        let back = serde_json::to_value(&nicks).unwrap();
719        assert_eq!(back, card.nicknames.unwrap());
720    }
721
722    /// Deserialize the sloppy `organizations` field through `HashMap<String, Organization>`.
723    /// Oracle: RFC 9553 Figure 22.
724    #[test]
725    fn sloppy_field_organizations_roundtrips_through_jscontact_organization_map() {
726        let card_json = json!({
727            "organizations": {
728                "o1": {
729                    "name": "ABC, Inc.",
730                    "units": [
731                        { "name": "North American Division" },
732                        { "name": "Marketing" }
733                    ],
734                    "sortAs": "ABC"
735                }
736            }
737        });
738        let card: ContactCard = serde_json::from_value(card_json).unwrap();
739        let orgs: HashMap<String, Organization> =
740            serde_json::from_value(card.organizations.clone().unwrap()).unwrap();
741        assert_eq!(orgs["o1"].name.as_deref(), Some("ABC, Inc."));
742        let back = serde_json::to_value(&orgs).unwrap();
743        assert_eq!(back, card.organizations.unwrap());
744    }
745
746    /// Deserialize the sloppy `speakToAs` field through `SpeakToAs`.
747    /// Oracle: RFC 9553 Figure 23.
748    #[test]
749    fn sloppy_field_speak_to_as_roundtrips_through_jscontact_speak_to_as() {
750        let card_json = json!({
751            "speakToAs": {
752                "grammaticalGender": "neuter",
753                "pronouns": {
754                    "k19": { "pronouns": "they/them", "pref": 2 }
755                }
756            }
757        });
758        let card: ContactCard = serde_json::from_value(card_json).unwrap();
759        let speak: SpeakToAs = serde_json::from_value(card.speak_to_as.clone().unwrap()).unwrap();
760        let back = serde_json::to_value(&speak).unwrap();
761        assert_eq!(back, card.speak_to_as.unwrap());
762    }
763
764    /// Deserialize the sloppy `titles` field through `HashMap<String, Title>`.
765    /// Oracle: RFC 9553 Figure 24.
766    #[test]
767    fn sloppy_field_titles_roundtrips_through_jscontact_title_map() {
768        let card_json = json!({
769            "titles": {
770                "le9": { "kind": "title", "name": "Research Scientist" }
771            }
772        });
773        let card: ContactCard = serde_json::from_value(card_json).unwrap();
774        let titles: HashMap<String, Title> =
775            serde_json::from_value(card.titles.clone().unwrap()).unwrap();
776        assert_eq!(titles["le9"].name, "Research Scientist");
777        let back = serde_json::to_value(&titles).unwrap();
778        assert_eq!(back, card.titles.unwrap());
779    }
780
781    /// Deserialize the sloppy `emails` field through `HashMap<String, EmailAddress>`.
782    /// Oracle: RFC 9553 Figure 25.
783    #[test]
784    fn sloppy_field_emails_roundtrips_through_jscontact_email_address_map() {
785        let card_json = json!({
786            "emails": {
787                "e1": { "contexts": { "work": true }, "address": "jqpublic@xyz.example.com" }
788            }
789        });
790        let card: ContactCard = serde_json::from_value(card_json).unwrap();
791        let emails: HashMap<String, EmailAddress> =
792            serde_json::from_value(card.emails.clone().unwrap()).unwrap();
793        assert_eq!(emails["e1"].address, "jqpublic@xyz.example.com");
794        let back = serde_json::to_value(&emails).unwrap();
795        assert_eq!(back, card.emails.unwrap());
796    }
797
798    /// Deserialize the sloppy `onlineServices` field through `HashMap<String, OnlineService>`.
799    /// Oracle: RFC 9553 Figure 26.
800    #[test]
801    fn sloppy_field_online_services_roundtrips_through_jscontact_online_service_map() {
802        let card_json = json!({
803            "onlineServices": {
804                "x1": { "uri": "xmpp:alice@example.com" }
805            }
806        });
807        let card: ContactCard = serde_json::from_value(card_json).unwrap();
808        let svcs: HashMap<String, OnlineService> =
809            serde_json::from_value(card.online_services.clone().unwrap()).unwrap();
810        let back = serde_json::to_value(&svcs).unwrap();
811        assert_eq!(back, card.online_services.unwrap());
812    }
813
814    /// Deserialize the sloppy `phones` field through `HashMap<String, Phone>`.
815    /// Oracle: RFC 9553 Figure 27.
816    #[test]
817    fn sloppy_field_phones_roundtrips_through_jscontact_phone_map() {
818        let card_json = json!({
819            "phones": {
820                "tel0": {
821                    "contexts": { "private": true },
822                    "features": { "voice": true },
823                    "number": "tel:+1-555-555-5555;ext=5555",
824                    "pref": 1
825                }
826            }
827        });
828        let card: ContactCard = serde_json::from_value(card_json).unwrap();
829        let phones: HashMap<String, Phone> =
830            serde_json::from_value(card.phones.clone().unwrap()).unwrap();
831        assert_eq!(phones["tel0"].number, "tel:+1-555-555-5555;ext=5555");
832        let back = serde_json::to_value(&phones).unwrap();
833        assert_eq!(back, card.phones.unwrap());
834    }
835
836    /// Deserialize the sloppy `preferredLanguages` field through `HashMap<String, LanguagePref>`.
837    /// Oracle: RFC 9553 Figure 28.
838    #[test]
839    fn sloppy_field_preferred_languages_roundtrips_through_jscontact_language_pref_map() {
840        let card_json = json!({
841            "preferredLanguages": {
842                "l1": { "language": "en", "contexts": { "work": true }, "pref": 1 }
843            }
844        });
845        let card: ContactCard = serde_json::from_value(card_json).unwrap();
846        let langs: HashMap<String, LanguagePref> =
847            serde_json::from_value(card.preferred_languages.clone().unwrap()).unwrap();
848        assert_eq!(langs["l1"].language, "en");
849        let back = serde_json::to_value(&langs).unwrap();
850        assert_eq!(back, card.preferred_languages.unwrap());
851    }
852
853    /// Deserialize the sloppy `calendars` field through `HashMap<String, Calendar>`.
854    /// Oracle: RFC 9553 Figure 29.
855    #[test]
856    fn sloppy_field_calendars_roundtrips_through_jscontact_calendar_map() {
857        let card_json = json!({
858            "calendars": {
859                "calA": { "kind": "calendar", "uri": "webcal://calendar.example.com/calA.ics" }
860            }
861        });
862        let card: ContactCard = serde_json::from_value(card_json).unwrap();
863        let cals: HashMap<String, Calendar> =
864            serde_json::from_value(card.calendars.clone().unwrap()).unwrap();
865        let back = serde_json::to_value(&cals).unwrap();
866        assert_eq!(back, card.calendars.unwrap());
867    }
868
869    /// Deserialize the sloppy `schedulingAddresses` field through `HashMap<String, SchedulingAddress>`.
870    /// Oracle: RFC 9553 Figure 30.
871    #[test]
872    fn sloppy_field_scheduling_addresses_roundtrips_through_jscontact_scheduling_address_map() {
873        let card_json = json!({
874            "schedulingAddresses": {
875                "sched1": { "uri": "mailto:janedoe@example.com" }
876            }
877        });
878        let card: ContactCard = serde_json::from_value(card_json).unwrap();
879        let scheds: HashMap<String, SchedulingAddress> =
880            serde_json::from_value(card.scheduling_addresses.clone().unwrap()).unwrap();
881        assert_eq!(scheds["sched1"].uri, "mailto:janedoe@example.com");
882        let back = serde_json::to_value(&scheds).unwrap();
883        assert_eq!(back, card.scheduling_addresses.unwrap());
884    }
885
886    /// Deserialize the sloppy `addresses` field through `HashMap<String, Address>`.
887    /// Oracle: RFC 9553 Figure 31.
888    #[test]
889    fn sloppy_field_addresses_roundtrips_through_jscontact_address_map() {
890        let card_json = json!({
891            "addresses": {
892                "k23": {
893                    "contexts": { "work": true },
894                    "components": [
895                        { "kind": "number", "value": "54321" },
896                        { "kind": "name", "value": "Oak St" }
897                    ],
898                    "countryCode": "US",
899                    "isOrdered": true
900                }
901            }
902        });
903        let card: ContactCard = serde_json::from_value(card_json).unwrap();
904        let addrs: HashMap<String, Address> =
905            serde_json::from_value(card.addresses.clone().unwrap()).unwrap();
906        assert_eq!(addrs["k23"].country_code.as_deref(), Some("US"));
907        let back = serde_json::to_value(&addrs).unwrap();
908        assert_eq!(back, card.addresses.unwrap());
909    }
910
911    /// Deserialize the sloppy `cryptoKeys` field through `HashMap<String, CryptoKey>`.
912    /// Oracle: RFC 9553 Figure 34.
913    #[test]
914    fn sloppy_field_crypto_keys_roundtrips_through_jscontact_crypto_key_map() {
915        let card_json = json!({
916            "cryptoKeys": {
917                "mykey1": { "uri": "https://www.example.com/keys/jdoe.cer" }
918            }
919        });
920        let card: ContactCard = serde_json::from_value(card_json).unwrap();
921        let keys: HashMap<String, CryptoKey> =
922            serde_json::from_value(card.crypto_keys.clone().unwrap()).unwrap();
923        let back = serde_json::to_value(&keys).unwrap();
924        assert_eq!(back, card.crypto_keys.unwrap());
925    }
926
927    /// Deserialize the sloppy `directories` field through `HashMap<String, Directory>`.
928    /// Oracle: RFC 9553 Figure 36.
929    #[test]
930    fn sloppy_field_directories_roundtrips_through_jscontact_directory_map() {
931        let card_json = json!({
932            "directories": {
933                "dir1": {
934                    "kind": "entry",
935                    "uri": "https://dir.example.com/addrbook/jdoe/Jean%20Dupont.vcf"
936                }
937            }
938        });
939        let card: ContactCard = serde_json::from_value(card_json).unwrap();
940        let dirs: HashMap<String, Directory> =
941            serde_json::from_value(card.directories.clone().unwrap()).unwrap();
942        assert_eq!(dirs["dir1"].kind.as_deref(), Some("entry"));
943        let back = serde_json::to_value(&dirs).unwrap();
944        assert_eq!(back, card.directories.unwrap());
945    }
946
947    /// Deserialize the sloppy `links` field through `HashMap<String, Link>`.
948    /// Oracle: RFC 9553 Figure 37.
949    #[test]
950    fn sloppy_field_links_roundtrips_through_jscontact_link_map() {
951        let card_json = json!({
952            "links": {
953                "link3": { "kind": "contact", "uri": "mailto:contact@example.com", "pref": 1 }
954            }
955        });
956        let card: ContactCard = serde_json::from_value(card_json).unwrap();
957        let links: HashMap<String, Link> =
958            serde_json::from_value(card.links.clone().unwrap()).unwrap();
959        let back = serde_json::to_value(&links).unwrap();
960        assert_eq!(back, card.links.unwrap());
961    }
962
963    /// Deserialize the sloppy `media` field through `HashMap<String, Media>`.
964    /// Oracle: RFC 9553 Figure 38.
965    #[test]
966    fn sloppy_field_media_roundtrips_through_jscontact_media_map() {
967        let card_json = json!({
968            "media": {
969                "res47": { "kind": "logo", "uri": "https://www.example.com/pub/logos/abccorp.jpg" }
970            }
971        });
972        let card: ContactCard = serde_json::from_value(card_json).unwrap();
973        let media: HashMap<String, Media> =
974            serde_json::from_value(card.media.clone().unwrap()).unwrap();
975        assert_eq!(media["res47"].kind.as_deref(), Some("logo"));
976        let back = serde_json::to_value(&media).unwrap();
977        assert_eq!(back, card.media.unwrap());
978    }
979
980    /// Deserialize the sloppy `anniversaries` field through `HashMap<String, Anniversary>`.
981    /// Oracle: RFC 9553 Figure 41.
982    #[test]
983    fn sloppy_field_anniversaries_roundtrips_through_jscontact_anniversary_map() {
984        let card_json = json!({
985            "anniversaries": {
986                "k8": {
987                    "kind": "birth",
988                    "date": { "year": 1953, "month": 4, "day": 15 }
989                }
990            }
991        });
992        let card: ContactCard = serde_json::from_value(card_json).unwrap();
993        let annivs: HashMap<String, Anniversary> =
994            serde_json::from_value(card.anniversaries.clone().unwrap()).unwrap();
995        assert_eq!(annivs["k8"].kind, "birth");
996        let back = serde_json::to_value(&annivs).unwrap();
997        assert_eq!(back, card.anniversaries.unwrap());
998    }
999
1000    /// Deserialize the sloppy `notes` field through `HashMap<String, Note>`.
1001    /// Oracle: RFC 9553 Figure 43.
1002    #[test]
1003    fn sloppy_field_notes_roundtrips_through_jscontact_note_map() {
1004        let card_json = json!({
1005            "notes": {
1006                "n1": {
1007                    "note": "Open office hours are 1600 to 1715 EST, Mon-Fri",
1008                    "created": "2022-11-23T15:01:32Z",
1009                    "author": { "name": "John" }
1010                }
1011            }
1012        });
1013        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1014        let notes: HashMap<String, Note> =
1015            serde_json::from_value(card.notes.clone().unwrap()).unwrap();
1016        assert_eq!(
1017            notes["n1"].author.as_ref().unwrap().name.as_deref(),
1018            Some("John")
1019        );
1020        let back = serde_json::to_value(&notes).unwrap();
1021        assert_eq!(back, card.notes.unwrap());
1022    }
1023
1024    /// Deserialize the sloppy `personalInfo` field through `HashMap<String, PersonalInfo>`.
1025    /// Oracle: RFC 9553 Figure 44.
1026    #[test]
1027    fn sloppy_field_personal_info_roundtrips_through_jscontact_personal_info_map() {
1028        let card_json = json!({
1029            "personalInfo": {
1030                "pi2": { "kind": "expertise", "value": "chemistry", "level": "high" }
1031            }
1032        });
1033        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1034        let info: HashMap<String, PersonalInfo> =
1035            serde_json::from_value(card.personal_info.clone().unwrap()).unwrap();
1036        assert_eq!(info["pi2"].kind, "expertise");
1037        let back = serde_json::to_value(&info).unwrap();
1038        assert_eq!(back, card.personal_info.unwrap());
1039    }
1040
1041    /// Deserialize the sloppy `relatedTo` field through `HashMap<String, Relation>`.
1042    /// Oracle: RFC 9553 Figure 13.
1043    #[test]
1044    fn sloppy_field_related_to_roundtrips_through_jscontact_relation_map() {
1045        let card_json = json!({
1046            "relatedTo": {
1047                "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": {
1048                    "relation": { "friend": true }
1049                }
1050            }
1051        });
1052        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1053        let rels: HashMap<String, Relation> =
1054            serde_json::from_value(card.related_to.clone().unwrap()).unwrap();
1055        let back = serde_json::to_value(&rels).unwrap();
1056        assert_eq!(back, card.related_to.unwrap());
1057    }
1058
1059    /// Deserialize the sloppy `keywords` field (String[Boolean]) through `HashMap<String, bool>`.
1060    /// Oracle: RFC 9553 Figure 42.
1061    ///
1062    /// RFC 9553 §2.8.2 declares `keywords` as `String[Boolean]` — there is
1063    /// no JSContact object type for keywords, only a tag→true map. The
1064    /// typed shape is therefore the standard library `HashMap<String, bool>`.
1065    #[test]
1066    fn sloppy_field_keywords_roundtrips_through_string_bool_map() {
1067        let card_json = json!({
1068            "keywords": {
1069                "internet": true,
1070                "IETF": true
1071            }
1072        });
1073        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1074        let kws: HashMap<String, bool> =
1075            serde_json::from_value(card.keywords.clone().unwrap()).unwrap();
1076        assert!(kws["internet"]);
1077        let back = serde_json::to_value(&kws).unwrap();
1078        assert_eq!(back, card.keywords.unwrap());
1079    }
1080
1081    /// Verify the `jscontact` module alias resolves to the same crate as
1082    /// the top-level re-exports.
1083    ///
1084    /// Because every public struct in `jmap-jscontact-types` is
1085    /// `#[non_exhaustive]`, external crates cannot construct instances
1086    /// with a struct literal. We deserialize from JSON instead to obtain
1087    /// concrete instances and then assert that the aliased path and the
1088    /// top-level re-export resolve to the same type.
1089    #[test]
1090    fn jscontact_module_alias_is_jmap_jscontact_types() {
1091        let v = json!({ "full": "Vincent van Gogh" });
1092        let direct: jmap_jscontact_types::Name = serde_json::from_value(v.clone()).unwrap();
1093        let aliased: crate::jscontact::Name = serde_json::from_value(v).unwrap();
1094        // Both paths resolve to the same type; equality is well-defined.
1095        assert_eq!(direct, aliased);
1096        // Same with the top-level re-export.
1097        let top_level: Name = direct.clone();
1098        assert_eq!(direct, top_level);
1099    }
1100
1101    // ── Extras-preservation policy tests (JMAP-lbdy.5) ───────────────────
1102
1103    /// `AddressBookRights.extra` captures vendor fields and preserves them.
1104    #[test]
1105    fn address_book_rights_preserves_vendor_extras() {
1106        let raw = json!({
1107            "mayRead": true,
1108            "mayWrite": false,
1109            "mayShare": false,
1110            "mayDelete": false,
1111            "acmeCorpMayMerge": true
1112        });
1113        let r: AddressBookRights = serde_json::from_value(raw).unwrap();
1114        assert_eq!(
1115            r.extra.get("acmeCorpMayMerge").and_then(|v| v.as_bool()),
1116            Some(true)
1117        );
1118        let back = serde_json::to_value(&r).unwrap();
1119        assert_eq!(back["acmeCorpMayMerge"], true);
1120    }
1121
1122    /// `AddressBook.extra` captures vendor fields and preserves them.
1123    #[test]
1124    fn address_book_preserves_vendor_extras() {
1125        let raw = json!({
1126            "id": "ab1",
1127            "name": "Personal",
1128            "description": null,
1129            "sortOrder": 0,
1130            "isDefault": true,
1131            "isSubscribed": true,
1132            "shareWith": null,
1133            "myRights": {
1134                "mayRead": true, "mayWrite": true,
1135                "mayShare": false, "mayDelete": false
1136            },
1137            "acmeCorpRetentionDays": 365
1138        });
1139        let ab: AddressBook = serde_json::from_value(raw).unwrap();
1140        assert_eq!(
1141            ab.extra
1142                .get("acmeCorpRetentionDays")
1143                .and_then(|v| v.as_u64()),
1144            Some(365)
1145        );
1146        let back = serde_json::to_value(&ab).unwrap();
1147        assert_eq!(back["acmeCorpRetentionDays"], 365);
1148    }
1149
1150    /// `ContactCard.extra` captures vendor fields and preserves them.
1151    #[test]
1152    fn contact_card_preserves_vendor_extras() {
1153        let raw = json!({
1154            "uid": "card-1",
1155            "version": "1.0",
1156            "acmeCorpExternalId": "ldap-42"
1157        });
1158        let c: ContactCard = serde_json::from_value(raw).unwrap();
1159        assert_eq!(
1160            c.extra.get("acmeCorpExternalId").and_then(|v| v.as_str()),
1161            Some("ldap-42")
1162        );
1163        let back = serde_json::to_value(&c).unwrap();
1164        assert_eq!(back["acmeCorpExternalId"], "ldap-42");
1165    }
1166}