1use 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
64pub const MSGRAPH_CONTACT_STASH_ID: &str =
71 "String {c8e5e5cf-3f6c-4f0a-9d4e-52f1e7b2a9d3} Name cardamum-vcard";
72
73pub const MSGRAPH_CONTACT_STASH_EXPAND: &str = "singleValueExtendedProperties($filter=id eq \
76 'String {c8e5e5cf-3f6c-4f0a-9d4e-52f1e7b2a9d3} Name cardamum-vcard')";
77
78const MAX_STASH_LINE: usize = 8 * 1024;
84
85const 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 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 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 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 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 let _ = card.push_raw(line);
303 }
304
305 String::from_utf8_lossy(&card.to_bytes()).into_owned()
306 }
307
308 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 for line in &card.props {
354 let consumed = match VcardPropKind::from_str(line.name.get()) {
355 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 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 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 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 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 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 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 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 pub fn create_from_vcard(vcard: &str) -> Result<Self, MsgraphContactVcardError> {
697 let mut contact = Self::from_vcard(vcard)?;
698
699 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#[derive(Debug)]
717pub enum MsgraphContactVcardError {
718 Parse(VcardParseError),
720 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
735fn 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
753fn 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
798fn 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
809fn type_param(value: &'static str) -> VcardParam<'static> {
811 VcardParam::Type(vec![Cow::Borrowed(value)])
812}
813
814fn opt(field: &MsgraphField<String>) -> Option<&str> {
816 let value = field.as_deref()?.trim();
817 if value.is_empty() { None } else { Some(value) }
818}
819
820fn list<T>(field: &MsgraphField<Vec<T>>) -> &[T] {
822 field.as_option().map(Vec::as_slice).unwrap_or(&[])
823}
824
825fn 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
833fn 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
843fn 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
855fn 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
872fn 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
888fn 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 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 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 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 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 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("ed));
1266 }
1267}