1use serde::Serialize;
37use serde_json::{Map, Value};
38use yaml_rust2::Yaml;
39
40use crate::markdown::SimpleMetadata;
41
42pub const ALIASES_KEY: &str = "aliases";
44
45pub const PRIVATE_DETAIL_KEYS: &[&str] = &["emails", "phones", "urls", "social", "im", "addresses"];
51
52const WELL_KNOWN_DATES: &[&str] = &["birthday", "death", "anniversary"];
54
55const ADDRESS_FIELDS: &[&str] = &[
59 "street",
60 "city",
61 "region",
62 "postcode",
63 "country",
64 "country_code",
65];
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
69#[serde(rename_all = "lowercase")]
70pub enum ContactKind {
71 Person,
72 Organization,
73}
74
75impl ContactKind {
76 pub fn from_type(value: &str) -> Option<Self> {
80 match value.trim() {
81 "person" => Some(Self::Person),
82 "organization" => Some(Self::Organization),
83 _ => None,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PartialDate {
95 Full { year: u16, month: u8, day: u8 },
96 YearMonth { year: u16, month: u8 },
97 Year { year: u16 },
98 MonthDay { month: u8, day: u8 },
99}
100
101const MONTH_NAMES: [&str; 12] = [
102 "January",
103 "February",
104 "March",
105 "April",
106 "May",
107 "June",
108 "July",
109 "August",
110 "September",
111 "October",
112 "November",
113 "December",
114];
115
116impl PartialDate {
117 pub fn parse(input: &str) -> Option<Self> {
123 let s = input.trim();
124 if let Some(md) = s.strip_prefix("--") {
125 return Self::parse_month_day(md);
126 }
127 let parts: Vec<&str> = s.split('-').collect();
128 match parts.as_slice() {
129 [y, m, d] => {
130 let (year, month) = (year4(y)?, month2(m)?);
131 let day = day2(d, month)?;
132 Some(Self::Full { year, month, day })
133 }
134 [y, m] if y.len() == 4 => Some(Self::YearMonth {
135 year: year4(y)?,
136 month: month2(m)?,
137 }),
138 [_, _] => Self::parse_month_day(s),
139 [y] => Some(Self::Year { year: year4(y)? }),
140 _ => None,
141 }
142 }
143
144 fn parse_month_day(s: &str) -> Option<Self> {
145 let (m, d) = s.split_once('-')?;
146 let month = month2(m)?;
147 Some(Self::MonthDay {
148 month,
149 day: day2(d, month)?,
150 })
151 }
152
153 pub fn display(&self) -> String {
155 let month_name = |m: u8| MONTH_NAMES[usize::from(m) - 1];
156 match *self {
157 Self::Full { year, month, day } => format!("{} {day}, {year}", month_name(month)),
158 Self::YearMonth { year, month } => format!("{} {year}", month_name(month)),
159 Self::Year { year } => year.to_string(),
160 Self::MonthDay { month, day } => format!("{} {day}", month_name(month)),
161 }
162 }
163
164 pub fn html_datetime(&self) -> String {
168 match *self {
169 Self::MonthDay { month, day } => format!("{month:02}-{day:02}"),
170 other => other.iso(),
171 }
172 }
173
174 pub fn iso(&self) -> String {
177 match *self {
178 Self::Full { year, month, day } => format!("{year:04}-{month:02}-{day:02}"),
179 Self::YearMonth { year, month } => format!("{year:04}-{month:02}"),
180 Self::Year { year } => format!("{year:04}"),
181 Self::MonthDay { month, day } => format!("--{month:02}-{day:02}"),
182 }
183 }
184}
185
186fn all_digits(s: &str) -> bool {
187 !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
188}
189
190fn year4(s: &str) -> Option<u16> {
191 (s.len() == 4 && all_digits(s))
192 .then(|| s.parse().ok())
193 .flatten()
194}
195
196fn month2(s: &str) -> Option<u8> {
197 (s.len() == 2 && all_digits(s))
198 .then(|| s.parse().ok())
199 .flatten()
200 .filter(|m| (1..=12).contains(m))
201}
202
203fn day2(s: &str, month: u8) -> Option<u8> {
204 const DAYS: [u8; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
205 (s.len() == 2 && all_digits(s))
206 .then(|| s.parse().ok())
207 .flatten()
208 .filter(|d| (1..=DAYS[usize::from(month) - 1]).contains(d))
209}
210
211pub fn humanize_date(input: &str) -> String {
214 PartialDate::parse(input)
215 .map(|d| d.display())
216 .unwrap_or_else(|| input.to_string())
217}
218
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
221pub struct NameParts {
222 pub prefix: Option<String>,
223 pub first: Option<String>,
224 pub middle: Option<String>,
225 pub last: Option<String>,
226 pub suffix: Option<String>,
227}
228
229impl NameParts {
230 fn full(&self) -> Option<String> {
232 let joined = [
233 &self.prefix,
234 &self.first,
235 &self.middle,
236 &self.last,
237 &self.suffix,
238 ]
239 .into_iter()
240 .flatten()
241 .map(String::as_str)
242 .collect::<Vec<_>>()
243 .join(" ");
244 (!joined.is_empty()).then_some(joined)
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
250pub struct Company {
251 pub name: String,
253 pub raw: String,
257 pub is_link: bool,
259 pub url: Option<String>,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
265pub struct Alias {
266 pub label: Option<String>,
267 pub name: String,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
272pub struct Labeled {
273 pub label: Option<String>,
275 pub label_display: Option<String>,
277 pub value: String,
278 pub href: Option<String>,
283}
284
285#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
287pub struct Address {
288 pub label: Option<String>,
289 pub label_display: Option<String>,
290 pub street: Option<String>,
291 pub city: Option<String>,
292 pub region: Option<String>,
293 pub postcode: Option<String>,
294 pub country: Option<String>,
295 pub country_code: Option<String>,
296 pub lines: Vec<String>,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
304pub struct LabeledDate {
305 pub label: String,
306 pub label_display: String,
307 pub raw: String,
309 pub value: String,
311 pub display: String,
313 pub datetime: Option<String>,
316 pub place: Option<String>,
318 #[serde(skip)]
319 pub date: Option<PartialDate>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324pub struct ContactProblem {
325 pub field: String,
327 pub message: String,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
332pub struct Contact {
333 pub kind: ContactKind,
334 pub display_name: Option<String>,
337 pub name: NameParts,
338 pub company: Option<Company>,
339 pub department: Option<String>,
340 pub job_title: Option<String>,
341 pub image: Option<String>,
344 pub gender: Option<String>,
345 pub aliases: Vec<Alias>,
346 pub alias_phrases: Vec<String>,
348 pub emails: Vec<Labeled>,
349 pub phones: Vec<Labeled>,
350 pub urls: Vec<Labeled>,
351 pub social: Vec<Labeled>,
352 pub im: Vec<Labeled>,
353 pub addresses: Vec<Address>,
354 pub dates: Vec<LabeledDate>,
356 pub born_place: Option<String>,
357 pub places: Vec<Labeled>,
360 pub died_place: Option<String>,
361 pub problems: Vec<ContactProblem>,
362}
363
364#[derive(Clone, Copy)]
366enum HrefKind {
367 Email,
368 Phone,
369 Web,
370}
371
372impl Contact {
373 pub fn from_yaml(yaml: &Yaml) -> Option<Self> {
379 let hash = yaml.as_hash()?;
380 let kind = hash
381 .get(&Yaml::String("type".to_string()))
382 .and_then(Yaml::as_str)
383 .and_then(ContactKind::from_type)?;
384 match crate::relationships::yaml_to_json(yaml) {
385 Value::Object(fm) => Some(Self::from_map(kind, &fm)),
386 _ => None,
387 }
388 }
389
390 pub fn from_frontmatter(fm: &Map<String, Value>) -> Option<Self> {
392 let kind = fm
393 .get("type")
394 .and_then(Value::as_str)
395 .and_then(ContactKind::from_type)?;
396 Some(Self::from_map(kind, fm))
397 }
398
399 fn from_map(kind: ContactKind, fm: &Map<String, Value>) -> Self {
400 let mut problems = Vec::new();
401 let text = |key: &str| fm.get(key).and_then(scalar_text);
402
403 let name = NameParts {
404 prefix: text("prefix"),
405 first: text("first_name"),
406 middle: text("middle_name"),
407 last: text("last_name"),
408 suffix: text("suffix"),
409 };
410 let company = text("company").map(|raw| parse_company(&raw));
411 let display_name = text("title").or_else(|| match kind {
412 ContactKind::Person => name.full(),
413 ContactKind::Organization => company.as_ref().map(|c| c.name.clone()),
414 });
415
416 let aliases = parse_aliases(fm.get(ALIASES_KEY), Some(&mut problems));
417 let alias_phrases = alias_phrases(&aliases);
418
419 let mut labeled = |key: &str, kind: HrefKind| {
420 labeled_values(key, fm.get(key), &mut problems)
421 .into_iter()
422 .filter_map(|(label, value)| {
423 let value = scalar_text(&value)?;
424 Some(Labeled {
425 label_display: label.as_deref().map(humanize_label),
426 href: href_for(kind, &value),
427 label,
428 value,
429 })
430 })
431 .collect::<Vec<_>>()
432 };
433 let emails = labeled("emails", HrefKind::Email);
434 let phones = labeled("phones", HrefKind::Phone);
435 let urls = labeled("urls", HrefKind::Web);
436 let social = labeled("social", HrefKind::Web);
437 let im = labeled("im", HrefKind::Web);
438
439 let addresses = labeled_values("addresses", fm.get("addresses"), &mut problems)
440 .into_iter()
441 .filter_map(|(label, value)| parse_address(label, &value, &mut problems))
442 .collect();
443 let mut dates = parse_dates(fm, Some(&mut problems));
444 let (born_place, died_place) = (text("born_place"), text("died_place"));
445 let places = attach_places(&mut dates, born_place.as_ref(), died_place.as_ref());
446
447 Self {
448 kind,
449 display_name,
450 name,
451 company,
452 department: text("department"),
453 job_title: text("job_title"),
454 image: text("image"),
455 gender: text("gender"),
456 aliases,
457 alias_phrases,
458 emails,
459 phones,
460 urls,
461 social,
462 im,
463 addresses,
464 dates,
465 born_place,
466 died_place,
467 places,
468 problems,
469 }
470 }
471
472 pub fn resolve_company(
486 &mut self,
487 relationships: &[crate::relationships::ResolvedRelationship],
488 ) {
489 let Some(company) = self.company.as_mut().filter(|c| c.is_link) else {
490 return;
491 };
492 company.url = relationships
493 .iter()
494 .find(|r| {
495 r.resolved
496 && r.implied
497 && r.predicate
498 .eq_ignore_ascii_case(crate::relationships::COMPANY_RELATION_TYPE)
499 })
500 .map(|r| r.neighbor.clone());
501 }
502}
503
504fn scalar_text(v: &Value) -> Option<String> {
508 let s = match v {
509 Value::String(s) => s.trim().to_string(),
510 Value::Number(n) => n.to_string(),
511 Value::Bool(b) => b.to_string(),
512 _ => return None,
513 };
514 (!s.is_empty()).then_some(s)
515}
516
517fn parse_company(raw: &str) -> Company {
518 let link = raw
519 .strip_prefix("[[")
520 .and_then(|r| r.strip_suffix("]]"))
521 .map(str::trim)
522 .filter(|inner| !inner.is_empty());
523 match link {
524 Some(inner) => {
525 let (target, shown) = inner.split_once('|').unwrap_or((inner, inner));
526 let name = Some(shown.trim())
527 .filter(|s| !s.is_empty())
528 .unwrap_or(target.trim());
529 Company {
530 name: name.to_string(),
531 raw: raw.to_string(),
532 is_link: true,
533 url: None,
534 }
535 }
536 None => Company {
537 name: raw.to_string(),
538 raw: raw.to_string(),
539 is_link: false,
540 url: None,
541 },
542 }
543}
544
545fn labeled_values(
551 field: &str,
552 v: Option<&Value>,
553 problems: &mut Vec<ContactProblem>,
554) -> Vec<(Option<String>, Value)> {
555 let mut report = |message: String| {
556 problems.push(ContactProblem {
557 field: field.to_string(),
558 message,
559 })
560 };
561 match v {
562 None | Some(Value::Null) => Vec::new(),
563 Some(Value::Array(items)) => items
564 .iter()
565 .enumerate()
566 .filter_map(|(i, item)| match labeled_item(field, item) {
567 Some(pair) => Some(pair),
568 None => {
569 report(format!(
570 "entry {} is not a value, a one-key `label: value` map or \
571 `{{label, value}}`; ignored",
572 i + 1
573 ));
574 None
575 }
576 })
577 .collect(),
578 Some(Value::Object(map)) => {
579 if let Some(pair) = label_value_object(map) {
580 return vec![pair];
581 }
582 if field == "addresses" && is_address_object(map) {
583 return vec![(None, Value::Object(map.clone()))];
584 }
585 map.iter().map(|(k, v)| (non_empty(k), v.clone())).collect()
586 }
587 Some(scalar) => vec![(None, scalar.clone())],
588 }
589}
590
591fn labeled_item(field: &str, item: &Value) -> Option<(Option<String>, Value)> {
593 match item {
594 Value::Object(map) => label_value_object(map)
595 .or_else(|| {
596 (field == "addresses" && is_address_object(map)).then(|| {
597 let label = map.get("label").and_then(scalar_text);
598 let mut rest = map.clone();
599 rest.remove("label");
600 (label, Value::Object(rest))
601 })
602 })
603 .or_else(|| match map.len() {
604 1 => map.iter().next().map(|(k, v)| (non_empty(k), v.clone())),
605 _ => None,
606 }),
607 Value::Array(_) | Value::Null => None,
608 scalar => Some((None, scalar.clone())),
609 }
610}
611
612fn label_value_object(map: &Map<String, Value>) -> Option<(Option<String>, Value)> {
614 if map.len() != 2 {
615 return None;
616 }
617 let label = map.get("label")?;
618 let value = map.get("value").or_else(|| map.get("name"))?;
619 Some((scalar_text(label), value.clone()))
620}
621
622fn is_address_object(map: &Map<String, Value>) -> bool {
623 map.keys().any(|k| ADDRESS_FIELDS.contains(&k.as_str()))
624}
625
626fn non_empty(s: &str) -> Option<String> {
627 let t = s.trim();
628 (!t.is_empty()).then(|| t.to_string())
629}
630
631fn parse_address(
632 label: Option<String>,
633 value: &Value,
634 problems: &mut Vec<ContactProblem>,
635) -> Option<Address> {
636 let label_display = label.as_deref().map(humanize_label);
637 match value {
638 Value::Object(map) => {
639 let field = |k: &str| map.get(k).and_then(scalar_text);
640 let street = field("street");
641 let (city, region, postcode) = (field("city"), field("region"), field("postcode"));
642 let country = field("country");
643 let tail = [®ion, &postcode]
646 .into_iter()
647 .flatten()
648 .map(String::as_str)
649 .collect::<Vec<_>>()
650 .join(" ");
651 let locality = match (&city, tail.is_empty()) {
652 (Some(c), true) => Some(c.clone()),
653 (Some(c), false) => Some(format!("{c}, {tail}")),
654 (None, false) => Some(tail),
655 (None, true) => None,
656 };
657 let lines: Vec<String> = street
658 .iter()
659 .flat_map(|s| s.lines().map(str::trim).filter(|l| !l.is_empty()))
660 .map(str::to_string)
661 .chain(locality)
662 .chain(country.clone())
663 .collect();
664 if lines.is_empty() {
665 problems.push(ContactProblem {
666 field: "addresses".to_string(),
667 message: format!(
668 "address{} has none of street, city, region, postcode or country; ignored",
669 label
670 .as_deref()
671 .map(|l| format!(" `{l}`"))
672 .unwrap_or_default()
673 ),
674 });
675 return None;
676 }
677 Some(Address {
678 label,
679 label_display,
680 street,
681 city,
682 region,
683 postcode,
684 country,
685 country_code: field("country_code"),
686 lines,
687 })
688 }
689 other => {
690 let text = scalar_text(other)?;
691 Some(Address {
692 label,
693 label_display,
694 lines: text
695 .lines()
696 .map(str::trim)
697 .filter(|l| !l.is_empty())
698 .map(str::to_string)
699 .collect(),
700 ..Address::default()
701 })
702 }
703 }
704}
705
706fn parse_aliases(v: Option<&Value>, mut problems: Option<&mut Vec<ContactProblem>>) -> Vec<Alias> {
711 let mut sink = Vec::new();
712 let items = labeled_values(ALIASES_KEY, v, &mut sink);
713 if let Some(p) = problems.as_deref_mut() {
714 p.extend(sink);
715 }
716 items
717 .into_iter()
718 .filter_map(|(label, value)| match scalar_text(&value) {
719 Some(name) => Some(Alias { label, name }),
720 None => {
721 if let Some(p) = problems.as_deref_mut() {
722 p.push(ContactProblem {
723 field: ALIASES_KEY.to_string(),
724 message: format!(
725 "alias{} is not a plain name; ignored",
726 label.map(|l| format!(" `{l}`")).unwrap_or_default()
727 ),
728 });
729 }
730 None
731 }
732 })
733 .collect()
734}
735
736pub fn alias_names(v: Option<&Value>) -> Vec<String> {
741 let mut seen = std::collections::HashSet::new();
742 parse_aliases(v, None)
743 .into_iter()
744 .map(|a| a.name)
745 .filter(|n| seen.insert(n.clone()))
746 .collect()
747}
748
749pub fn alias_names_in(frontmatter: &SimpleMetadata) -> Vec<String> {
751 alias_names(frontmatter.get(ALIASES_KEY))
752}
753
754fn alias_phrases(aliases: &[Alias]) -> Vec<String> {
757 let unlabeled: Vec<&str> = aliases
758 .iter()
759 .filter(|a| a.label.is_none())
760 .map(|a| a.name.as_str())
761 .collect();
762 let aka = (!unlabeled.is_empty()).then(|| format!("aka {}", unlabeled.join(", ")));
763 let labeled = aliases.iter().filter_map(|a| {
764 let label = a.label.as_deref()?;
765 Some(match label.to_lowercase().replace('-', "_").as_str() {
766 "maiden_name" | "birth_name" | "nee" | "née" => format!("née {}", a.name),
767 "nickname" => format!("Nickname: {}", a.name),
768 "formerly" | "former_name" | "previous_name" => format!("formerly {}", a.name),
769 _ => format!("{}: {}", humanize_label(label), a.name),
770 })
771 });
772 aka.into_iter().chain(labeled).collect()
773}
774
775fn parse_dates(
780 fm: &Map<String, Value>,
781 problems: Option<&mut Vec<ContactProblem>>,
782) -> Vec<LabeledDate> {
783 let mut sink = Vec::new();
784 let authored: Vec<LabeledDate> = labeled_values("dates", fm.get("dates"), &mut sink)
785 .into_iter()
786 .filter_map(|(label, value)| {
787 let raw = scalar_text(&value);
788 match (label, raw) {
789 (Some(label), Some(raw)) => Some(labeled_date(label, raw)),
790 (label, _) => {
791 sink.push(ContactProblem {
792 field: "dates".to_string(),
793 message: match label {
794 Some(l) => format!("date `{l}` is not a single value; ignored"),
795 None => {
796 "a date needs a label (`birthday: 1927-03-19`); ignored".to_string()
797 }
798 },
799 });
800 None
801 }
802 }
803 })
804 .collect();
805 if let Some(p) = problems {
806 p.extend(sink);
807 }
808
809 let has = |label: &str| authored.iter().any(|d| d.label.eq_ignore_ascii_case(label));
810 let legacy = [("born", "birthday"), ("died", "death")]
811 .into_iter()
812 .filter(|(_, label)| !has(label))
813 .filter_map(|(key, label)| {
814 let raw = fm.get(key).and_then(scalar_text)?;
815 Some(labeled_date(label.to_string(), raw))
816 })
817 .collect::<Vec<_>>();
818
819 let rank = |d: &LabeledDate| {
820 WELL_KNOWN_DATES
821 .iter()
822 .position(|w| d.label.eq_ignore_ascii_case(w))
823 .unwrap_or(WELL_KNOWN_DATES.len())
824 };
825 let mut all: Vec<LabeledDate> = authored.into_iter().chain(legacy).collect();
826 all.sort_by_key(rank);
828 all
829}
830
831fn labeled_date(label: String, raw: String) -> LabeledDate {
832 let date = PartialDate::parse(&raw);
833 LabeledDate {
834 label_display: humanize_label(&label),
835 value: date.map(|d| d.iso()).unwrap_or_else(|| raw.clone()),
836 display: date.map(|d| d.display()).unwrap_or_else(|| raw.clone()),
837 datetime: date.map(|d| d.html_datetime()),
838 place: None,
839 label,
840 raw,
841 date,
842 }
843}
844
845fn attach_places(
848 dates: &mut [LabeledDate],
849 born_place: Option<&String>,
850 died_place: Option<&String>,
851) -> Vec<Labeled> {
852 [
853 ("birthday", born_place, "Born"),
854 ("death", died_place, "Died"),
855 ]
856 .into_iter()
857 .filter_map(|(label, place, shown)| {
858 let place = place?;
859 match dates
860 .iter_mut()
861 .find(|d| d.label.eq_ignore_ascii_case(label))
862 {
863 Some(date) => {
864 date.place = Some(place.clone());
865 None
866 }
867 None => Some(Labeled {
868 label: Some(shown.to_lowercase()),
869 label_display: Some(shown.to_string()),
870 value: place.clone(),
871 href: None,
872 }),
873 }
874 })
875 .collect()
876}
877
878pub fn humanize_label(label: &str) -> String {
883 const SERVICES: &[(&str, &str)] = &[
884 ("linkedin", "LinkedIn"),
885 ("github", "GitHub"),
886 ("gitlab", "GitLab"),
887 ("youtube", "YouTube"),
888 ("tiktok", "TikTok"),
889 ("whatsapp", "WhatsApp"),
890 ("bluesky", "Bluesky"),
891 ("x", "X"),
892 ("imessage", "iMessage"),
893 ("facetime", "FaceTime"),
894 ];
895 let spaced = label.trim().replace(['_', '-'], " ");
896 let lower = spaced.to_lowercase();
897 if let Some((_, proper)) = SERVICES.iter().find(|(k, _)| *k == lower) {
898 return (*proper).to_string();
899 }
900 if spaced.chars().any(char::is_uppercase) {
901 return spaced;
902 }
903 let mut chars = spaced.chars();
904 match chars.next() {
905 Some(first) => first.to_uppercase().chain(chars).collect(),
906 None => String::new(),
907 }
908}
909
910fn href_for(kind: HrefKind, value: &str) -> Option<String> {
917 let v = value.trim();
918 match kind {
919 HrefKind::Email => (v.contains('@')
920 && !v.contains(char::is_whitespace)
921 && !v.contains([':', '/', '?', '#', '<', '>', '"']))
922 .then(|| format!("mailto:{v}")),
923 HrefKind::Phone => tel_href(v),
924 HrefKind::Web => {
925 let lower = v.to_ascii_lowercase();
926 if v.contains(char::is_whitespace) {
927 None
928 } else if lower.starts_with("https://") || lower.starts_with("http://") {
929 Some(v.to_string())
930 } else if lower.starts_with("www.") {
931 Some(format!("https://{v}"))
932 } else {
933 None
934 }
935 }
936 }
937}
938
939fn tel_href(value: &str) -> Option<String> {
949 let lower = value.trim().to_ascii_lowercase();
950 let (number, extension) = split_extension(&lower);
951 let mut out = String::with_capacity(number.len());
952 let mut digits = 0usize;
953 for (i, c) in number.chars().enumerate() {
954 match c {
955 '+' if i == 0 => out.push('+'),
956 '0'..='9' => {
957 digits += 1;
958 out.push(c);
959 }
960 ',' | ';' | 'p' | 'w' if digits > 0 => out.push(c),
961 ' ' | '.' | '(' | ')' | '-' | '\u{a0}' => {}
962 _ => return None,
963 }
964 }
965 let ext = match extension {
966 Some(ext) if !ext.is_empty() && ext.bytes().all(|b| b.is_ascii_digit()) => {
967 format!(";ext={ext}")
968 }
969 Some(_) => return None,
970 None => String::new(),
971 };
972 (digits >= 3).then(|| format!("tel:{out}{ext}"))
973}
974
975fn split_extension(lower: &str) -> (&str, Option<String>) {
978 ["extension", "ext.", "ext", "x"]
979 .iter()
980 .find_map(|marker| {
981 let at = lower.rfind(marker)?;
982 let rest: String = lower[at + marker.len()..]
983 .chars()
984 .filter(|c| !c.is_whitespace())
985 .collect();
986 Some((lower[..at].trim_end(), Some(rest)))
987 })
988 .unwrap_or((lower, None))
989}
990
991pub fn normalize_simplified(hm: &mut SimpleMetadata, hash: &yaml_rust2::yaml::Hash) {
1010 let key = |k: &str| Yaml::String(k.to_string());
1011 normalize_type(hm);
1012 if let Some(aliases) = hash.get(&key(ALIASES_KEY)) {
1013 let json = crate::relationships::yaml_to_json(aliases);
1014 let names = alias_names(Some(&json))
1015 .into_iter()
1016 .map(Value::String)
1017 .collect();
1018 hm.insert(ALIASES_KEY.to_string(), Value::Array(names));
1019 }
1020
1021 let is_contact = hash
1022 .get(&key("type"))
1023 .and_then(Yaml::as_str)
1024 .and_then(ContactKind::from_type)
1025 .is_some();
1026 if !is_contact {
1027 return;
1028 }
1029 let date_keys = ["dates", "born", "died"];
1030 if !date_keys.iter().any(|k| hash.contains_key(&key(k))) {
1031 return;
1032 }
1033 let fm: Map<String, Value> = date_keys
1034 .iter()
1035 .filter_map(|k| {
1036 hash.get(&key(k))
1037 .map(|v| (k.to_string(), crate::relationships::yaml_to_json(v)))
1038 })
1039 .collect();
1040 for date in parse_dates(&fm, None) {
1041 let canonical = date.label.to_lowercase();
1047 if canonical != date.label {
1048 hm.remove(&format!("dates.{}", date.label));
1049 }
1050 hm.insert(format!("dates.{canonical}"), Value::String(date.value));
1051 }
1052}
1053
1054fn normalize_type(hm: &mut SimpleMetadata) {
1057 let Some(Value::String(raw)) = hm.get("type") else {
1058 return;
1059 };
1060 let trimmed = raw.trim();
1061 if trimmed.len() == raw.len() {
1062 return;
1063 }
1064 if trimmed.is_empty() {
1065 hm.remove("type");
1066 } else {
1067 let trimmed = Value::String(trimmed.to_string());
1068 hm.insert("type".to_string(), trimmed);
1069 }
1070}
1071
1072pub fn is_public_frontmatter_key(key: &str) -> bool {
1085 let root = key.split_once('.').map_or(key, |(root, _)| root);
1086 !PRIVATE_DETAIL_KEYS
1087 .iter()
1088 .any(|private| private.eq_ignore_ascii_case(root))
1089}
1090
1091pub fn serialize_public_frontmatter<S: serde::Serializer>(
1094 frontmatter: &Option<SimpleMetadata>,
1095 serializer: S,
1096) -> Result<S::Ok, S::Error> {
1097 use serde::ser::SerializeMap;
1098 match frontmatter {
1099 None => serializer.serialize_none(),
1100 Some(fm) => {
1101 let public = fm.iter().filter(|(k, _)| is_public_frontmatter_key(k));
1102 let mut map = serializer.serialize_map(None)?;
1103 for (k, v) in public {
1104 map.serialize_entry(k, v)?;
1105 }
1106 map.end()
1107 }
1108 }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use proptest::prelude::*;
1115 use serde_json::json;
1116 use yaml_rust2::YamlLoader;
1117
1118 fn yaml(s: &str) -> Yaml {
1119 YamlLoader::load_from_str(s)
1120 .unwrap()
1121 .into_iter()
1122 .next()
1123 .unwrap()
1124 }
1125
1126 fn contact(s: &str) -> Contact {
1127 Contact::from_yaml(&yaml(s)).expect("a contact")
1128 }
1129
1130 #[test]
1133 fn partial_date_forms() {
1134 use PartialDate::*;
1135 assert_eq!(
1136 PartialDate::parse("1927-03-19"),
1137 Some(Full {
1138 year: 1927,
1139 month: 3,
1140 day: 19
1141 })
1142 );
1143 assert_eq!(
1144 PartialDate::parse("1927-03"),
1145 Some(YearMonth {
1146 year: 1927,
1147 month: 3
1148 })
1149 );
1150 assert_eq!(PartialDate::parse("1898"), Some(Year { year: 1898 }));
1151 assert_eq!(
1152 PartialDate::parse("03-19"),
1153 Some(MonthDay { month: 3, day: 19 })
1154 );
1155 assert_eq!(
1156 PartialDate::parse("--03-19"),
1157 Some(MonthDay { month: 3, day: 19 })
1158 );
1159 assert_eq!(
1160 PartialDate::parse("--02-29"),
1161 Some(MonthDay { month: 2, day: 29 })
1162 );
1163 }
1164
1165 #[test]
1166 fn partial_date_rejects_garbage() {
1167 for bad in [
1168 "",
1169 "circa 1855",
1170 "2020-13-40",
1171 "2020-00-10",
1172 "2020-02-32",
1173 "2020-02-30",
1174 "04-31",
1175 "1855-1-1",
1176 "55-10-30",
1177 "--3-19",
1178 "13-01",
1179 "1927-03-19-01",
1180 ] {
1181 assert_eq!(PartialDate::parse(bad), None, "{bad:?}");
1182 }
1183 }
1184
1185 #[test]
1186 fn partial_date_display_and_iso() {
1187 let show = |s: &str| {
1188 let d = PartialDate::parse(s).unwrap();
1189 (d.display(), d.iso())
1190 };
1191 assert_eq!(
1192 show("1927-03-09"),
1193 ("March 9, 1927".into(), "1927-03-09".into())
1194 );
1195 assert_eq!(show("1927-03"), ("March 1927".into(), "1927-03".into()));
1196 assert_eq!(show("1898"), ("1898".into(), "1898".into()));
1197 assert_eq!(show("03-19"), ("March 19".into(), "--03-19".into()));
1198 assert_eq!(humanize_date("circa 1855"), "circa 1855");
1199 assert_eq!(humanize_date("--12-25"), "December 25");
1200 }
1201
1202 #[test]
1205 fn display_name_prefers_title_then_parts_then_company() {
1206 assert_eq!(
1207 contact("type: person\ntitle: Jane\nfirst_name: J\n").display_name,
1208 Some("Jane".into())
1209 );
1210 let parts = contact(
1211 "type: person\nprefix: Dr.\nfirst_name: Jane\nmiddle_name: Q.\nlast_name: Doe\nsuffix: Jr.\n",
1212 );
1213 assert_eq!(parts.display_name.as_deref(), Some("Dr. Jane Q. Doe Jr."));
1214 assert_eq!(
1215 contact("type: organization\ncompany: Acme Corp\n").display_name,
1216 Some("Acme Corp".into())
1217 );
1218 assert_eq!(contact("type: person\n").display_name, None);
1219 }
1220
1221 #[test]
1222 fn non_contacts_are_none() {
1223 assert!(Contact::from_yaml(&yaml("type: character\ntitle: Gandalf\n")).is_none());
1224 assert!(Contact::from_yaml(&yaml("title: x\n")).is_none());
1225 assert!(Contact::from_yaml(&yaml("- a\n")).is_none());
1226 assert!(Contact::from_yaml(&yaml("type: Person\n")).is_none());
1227 }
1228
1229 #[test]
1232 fn company_plain_and_wikilink() {
1233 let plain = contact("type: person\ncompany: Acme\n").company.unwrap();
1234 assert!(!plain.is_link);
1235 assert_eq!(plain.name, "Acme");
1236
1237 let link = contact("type: person\ncompany: \"[[Acme Corp|Acme]]\"\n")
1238 .company
1239 .unwrap();
1240 assert!(link.is_link);
1241 assert_eq!(link.name, "Acme");
1242 assert_eq!(link.raw, "[[Acme Corp|Acme]]");
1243 }
1244
1245 #[test]
1246 fn resolve_company_reads_the_implied_employer_edge() {
1247 use crate::relationships::{Direction, ResolvedRelationship};
1248 let mut c = contact("type: person\ncompany: \"[[Acme Corp]]\"\n");
1249 let edge = |rel_type: &str, implied: bool, resolved: bool| ResolvedRelationship {
1250 rel_type: rel_type.into(),
1251 predicate: rel_type.into(),
1252 neighbor: if resolved {
1253 "/orgs/acme/".into()
1254 } else {
1255 String::new()
1256 },
1257 neighbor_title: "Acme Corp".into(),
1258 neighbor_raw: "[[Acme Corp]]".into(),
1259 resolved,
1260 direction: Direction::Outgoing,
1261 label: None,
1262 attributes: Default::default(),
1263 derived: true,
1264 implied,
1265 };
1266 c.resolve_company(&[edge("spouse", true, true)]);
1267 assert_eq!(c.company.as_ref().unwrap().url, None);
1268 c.resolve_company(&[edge("employer", true, false)]);
1269 assert_eq!(c.company.as_ref().unwrap().url, None);
1270 c.resolve_company(&[edge("employer", false, true)]);
1272 assert_eq!(c.company.as_ref().unwrap().url, None);
1273 c.resolve_company(&[edge("employer", true, true)]);
1274 assert_eq!(
1275 c.company.as_ref().unwrap().url.as_deref(),
1276 Some("/orgs/acme/")
1277 );
1278 }
1279
1280 #[test]
1283 fn mixed_aliases_list() {
1284 let c = contact(
1285 "type: person\naliases:\n - Mare\n - maiden_name: Mary Smith\n - nickname: Mimi\n - {label: married_name, name: Mary Doe}\n - {a: 1, b: 2}\n - [nested]\n",
1286 );
1287 let names: Vec<_> = c.aliases.iter().map(|a| a.name.as_str()).collect();
1288 assert_eq!(names, ["Mare", "Mary Smith", "Mimi", "Mary Doe"]);
1289 assert_eq!(c.aliases[1].label.as_deref(), Some("maiden_name"));
1290 assert_eq!(
1291 c.alias_phrases,
1292 [
1293 "aka Mare",
1294 "née Mary Smith",
1295 "Nickname: Mimi",
1296 "Married name: Mary Doe"
1297 ]
1298 );
1299 assert_eq!(c.problems.len(), 2, "{:?}", c.problems);
1300 assert!(c.problems.iter().all(|p| p.field == "aliases"));
1301 }
1302
1303 #[test]
1304 fn alias_names_shapes() {
1305 assert_eq!(alias_names(Some(&json!("Bob"))), ["Bob"]);
1306 assert_eq!(alias_names(Some(&json!(["A", "A", " ", "B"]))), ["A", "B"]);
1307 assert_eq!(
1308 alias_names(Some(&json!(["A", {"nickname": "N"}, {"x": 1, "y": 2}]))),
1309 ["A", "N"]
1310 );
1311 assert_eq!(alias_names(Some(&json!({"nickname": "N"}))), ["N"]);
1312 assert!(alias_names(None).is_empty());
1313 assert!(alias_names(Some(&json!(null))).is_empty());
1314 }
1315
1316 #[test]
1319 fn labeled_map_list_and_bare_forms() {
1320 let c = contact(
1321 "type: person\nemails:\n work: jane@abc.com\n home: jane@gmail.com\nphones:\n - mobile: \"+1 303 555 0100\"\n - home: \"+1 303 555 0101\"\n - \"+1 303 555 0199\"\nurls: https://example.com\n",
1322 );
1323 let emails: Vec<_> = c
1324 .emails
1325 .iter()
1326 .map(|e| (e.label.as_deref(), e.value.as_str(), e.href.as_deref()))
1327 .collect();
1328 assert_eq!(
1329 emails,
1330 [
1331 (Some("work"), "jane@abc.com", Some("mailto:jane@abc.com")),
1332 (
1333 Some("home"),
1334 "jane@gmail.com",
1335 Some("mailto:jane@gmail.com")
1336 ),
1337 ]
1338 );
1339 assert_eq!(c.phones.len(), 3);
1340 assert_eq!(c.phones[0].href.as_deref(), Some("tel:+13035550100"));
1341 assert_eq!(c.phones[2].label, None);
1342 assert_eq!(c.urls[0].href.as_deref(), Some("https://example.com"));
1343 assert!(c.problems.is_empty(), "{:?}", c.problems);
1344 }
1345
1346 #[test]
1347 fn labeled_tolerated_object_form_and_duplicates() {
1348 let c = contact(
1349 "type: person\nphones:\n - {label: work, value: '555 0100'}\n - work: '555 0101'\n",
1350 );
1351 assert_eq!(c.phones.len(), 2);
1352 assert!(c.phones.iter().all(|p| p.label.as_deref() == Some("work")));
1353 }
1354
1355 #[test]
1356 fn labeled_bad_entries_are_reported_not_fatal() {
1357 let c = contact("type: person\nemails:\n - {a: x, b: y}\n - ok@example.com\n");
1358 assert_eq!(c.emails.len(), 1);
1359 assert_eq!(c.problems.len(), 1);
1360 assert_eq!(c.problems[0].field, "emails");
1361 }
1362
1363 #[test]
1364 fn label_display() {
1365 assert_eq!(humanize_label("home_fax"), "Home fax");
1366 assert_eq!(humanize_label("linkedin"), "LinkedIn");
1367 assert_eq!(humanize_label("first-met"), "First met");
1368 assert_eq!(humanize_label("My Label"), "My Label");
1369 }
1370
1371 #[test]
1372 fn hrefs_are_an_allowlist() {
1373 let c = contact(
1374 "type: person\nurls:\n evil: \"javascript:alert(1)\"\n data: \"data:text/html,x\"\n ok: \"HTTPS://Example.com/x\"\n www: www.example.com\n bare: example.com\nemails:\n bad: \"javascript:alert(1)@x\"\n worse: \"a@b?subject=x\"\nphones:\n - \"call me\"\n",
1375 );
1376 let hrefs: Vec<_> = c.urls.iter().map(|u| u.href.as_deref()).collect();
1377 assert_eq!(
1378 hrefs,
1379 [
1380 None,
1381 None,
1382 Some("HTTPS://Example.com/x"),
1383 Some("https://www.example.com"),
1384 None
1385 ]
1386 );
1387 assert!(c.emails.iter().all(|e| e.href.is_none()));
1388 assert_eq!(c.phones[0].href, None);
1389 }
1390
1391 #[test]
1392 fn tel_hrefs_strip_layout_and_keep_dial_characters() {
1393 let cases = [
1394 ("+1 (303) 555-0100 ext. 12", Some("tel:+13035550100;ext=12")),
1395 ("+1 303 555 0100", Some("tel:+13035550100")),
1396 ("303.555.0100", Some("tel:3035550100")),
1397 ("303-555-0100,,42", Some("tel:3035550100,,42")),
1398 ("555 0100;123", Some("tel:5550100;123")),
1399 ("555 0100p12", Some("tel:5550100p12")),
1400 ("555 0100 W 9", Some("tel:5550100w9")),
1401 ("555 0100 x7", Some("tel:5550100;ext=7")),
1402 ("555 0100 Extension 7", Some("tel:5550100;ext=7")),
1403 ("Ask for Pam 555 0100", None),
1404 ("call me", None),
1405 ("12", None),
1406 ("555 0100 ext. twelve", None),
1407 ("javascript:alert(1)", None),
1408 ];
1409 for (input, want) in cases {
1410 assert_eq!(tel_href(input).as_deref(), want, "{input:?}");
1411 }
1412 let c = contact("type: person\nphones:\n work: \"+1 (303) 555-0100 ext. 12\"\n");
1414 assert_eq!(c.phones[0].value, "+1 (303) 555-0100 ext. 12");
1415 assert_eq!(c.phones[0].href.as_deref(), Some("tel:+13035550100;ext=12"));
1416 }
1417
1418 #[test]
1419 fn social_handles_stay_text_and_urls_link() {
1420 let c = contact(
1421 "type: person\nsocial:\n twitter: \"@jdoe\"\n mastodon: https://example.social/@jd\nurls:\n homepage: \"javascript:alert(document.cookie)\"\nemails:\n work: jane@abc.example\n",
1422 );
1423 assert_eq!(c.social[0].value, "@jdoe");
1424 assert_eq!(c.social[0].href, None, "no guessed URL templates");
1425 assert_eq!(
1426 c.social[1].href.as_deref(),
1427 Some("https://example.social/@jd")
1428 );
1429 assert_eq!(c.urls[0].href, None);
1430 assert_eq!(c.emails[0].href.as_deref(), Some("mailto:jane@abc.example"));
1431 }
1432
1433 #[test]
1436 fn addresses_structured_string_and_list() {
1437 let c = contact(
1438 "type: person\naddresses:\n home:\n street: \"52 Rue Bonsergent\\nBâtiment B\"\n city: Paris\n region: Île-de-France\n postcode: \"75010\"\n country: France\n country_code: FR\n work: \"1 Main St\\nSpringfield\"\n",
1439 );
1440 assert_eq!(c.addresses.len(), 2);
1441 assert_eq!(
1442 c.addresses[0].lines,
1443 [
1444 "52 Rue Bonsergent",
1445 "Bâtiment B",
1446 "Paris, Île-de-France 75010",
1447 "France"
1448 ]
1449 );
1450 assert_eq!(c.addresses[0].country_code.as_deref(), Some("FR"));
1451 assert_eq!(c.addresses[1].lines, ["1 Main St", "Springfield"]);
1452 assert_eq!(c.addresses[1].label_display.as_deref(), Some("Work"));
1453
1454 let listed = contact(
1455 "type: person\naddresses:\n - label: home\n city: Paris\n - street: 9 Elm\n",
1456 );
1457 assert_eq!(listed.addresses[0].label.as_deref(), Some("home"));
1458 assert_eq!(listed.addresses[0].lines, ["Paris"]);
1459 assert_eq!(listed.addresses[1].label, None);
1460 assert_eq!(listed.addresses[1].lines, ["9 Elm"]);
1461
1462 let single = contact("type: person\naddresses:\n city: Paris\n");
1463 assert_eq!(single.addresses.len(), 1);
1464 assert_eq!(single.addresses[0].label, None);
1465 }
1466
1467 #[test]
1470 fn dates_order_and_legacy_merge() {
1471 let c = contact(
1472 "type: person\nborn: 1900-01-01\ndied: 2010-08-05\ndates:\n first_met: 2019-04-02\n anniversary: 1950-06-10\n birthday: 1927-03-19\n",
1473 );
1474 let got: Vec<_> = c
1475 .dates
1476 .iter()
1477 .map(|d| (d.label.as_str(), d.value.as_str()))
1478 .collect();
1479 assert_eq!(
1480 got,
1481 [
1482 ("birthday", "1927-03-19"),
1483 ("death", "2010-08-05"),
1484 ("anniversary", "1950-06-10"),
1485 ("first_met", "2019-04-02"),
1486 ]
1487 );
1488 assert_eq!(c.dates[3].label_display, "First met");
1489 }
1490
1491 #[test]
1492 fn dates_integer_year_and_prose() {
1493 let c = contact("type: person\nborn: 1898\ndates:\n - death: circa 1960\n");
1494 let birthday = c.dates.iter().find(|d| d.label == "birthday").unwrap();
1495 assert_eq!(birthday.display, "1898");
1496 let death = c.dates.iter().find(|d| d.label == "death").unwrap();
1497 assert_eq!(death.display, "circa 1960");
1498 assert_eq!(death.date, None);
1499 }
1500
1501 #[test]
1502 fn dates_without_label_is_a_problem() {
1503 let c = contact("type: person\ndates:\n - 1927-03-19\n");
1504 assert!(c.dates.is_empty());
1505 assert_eq!(c.problems[0].field, "dates");
1506 }
1507
1508 fn simplified(src: &str) -> SimpleMetadata {
1511 let y = yaml(src);
1512 let mut hm = SimpleMetadata::new();
1513 normalize_simplified(&mut hm, y.as_hash().unwrap());
1514 hm
1515 }
1516
1517 #[test]
1518 fn normalize_trims_type_for_every_note() {
1519 let run = |v: Value| {
1520 let mut hm = SimpleMetadata::from([("type".to_string(), v)]);
1521 normalize_simplified(&mut hm, yaml("title: x\n").as_hash().unwrap());
1522 hm.get("type").cloned()
1523 };
1524 assert_eq!(run(json!(" person ")), Some(json!("person")));
1525 assert_eq!(run(json!("Meeting Notes\t")), Some(json!("Meeting Notes")));
1526 assert_eq!(run(json!("flashcard")), Some(json!("flashcard")));
1527 assert_eq!(run(json!(" ")), None, "blank type is no type");
1528 assert_eq!(run(json!(["a ", "b"])), Some(json!(["a ", "b"])));
1529 }
1530
1531 #[test]
1532 fn normalize_flattens_aliases_for_every_note() {
1533 let hm = simplified("aliases:\n - A\n - nickname: N\n");
1534 assert_eq!(hm["aliases"], json!(["A", "N"]));
1535 let scalar = simplified("aliases: Bob\n");
1536 assert_eq!(scalar["aliases"], json!(["Bob"]));
1537 }
1538
1539 #[test]
1540 fn normalize_dates_only_for_contacts() {
1541 let hm = simplified("type: person\nborn: 1898\ndates:\n - anniversary: 03-19\n");
1542 assert_eq!(hm["dates.birthday"], json!("1898"));
1543 assert_eq!(hm["dates.anniversary"], json!("--03-19"));
1544 let other = simplified("type: event\nborn: 1898\n");
1545 assert!(other.is_empty());
1546 }
1547
1548 #[test]
1551 fn normalize_writes_lowercase_date_keys() {
1552 let hm = simplified("type: person\nborn: 1950\ndates:\n Birthday: 1960-01-02\n");
1553 assert_eq!(hm["dates.birthday"], json!("1960-01-02"));
1554 assert!(!hm.contains_key("dates.Birthday"), "{hm:?}");
1555
1556 let mut flattened = SimpleMetadata::new();
1557 flattened.insert("dates.Birthday".to_string(), json!("1960-01-02"));
1560 let y = yaml("type: person\ndates:\n Birthday: 1960-01-02\n");
1561 normalize_simplified(&mut flattened, y.as_hash().unwrap());
1562 assert_eq!(flattened.len(), 1, "{flattened:?}");
1563 assert_eq!(flattened["dates.birthday"], json!("1960-01-02"));
1564 }
1565
1566 #[test]
1567 fn mixed_case_birthday_beats_legacy_born_on_the_card() {
1568 let c = contact("type: person\nborn: 1950\ndates:\n Birthday: 1960-01-02\n");
1569 let birthdays: Vec<_> = c
1570 .dates
1571 .iter()
1572 .filter(|d| d.label.eq_ignore_ascii_case("birthday"))
1573 .map(|d| d.value.as_str())
1574 .collect();
1575 assert_eq!(birthdays, ["1960-01-02"]);
1576 }
1577
1578 #[test]
1579 fn public_keys_drop_contact_details_and_their_dot_variants() {
1580 for private in [
1581 "emails",
1582 "phones",
1583 "urls.homepage",
1584 "social.linkedin",
1585 "im",
1586 "addresses.home.city",
1587 ] {
1588 assert!(!is_public_frontmatter_key(private), "{private}");
1589 }
1590 for public in [
1591 "title",
1592 "dates.birthday",
1593 "aliases",
1594 "company",
1595 "emailsx",
1596 "phone",
1597 ] {
1598 assert!(is_public_frontmatter_key(public), "{public}");
1599 }
1600 }
1601
1602 #[test]
1605 fn public_keys_drop_contact_details_in_any_case() {
1606 for private in [
1607 "Emails",
1608 "EMAILS",
1609 "eMaIlS",
1610 "Phones",
1611 "PHONES.work",
1612 "Urls.Homepage",
1613 "SOCIAL.linkedin",
1614 "IM",
1615 "Im.signal",
1616 "Addresses.home",
1617 "ADDRESSES.home.city",
1618 ] {
1619 assert!(!is_public_frontmatter_key(private), "{private}");
1620 }
1621 for public in ["Title", "Dates.birthday", "EmailsX", "PHONE", "Imx"] {
1622 assert!(is_public_frontmatter_key(public), "{public}");
1623 }
1624 }
1625
1626 #[test]
1629 fn serialized_frontmatter_omits_private_keys_in_any_case() {
1630 #[derive(serde::Serialize)]
1631 struct Wrapper {
1632 #[serde(serialize_with = "serialize_public_frontmatter")]
1633 frontmatter: Option<SimpleMetadata>,
1634 }
1635 let mut fm = SimpleMetadata::new();
1636 for (k, v) in [
1637 ("title", "Ada"),
1638 ("Emails", "UPPERLEAK@acme.com"),
1639 ("PHONES", "+1 555 0100"),
1640 ("Addresses.home", "1 Leak Lane"),
1641 ("Urls.blog", "https://leak.example"),
1642 ] {
1643 fm.insert(k.to_string(), json!(v));
1644 }
1645 let out = serde_json::to_string(&Wrapper {
1646 frontmatter: Some(fm),
1647 })
1648 .unwrap();
1649 assert!(out.contains("\"title\":\"Ada\""), "{out}");
1650 for leaked in ["UPPERLEAK", "555", "Leak Lane", "leak.example"] {
1651 assert!(!out.contains(leaked), "{leaked} leaked: {out}");
1652 }
1653 }
1654
1655 fn arb_value() -> impl Strategy<Value = Value> {
1659 let leaf = prop_oneof![
1660 Just(Value::Null),
1661 any::<bool>().prop_map(Value::Bool),
1662 any::<i64>().prop_map(|n| json!(n)),
1663 ".{0,12}".prop_map(Value::String),
1664 ];
1665 leaf.prop_recursive(3, 24, 4, |inner| {
1666 prop_oneof![
1667 prop::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
1668 prop::collection::btree_map("[a-z_]{1,8}", inner, 0..4)
1669 .prop_map(|m| Value::Object(m.into_iter().collect())),
1670 ]
1671 })
1672 }
1673
1674 const FIELDS: &[&str] = &[
1675 "title",
1676 "first_name",
1677 "company",
1678 "aliases",
1679 "emails",
1680 "phones",
1681 "urls",
1682 "social",
1683 "im",
1684 "addresses",
1685 "dates",
1686 "born",
1687 "died",
1688 "image",
1689 ];
1690
1691 proptest! {
1692 #[test]
1695 fn never_panics_and_hrefs_stay_safe(values in prop::collection::vec(arb_value(), FIELDS.len())) {
1696 let mut fm: Map<String, Value> = FIELDS
1697 .iter()
1698 .zip(values)
1699 .map(|(k, v)| (k.to_string(), v))
1700 .collect();
1701 fm.insert("type".into(), json!("person"));
1702 let c = Contact::from_frontmatter(&fm).unwrap();
1703 for l in c.emails.iter().chain(&c.phones).chain(&c.urls).chain(&c.social).chain(&c.im) {
1704 if let Some(href) = &l.href {
1705 let lower = href.to_ascii_lowercase();
1706 prop_assert!(
1707 ["mailto:", "tel:", "http://", "https://"].iter().any(|s| lower.starts_with(s)),
1708 "unsafe href {href}"
1709 );
1710 }
1711 }
1712 let _ = alias_names(fm.get("aliases"));
1713 }
1714
1715 #[test]
1718 fn map_and_list_forms_agree(entries in prop::collection::btree_map("[a-z]{1,6}", "[a-z0-9@.]{1,10}", 0..5)) {
1719 let map: Map<String, Value> = entries.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
1720 let list: Vec<Value> = entries.iter().map(|(k, v)| json!({ k.clone(): v })).collect();
1721 let as_map = Contact::from_frontmatter(&Map::from_iter([
1722 ("type".to_string(), json!("person")),
1723 ("emails".to_string(), Value::Object(map)),
1724 ])).unwrap();
1725 let as_list = Contact::from_frontmatter(&Map::from_iter([
1726 ("type".to_string(), json!("person")),
1727 ("emails".to_string(), Value::Array(list)),
1728 ])).unwrap();
1729 prop_assert_eq!(as_map.emails, as_list.emails);
1730 }
1731
1732 #[test]
1734 fn date_iso_round_trips(year in 1000u16..=9999, month in 1u8..=12, day in 1u8..=28) {
1735 for d in [
1736 PartialDate::Full { year, month, day },
1737 PartialDate::YearMonth { year, month },
1738 PartialDate::Year { year },
1739 PartialDate::MonthDay { month, day },
1740 ] {
1741 prop_assert_eq!(PartialDate::parse(&d.iso()), Some(d));
1742 }
1743 }
1744 }
1745}