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