Skip to main content

io_msgraph/v1/rest/users/contacts/
vcard.rs

1//! # vCard projection
2//!
3//! Projects a Microsoft Graph contact onto a vCard 4.0 document
4//! ([`MsgraphContact::to_vcard`]), and a vCard back onto a contact
5//! ([`MsgraphContact::from_vcard`]).
6//!
7//! Graph exposes no vCard representation of a contact, its endpoints only
8//! speaking the JSON contact resource, so a consumer needing one (an
9//! address book client, a sync engine) synthesizes the document of record
10//! here. Only fields with a well-defined vCard slot are projected:
11//!
12//! - Graph-only fields (`fileAs`, `officeLocation`, `assistantName`,
13//!   `manager`) are minted as read-only `X-MSGRAPH-*` properties and
14//!   consumed on the way back, the server value staying authoritative;
15//! - every other vCard line rides a single-value extended property, the
16//!   stash ([`MSGRAPH_CONTACT_STASH_ID`]), and is spliced back verbatim on
17//!   read. Graph omits extended properties unless expanded, so a read that
18//!   should carry the stash passes [`MSGRAPH_CONTACT_STASH_EXPAND`].
19//!
20//! [`MsgraphContact::from_vcard`] is the full-state projection: every
21//! managed field is Set when the vCard carries it and Null when it does
22//! not, while Graph-only fields stay Unset and survive updates untouched.
23//! [`MsgraphContact::create_from_vcard`] shapes a create body and
24//! [`MsgraphContact::update_from_vcard`] an update body, both stripping the
25//! gratuitous nulls the Outlook backend rejects with HTTP 500.
26
27use core::{fmt, str::FromStr};
28
29use alloc::{
30    borrow::Cow,
31    format,
32    string::{String, ToString},
33    vec,
34    vec::Vec,
35};
36
37use crate::v1::{
38    field::MsgraphField,
39    rest::users::{
40        contacts::{MsgraphContact, MsgraphPhysicalAddress, MsgraphSingleValueExtendedProperty},
41        messages::MsgraphEmailAddress,
42    },
43};
44use serde_json::{from_value, to_value};
45use vcard::{
46    param::VcardParam,
47    prop::{
48        VcardProp, VcardPropKind, VcardPropName, adr::ADR, categories::CATEGORIES, email::EMAIL,
49        r#fn::FN, impp::IMPP, n::N, nickname::NICKNAME, note::NOTE, org::ORG, related::RELATED,
50        role::ROLE, tel::TEL, title::TITLE, url::URL,
51    },
52    tree::{cst::VcardCst, error::VcardParseError, line::VcardLine, prop::lens::VcardPropLens},
53    value::{
54        VcardValue,
55        adr::VcardAdr,
56        datetime::VcardDateAndOrTime,
57        n::VcardN,
58        org::VcardOrg,
59        text::{VcardText, VcardTextList},
60        uri::VcardUri,
61    },
62};
63
64/// Extended-property id under which the vCard remainder is stashed on the
65/// Graph contact, a fixed app GUID plus a name (the String MAPI form).
66///
67/// It rides inline in create and update bodies. The name predates this
68/// crate (Cardamum wrote it first) and is kept so contacts stashed then
69/// still read back.
70pub const MSGRAPH_CONTACT_STASH_ID: &str =
71    "String {c8e5e5cf-3f6c-4f0a-9d4e-52f1e7b2a9d3} Name cardamum-vcard";
72
73/// The `$expand` clause reading the stash back with a contact, which Graph
74/// omits otherwise.
75pub const MSGRAPH_CONTACT_STASH_EXPAND: &str = "singleValueExtendedProperties($filter=id eq \
76     'String {c8e5e5cf-3f6c-4f0a-9d4e-52f1e7b2a9d3} Name cardamum-vcard')";
77
78/// Longest raw property line stashed server-side.
79///
80/// A longer line, a base64 PHOTO blob essentially, stays in the local
81/// document of record rather than risking the whole write against an
82/// undocumented size limit.
83const MAX_STASH_LINE: usize = 8 * 1024;
84
85/// Property names minted by [`to_vcard`] from the Graph-only contact
86/// fields.
87///
88/// [`to_contact`] consumes them, the server value being authoritative: the
89/// fields stay Unset, out of every body.
90const MINTED_PROPS: &[&str] = &[
91    "X-MSGRAPH-FILE-AS",
92    "X-MSGRAPH-OFFICE-LOCATION",
93    "X-MSGRAPH-ASSISTANT",
94    "X-MSGRAPH-MANAGER",
95];
96
97impl MsgraphContact {
98    /// Projects an io-msgraph contact onto a fresh vCard 4.0 document.
99    ///
100    /// The UID is the one the stash carries ([`stashed_uid`]), else one minted
101    /// from the Graph id for a contact Graph created itself. The phone and
102    /// address slots become typed TEL and ADR properties (business work, home
103    /// home, mobile cell), and spouse and children become RELATED names, their
104    /// types being standard in vCard 4.0.
105    ///
106    /// [`stashed_uid`]: Self::stashed_uid
107    pub fn to_vcard(&self) -> String {
108        let contact = self;
109        let mut card = VcardCst::v4();
110
111        let id = contact.id.trim();
112        if contact.stashed_uid().is_none() && !id.is_empty() {
113            card.push(VcardProp::text(VcardPropKind::Uid, vec![], id));
114        }
115
116        card.push(VcardProp::text(
117            VcardPropKind::Fn,
118            vec![],
119            display_name(contact),
120        ));
121
122        // NOTE: the Graph title is the honorific (Dr., Mrs.), hence the N
123        // prefix slot; the job title maps to TITLE below.
124        let n = VcardN {
125            family: component(&contact.surname),
126            given: component(&contact.given_name),
127            additional: component(&contact.middle_name),
128            prefixes: component(&contact.title),
129            suffixes: Vec::new(),
130        };
131
132        let empty = n.family.is_empty()
133            && n.given.is_empty()
134            && n.additional.is_empty()
135            && n.prefixes.is_empty();
136        if !empty {
137            card.push(VcardProp {
138                group: None,
139                name: VcardPropName::Kind(VcardPropKind::N),
140                params: vec![],
141                value: VcardValue::N(n),
142            });
143        }
144
145        if let Some(nick) = opt(&contact.nick_name) {
146            card.push(VcardProp {
147                group: None,
148                name: VcardPropName::Kind(VcardPropKind::Nickname),
149                params: vec![],
150                value: VcardValue::TextList(VcardTextList(vec![Cow::Owned(nick.to_string())])),
151            });
152        }
153
154        for email in list(&contact.email_addresses) {
155            if let Some(address) = email.address.as_deref() {
156                let address = address.trim();
157                if !address.is_empty() {
158                    card.push(VcardProp::text(VcardPropKind::Email, vec![], address));
159                }
160            }
161        }
162
163        for im in list(&contact.im_addresses) {
164            let im = im.trim();
165            if !im.is_empty() {
166                card.push(VcardProp {
167                    group: None,
168                    name: VcardPropName::Kind(VcardPropKind::Impp),
169                    params: vec![],
170                    value: VcardValue::Uri(VcardUri(Cow::Owned(im.to_string()))),
171                });
172            }
173        }
174
175        for phone in list(&contact.business_phones) {
176            let phone = phone.trim();
177            if !phone.is_empty() {
178                card.push(VcardProp::text(
179                    VcardPropKind::Tel,
180                    vec![type_param("work")],
181                    phone,
182                ));
183            }
184        }
185        for phone in list(&contact.home_phones) {
186            let phone = phone.trim();
187            if !phone.is_empty() {
188                card.push(VcardProp::text(
189                    VcardPropKind::Tel,
190                    vec![type_param("home")],
191                    phone,
192                ));
193            }
194        }
195        if let Some(phone) = opt(&contact.mobile_phone) {
196            card.push(VcardProp::text(
197                VcardPropKind::Tel,
198                vec![type_param("cell")],
199                phone,
200            ));
201        }
202
203        if let Some(prop) = adr_prop(contact.home_address.as_option(), Some("home")) {
204            card.push(prop);
205        }
206        if let Some(prop) = adr_prop(contact.business_address.as_option(), Some("work")) {
207            card.push(prop);
208        }
209        if let Some(prop) = adr_prop(contact.other_address.as_option(), None) {
210            card.push(prop);
211        }
212
213        let company = opt(&contact.company_name);
214        let department = opt(&contact.department);
215        if company.is_some() || department.is_some() {
216            let mut components: Vec<Cow<'static, str>> =
217                vec![Cow::Owned(company.unwrap_or_default().to_string())];
218            if let Some(department) = department {
219                components.push(Cow::Owned(department.to_string()));
220            }
221            card.push(VcardProp {
222                group: None,
223                name: VcardPropName::Kind(VcardPropKind::Org),
224                params: vec![],
225                value: VcardValue::Org(VcardOrg(components)),
226            });
227        }
228
229        if let Some(title) = opt(&contact.job_title) {
230            card.push(VcardProp::text(VcardPropKind::Title, vec![], title));
231        }
232        if let Some(role) = opt(&contact.profession) {
233            card.push(VcardProp::text(VcardPropKind::Role, vec![], role));
234        }
235        if let Some(page) = opt(&contact.business_home_page) {
236            card.push(VcardProp {
237                group: None,
238                name: VcardPropName::Kind(VcardPropKind::Url),
239                params: vec![],
240                value: VcardValue::Uri(VcardUri(Cow::Owned(page.to_string()))),
241            });
242        }
243
244        // NOTE: Graph serves the birthday as an ISO 8601 date-time
245        // (1983-04-01T00:00:00Z); only its date part has a vCard slot.
246        if let Some(birthday) = opt(&contact.birthday) {
247            let date = birthday.split('T').next().unwrap_or(birthday);
248            if let Some(date) = VcardDateAndOrTime::from(date).full_date() {
249                card.push(VcardProp {
250                    group: None,
251                    name: VcardPropName::Kind(VcardPropKind::Bday),
252                    params: vec![],
253                    value: VcardValue::DateAndOrTime(VcardDateAndOrTime(Cow::Owned(date))),
254                });
255            }
256        }
257
258        if let Some(notes) = opt(&contact.personal_notes) {
259            card.push(VcardProp::text(VcardPropKind::Note, vec![], notes));
260        }
261
262        let categories: Vec<Cow<'static, str>> = list(&contact.categories)
263            .iter()
264            .map(|category| category.trim())
265            .filter(|category| !category.is_empty())
266            .map(|category| Cow::Owned(category.to_string()))
267            .collect();
268        if !categories.is_empty() {
269            card.push(VcardProp {
270                group: None,
271                name: VcardPropName::Kind(VcardPropKind::Categories),
272                params: vec![],
273                value: VcardValue::TextList(VcardTextList(categories)),
274            });
275        }
276
277        if let Some(spouse) = opt(&contact.spouse_name) {
278            card.push(related_prop("spouse", spouse));
279        }
280        for child in list(&contact.children) {
281            let child = child.trim();
282            if !child.is_empty() {
283                card.push(related_prop("child", child));
284            }
285        }
286
287        // NOTE: Graph-only fields ride the vCard as read-only X-MSGRAPH-*
288        // vendor properties, and the stashed remainder restores verbatim.
289        let minted = [
290            ("X-MSGRAPH-FILE-AS", opt(&contact.file_as)),
291            ("X-MSGRAPH-OFFICE-LOCATION", opt(&contact.office_location)),
292            ("X-MSGRAPH-ASSISTANT", opt(&contact.assistant_name)),
293            ("X-MSGRAPH-MANAGER", opt(&contact.manager)),
294        ];
295        for (name, value) in minted {
296            if let Some(value) = value {
297                card.push(VcardProp::text(name, vec![], value));
298            }
299        }
300
301        let stash = stash_lines(contact);
302        for line in &stash {
303            // NOTE: a line that no longer tokenises restores nothing rather
304            // than corrupting the card.
305            let _ = card.push_raw(line);
306        }
307
308        String::from_utf8_lossy(&card.to_bytes()).into_owned()
309    }
310
311    /// Projects a vCard onto an io-msgraph contact, in full state: each managed
312    /// field is Set from the vCard or Null when absent (collections clear
313    /// through an empty Set), while unmanaged Graph fields stay Unset.
314    ///
315    /// Graph keeps fixed slots even behind its collection-typed properties, so
316    /// the first matching vCard property fills a slot and the extras fall to
317    /// the stash rather than into another slot. Graph has no UID slot, so the
318    /// UID rides the stash too, which is what keeps a contact's identity
319    /// across a sync with another source.
320    pub fn from_vcard(vcard: &str) -> Result<Self, MsgraphContactVcardError> {
321        let card = VcardCst::parse(vcard).map_err(MsgraphContactVcardError::Parse)?;
322        let version = card.version();
323
324        let mut display_name = None;
325        let mut surname = None;
326        let mut given_name = None;
327        let mut middle_name = None;
328        let mut title = None;
329        let mut nick_name = None;
330        let mut emails = Vec::new();
331        let mut ims = Vec::new();
332        let mut business_phones = Vec::new();
333        let mut home_phones = Vec::new();
334        let mut mobile_phone = None;
335        let mut home_address = None;
336        let mut business_address = None;
337        let mut other_address = None;
338        let mut company_name = None;
339        let mut department = None;
340        let mut job_title = None;
341        let mut profession = None;
342        let mut business_home_page = None;
343        let mut birthday = None;
344        let mut spouse_name = None;
345        let mut children = Vec::new();
346        let mut categories = Vec::new();
347        let mut notes = Vec::new();
348        let mut stash = Vec::new();
349        let mut name_seen = false;
350
351        // NOTE: Outlook stores fixed slots behind the Graph collections:
352        // three email addresses, three IM addresses, two business and two
353        // home phones (Graph rejects bodies overflowing a slot set). The
354        // first properties win, and the extras land in the stash remainder
355        // like every other line that does not project, so they survive on
356        // the server and restore on read (cairn/spec/projection.md).
357        for line in &card.props {
358            let consumed = match VcardPropKind::from_str(line.name.get()) {
359                // NOTE: the VERSION line is structural and the minted
360                // X-MSGRAPH-* properties are read-only projections;
361                // neither belongs to the remainder.
362                Err(_) => {
363                    let raw_name = line.name.get();
364                    raw_name.eq_ignore_ascii_case("VERSION")
365                        || MINTED_PROPS
366                            .iter()
367                            .any(|prop| raw_name.eq_ignore_ascii_case(prop))
368                }
369                Ok(VcardPropKind::Fn) => {
370                    let name = FN::decode(line, version);
371                    set_first(&mut display_name, &name.0)
372                }
373                Ok(VcardPropKind::N) => {
374                    if !name_seen {
375                        name_seen = true;
376                        let n = N::decode(line, version);
377                        set_first(&mut surname, n.family.join(" "));
378                        set_first(&mut given_name, n.given.join(" "));
379                        set_first(&mut middle_name, n.additional.join(" "));
380                        set_first(&mut title, n.prefixes.join(" "));
381                        true
382                    } else {
383                        false
384                    }
385                }
386                Ok(VcardPropKind::Nickname) => {
387                    let all = NICKNAME::decode(line, version);
388                    match all.0.first() {
389                        Some(nick) => set_first(&mut nick_name, nick),
390                        None => false,
391                    }
392                }
393                Ok(VcardPropKind::Email) => {
394                    let email = EMAIL::decode(line, version);
395                    let address = email.0.trim();
396                    if emails.len() < 3 && !address.is_empty() {
397                        emails.push(MsgraphEmailAddress {
398                            name: None,
399                            address: Some(address.to_string()),
400                        });
401                        true
402                    } else {
403                        false
404                    }
405                }
406                Ok(VcardPropKind::Impp) => {
407                    let impp = IMPP::decode(line, version);
408                    let address = impp.0.trim();
409                    if ims.len() < 3 && !address.is_empty() {
410                        ims.push(address.to_string());
411                        true
412                    } else {
413                        false
414                    }
415                }
416                Ok(VcardPropKind::Tel) => {
417                    let tel = TEL::decode(line, version);
418                    let number = tel.0.trim();
419                    if number.is_empty() {
420                        false
421                    } else {
422                        let types = type_values(line);
423                        if types.iter().any(|t| t == "cell") {
424                            // NOTE: one mobile slot; further cell TELs are
425                            // stashed rather than misfiled as business
426                            // phones.
427                            if mobile_phone.is_none() {
428                                mobile_phone = Some(number.to_string());
429                                true
430                            } else {
431                                false
432                            }
433                        } else if types.iter().any(|t| t == "home") {
434                            if home_phones.len() < 2 {
435                                home_phones.push(number.to_string());
436                                true
437                            } else {
438                                false
439                            }
440                        } else if business_phones.len() < 2 {
441                            business_phones.push(number.to_string());
442                            true
443                        } else {
444                            false
445                        }
446                    }
447                }
448                Ok(VcardPropKind::Adr) => {
449                    let adr = ADR::decode(line, version);
450
451                    // NOTE: Graph's street is one multiline field; the po
452                    // box and extended components fold into it so nothing
453                    // is lost.
454                    let street: Vec<&str> = adr
455                        .po_box
456                        .iter()
457                        .chain(&adr.extended)
458                        .chain(&adr.street)
459                        .map(|component| component.as_ref().trim())
460                        .filter(|component| !component.is_empty())
461                        .collect();
462
463                    let address = MsgraphPhysicalAddress {
464                        street: (!street.is_empty()).then(|| street.join("\n")),
465                        city: joined(&adr.locality),
466                        state: joined(&adr.region),
467                        country_or_region: joined(&adr.country),
468                        postal_code: joined(&adr.postal_code),
469                    };
470
471                    let empty = address.street.is_none()
472                        && address.city.is_none()
473                        && address.state.is_none()
474                        && address.country_or_region.is_none()
475                        && address.postal_code.is_none();
476                    if empty {
477                        false
478                    } else {
479                        let types = type_values(line);
480                        let slot = if types.iter().any(|t| t == "home") {
481                            &mut home_address
482                        } else if types.iter().any(|t| t == "work") {
483                            &mut business_address
484                        } else {
485                            &mut other_address
486                        };
487                        if slot.is_none() {
488                            *slot = Some(address);
489                            true
490                        } else {
491                            false
492                        }
493                    }
494                }
495                Ok(VcardPropKind::Org) => {
496                    if company_name.is_none() && department.is_none() {
497                        let org = ORG::decode(line, version);
498                        let mut components = org.0.iter().map(|component| component.as_ref());
499                        let company = components.next().unwrap_or_default();
500                        let rest = components.collect::<Vec<_>>().join(" ");
501                        set_first(&mut company_name, company);
502                        set_first(&mut department, rest);
503                        true
504                    } else {
505                        false
506                    }
507                }
508                Ok(VcardPropKind::Title) => {
509                    let value = TITLE::decode(line, version);
510                    set_first(&mut job_title, &value.0)
511                }
512                Ok(VcardPropKind::Role) => {
513                    let role = ROLE::decode(line, version);
514                    set_first(&mut profession, &role.0)
515                }
516                Ok(VcardPropKind::Url) => {
517                    let url = URL::decode(line, version);
518                    set_first(&mut business_home_page, &url.0)
519                }
520                Ok(VcardPropKind::Bday) => {
521                    // NOTE: a partial (year-less) birthday has no Graph
522                    // date it round-trips through; it lands in the stash.
523                    if birthday.is_none()
524                        && let Some(date) =
525                            VcardDateAndOrTime::from(line.raw_value_str()).full_date()
526                    {
527                        birthday = Some(format!("{date}T00:00:00Z"));
528                        true
529                    } else {
530                        false
531                    }
532                }
533                Ok(VcardPropKind::Note) => {
534                    let note = NOTE::decode(line, version);
535                    let note = note.0.trim();
536                    if !note.is_empty() {
537                        notes.push(note.to_string());
538                        true
539                    } else {
540                        false
541                    }
542                }
543                Ok(VcardPropKind::Categories) => {
544                    let all = CATEGORIES::decode(line, version);
545                    let mut pushed = false;
546                    for category in &all.0 {
547                        let category = category.trim();
548                        if !category.is_empty() {
549                            categories.push(category.to_string());
550                            pushed = true;
551                        }
552                    }
553                    pushed
554                }
555                Ok(VcardPropKind::Related) => {
556                    // NOTE: only free-form spouse and child names
557                    // (VALUE=text) project; other types and URI RELATED
558                    // lines land in the stash.
559                    let text = line.params.iter().any(|param| {
560                        param.name.get().eq_ignore_ascii_case("VALUE")
561                            && param
562                                .values
563                                .iter()
564                                .any(|value| value.get().eq_ignore_ascii_case("text"))
565                    });
566                    if text {
567                        let related = RELATED::decode(line, version);
568                        let types = type_values(line);
569                        if types.iter().any(|t| t == "spouse") {
570                            set_first(&mut spouse_name, &related.0)
571                        } else if types.iter().any(|t| t == "child") {
572                            let child = related.0.trim();
573                            if !child.is_empty() {
574                                children.push(child.to_string());
575                                true
576                            } else {
577                                false
578                            }
579                        } else {
580                            false
581                        }
582                    } else {
583                        false
584                    }
585                }
586                Ok(_) => false,
587            };
588
589            if !consumed {
590                let raw = line.to_string();
591                let raw = raw.trim_end().to_string();
592                if raw.len() <= MAX_STASH_LINE {
593                    stash.push(raw);
594                }
595            }
596        }
597
598        Ok(MsgraphContact {
599            display_name: MsgraphField::set_or_null(display_name),
600            given_name: MsgraphField::set_or_null(given_name),
601            middle_name: MsgraphField::set_or_null(middle_name),
602            surname: MsgraphField::set_or_null(surname),
603            nick_name: MsgraphField::set_or_null(nick_name),
604            title: MsgraphField::set_or_null(title),
605            email_addresses: MsgraphField::Set(emails),
606            im_addresses: MsgraphField::Set(ims),
607            business_phones: MsgraphField::Set(business_phones),
608            home_phones: MsgraphField::Set(home_phones),
609            mobile_phone: MsgraphField::set_or_null(mobile_phone),
610            home_address: MsgraphField::set_or_null(home_address),
611            business_address: MsgraphField::set_or_null(business_address),
612            other_address: MsgraphField::set_or_null(other_address),
613            job_title: MsgraphField::set_or_null(job_title),
614            company_name: MsgraphField::set_or_null(company_name),
615            department: MsgraphField::set_or_null(department),
616            profession: MsgraphField::set_or_null(profession),
617            business_home_page: MsgraphField::set_or_null(business_home_page),
618            birthday: MsgraphField::set_or_null(birthday),
619            spouse_name: MsgraphField::set_or_null(spouse_name),
620            children: MsgraphField::Set(children),
621            personal_notes: MsgraphField::set_or_null(
622                (!notes.is_empty()).then(|| notes.join("\n")),
623            ),
624            categories: MsgraphField::Set(categories),
625            // NOTE: the stash entry is always Set (empty value when the
626            // card has no remainder) so the delta projection can tell a
627            // cleared stash from an unchanged one.
628            single_value_extended_properties: MsgraphField::Set(vec![
629                MsgraphSingleValueExtendedProperty {
630                    id: MSGRAPH_CONTACT_STASH_ID.to_string(),
631                    value: stash.join("\n"),
632                },
633            ]),
634            ..Default::default()
635        })
636    }
637
638    /// Projects a vCard onto an update body: the fields differing from the base
639    /// vCard (the state last synced with the server), the unchanged ones
640    /// turning Unset and staying out of the PATCH.
641    ///
642    /// The Outlook backend rejects the gratuitous nulls of a full-state body
643    /// with an internal server error (HTTP 500), so a Null survives only when
644    /// the edit cleared a field the base carried (collections clear through an
645    /// explicit empty Set instead).
646    pub fn update_from_vcard(
647        vcard: &str,
648        base_vcard: &str,
649    ) -> Result<Self, MsgraphContactVcardError> {
650        let mut contact = Self::from_vcard(vcard)?;
651        let base = Self::from_vcard(base_vcard)?;
652
653        macro_rules! unset_unchanged {
654        ($($field:ident),* $(,)?) => {$(
655            if contact.$field == base.$field {
656                contact.$field = MsgraphField::Unset;
657            }
658        )*};
659    }
660
661        unset_unchanged!(
662            display_name,
663            given_name,
664            middle_name,
665            surname,
666            nick_name,
667            title,
668            email_addresses,
669            im_addresses,
670            business_phones,
671            home_phones,
672            mobile_phone,
673            home_address,
674            business_address,
675            other_address,
676            job_title,
677            company_name,
678            department,
679            profession,
680            business_home_page,
681            birthday,
682            spouse_name,
683            children,
684            personal_notes,
685            categories,
686            single_value_extended_properties,
687        );
688
689        Ok(contact)
690    }
691
692    /// Projects a vCard onto a create body: the full-state projection minus the
693    /// explicit nulls and empty collections.
694    ///
695    /// They are no-ops on a fresh resource, and the Outlook backend rejects null
696    /// complex properties on POST with an internal server error (HTTP 500).
697    pub fn create_from_vcard(vcard: &str) -> Result<Self, MsgraphContactVcardError> {
698        let mut contact = Self::from_vcard(vcard)?;
699
700        // NOTE: an empty stash entry is a no-op on a fresh resource.
701        if stash_lines(&contact).is_empty() {
702            contact.single_value_extended_properties = MsgraphField::Unset;
703        }
704
705        let mut body = to_value(&contact).map_err(MsgraphContactVcardError::Json)?;
706        if let Some(object) = body.as_object_mut() {
707            object.retain(|_, value| {
708                !value.is_null() && value.as_array().is_none_or(|array| !array.is_empty())
709            });
710        }
711
712        from_value(body).map_err(MsgraphContactVcardError::Json)
713    }
714}
715
716/// A vCard that cannot become a Graph contact body.
717#[derive(Debug)]
718pub enum MsgraphContactVcardError {
719    /// The vCard does not parse.
720    Parse(VcardParseError),
721    /// The projected contact does not reshape through JSON.
722    Json(serde_json::Error),
723}
724
725impl fmt::Display for MsgraphContactVcardError {
726    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
727        match self {
728            Self::Parse(err) => write!(f, "Invalid vCard: {err}"),
729            Self::Json(err) => write!(f, "Cannot shape the Graph contact body: {err}"),
730        }
731    }
732}
733
734impl core::error::Error for MsgraphContactVcardError {}
735
736/// The contact's display name, composed from the name parts when
737/// blank, with the filing name as a last resort.
738fn display_name(contact: &MsgraphContact) -> String {
739    if let Some(name) = opt(&contact.display_name) {
740        return name.to_string();
741    }
742
743    let composed: Vec<&str> = [&contact.given_name, &contact.middle_name, &contact.surname]
744        .into_iter()
745        .filter_map(opt)
746        .collect();
747    if !composed.is_empty() {
748        return composed.join(" ");
749    }
750
751    opt(&contact.file_as).unwrap_or_default().to_string()
752}
753
754/// An ADR property from a Graph physical address, None when every component
755/// is empty.
756///
757/// Graph's street is one multiline field, so each of its lines becomes a
758/// vCard street component.
759fn adr_prop(
760    address: Option<&MsgraphPhysicalAddress>,
761    r#type: Option<&'static str>,
762) -> Option<VcardProp<'static>> {
763    let address = address?;
764
765    let street = address.street.as_deref().unwrap_or("");
766    let value = VcardAdr {
767        po_box: Vec::new(),
768        extended: Vec::new(),
769        street: street
770            .split('\n')
771            .map(str::trim)
772            .filter(|line| !line.is_empty())
773            .map(|line| Cow::Owned(line.to_string()))
774            .collect(),
775        locality: option_component(&address.city),
776        region: option_component(&address.state),
777        postal_code: option_component(&address.postal_code),
778        country: option_component(&address.country_or_region),
779        ..Default::default()
780    };
781
782    let empty = value.street.is_empty()
783        && value.locality.is_empty()
784        && value.region.is_empty()
785        && value.postal_code.is_empty()
786        && value.country.is_empty();
787    if empty {
788        return None;
789    }
790
791    Some(VcardProp {
792        group: None,
793        name: VcardPropName::Kind(VcardPropKind::Adr),
794        params: r#type.map(type_param).into_iter().collect(),
795        value: VcardValue::Adr(value),
796    })
797}
798
799/// A RELATED name property (spouse, child): free-form text, hence the
800/// explicit VALUE=text (RELATED defaults to a URI).
801fn related_prop(r#type: &'static str, name: &str) -> VcardProp<'static> {
802    VcardProp {
803        group: None,
804        name: VcardPropName::Kind(VcardPropKind::Related),
805        params: vec![type_param(r#type), VcardParam::Value(Cow::Borrowed("text"))],
806        value: VcardValue::Text(VcardText(Cow::Owned(name.to_string()))),
807    }
808}
809
810/// A single-value TYPE parameter.
811fn type_param(value: &'static str) -> VcardParam<'static> {
812    VcardParam::Type(vec![Cow::Borrowed(value)])
813}
814
815/// The trimmed field, None when unset, null or blank.
816fn opt(field: &MsgraphField<String>) -> Option<&str> {
817    let value = field.as_deref()?.trim();
818    if value.is_empty() { None } else { Some(value) }
819}
820
821/// The collection items, empty when the field is unset or null.
822fn list<T>(field: &MsgraphField<Vec<T>>) -> &[T] {
823    field.as_option().map(Vec::as_slice).unwrap_or(&[])
824}
825
826/// A structured-value component holding the trimmed field, or empty.
827fn component(field: &MsgraphField<String>) -> Vec<Cow<'static, str>> {
828    match opt(field) {
829        Some(value) => vec![Cow::Owned(value.to_string())],
830        None => Vec::new(),
831    }
832}
833
834/// Like [`component`], for the plain options of a physical address.
835fn option_component(value: &Option<String>) -> Vec<Cow<'static, str>> {
836    let value = value.as_deref().unwrap_or("").trim();
837    if value.is_empty() {
838        Vec::new()
839    } else {
840        vec![Cow::Owned(value.to_string())]
841    }
842}
843
844/// Fills a single-instance Graph field, first non-empty value wins;
845/// true when this value took the slot.
846fn set_first(slot: &mut Option<String>, value: impl AsRef<str>) -> bool {
847    let value = value.as_ref().trim();
848    if slot.is_none() && !value.is_empty() {
849        *slot = Some(value.to_string());
850        true
851    } else {
852        false
853    }
854}
855
856impl MsgraphContact {
857    /// The vCard UID the stash carries, `None` for a contact no vCard was
858    /// ever written to, Graph having no UID slot of its own.
859    ///
860    /// A sync engine checks it after a write: a server that dropped the
861    /// stash would hand the contact back with an identity minted from its
862    /// Graph id rather than the UID it was written under.
863    pub fn stashed_uid(&self) -> Option<String> {
864        stash_lines(self).iter().find_map(|line| {
865            let mut bytes = line.clone();
866            bytes.push_str("\r\n");
867            let (line, _) = VcardLine::take(bytes.as_bytes()).ok()?;
868            line.bare_name()
869                .eq_ignore_ascii_case("UID")
870                .then(|| line.raw_value_str().trim().to_string())
871                .filter(|uid| !uid.is_empty())
872        })
873    }
874}
875
876/// The stashed vCard remainder lines behind the contact's cardamum
877/// extended property (matched by name: Graph may normalize the GUID
878/// spelling in responses).
879fn stash_lines(contact: &MsgraphContact) -> Vec<String> {
880    contact
881        .single_value_extended_properties
882        .as_option()
883        .map(Vec::as_slice)
884        .unwrap_or(&[])
885        .iter()
886        .filter(|prop| prop.id.to_ascii_lowercase().contains("cardamum-vcard"))
887        .flat_map(|prop| prop.value.split('\n'))
888        .filter(|line| !line.is_empty())
889        .map(str::to_string)
890        .collect()
891}
892
893/// The structured-value components joined into one Graph field, None
894/// when nothing remains.
895fn joined(components: &[Cow<'_, str>]) -> Option<String> {
896    let joined = components
897        .iter()
898        .map(|component| component.as_ref().trim())
899        .filter(|component| !component.is_empty())
900        .collect::<Vec<_>>()
901        .join(" ");
902    if joined.is_empty() {
903        None
904    } else {
905        Some(joined)
906    }
907}
908
909/// Collects every TYPE parameter value, lowercased.
910fn type_values(line: &VcardLine) -> Vec<String> {
911    let mut types = Vec::new();
912    for param in &line.params {
913        if param.name.get().eq_ignore_ascii_case("TYPE") {
914            types.extend(
915                param
916                    .values
917                    .iter()
918                    .map(|value| value.get().to_ascii_lowercase()),
919            );
920        }
921    }
922    types
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    /// A contact filling every field the projection manages.
930    fn full_contact() -> MsgraphContact {
931        MsgraphContact {
932            display_name: MsgraphField::Set("Jane Doe".into()),
933            given_name: MsgraphField::Set("Jane".into()),
934            middle_name: MsgraphField::Set("Q.".into()),
935            surname: MsgraphField::Set("Doe".into()),
936            title: MsgraphField::Set("Dr.".into()),
937            nick_name: MsgraphField::Set("Janie".into()),
938            email_addresses: MsgraphField::Set(vec![MsgraphEmailAddress {
939                name: None,
940                address: Some("jane@doe.org".into()),
941            }]),
942            im_addresses: MsgraphField::Set(vec!["xmpp:jane@doe.org".into()]),
943            business_phones: MsgraphField::Set(vec!["+331111".into()]),
944            home_phones: MsgraphField::Set(vec!["+332222".into()]),
945            mobile_phone: MsgraphField::Set("+333333".into()),
946            home_address: MsgraphField::Set(MsgraphPhysicalAddress {
947                street: Some("12 Main St".into()),
948                city: Some("Paris".into()),
949                state: Some("IDF".into()),
950                country_or_region: Some("France".into()),
951                postal_code: Some("75000".into()),
952            }),
953            business_address: MsgraphField::Set(MsgraphPhysicalAddress {
954                street: Some("1 Work Way".into()),
955                city: Some("Lyon".into()),
956                state: None,
957                country_or_region: None,
958                postal_code: None,
959            }),
960            other_address: MsgraphField::Set(MsgraphPhysicalAddress {
961                street: Some("3 Other Rd".into()),
962                city: None,
963                state: None,
964                country_or_region: None,
965                postal_code: None,
966            }),
967            company_name: MsgraphField::Set("ACME".into()),
968            department: MsgraphField::Set("R&D".into()),
969            job_title: MsgraphField::Set("Boss".into()),
970            profession: MsgraphField::Set("Engineer".into()),
971            business_home_page: MsgraphField::Set("https://doe.org".into()),
972            birthday: MsgraphField::Set("1983-04-01T00:00:00Z".into()),
973            spouse_name: MsgraphField::Set("John Doe".into()),
974            children: MsgraphField::Set(vec!["Jimmy".into()]),
975            personal_notes: MsgraphField::Set("a note".into()),
976            categories: MsgraphField::Set(vec!["friends".into(), "band".into()]),
977            ..Default::default()
978        }
979    }
980
981    #[test]
982    fn to_vcard_projects_every_mapped_field() {
983        let mut contact = full_contact();
984        contact.id = "AAMkAGI2".into();
985
986        let vcard = (contact).to_vcard();
987        assert!(vcard.contains("VERSION:4.0\r\n"));
988        assert!(vcard.contains("UID:AAMkAGI2\r\n"));
989        assert!(vcard.contains("FN:Jane Doe\r\n"));
990        assert!(vcard.contains("N:Doe;Jane;Q.;Dr.;\r\n"));
991        assert!(vcard.contains("NICKNAME:Janie\r\n"));
992        assert!(vcard.contains("EMAIL:jane@doe.org\r\n"));
993        assert!(vcard.contains("IMPP:xmpp:jane@doe.org\r\n"));
994        assert!(vcard.contains("TEL;TYPE=work:+331111\r\n"));
995        assert!(vcard.contains("TEL;TYPE=home:+332222\r\n"));
996        assert!(vcard.contains("TEL;TYPE=cell:+333333\r\n"));
997        assert!(vcard.contains("ADR;TYPE=home:;;12 Main St;Paris;IDF;75000;France\r\n"));
998        assert!(vcard.contains("ADR;TYPE=work:;;1 Work Way;Lyon;;;\r\n"));
999        assert!(vcard.contains("ADR:;;3 Other Rd;;;;\r\n"));
1000        assert!(vcard.contains("ORG:ACME;R&D\r\n"));
1001        assert!(vcard.contains("TITLE:Boss\r\n"));
1002        assert!(vcard.contains("ROLE:Engineer\r\n"));
1003        assert!(vcard.contains("URL:https://doe.org\r\n"));
1004        assert!(vcard.contains("BDAY:1983-04-01\r\n"));
1005        assert!(vcard.contains("NOTE:a note\r\n"));
1006        assert!(vcard.contains("CATEGORIES:friends,band\r\n"));
1007        assert!(vcard.contains("RELATED;TYPE=spouse;VALUE=text:John Doe\r\n"));
1008        assert!(vcard.contains("RELATED;TYPE=child;VALUE=text:Jimmy\r\n"));
1009    }
1010
1011    #[test]
1012    fn to_vcard_composes_fn_from_name_parts() {
1013        let contact = MsgraphContact {
1014            given_name: MsgraphField::Set("Jane".into()),
1015            surname: MsgraphField::Set("Doe".into()),
1016            ..Default::default()
1017        };
1018        assert!((contact).to_vcard().contains("FN:Jane Doe\r\n"));
1019    }
1020
1021    #[test]
1022    fn from_vcard_reads_managed_props() {
1023        let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:abc\r\nFN:Jane Doe\r\n\
1024            N:Doe;Jane;Q.;Dr.;\r\nNICKNAME:Janie,JJ\r\nEMAIL:jane@doe.org\r\n\
1025            IMPP:xmpp:jane@doe.org\r\nTEL;TYPE=cell:+333333\r\n\
1026            TEL;TYPE=home,voice:+332222\r\nTEL:+331111\r\n\
1027            ADR;TYPE=home:;;12 Main St;Paris;IDF;75000;France\r\nORG:ACME;R&D\r\n\
1028            TITLE:Boss\r\nROLE:Engineer\r\nURL:https://doe.org\r\nBDAY:1983-04-01\r\n\
1029            NOTE:a note\r\nCATEGORIES:friends,band\r\n\
1030            RELATED;TYPE=spouse;VALUE=text:John Doe\r\n\
1031            RELATED;TYPE=child;VALUE=text:Jimmy\r\nEND:VCARD\r\n";
1032
1033        let contact = MsgraphContact::from_vcard(vcard).unwrap();
1034        assert_eq!(contact.id, "");
1035        assert_eq!(contact.display_name.as_deref(), Some("Jane Doe"));
1036        assert_eq!(contact.surname.as_deref(), Some("Doe"));
1037        assert_eq!(contact.given_name.as_deref(), Some("Jane"));
1038        assert_eq!(contact.middle_name.as_deref(), Some("Q."));
1039        assert_eq!(contact.title.as_deref(), Some("Dr."));
1040        assert_eq!(contact.nick_name.as_deref(), Some("Janie"));
1041        let emails = contact.email_addresses.as_option().unwrap();
1042        assert_eq!(emails[0].address.as_deref(), Some("jane@doe.org"));
1043        assert_eq!(
1044            contact.im_addresses.as_option().unwrap(),
1045            &vec!["xmpp:jane@doe.org".to_string()]
1046        );
1047        assert_eq!(contact.mobile_phone.as_deref(), Some("+333333"));
1048        assert_eq!(
1049            contact.home_phones.as_option().unwrap(),
1050            &vec!["+332222".to_string()]
1051        );
1052        assert_eq!(
1053            contact.business_phones.as_option().unwrap(),
1054            &vec!["+331111".to_string()]
1055        );
1056        let home = contact.home_address.as_option().unwrap();
1057        assert_eq!(home.street.as_deref(), Some("12 Main St"));
1058        assert_eq!(home.city.as_deref(), Some("Paris"));
1059        assert_eq!(home.state.as_deref(), Some("IDF"));
1060        assert_eq!(home.postal_code.as_deref(), Some("75000"));
1061        assert_eq!(home.country_or_region.as_deref(), Some("France"));
1062        assert_eq!(contact.company_name.as_deref(), Some("ACME"));
1063        assert_eq!(contact.department.as_deref(), Some("R&D"));
1064        assert_eq!(contact.job_title.as_deref(), Some("Boss"));
1065        assert_eq!(contact.profession.as_deref(), Some("Engineer"));
1066        assert_eq!(
1067            contact.business_home_page.as_deref(),
1068            Some("https://doe.org")
1069        );
1070        assert_eq!(contact.birthday.as_deref(), Some("1983-04-01T00:00:00Z"));
1071        assert_eq!(contact.spouse_name.as_deref(), Some("John Doe"));
1072        assert_eq!(
1073            contact.children.as_option().unwrap(),
1074            &vec!["Jimmy".to_string()]
1075        );
1076        assert_eq!(contact.personal_notes.as_deref(), Some("a note"));
1077        assert_eq!(
1078            contact.categories.as_option().unwrap(),
1079            &vec!["friends".to_string(), "band".to_string()]
1080        );
1081    }
1082
1083    #[test]
1084    fn from_vcard_clears_absent_managed_fields_and_keeps_unmanaged_unset() {
1085        let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\nEND:VCARD\r\n";
1086        let contact = MsgraphContact::from_vcard(vcard).unwrap();
1087
1088        assert_eq!(contact.spouse_name, MsgraphField::Null);
1089        assert_eq!(contact.mobile_phone, MsgraphField::Null);
1090        assert_eq!(contact.home_address, MsgraphField::Null);
1091        assert_eq!(contact.business_phones, MsgraphField::Set(vec![]));
1092        assert_eq!(contact.email_addresses, MsgraphField::Set(vec![]));
1093
1094        assert_eq!(contact.file_as, MsgraphField::Unset);
1095        assert_eq!(contact.assistant_name, MsgraphField::Unset);
1096        assert_eq!(contact.manager, MsgraphField::Unset);
1097        assert_eq!(contact.office_location, MsgraphField::Unset);
1098    }
1099
1100    #[test]
1101    fn update_from_vcard_keeps_only_changed_fields() {
1102        let base = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\n\
1103            TEL;TYPE=cell:+333333\r\nEMAIL:jane@doe.org\r\n\
1104            NOTE:a note\r\nEND:VCARD\r\n";
1105        let edited = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\n\
1106            TEL;TYPE=cell:+444444\r\nEMAIL:jane@doe.org\r\nEND:VCARD\r\n";
1107
1108        let contact = MsgraphContact::update_from_vcard(edited, base).unwrap();
1109
1110        assert_eq!(contact.mobile_phone.as_deref(), Some("+444444"));
1111        assert_eq!(contact.personal_notes, MsgraphField::Null);
1112
1113        // NOTE: unchanged on both sides means out of the body, since a
1114        // gratuitous null is what the Outlook backend chokes on.
1115        assert_eq!(contact.display_name, MsgraphField::Unset);
1116        assert_eq!(contact.email_addresses, MsgraphField::Unset);
1117        assert_eq!(contact.birthday, MsgraphField::Unset);
1118        assert_eq!(contact.home_address, MsgraphField::Unset);
1119        assert_eq!(contact.categories, MsgraphField::Unset);
1120    }
1121
1122    #[test]
1123    fn update_from_vcard_clears_collections_with_empty_sets() {
1124        let base = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\n\
1125            TEL;TYPE=home:+332222\r\nCATEGORIES:friends\r\nEND:VCARD\r\n";
1126        let edited = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\nEND:VCARD\r\n";
1127
1128        let contact = MsgraphContact::update_from_vcard(edited, base).unwrap();
1129        assert_eq!(contact.home_phones, MsgraphField::Set(vec![]));
1130        assert_eq!(contact.categories, MsgraphField::Set(vec![]));
1131    }
1132
1133    #[test]
1134    fn update_from_vcard_of_identical_cards_is_empty() {
1135        let vcard = (full_contact()).to_vcard();
1136        let contact = MsgraphContact::update_from_vcard(&vcard, &vcard).unwrap();
1137        assert_eq!(contact, MsgraphContact::default());
1138    }
1139
1140    #[test]
1141    fn create_from_vcard_drops_nulls_and_empty_collections() {
1142        let vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:X\r\nTEL;TYPE=CELL:+331111\r\nEND:VCARD\r\n";
1143        let contact = MsgraphContact::create_from_vcard(vcard).unwrap();
1144
1145        assert_eq!(contact.display_name.as_deref(), Some("X"));
1146        assert_eq!(contact.mobile_phone.as_deref(), Some("+331111"));
1147        assert_eq!(contact.spouse_name, MsgraphField::Unset);
1148        assert_eq!(contact.home_address, MsgraphField::Unset);
1149        assert_eq!(contact.business_phones, MsgraphField::Unset);
1150        assert_eq!(contact.email_addresses, MsgraphField::Unset);
1151    }
1152
1153    #[test]
1154    fn from_vcard_respects_graph_slots() {
1155        let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\n\
1156            TEL;TYPE=cell:+331111\r\nTEL;TYPE=cell:+332222\r\n\
1157            TEL;TYPE=work:+333331\r\nTEL;TYPE=work:+333332\r\nTEL:+333333\r\n\
1158            TEL;TYPE=home:+334441\r\nTEL;TYPE=home:+334442\r\nTEL;TYPE=home:+334443\r\n\
1159            ADR;TYPE=home:;;First St;;;;\r\nADR;TYPE=home:;;Second St;;;;\r\n\
1160            EMAIL:a@x.org\r\nEMAIL:b@x.org\r\nEMAIL:c@x.org\r\nEMAIL:d@x.org\r\n\
1161            IMPP:xmpp:a@x.org\r\nIMPP:xmpp:b@x.org\r\nIMPP:xmpp:c@x.org\r\nIMPP:xmpp:d@x.org\r\n\
1162            RELATED;TYPE=spouse:urn:uuid:03a0e51f\r\nEND:VCARD\r\n";
1163
1164        let contact = MsgraphContact::from_vcard(vcard).unwrap();
1165
1166        // NOTE: one mobile slot, so the second cell TEL is dropped
1167        // rather than misfiled as a business phone.
1168        assert_eq!(contact.mobile_phone.as_deref(), Some("+331111"));
1169
1170        assert_eq!(
1171            contact.business_phones.as_option().unwrap(),
1172            &vec!["+333331".to_string(), "+333332".to_string()]
1173        );
1174        assert_eq!(
1175            contact.home_phones.as_option().unwrap(),
1176            &vec!["+334441".to_string(), "+334442".to_string()]
1177        );
1178        assert_eq!(contact.email_addresses.as_option().unwrap().len(), 3);
1179        assert_eq!(contact.im_addresses.as_option().unwrap().len(), 3);
1180
1181        let home = contact.home_address.as_option().unwrap();
1182        assert_eq!(home.street.as_deref(), Some("First St"));
1183        assert_eq!(contact.spouse_name, MsgraphField::Null);
1184    }
1185
1186    #[test]
1187    fn round_trip() {
1188        let contact = full_contact();
1189
1190        // NOTE: the full-state projection always carries the (empty)
1191        // stash slot, so a cleared stash is distinguishable from an
1192        // unchanged one.
1193        let mut expected = contact.clone();
1194        expected.single_value_extended_properties =
1195            MsgraphField::Set(vec![MsgraphSingleValueExtendedProperty {
1196                id: MSGRAPH_CONTACT_STASH_ID.to_string(),
1197                value: String::new(),
1198            }]);
1199
1200        assert_eq!(
1201            MsgraphContact::from_vcard(&contact.to_vcard()).unwrap(),
1202            expected
1203        );
1204    }
1205
1206    #[test]
1207    fn stash_preserves_unprojected_props() {
1208        let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\nX-FOO;TYPE=bar:baz\r\n\
1209            EMAIL:a@x.org\r\nEMAIL:b@x.org\r\nEMAIL:c@x.org\r\nEMAIL:d@x.org\r\nEND:VCARD\r\n";
1210
1211        let contact = MsgraphContact::from_vcard(vcard).unwrap();
1212        let props = contact
1213            .single_value_extended_properties
1214            .as_option()
1215            .unwrap();
1216        assert_eq!(props[0].id, MSGRAPH_CONTACT_STASH_ID);
1217        assert_eq!(props[0].value, "X-FOO;TYPE=bar:baz\nEMAIL:d@x.org");
1218
1219        let restored = (contact).to_vcard();
1220        assert!(restored.contains("X-FOO;TYPE=bar:baz\r\n"));
1221        assert!(restored.contains("EMAIL:d@x.org\r\n"));
1222        assert!(restored.ends_with("END:VCARD\r\n"));
1223    }
1224
1225    #[test]
1226    fn minted_props_project_and_consume() {
1227        let mut contact = full_contact();
1228        contact.file_as = MsgraphField::Set("Doe, Jane".into());
1229        contact.office_location = MsgraphField::Set("B2".into());
1230        contact.assistant_name = MsgraphField::Set("Sam".into());
1231        contact.manager = MsgraphField::Set("Alex".into());
1232
1233        let vcard = (contact).to_vcard();
1234        assert!(vcard.contains("X-MSGRAPH-FILE-AS:Doe\\, Jane\r\n"));
1235        assert!(vcard.contains("X-MSGRAPH-OFFICE-LOCATION:B2\r\n"));
1236        assert!(vcard.contains("X-MSGRAPH-ASSISTANT:Sam\r\n"));
1237        assert!(vcard.contains("X-MSGRAPH-MANAGER:Alex\r\n"));
1238
1239        // NOTE: consumed on the way back, so the fields stay Unset and
1240        // the lines stay out of the stash.
1241        let back = MsgraphContact::from_vcard(&vcard).unwrap();
1242        assert_eq!(back.file_as, MsgraphField::Unset);
1243        assert_eq!(back.office_location, MsgraphField::Unset);
1244        assert_eq!(back.assistant_name, MsgraphField::Unset);
1245        assert_eq!(back.manager, MsgraphField::Unset);
1246        let props = back.single_value_extended_properties.as_option().unwrap();
1247        assert_eq!(props[0].value, "");
1248    }
1249
1250    #[test]
1251    fn update_from_vcard_tracks_the_stash() {
1252        let base = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\nX-FOO:bar\r\nEND:VCARD\r\n";
1253        let unchanged = MsgraphContact::update_from_vcard(base, base).unwrap();
1254        assert_eq!(
1255            unchanged.single_value_extended_properties,
1256            MsgraphField::Unset
1257        );
1258
1259        let cleared = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\nEND:VCARD\r\n";
1260        let delta = MsgraphContact::update_from_vcard(cleared, base).unwrap();
1261        let props = delta.single_value_extended_properties.as_option().unwrap();
1262        assert_eq!(props[0].value, "");
1263    }
1264
1265    #[test]
1266    fn create_from_vcard_drops_the_empty_stash() {
1267        let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\nEND:VCARD\r\n";
1268        let contact = MsgraphContact::create_from_vcard(vcard).unwrap();
1269        assert_eq!(
1270            contact.single_value_extended_properties,
1271            MsgraphField::Unset
1272        );
1273
1274        let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:X\r\nX-FOO:bar\r\nEND:VCARD\r\n";
1275        let contact = MsgraphContact::create_from_vcard(vcard).unwrap();
1276        let props = contact
1277            .single_value_extended_properties
1278            .as_option()
1279            .unwrap();
1280        assert_eq!(props[0].value, "X-FOO:bar");
1281    }
1282
1283    #[test]
1284    fn the_expand_clause_names_the_stash() {
1285        let quoted = format!("'{MSGRAPH_CONTACT_STASH_ID}'");
1286        assert!(MSGRAPH_CONTACT_STASH_EXPAND.contains(&quoted));
1287    }
1288
1289    #[test]
1290    fn a_vcard_uid_rides_the_stash_and_wins_over_the_graph_id() {
1291        let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:urn:uuid:4fbe8971\r\n\
1292            FN:Jane Doe\r\nEND:VCARD\r\n";
1293
1294        let mut contact = MsgraphContact::create_from_vcard(vcard).unwrap();
1295        // NOTE: what Graph hands back after the create: its own id, and the
1296        // stash it was given.
1297        contact.id = "AAMkAGI2".into();
1298
1299        assert_eq!(contact.stashed_uid().as_deref(), Some("urn:uuid:4fbe8971"));
1300        let vcard = contact.to_vcard();
1301        assert!(vcard.contains("UID:urn:uuid:4fbe8971\r\n"));
1302        assert!(!vcard.contains("UID:AAMkAGI2"));
1303    }
1304
1305    #[test]
1306    fn a_contact_graph_created_mints_its_uid_from_the_graph_id() {
1307        let contact = MsgraphContact {
1308            id: "AAMkAGI2".into(),
1309            display_name: MsgraphField::Set("Jane Doe".into()),
1310            ..Default::default()
1311        };
1312
1313        assert_eq!(contact.stashed_uid(), None);
1314        assert!(contact.to_vcard().contains("UID:AAMkAGI2\r\n"));
1315    }
1316}