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