1use std::borrow::Cow;
36use std::collections::btree_map::Entry;
37use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
38
39use papaya::HashMap as ConcurrentHashMap;
40use serde::{Deserialize, Serialize};
41use yaml_rust2::Yaml;
42
43use crate::config::{Hierarchy, RelationType};
44use crate::link_index::resolve_relative_url;
45
46const RESERVED_EDGE_KEYS: &[&str] = &["type", "to", "from", "label"];
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum Direction {
58 Outgoing,
59 Incoming,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct RawRelationship {
72 pub rel_type: String,
74 pub to: Option<String>,
76 pub from: Option<String>,
78 pub label: Option<String>,
80 pub attributes: BTreeMap<String, serde_json::Value>,
83 pub implicit: bool,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct ResolvedRelationship {
99 pub rel_type: String,
101 pub predicate: String,
104 pub neighbor: String,
106 pub neighbor_title: String,
108 pub neighbor_raw: String,
110 pub resolved: bool,
112 pub direction: Direction,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub label: Option<String>,
117 pub attributes: BTreeMap<String, serde_json::Value>,
119 pub derived: bool,
123 #[serde(skip)]
136 pub implied: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct RelationshipCycle {
148 pub members: Vec<String>,
152 pub rel_type: String,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct AmbiguousEndpoint {
165 pub raw: String,
167 pub resolved_to: String,
169 pub candidates: Vec<String>,
171}
172
173const AMBIGUOUS_WARN_CAP: usize = 20;
179
180pub(crate) struct AmbiguousNameReport<'a> {
182 pub name: &'a str,
184 pub winner: &'a str,
186 pub others: Vec<&'a str>,
188}
189
190pub(crate) fn warn_ambiguous_names(kind: &str, reports: &[AmbiguousNameReport<'_>]) {
217 for report in reports.iter().take(AMBIGUOUS_WARN_CAP) {
218 tracing::warn!(
219 "ambiguous {kind} name `{}`: resolved to {}; also matched by {}. \
220 mbr always picks the first — rename a note, give it a distinguishing \
221 `aliases:` entry, or name the file explicitly to choose",
222 report.name,
223 report.winner,
224 report.others.join(", "),
225 );
226 }
227 let extra = reports.len().saturating_sub(AMBIGUOUS_WARN_CAP);
228 if extra > 0 {
229 tracing::warn!(
230 "... and {extra} more ambiguous {kind} names; each affected page lists \
231 its own in the page-problems panel"
232 );
233 }
234}
235
236#[derive(Debug, Clone)]
239pub struct RelationTypeRegistry {
240 by_name: BTreeMap<String, RelationType>,
242}
243
244impl RelationTypeRegistry {
245 pub fn from_types(types: &[RelationType]) -> Self {
263 let types: Vec<RelationType> = types.iter().map(coerce_self_inverse).collect();
268 let configured: BTreeMap<String, RelationType> = types
269 .iter()
270 .map(|t| (t.name.to_lowercase(), t.clone()))
271 .collect();
272 Self {
273 by_name: reconcile_pair_metadata(derive_reciprocal_types(configured, &types), &types),
274 }
275 }
276
277 pub fn get(&self, name: &str) -> Option<&RelationType> {
279 self.by_name.get(&name.to_lowercase())
280 }
281
282 pub fn is_symmetric(&self, name: &str) -> bool {
284 self.get(name).is_some_and(|t| t.symmetric)
285 }
286
287 pub fn inverse_of(&self, name: &str) -> Option<String> {
295 self.get(name)
296 .and_then(|t| t.inverse.as_deref())
297 .map(str::trim)
298 .filter(|inverse| !inverse.is_empty())
299 .map(str::to_string)
300 }
301
302 fn canonical_name(&self, name: &str) -> String {
305 self.get(name)
306 .map(|t| t.name.clone())
307 .unwrap_or_else(|| name.trim().to_string())
308 }
309
310 fn predicate_subject(&self, rel_type: &str) -> String {
313 self.canonical_name(rel_type)
314 }
315
316 fn predicate_object(&self, rel_type: &str) -> String {
320 let lower = rel_type.to_lowercase();
321 if self.is_symmetric(&lower) {
322 return self.canonical_name(rel_type);
323 }
324 match self.inverse_of(&lower) {
325 Some(inv) => self.canonical_name(&inv),
326 None => self.canonical_name(rel_type),
327 }
328 }
329
330 pub fn to_json(&self) -> serde_json::Value {
333 let arr: Vec<serde_json::Value> = self
334 .by_name
335 .values()
336 .map(|t| {
337 let mut entry = serde_json::json!({
338 "name": t.name,
339 "symmetric": t.symmetric,
340 "inverse": t.inverse,
341 "label": t.singular_label(),
342 "label_plural": t.plural_label(),
343 });
344 if let Some(obj) = entry.as_object_mut() {
347 if let Some(h) = t.hierarchy {
348 obj.insert("hierarchy".into(), h.as_str().into());
349 }
350 if let Some(c) = &t.category {
351 obj.insert("category".into(), c.clone().into());
352 }
353 }
354 entry
355 })
356 .collect();
357 serde_json::Value::Array(arr)
358 }
359}
360
361fn coerce_self_inverse(t: &RelationType) -> RelationType {
379 let is_self_inverse = t
380 .inverse
381 .as_deref()
382 .is_some_and(|inverse| normalize_name(inverse) == normalize_name(&t.name));
383 if !is_self_inverse {
384 return t.clone();
385 }
386 tracing::warn!(
387 "relation type `{}` names itself as its own `inverse`; a type cannot be \
388 its own inverse (`inverse` names the *other* half of a pair) and both \
389 ends of such an edge mean the same thing, so it is being treated as \
390 `symmetric = true`. Declare `symmetric = true` and drop `inverse` to \
391 silence this.",
392 t.name,
393 );
394 RelationType {
395 symmetric: true,
396 inverse: None,
397 ..t.clone()
398 }
399}
400
401fn reciprocal_type(t: &RelationType) -> Option<RelationType> {
408 let inverse = t.inverse.as_deref()?.trim();
409 if inverse.is_empty() {
410 tracing::warn!(
411 "relation type `{}` declares an empty `inverse`; ignoring it",
412 t.name
413 );
414 return None;
415 }
416 if t.symmetric {
417 tracing::warn!(
418 "relation type `{}` is both `symmetric` and `inverse = \"{inverse}\"`; \
419 ignoring the inverse (symmetric wins)",
420 t.name
421 );
422 return None;
423 }
424 Some(RelationType {
425 name: inverse.to_string(),
426 symmetric: false,
427 inverse: Some(t.name.trim().to_string()),
428 label: None,
429 label_plural: None,
430 hierarchy: t.hierarchy.map(Hierarchy::flipped),
431 category: t.category.clone(),
432 })
433}
434
435fn reconcile_pair_metadata(
448 mut by_name: BTreeMap<String, RelationType>,
449 types: &[RelationType],
450) -> BTreeMap<String, RelationType> {
451 for t in types {
452 let key = t.name.to_lowercase();
453 let Some(current) = by_name.get(&key).cloned() else {
454 continue;
455 };
456 if current.symmetric {
457 if current.hierarchy.is_some() {
458 tracing::warn!(
459 "relation type `{}` is symmetric, so it cannot have a `hierarchy`; ignoring it",
460 current.name
461 );
462 if let Some(slot) = by_name.get_mut(&key) {
463 slot.hierarchy = None;
464 }
465 }
466 continue;
467 }
468 let Some(partner_key) = current
469 .inverse
470 .as_deref()
471 .map(str::trim)
472 .filter(|i| !i.is_empty())
473 .map(str::to_lowercase)
474 else {
475 continue;
476 };
477 let Some(partner) = by_name.get(&partner_key).cloned() else {
478 continue;
479 };
480 let names_back = partner
481 .inverse
482 .as_deref()
483 .is_some_and(|i| normalize_name(i) == normalize_name(¤t.name));
484 if !names_back || partner.symmetric {
485 continue;
486 }
487
488 let (own_hierarchy, partner_hierarchy) = match (current.hierarchy, partner.hierarchy) {
489 (Some(h), Some(p)) if h == p => {
490 tracing::warn!(
491 "relation types `{}` and `{}` are inverses but both declare \
492 `hierarchy = \"{}\"`; one end of an edge must rank above the \
493 other, so `{}` is being treated as `{}`",
494 current.name,
495 partner.name,
496 h.as_str(),
497 partner.name,
498 h.flipped().as_str(),
499 );
500 (Some(h), Some(h.flipped()))
501 }
502 (Some(h), None) => (Some(h), Some(h.flipped())),
503 (None, Some(p)) => (Some(p.flipped()), Some(p)),
504 other => other,
505 };
506 let category = current
507 .category
508 .clone()
509 .or_else(|| partner.category.clone());
510
511 if let Some(slot) = by_name.get_mut(&key) {
512 slot.hierarchy = own_hierarchy;
513 slot.category = slot.category.clone().or_else(|| category.clone());
514 }
515 if let Some(slot) = by_name.get_mut(&partner_key) {
516 slot.hierarchy = partner_hierarchy;
517 slot.category = slot.category.clone().or(category);
518 }
519 }
520 by_name
521}
522
523fn derive_reciprocal_types(
529 configured: BTreeMap<String, RelationType>,
530 types: &[RelationType],
531) -> BTreeMap<String, RelationType> {
532 types
533 .iter()
534 .filter_map(|t| reciprocal_type(t).map(|reciprocal| (t, reciprocal)))
535 .fold(configured, |mut acc, (t, reciprocal)| {
536 let key = reciprocal.name.to_lowercase();
537 let was_configured = types.iter().any(|other| other.name.to_lowercase() == key);
538 match acc.entry(key) {
539 Entry::Vacant(slot) => {
540 slot.insert(reciprocal);
541 }
542 Entry::Occupied(slot) => {
543 let existing = slot.get();
544 let agrees = existing
545 .inverse
546 .as_deref()
547 .is_some_and(|i| normalize_name(i) == normalize_name(&t.name));
548 if !agrees {
549 let origin = if was_configured {
550 "configured"
551 } else {
552 "auto-derived"
553 };
554 tracing::warn!(
555 "relation type `{}` declares `inverse = \"{}\"`, but the {origin} \
556 type `{}` has `inverse = {}`; keeping both as-is — reverse edges \
557 for one of them will not be relabelled as expected",
558 t.name,
559 reciprocal.name,
560 existing.name,
561 existing.inverse.as_deref().unwrap_or("none"),
562 );
563 }
564 }
565 }
566 acc
567 })
568}
569
570#[derive(Debug, Clone)]
572pub struct NoteRelInput {
573 pub url: String,
575 pub title: String,
577 pub stem: String,
579 pub aliases: Vec<String>,
581 pub is_index: bool,
583 pub relationships: Vec<RawRelationship>,
585}
586
587pub struct RelationshipIndex {
592 by_note: ConcurrentHashMap<String, Vec<ResolvedRelationship>>,
594 registry: RelationTypeRegistry,
596 cycles_by_note: ConcurrentHashMap<String, Vec<RelationshipCycle>>,
602 ambiguous_by_note: ConcurrentHashMap<String, Vec<AmbiguousEndpoint>>,
606}
607
608impl RelationshipIndex {
609 pub fn new(registry: RelationTypeRegistry) -> Self {
611 Self {
612 by_note: ConcurrentHashMap::new(),
613 registry,
614 cycles_by_note: ConcurrentHashMap::new(),
615 ambiguous_by_note: ConcurrentHashMap::new(),
616 }
617 }
618
619 pub fn from_relation_types(types: &[RelationType]) -> Self {
621 Self::new(RelationTypeRegistry::from_types(types))
622 }
623
624 pub fn registry(&self) -> &RelationTypeRegistry {
626 &self.registry
627 }
628
629 fn normalize_url_key(url: &str) -> String {
635 format!("/{}", url.trim_start_matches('/'))
636 }
637
638 pub fn rebuild(&self, notes: &[NoteRelInput], markdown_extensions: &[String]) {
647 let built = build_relationships(notes, &self.registry, markdown_extensions);
648
649 let guard = self.by_note.pin();
650 guard.clear();
651 for (url, rels) in built.by_note {
652 guard.insert(Self::normalize_url_key(&url), rels);
653 }
654
655 let mut cycles_by_member: BTreeMap<String, Vec<RelationshipCycle>> = BTreeMap::new();
657 for cycle in &built.cycles {
658 for member in &cycle.members {
659 cycles_by_member
660 .entry(Self::normalize_url_key(member))
661 .or_default()
662 .push(cycle.clone());
663 }
664 }
665 let cycles = self.cycles_by_note.pin();
666 cycles.clear();
667 for (url, member_cycles) in cycles_by_member {
668 cycles.insert(url, member_cycles);
669 }
670
671 let ambiguous = self.ambiguous_by_note.pin();
672 ambiguous.clear();
673 for (url, endpoints) in built.ambiguous_by_note {
674 ambiguous.insert(Self::normalize_url_key(&url), endpoints);
675 }
676 }
677
678 pub fn get(&self, url: &str) -> Vec<ResolvedRelationship> {
680 self.by_note
681 .pin()
682 .get(&Self::normalize_url_key(url))
683 .cloned()
684 .unwrap_or_default()
685 }
686
687 pub fn cycles_for(&self, url: &str) -> Vec<RelationshipCycle> {
690 self.cycles_by_note
691 .pin()
692 .get(&Self::normalize_url_key(url))
693 .cloned()
694 .unwrap_or_default()
695 }
696
697 pub fn is_in_cycle(&self, url: &str) -> bool {
702 self.cycles_by_note
703 .pin()
704 .get(&Self::normalize_url_key(url))
705 .is_some_and(|cycles| !cycles.is_empty())
706 }
707
708 pub fn ambiguous_endpoints_for(&self, url: &str) -> Vec<AmbiguousEndpoint> {
711 self.ambiguous_by_note
712 .pin()
713 .get(&Self::normalize_url_key(url))
714 .cloned()
715 .unwrap_or_default()
716 }
717
718 pub fn clear(&self) {
720 self.by_note.pin().clear();
721 self.cycles_by_note.pin().clear();
722 self.ambiguous_by_note.pin().clear();
723 }
724
725 pub fn len(&self) -> usize {
727 self.by_note.pin().len()
728 }
729
730 pub fn is_empty(&self) -> bool {
732 self.by_note.pin().is_empty()
733 }
734
735 pub fn inject_into_site_json(&self, value: &mut serde_json::Value) {
740 let Some(obj) = value.as_object_mut() else {
741 return;
742 };
743 obj.insert("relationship_types".to_string(), self.registry.to_json());
744
745 if let Some(files) = obj.get_mut("markdown_files").and_then(|v| v.as_array_mut()) {
746 let guard = self.by_note.pin();
750 for entry in files.iter_mut() {
751 let url = entry
752 .get("url_path")
753 .and_then(|v| v.as_str())
754 .map(Self::normalize_url_key);
755 if let Some(url) = url {
756 let value = match guard.get(&url) {
757 Some(rels) => serde_json::to_value(rels)
758 .unwrap_or_else(|_| serde_json::Value::Array(vec![])),
759 None => serde_json::Value::Array(vec![]),
760 };
761 if let Some(entry_obj) = entry.as_object_mut() {
762 entry_obj.insert("relationships".to_string(), value);
763 }
764 }
765 }
766 }
767 }
768}
769
770pub fn parse_relationships(yaml: &Yaml) -> Vec<RawRelationship> {
781 let Some(hash) = yaml.as_hash() else {
782 return Vec::new();
783 };
784 let mut out = parse_declared_relationships(hash);
785 out.extend(company_employer_relationship(hash));
786 out
787}
788
789pub const COMPANY_RELATION_TYPE: &str = "employer";
791
792fn company_employer_relationship(hash: &yaml_rust2::yaml::Hash) -> Option<RawRelationship> {
800 let field = |name: &str| {
801 hash.get(&Yaml::String(name.to_string()))
802 .and_then(Yaml::as_str)
803 };
804 if field("type").map(str::trim) != Some("person") {
805 return None;
806 }
807 let company = field("company")?.trim();
808 strip_wikilink(company).filter(|inner| !inner.trim().is_empty())?;
809 Some(RawRelationship {
810 implicit: true,
811 rel_type: COMPANY_RELATION_TYPE.to_string(),
812 to: Some(company.to_string()),
813 from: None,
814 label: None,
815 attributes: BTreeMap::new(),
816 })
817}
818
819fn parse_declared_relationships(hash: &yaml_rust2::yaml::Hash) -> Vec<RawRelationship> {
821 let rel_key = Yaml::String("relationships".to_string());
822 let items = match hash.get(&rel_key) {
823 Some(Yaml::Array(items)) => items,
824 Some(_) => {
825 tracing::warn!("`relationships` frontmatter is not an array; ignoring");
826 return Vec::new();
827 }
828 None => return Vec::new(),
829 };
830
831 let mut out = Vec::with_capacity(items.len());
832 for item in items {
833 let Some(item_hash) = item.as_hash() else {
834 tracing::warn!("skipping relationship entry that is not a mapping");
835 continue;
836 };
837
838 let rel_type = item_hash
839 .get(&Yaml::String("type".to_string()))
840 .and_then(|v| v.as_str())
841 .map(|s| s.trim().to_string());
842 let Some(rel_type) = rel_type.filter(|s| !s.is_empty()) else {
843 tracing::warn!("skipping relationship entry with missing/empty `type`");
844 continue;
845 };
846
847 let str_field = |name: &str| -> Option<String> {
848 item_hash
849 .get(&Yaml::String(name.to_string()))
850 .and_then(|v| v.as_str())
851 .map(|s| s.trim().to_string())
852 .filter(|s| !s.is_empty())
853 };
854
855 let to = str_field("to");
856 let from = str_field("from");
857 let label = str_field("label");
858
859 let mut attributes = BTreeMap::new();
860 for (k, v) in item_hash.iter() {
861 if let Some(key) = k.as_str()
862 && !RESERVED_EDGE_KEYS.contains(&key)
863 {
864 attributes.insert(key.to_string(), yaml_to_json(v));
865 }
866 }
867
868 out.push(RawRelationship {
869 rel_type,
870 to,
871 from,
872 label,
873 attributes,
874 implicit: false,
875 });
876 }
877 out
878}
879
880pub(crate) fn yaml_to_json(y: &Yaml) -> serde_json::Value {
883 use serde_json::Value;
884 match y {
885 Yaml::String(s) => Value::String(s.clone()),
886 Yaml::Integer(i) => Value::Number((*i).into()),
887 Yaml::Real(r) => serde_json::Number::from_f64(r.parse::<f64>().unwrap_or(f64::NAN))
888 .map(Value::Number)
889 .unwrap_or_else(|| Value::String(r.clone())),
890 Yaml::Boolean(b) => Value::Bool(*b),
891 Yaml::Array(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
892 Yaml::Hash(h) => {
893 let mut map = serde_json::Map::with_capacity(h.len());
894 for (k, v) in h.iter() {
895 let key = k
896 .as_str()
897 .map(|s| s.to_string())
898 .or_else(|| k.as_i64().map(|i| i.to_string()))
899 .unwrap_or_default();
900 map.insert(key, yaml_to_json(v));
901 }
902 Value::Object(map)
903 }
904 Yaml::Null | Yaml::BadValue | Yaml::Alias(_) => Value::Null,
905 }
906}
907
908#[derive(Debug, Clone)]
910struct ResolvedEndpoint {
911 url: String,
913 title: String,
915 raw: String,
917 resolved: bool,
919}
920
921impl ResolvedEndpoint {
922 fn resolved(url: String, title: String, raw: &str) -> Self {
924 Self {
925 url,
926 title,
927 raw: raw.to_string(),
928 resolved: true,
929 }
930 }
931
932 fn unresolved(title: String, raw: &str) -> Self {
934 Self {
935 url: String::new(),
936 title,
937 raw: raw.to_string(),
938 resolved: false,
939 }
940 }
941
942 fn key(&self) -> Cow<'_, str> {
946 if self.resolved {
947 Cow::Borrowed(&self.url)
948 } else {
949 Cow::Owned(format!("~{}", normalize_name(&self.title)))
950 }
951 }
952}
953
954struct NameIndex {
956 by_title: HashMap<String, String>,
957 by_alias: HashMap<String, String>,
958 by_stem: HashMap<String, String>,
959 urls: HashSet<String>,
960 title_by_url: HashMap<String, String>,
961 ambiguous: HashMap<String, Vec<String>>,
969}
970
971impl NameIndex {
972 fn build(notes: &[&NoteRelInput]) -> Self {
976 let mut by_title = HashMap::new();
977 let mut by_alias = HashMap::new();
978 let mut by_stem = HashMap::new();
979 let mut urls = HashSet::new();
980 let mut title_by_url = HashMap::new();
981 let mut owners: HashMap<String, Vec<String>> = HashMap::new();
985 for note in notes {
986 urls.insert(note.url.clone());
987 title_by_url.insert(note.url.clone(), note.title.clone());
988 by_title
989 .entry(normalize_name(¬e.title))
990 .or_insert_with(|| note.url.clone());
991 for alias in ¬e.aliases {
992 by_alias
993 .entry(normalize_name(alias))
994 .or_insert_with(|| note.url.clone());
995 }
996 by_stem
997 .entry(normalize_name(¬e.stem))
998 .or_insert_with(|| note.url.clone());
999
1000 let stem_as_name = (!note.is_index).then_some(¬e.stem);
1006 let names = std::iter::once(¬e.title)
1007 .chain(¬e.aliases)
1008 .chain(stem_as_name);
1009 for name in names {
1010 let entry = owners.entry(normalize_name(name)).or_default();
1011 if !entry.contains(¬e.url) {
1012 entry.push(note.url.clone());
1013 }
1014 }
1015 }
1016 let ambiguous = owners
1017 .into_iter()
1018 .filter(|(_, urls)| urls.len() > 1)
1019 .collect();
1020 Self {
1021 by_title,
1022 by_alias,
1023 by_stem,
1024 urls,
1025 title_by_url,
1026 ambiguous,
1027 }
1028 }
1029
1030 fn resolve(
1031 &self,
1032 raw: &str,
1033 source_url: &str,
1034 source_is_index: bool,
1035 markdown_extensions: &[String],
1036 ) -> ResolvedEndpoint {
1037 let trimmed = raw.trim();
1038
1039 if let Some(name) = lookup_name(trimmed, markdown_extensions) {
1042 return self.resolve_by_name(name, raw);
1043 }
1044
1045 let url = path_to_url(trimmed, source_url, source_is_index, markdown_extensions);
1046 if self.urls.contains(&url) {
1047 let title = self
1048 .title_by_url
1049 .get(&url)
1050 .cloned()
1051 .unwrap_or_else(|| url.clone());
1052 return ResolvedEndpoint::resolved(url, title, raw);
1053 }
1054 tracing::warn!("unresolved relationship endpoint path: {raw}");
1055 ResolvedEndpoint::unresolved(trimmed.to_string(), raw)
1056 }
1057
1058 fn ambiguity_for(
1067 &self,
1068 raw: &str,
1069 resolved: &ResolvedEndpoint,
1070 markdown_extensions: &[String],
1071 ) -> Option<AmbiguousEndpoint> {
1072 if !resolved.resolved || self.ambiguous.is_empty() {
1073 return None;
1074 }
1075 let name = lookup_name(raw.trim(), markdown_extensions)?;
1076 let owners = self.ambiguous.get(&normalize_name(name))?;
1077 let candidates: Vec<String> = owners
1078 .iter()
1079 .filter(|url| **url != resolved.url)
1080 .cloned()
1081 .collect();
1082 (!candidates.is_empty()).then(|| AmbiguousEndpoint {
1083 raw: raw.trim().to_string(),
1084 resolved_to: resolved.url.clone(),
1085 candidates,
1086 })
1087 }
1088
1089 fn resolve_by_name(&self, name: &str, raw: &str) -> ResolvedEndpoint {
1090 let key = normalize_name(name);
1091 if let Some(url) = self
1093 .by_title
1094 .get(&key)
1095 .or_else(|| self.by_alias.get(&key))
1096 .or_else(|| self.by_stem.get(&key))
1097 {
1098 let title = self
1099 .title_by_url
1100 .get(url)
1101 .cloned()
1102 .unwrap_or_else(|| name.to_string());
1103 ResolvedEndpoint::resolved(url.clone(), title, raw)
1104 } else {
1105 tracing::warn!("unresolved relationship endpoint: {raw}");
1106 ResolvedEndpoint::unresolved(name.to_string(), raw)
1107 }
1108 }
1109}
1110
1111fn lookup_name<'a>(trimmed: &'a str, markdown_extensions: &[String]) -> Option<&'a str> {
1118 if let Some(inner) = strip_wikilink(trimmed) {
1119 return Some(inner.split('|').next().unwrap_or(inner).trim());
1120 }
1121 if looks_like_path(trimmed, markdown_extensions) {
1122 return None;
1123 }
1124 Some(trimmed)
1125}
1126
1127struct AggregatedEdge {
1129 rel_type: String,
1130 subject: ResolvedEndpoint,
1131 object: ResolvedEndpoint,
1132 label: Option<String>,
1133 attributes: BTreeMap<String, serde_json::Value>,
1134 declarers: HashSet<String>,
1136 implied_by: HashSet<String>,
1138}
1139
1140pub struct RelationshipBuild {
1146 pub by_note: BTreeMap<String, Vec<ResolvedRelationship>>,
1148 pub cycles: Vec<RelationshipCycle>,
1150 pub ambiguous_by_note: BTreeMap<String, Vec<AmbiguousEndpoint>>,
1153}
1154
1155pub fn build_relationship_map(
1164 notes: &[NoteRelInput],
1165 registry: &RelationTypeRegistry,
1166 markdown_extensions: &[String],
1167) -> BTreeMap<String, Vec<ResolvedRelationship>> {
1168 build_relationships(notes, registry, markdown_extensions).by_note
1169}
1170
1171pub fn build_relationships(
1185 notes: &[NoteRelInput],
1186 registry: &RelationTypeRegistry,
1187 markdown_extensions: &[String],
1188) -> RelationshipBuild {
1189 let mut sorted: Vec<&NoteRelInput> = notes.iter().collect();
1192 sorted.sort_by(|a, b| a.url.cmp(&b.url));
1193
1194 let name_index = NameIndex::build(&sorted);
1195
1196 let mut edges: BTreeMap<(String, String, String), AggregatedEdge> = BTreeMap::new();
1197 let mut ambiguous_by_note: BTreeMap<String, Vec<AmbiguousEndpoint>> = BTreeMap::new();
1201 let mut ambiguous_by_name: BTreeMap<String, AmbiguousEndpoint> = BTreeMap::new();
1202
1203 for note in &sorted {
1204 for rel in ¬e.relationships {
1205 let rel_type = rel.rel_type.trim();
1206 if rel_type.is_empty() {
1207 continue;
1208 }
1209 if rel.implicit && registry.get(rel_type).is_none() {
1212 continue;
1213 }
1214 if rel.from.is_none() && rel.to.is_none() {
1215 tracing::warn!(
1216 "relationship on {} has neither `to` nor `from`; skipping",
1217 note.url
1218 );
1219 continue;
1220 }
1221
1222 let self_ep =
1223 || ResolvedEndpoint::resolved(note.url.clone(), note.title.clone(), ¬e.title);
1224
1225 let mut resolve_authored = |authored: &str| {
1228 let endpoint =
1229 name_index.resolve(authored, ¬e.url, note.is_index, markdown_extensions);
1230 if let Some(found) =
1231 name_index.ambiguity_for(authored, &endpoint, markdown_extensions)
1232 {
1233 record_ambiguity(
1234 &mut ambiguous_by_note,
1235 &mut ambiguous_by_name,
1236 ¬e.url,
1237 found,
1238 );
1239 }
1240 endpoint
1241 };
1242
1243 let subject = match &rel.from {
1244 Some(s) => resolve_authored(s),
1245 None => self_ep(),
1246 };
1247 let object = match &rel.to {
1248 Some(s) => resolve_authored(s),
1249 None => self_ep(),
1250 };
1251
1252 let subject_key = subject.key();
1253 let object_key = object.key();
1254 if subject_key == object_key {
1255 tracing::warn!(
1256 "self-referential relationship on {} ({rel_type}); skipping",
1257 note.url
1258 );
1259 continue;
1260 }
1261
1262 let key = canonical_key(registry, rel_type, &subject_key, &object_key);
1263 let edge = edges.entry(key).or_insert_with(|| AggregatedEdge {
1264 rel_type: rel_type.to_string(),
1265 subject: subject.clone(),
1266 object: object.clone(),
1267 label: rel.label.clone(),
1268 attributes: rel.attributes.clone(),
1269 declarers: HashSet::new(),
1270 implied_by: HashSet::new(),
1271 });
1272 if rel.implicit {
1273 edge.implied_by.insert(note.url.clone());
1274 } else {
1275 edge.declarers.insert(note.url.clone());
1276 }
1277 for (k, v) in &rel.attributes {
1279 edge.attributes
1280 .entry(k.clone())
1281 .or_insert_with(|| v.clone());
1282 }
1283 if edge.label.is_none() {
1284 edge.label = rel.label.clone();
1285 }
1286 }
1287 }
1288
1289 let mut result: BTreeMap<String, Vec<ResolvedRelationship>> = BTreeMap::new();
1293 for edge in edges.values() {
1294 let mut emit = |anchor: &ResolvedEndpoint,
1295 neighbor: &ResolvedEndpoint,
1296 predicate: String,
1297 direction: Direction| {
1298 if !anchor.resolved {
1299 return;
1300 }
1301 let entry = ResolvedRelationship {
1302 rel_type: edge.rel_type.clone(),
1303 predicate,
1304 neighbor: neighbor.url.clone(),
1305 neighbor_title: neighbor.title.clone(),
1306 neighbor_raw: neighbor.raw.clone(),
1307 resolved: neighbor.resolved,
1308 direction,
1309 label: edge.label.clone(),
1310 attributes: edge.attributes.clone(),
1311 derived: !edge.declarers.contains(&anchor.url),
1312 implied: edge.implied_by.contains(&anchor.url),
1313 };
1314 result.entry(anchor.url.clone()).or_default().push(entry);
1315 };
1316 emit(
1317 &edge.subject,
1318 &edge.object,
1319 registry.predicate_subject(&edge.rel_type),
1320 Direction::Outgoing,
1321 );
1322 emit(
1323 &edge.object,
1324 &edge.subject,
1325 registry.predicate_object(&edge.rel_type),
1326 Direction::Incoming,
1327 );
1328 }
1329
1330 for rels in result.values_mut() {
1332 rels.sort_by(|a, b| {
1333 a.predicate
1334 .cmp(&b.predicate)
1335 .then_with(|| a.neighbor_title.cmp(&b.neighbor_title))
1336 .then_with(|| a.neighbor.cmp(&b.neighbor))
1337 .then_with(|| a.neighbor_raw.cmp(&b.neighbor_raw))
1338 });
1339 rels.dedup_by(|a, b| {
1340 let duplicate = a.predicate == b.predicate
1341 && a.neighbor == b.neighbor
1342 && a.neighbor_raw == b.neighbor_raw
1343 && a.direction == b.direction;
1344 if duplicate {
1346 b.implied |= a.implied;
1347 }
1348 duplicate
1349 });
1350 }
1351
1352 let cycles = detect_relationship_cycles(&result, registry);
1353 warn_relationship_cycles(&cycles);
1354 warn_ambiguous_names(
1355 "relationship endpoint",
1356 &ambiguous_by_name
1357 .iter()
1358 .map(|(name, found)| AmbiguousNameReport {
1359 name,
1360 winner: &found.resolved_to,
1361 others: found.candidates.iter().map(String::as_str).collect(),
1362 })
1363 .collect::<Vec<_>>(),
1364 );
1365
1366 RelationshipBuild {
1367 by_note: result,
1368 cycles,
1369 ambiguous_by_note,
1370 }
1371}
1372
1373fn record_ambiguity(
1379 per_note: &mut BTreeMap<String, Vec<AmbiguousEndpoint>>,
1380 per_name: &mut BTreeMap<String, AmbiguousEndpoint>,
1381 declarer: &str,
1382 found: AmbiguousEndpoint,
1383) {
1384 per_name
1385 .entry(normalize_name(&found.raw))
1386 .or_insert_with(|| found.clone());
1387 let entries = per_note.entry(declarer.to_string()).or_default();
1388 if !entries.contains(&found) {
1389 entries.push(found);
1390 }
1391}
1392
1393fn warn_relationship_cycles(cycles: &[RelationshipCycle]) {
1396 for cycle in cycles {
1397 let chain: Vec<&str> = cycle
1399 .members
1400 .iter()
1401 .chain(cycle.members.first())
1402 .map(String::as_str)
1403 .collect();
1404 tracing::warn!(
1405 "relationship cycle over `{}`: {} — each note is the previous note's \
1406 `{}`, which cannot be true in a hierarchy (nobody is their own \
1407 ancestor). The genealogy chart cannot be drawn for these notes until \
1408 one of those `{}` relationships is removed or corrected.",
1409 cycle.rel_type,
1410 chain.join(" -> "),
1411 cycle.rel_type,
1412 cycle.rel_type,
1413 );
1414 }
1415}
1416
1417pub fn detect_relationship_cycles(
1434 map: &BTreeMap<String, Vec<ResolvedRelationship>>,
1435 registry: &RelationTypeRegistry,
1436) -> Vec<RelationshipCycle> {
1437 let mut cycles = Vec::new();
1438 for (rel_type, edges) in hierarchical_edges(map, registry) {
1439 let graph = NodeGraph::from_edges(&edges);
1440 for component in strongly_connected_components(&graph.adjacency) {
1441 let self_looped = component.len() == 1
1446 && component
1447 .first()
1448 .is_some_and(|&node| graph.adjacency[node].contains(&node));
1449 if component.len() < 2 && !self_looped {
1450 continue;
1451 }
1452 let members = simple_cycle_in(&component, &graph.adjacency)
1453 .into_iter()
1454 .map(|node| graph.nodes[node].to_string())
1455 .collect();
1456 cycles.push(RelationshipCycle {
1457 members,
1458 rel_type: rel_type.clone(),
1459 });
1460 }
1461 }
1462 cycles
1463}
1464
1465fn hierarchical_edges(
1474 map: &BTreeMap<String, Vec<ResolvedRelationship>>,
1475 registry: &RelationTypeRegistry,
1476) -> BTreeMap<String, BTreeSet<(String, String)>> {
1477 let mut by_type: BTreeMap<String, BTreeSet<(String, String)>> = BTreeMap::new();
1478 for (url, rels) in map {
1479 for rel in rels {
1480 if !rel.resolved || rel.neighbor.is_empty() {
1481 continue;
1482 }
1483 let predicate = rel.predicate.to_lowercase();
1487 if registry.is_symmetric(&predicate) {
1488 continue;
1489 }
1490 let Some(inverse) = registry.inverse_of(&predicate) else {
1491 continue;
1492 };
1493 let inverse = inverse.to_lowercase();
1494 let (canon, edge) = if predicate <= inverse {
1499 (predicate, (url.clone(), rel.neighbor.clone()))
1500 } else {
1501 (inverse, (rel.neighbor.clone(), url.clone()))
1502 };
1503 by_type
1504 .entry(registry.canonical_name(&canon))
1505 .or_default()
1506 .insert(edge);
1507 }
1508 }
1509 by_type
1510}
1511
1512struct NodeGraph<'a> {
1515 nodes: Vec<&'a str>,
1517 adjacency: Vec<Vec<usize>>,
1519}
1520
1521impl<'a> NodeGraph<'a> {
1522 fn from_edges(edges: &'a BTreeSet<(String, String)>) -> Self {
1526 let mut ids: HashMap<&str, usize> = HashMap::new();
1527 let mut nodes: Vec<&str> = Vec::new();
1528 for (from, to) in edges {
1529 for endpoint in [from.as_str(), to.as_str()] {
1530 if let std::collections::hash_map::Entry::Vacant(slot) = ids.entry(endpoint) {
1531 slot.insert(nodes.len());
1532 nodes.push(endpoint);
1533 }
1534 }
1535 }
1536 let mut adjacency = vec![Vec::new(); nodes.len()];
1537 for (from, to) in edges {
1538 adjacency[ids[from.as_str()]].push(ids[to.as_str()]);
1539 }
1540 Self { nodes, adjacency }
1541 }
1542}
1543
1544fn strongly_connected_components(adjacency: &[Vec<usize>]) -> Vec<Vec<usize>> {
1550 const UNVISITED: usize = usize::MAX;
1551 let node_count = adjacency.len();
1552 let mut index = vec![UNVISITED; node_count];
1553 let mut lowlink = vec![UNVISITED; node_count];
1554 let mut on_stack = vec![false; node_count];
1555 let mut component_stack: Vec<usize> = Vec::new();
1556 let mut call_stack: Vec<(usize, usize)> = Vec::new();
1558 let mut next_index = 0usize;
1559 let mut components: Vec<Vec<usize>> = Vec::new();
1560
1561 for root in 0..node_count {
1562 if index[root] != UNVISITED {
1563 continue;
1564 }
1565 index[root] = next_index;
1566 lowlink[root] = next_index;
1567 next_index += 1;
1568 component_stack.push(root);
1569 on_stack[root] = true;
1570 call_stack.push((root, 0));
1571
1572 while let Some(&(node, edge_cursor)) = call_stack.last() {
1573 if edge_cursor < adjacency[node].len() {
1574 if let Some(frame) = call_stack.last_mut() {
1575 frame.1 += 1;
1576 }
1577 let next = adjacency[node][edge_cursor];
1578 if index[next] == UNVISITED {
1579 index[next] = next_index;
1580 lowlink[next] = next_index;
1581 next_index += 1;
1582 component_stack.push(next);
1583 on_stack[next] = true;
1584 call_stack.push((next, 0));
1585 } else if on_stack[next] {
1586 lowlink[node] = lowlink[node].min(index[next]);
1587 }
1588 continue;
1589 }
1590
1591 call_stack.pop();
1594 if lowlink[node] == index[node] {
1595 let mut component = Vec::new();
1596 while let Some(member) = component_stack.pop() {
1597 on_stack[member] = false;
1598 component.push(member);
1599 if member == node {
1600 break;
1601 }
1602 }
1603 components.push(component);
1604 }
1605 if let Some(&(parent, _)) = call_stack.last() {
1606 lowlink[parent] = lowlink[parent].min(lowlink[node]);
1607 }
1608 }
1609 }
1610
1611 components
1612}
1613
1614fn simple_cycle_in(component: &[usize], adjacency: &[Vec<usize>]) -> Vec<usize> {
1625 let members: HashSet<usize> = component.iter().copied().collect();
1626 let Some(&start) = component.iter().min() else {
1627 return Vec::new();
1628 };
1629
1630 let mut path: Vec<usize> = vec![start];
1631 let mut position_on_path: HashMap<usize, usize> = HashMap::from([(start, 0)]);
1633 let mut cursors: Vec<usize> = vec![0];
1635 let mut visited: HashSet<usize> = HashSet::from([start]);
1637
1638 while let Some(&node) = path.last() {
1639 let Some(&cursor) = cursors.last() else {
1640 break;
1641 };
1642 if cursor >= adjacency[node].len() {
1643 path.pop();
1644 cursors.pop();
1645 position_on_path.remove(&node);
1646 continue;
1647 }
1648 if let Some(slot) = cursors.last_mut() {
1649 *slot += 1;
1650 }
1651 let next = adjacency[node][cursor];
1652 if !members.contains(&next) {
1653 continue;
1654 }
1655 if let Some(&position) = position_on_path.get(&next) {
1656 return path[position..].to_vec();
1657 }
1658 if !visited.insert(next) {
1659 continue;
1660 }
1661 position_on_path.insert(next, path.len());
1662 path.push(next);
1663 cursors.push(0);
1664 }
1665
1666 component.to_vec()
1667}
1668
1669fn canonical_key(
1677 registry: &RelationTypeRegistry,
1678 rel_type: &str,
1679 subject_id: &str,
1680 object_id: &str,
1681) -> (String, String, String) {
1682 let lower = rel_type.to_lowercase();
1683
1684 if registry.is_symmetric(&lower) {
1685 let name = registry.canonical_name(&lower);
1686 let (a, b) = if subject_id <= object_id {
1687 (subject_id, object_id)
1688 } else {
1689 (object_id, subject_id)
1690 };
1691 return (name, a.to_string(), b.to_string());
1692 }
1693
1694 if let Some(inv) = registry.inverse_of(&lower) {
1695 let inv_lower = inv.to_lowercase();
1696 let lower_is_canon = lower <= inv_lower;
1700 let canon = if lower_is_canon { &lower } else { &inv_lower };
1701 let name = registry.canonical_name(canon);
1702 if lower_is_canon {
1703 (name, subject_id.to_string(), object_id.to_string())
1704 } else {
1705 (name, object_id.to_string(), subject_id.to_string())
1706 }
1707 } else {
1708 (lower, subject_id.to_string(), object_id.to_string())
1709 }
1710}
1711
1712pub fn normalize_name(s: &str) -> String {
1718 s.trim().to_lowercase()
1719}
1720
1721fn strip_wikilink(s: &str) -> Option<&str> {
1722 s.strip_prefix("[[").and_then(|r| r.strip_suffix("]]"))
1723}
1724
1725fn looks_like_path(s: &str, markdown_extensions: &[String]) -> bool {
1726 s.starts_with('/')
1727 || s.starts_with('.')
1728 || s.contains('/')
1729 || has_markdown_extension(s, markdown_extensions)
1730}
1731
1732fn has_markdown_extension(s: &str, markdown_extensions: &[String]) -> bool {
1733 s.rsplit_once('.')
1734 .is_some_and(|(_, ext)| crate::repo::is_markdown_extension(ext, markdown_extensions))
1735}
1736
1737fn path_to_url(
1740 path: &str,
1741 source_url: &str,
1742 source_is_index: bool,
1743 markdown_extensions: &[String],
1744) -> String {
1745 let (path, _anchor) = crate::link_index::split_url_anchor(path);
1747 let relative = markdown_path_to_slash(&path, markdown_extensions);
1748 resolve_relative_url(source_url, &relative, source_is_index)
1749}
1750
1751fn markdown_path_to_slash(path: &str, markdown_extensions: &[String]) -> String {
1754 if let Some((base, ext)) = path.rsplit_once('.')
1755 && crate::repo::is_markdown_extension(ext, markdown_extensions)
1756 {
1757 return format!("{}/", base);
1758 }
1759 path.to_string()
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764 use super::*;
1765 use yaml_rust2::YamlLoader;
1766
1767 fn genealogy_types() -> Vec<RelationType> {
1768 crate::config::default_relationship_types()
1769 }
1770
1771 fn inverse_type(name: &str, inverse: &str) -> RelationType {
1774 RelationType {
1775 name: name.to_string(),
1776 symmetric: false,
1777 inverse: Some(inverse.to_string()),
1778 label: None,
1779 label_plural: None,
1780 hierarchy: None,
1781 category: None,
1782 }
1783 }
1784
1785 fn symmetric_type(name: &str) -> RelationType {
1787 RelationType {
1788 name: name.to_string(),
1789 symmetric: true,
1790 inverse: None,
1791 label: None,
1792 label_plural: None,
1793 hierarchy: None,
1794 category: None,
1795 }
1796 }
1797
1798 fn half_declared_types() -> Vec<RelationType> {
1801 vec![
1802 inverse_type("employer", "employee"),
1803 symmetric_type("spouse"),
1804 ]
1805 }
1806
1807 fn rel_to(rel_type: &str, to: &str) -> RawRelationship {
1808 RawRelationship {
1809 implicit: false,
1810 rel_type: rel_type.to_string(),
1811 to: Some(to.to_string()),
1812 from: None,
1813 label: None,
1814 attributes: BTreeMap::new(),
1815 }
1816 }
1817
1818 fn parse_yaml(s: &str) -> Yaml {
1819 YamlLoader::load_from_str(s)
1820 .unwrap()
1821 .into_iter()
1822 .next()
1823 .unwrap()
1824 }
1825
1826 fn note(url: &str, title: &str, stem: &str, rels: Vec<RawRelationship>) -> NoteRelInput {
1827 NoteRelInput {
1828 url: url.to_string(),
1829 title: title.to_string(),
1830 stem: stem.to_string(),
1831 aliases: Vec::new(),
1832 is_index: false,
1833 relationships: rels,
1834 }
1835 }
1836
1837 #[test]
1840 fn parse_array_of_objects() {
1841 let yaml = parse_yaml(
1842 "type: person\nrelationships:\n - type: spouse\n to: \"[[Mary Doe]]\"\n married: 1925-06-01\n divorced: 1940-03-14\n - type: parent\n to: \"[[Alice Doe]]\"\n",
1843 );
1844 let rels = parse_relationships(&yaml);
1845 assert_eq!(rels.len(), 2);
1846 assert_eq!(rels[0].rel_type, "spouse");
1847 assert_eq!(rels[0].to.as_deref(), Some("[[Mary Doe]]"));
1848 assert!(rels[0].from.is_none());
1849 assert_eq!(
1850 rels[0].attributes.get("married"),
1851 Some(&serde_json::Value::String("1925-06-01".to_string()))
1852 );
1853 assert_eq!(
1854 rels[0].attributes.get("divorced"),
1855 Some(&serde_json::Value::String("1940-03-14".to_string()))
1856 );
1857 assert_eq!(rels[1].rel_type, "parent");
1858 assert_eq!(rels[1].to.as_deref(), Some("[[Alice Doe]]"));
1859 }
1860
1861 #[test]
1862 fn parse_from_endpoint() {
1863 let yaml = parse_yaml("relationships:\n - type: child\n from: \"[[Sam Doe]]\"\n");
1864 let rels = parse_relationships(&yaml);
1865 assert_eq!(rels.len(), 1);
1866 assert_eq!(rels[0].from.as_deref(), Some("[[Sam Doe]]"));
1867 assert!(rels[0].to.is_none());
1868 }
1869
1870 #[test]
1871 fn parse_both_present_edge() {
1872 let yaml = parse_yaml(
1873 "relationships:\n - type: parent\n from: \"[[A]]\"\n to: \"[[B]]\"\n",
1874 );
1875 let rels = parse_relationships(&yaml);
1876 assert_eq!(rels[0].from.as_deref(), Some("[[A]]"));
1877 assert_eq!(rels[0].to.as_deref(), Some("[[B]]"));
1878 }
1879
1880 #[test]
1881 fn parse_preserves_typed_attributes() {
1882 let yaml = parse_yaml(
1883 "relationships:\n - type: spouse\n to: \"[[X]]\"\n since_year: 1925\n happy: true\n place: \"Denver, CO\"\n",
1884 );
1885 let rels = parse_relationships(&yaml);
1886 assert_eq!(
1887 rels[0].attributes.get("since_year"),
1888 Some(&serde_json::json!(1925))
1889 );
1890 assert_eq!(
1891 rels[0].attributes.get("happy"),
1892 Some(&serde_json::json!(true))
1893 );
1894 assert_eq!(
1895 rels[0].attributes.get("place"),
1896 Some(&serde_json::Value::String("Denver, CO".to_string()))
1897 );
1898 }
1899
1900 #[test]
1901 fn parse_tolerates_missing_type_and_malformed() {
1902 let yaml = parse_yaml(
1903 "relationships:\n - to: \"[[X]]\"\n - type: spouse\n to: \"[[Y]]\"\n - just a string\n",
1904 );
1905 let rels = parse_relationships(&yaml);
1906 assert_eq!(rels.len(), 1);
1908 assert_eq!(rels[0].rel_type, "spouse");
1909 }
1910
1911 #[test]
1912 fn parse_empty_when_no_relationships_key() {
1913 let yaml = parse_yaml("title: Just A Note\ntags:\n - a\n - b\n");
1914 assert!(parse_relationships(&yaml).is_empty());
1915 }
1916
1917 #[test]
1918 fn parse_non_array_relationships_ignored() {
1919 let yaml = parse_yaml("relationships: not-an-array\n");
1920 assert!(parse_relationships(&yaml).is_empty());
1921 }
1922
1923 #[test]
1926 fn registry_symmetric_and_inverse() {
1927 let reg = RelationTypeRegistry::from_types(&genealogy_types());
1928 assert!(reg.is_symmetric("spouse"));
1929 assert!(reg.is_symmetric("SPOUSE"));
1930 assert!(reg.is_symmetric("sibling"));
1931 assert!(!reg.is_symmetric("parent"));
1932 assert_eq!(reg.inverse_of("parent").as_deref(), Some("child"));
1933 assert_eq!(reg.inverse_of("child").as_deref(), Some("parent"));
1934 assert_eq!(reg.predicate_object("parent"), "child");
1935 assert_eq!(reg.predicate_object("child"), "parent");
1936 assert_eq!(reg.predicate_object("spouse"), "spouse");
1937 assert_eq!(reg.predicate_subject("parent"), "parent");
1938 }
1939
1940 fn typed(name: &str, inverse: &str, hierarchy: Option<Hierarchy>) -> RelationType {
1943 RelationType {
1944 hierarchy,
1945 category: Some("work".to_string()),
1946 ..inverse_type(name, inverse)
1947 }
1948 }
1949
1950 #[test]
1951 fn registry_derived_reciprocal_flips_hierarchy_and_keeps_category() {
1952 let reg =
1953 RelationTypeRegistry::from_types(&[typed("mentor", "mentee", Some(Hierarchy::Up))]);
1954 let mentee = reg.get("mentee").expect("auto-registered");
1955 assert_eq!(mentee.hierarchy, Some(Hierarchy::Down));
1956 assert_eq!(mentee.category.as_deref(), Some("work"));
1957 }
1958
1959 #[test]
1960 fn registry_fills_in_a_missing_half_of_a_configured_pair() {
1961 let reg = RelationTypeRegistry::from_types(&[
1962 inverse_type("mentee", "mentor"),
1963 typed("mentor", "mentee", Some(Hierarchy::Up)),
1964 ]);
1965 assert_eq!(reg.get("mentee").unwrap().hierarchy, Some(Hierarchy::Down));
1966 assert_eq!(reg.get("mentee").unwrap().category.as_deref(), Some("work"));
1967 assert_eq!(reg.get("mentor").unwrap().hierarchy, Some(Hierarchy::Up));
1968 }
1969
1970 #[test]
1971 fn registry_repairs_contradictory_hierarchy_in_favour_of_first_declared() {
1972 let reg = RelationTypeRegistry::from_types(&[
1975 typed("boss", "report", Some(Hierarchy::Up)),
1976 typed("report", "boss", Some(Hierarchy::Up)),
1977 ]);
1978 assert_eq!(reg.get("boss").unwrap().hierarchy, Some(Hierarchy::Up));
1979 assert_eq!(reg.get("report").unwrap().hierarchy, Some(Hierarchy::Down));
1980
1981 let reversed = RelationTypeRegistry::from_types(&[
1982 typed("report", "boss", Some(Hierarchy::Up)),
1983 typed("boss", "report", Some(Hierarchy::Up)),
1984 ]);
1985 assert_eq!(
1986 reversed.get("report").unwrap().hierarchy,
1987 Some(Hierarchy::Up)
1988 );
1989 assert_eq!(
1990 reversed.get("boss").unwrap().hierarchy,
1991 Some(Hierarchy::Down)
1992 );
1993 }
1994
1995 #[test]
1996 fn registry_drops_hierarchy_on_symmetric_type() {
1997 let reg = RelationTypeRegistry::from_types(&[RelationType {
1998 hierarchy: Some(Hierarchy::Up),
1999 ..symmetric_type("peer")
2000 }]);
2001 assert_eq!(reg.get("peer").unwrap().hierarchy, None);
2002 }
2003
2004 #[test]
2005 fn registry_json_carries_hierarchy_and_category_only_when_set() {
2006 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2007 let json = reg.to_json();
2008 let arr = json.as_array().unwrap();
2009 let find = |n: &str| arr.iter().find(|t| t["name"] == n).unwrap().clone();
2010 assert_eq!(find("reports_to")["hierarchy"], "up");
2011 assert_eq!(find("manages")["hierarchy"], "down");
2012 assert_eq!(find("manages")["category"], "work");
2013 assert_eq!(find("child")["category"], "family");
2014 let colleague = find("colleague");
2015 assert!(colleague.get("hierarchy").is_none(), "{colleague}");
2016 assert_eq!(colleague["symmetric"], true);
2017
2018 let bare = RelationTypeRegistry::from_types(&[symmetric_type("friend")]).to_json();
2019 let friend = &bare.as_array().unwrap()[0];
2020 assert!(friend.get("hierarchy").is_none());
2021 assert!(friend.get("category").is_none());
2022 }
2023
2024 fn person_with_company(url: &str, title: &str, company: &str) -> NoteRelInput {
2027 let yaml = parse_yaml(&format!("type: person\ncompany: \"{company}\"\n"));
2028 note(url, title, title, parse_relationships(&yaml))
2029 }
2030
2031 #[test]
2032 fn company_wikilink_implies_employer_edge_on_both_ends() {
2033 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2034 let notes = vec![
2035 person_with_company("/people/jane/", "Jane Doe", "[[Acme Corp]]"),
2036 note("/orgs/acme/", "Acme Corp", "acme", vec![]),
2037 ];
2038 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2039
2040 let jane = &map["/people/jane/"];
2041 assert_eq!(jane.len(), 1);
2042 assert_eq!(jane[0].predicate, "employer");
2043 assert_eq!(jane[0].neighbor, "/orgs/acme/");
2044 assert!(jane[0].resolved);
2045 assert!(jane[0].derived, "implied by `company`, not declared");
2046
2047 let acme = &map["/orgs/acme/"];
2048 assert_eq!(acme[0].predicate, "employee");
2049 assert_eq!(acme[0].neighbor, "/people/jane/");
2050 assert!(acme[0].derived);
2051 }
2052
2053 #[test]
2054 fn company_edge_is_declared_once_the_author_writes_it_out() {
2055 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2056 let mut jane = person_with_company("/people/jane/", "Jane Doe", "[[Acme Corp]]");
2057 jane.relationships.push(rel_to("employer", "[[Acme Corp]]"));
2058 let notes = vec![jane, note("/orgs/acme/", "Acme Corp", "acme", vec![])];
2059 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2060 let jane = &map["/people/jane/"];
2061 assert_eq!(
2062 jane.len(),
2063 1,
2064 "explicit and implied edge collapse: {jane:?}"
2065 );
2066 assert!(!jane[0].derived);
2067 }
2068
2069 #[test]
2070 fn company_edge_needs_employer_in_the_registry() {
2071 let reg = RelationTypeRegistry::from_types(&[symmetric_type("spouse")]);
2072 let notes = vec![
2073 person_with_company("/people/jane/", "Jane Doe", "[[Acme Corp]]"),
2074 note("/orgs/acme/", "Acme Corp", "acme", vec![]),
2075 ];
2076 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2077 assert!(map.is_empty(), "{map:?}");
2078 }
2079
2080 #[test]
2081 fn unresolved_company_wikilink_is_reported_like_any_unresolved_endpoint() {
2082 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2083 let notes = vec![person_with_company(
2084 "/people/jane/",
2085 "Jane Doe",
2086 "[[Nowhere Inc]]",
2087 )];
2088 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2089 let jane = &map["/people/jane/"];
2090 assert_eq!(jane.len(), 1);
2091 assert!(!jane[0].resolved);
2092 assert_eq!(jane[0].neighbor, "");
2093 assert_eq!(jane[0].neighbor_title, "Nowhere Inc");
2094 }
2095
2096 fn card_company_url(
2098 notes: &[NoteRelInput],
2099 person_url: &str,
2100 frontmatter: &str,
2101 ) -> Option<String> {
2102 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2103 let map = build_relationship_map(notes, ®, &["md".to_string()]);
2104 let mut contact = crate::contact::Contact::from_yaml(&parse_yaml(frontmatter)).unwrap();
2105 contact.resolve_company(map.get(person_url).map(Vec::as_slice).unwrap_or(&[]));
2106 contact.company.and_then(|c| c.url)
2107 }
2108
2109 #[test]
2114 fn company_links_when_the_org_declares_employee_and_sorts_first() {
2115 let fm = "type: person\ncompany: \"[[Acme]]\"\n";
2116 let notes = vec![
2117 note(
2118 "/people/acme/",
2119 "Acme",
2120 "acme",
2121 vec![rel_to("employee", "[[Jane Doe]]")],
2122 ),
2123 note(
2124 "/people/jane/",
2125 "Jane Doe",
2126 "jane",
2127 parse_relationships(&parse_yaml(fm)),
2128 ),
2129 ];
2130 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2131 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2132 let jane = &map["/people/jane/"];
2133 assert_eq!(jane.len(), 1, "{jane:?}");
2134 assert_eq!(jane[0].predicate, "employer");
2135 assert_eq!(jane[0].direction, Direction::Incoming);
2136 assert!(jane[0].implied);
2137 assert!(!map["/people/acme/"][0].implied, "only the implying note");
2138
2139 assert_eq!(
2140 card_company_url(¬es, "/people/jane/", fm).as_deref(),
2141 Some("/people/acme/")
2142 );
2143 }
2144
2145 #[test]
2148 fn company_links_through_alias_and_casing_differences() {
2149 let fm = "type: person\ncompany: \"[[acme]]\"\nrelationships:\n - type: employer\n to: \"[[ACME CORPORATION]]\"\n";
2150 let mut acme = note("/orgs/acme-corp/", "Acme Corporation", "acme-corp", vec![]);
2151 acme.aliases = vec!["Acme".to_string()];
2152 let notes = vec![
2153 note(
2154 "/people/jane/",
2155 "Jane Doe",
2156 "jane",
2157 parse_relationships(&parse_yaml(fm)),
2158 ),
2159 acme,
2160 ];
2161 assert_eq!(
2162 card_company_url(¬es, "/people/jane/", fm).as_deref(),
2163 Some("/orgs/acme-corp/")
2164 );
2165 }
2166
2167 #[test]
2170 fn company_links_the_named_employer_among_several() {
2171 let fm = "type: person\ncompany: \"[[Zeta]]\"\nrelationships:\n - type: employer\n to: \"[[Alpha]]\"\n";
2172 let notes = vec![
2173 note("/orgs/alpha/", "Alpha", "alpha", vec![]),
2174 note("/orgs/zeta/", "Zeta", "zeta", vec![]),
2175 note(
2176 "/people/jane/",
2177 "Jane Doe",
2178 "jane",
2179 parse_relationships(&parse_yaml(fm)),
2180 ),
2181 ];
2182 assert_eq!(
2183 card_company_url(¬es, "/people/jane/", fm).as_deref(),
2184 Some("/orgs/zeta/")
2185 );
2186 }
2187
2188 #[test]
2189 fn implied_flag_is_not_serialized() {
2190 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2191 let notes = vec![
2192 person_with_company("/people/jane/", "Jane Doe", "[[Acme Corp]]"),
2193 note("/orgs/acme/", "Acme Corp", "acme", vec![]),
2194 ];
2195 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2196 let json = serde_json::to_value(&map["/people/jane/"][0]).unwrap();
2197 assert!(json.get("implied").is_none(), "{json}");
2198 }
2199
2200 #[test]
2201 fn company_plain_text_or_non_person_implies_nothing() {
2202 let plain = parse_yaml("type: person\ncompany: Acme Corp\n");
2203 assert!(parse_relationships(&plain).is_empty());
2204 let org = parse_yaml("type: organization\ncompany: \"[[Parent Co]]\"\n");
2205 assert!(parse_relationships(&org).is_empty());
2206 let empty = parse_yaml("type: person\ncompany: \"[[ ]]\"\n");
2207 assert!(parse_relationships(&empty).is_empty());
2208
2209 let linked = parse_yaml("type: person\ncompany: \"[[Acme Corp|Acme]]\"\n");
2210 let rels = parse_relationships(&linked);
2211 assert_eq!(rels.len(), 1);
2212 assert!(rels[0].implicit);
2213 assert_eq!(rels[0].rel_type, COMPANY_RELATION_TYPE);
2214 assert_eq!(rels[0].to.as_deref(), Some("[[Acme Corp|Acme]]"));
2215 }
2216
2217 #[test]
2218 fn registry_auto_registers_missing_inverse_half() {
2219 let reg = RelationTypeRegistry::from_types(&half_declared_types());
2223 assert_eq!(reg.inverse_of("employee").as_deref(), Some("employer"));
2224 assert_eq!(reg.predicate_object("employee"), "employer");
2225 assert_eq!(reg.predicate_object("employer"), "employee");
2226 assert_eq!(reg.predicate_subject("employee"), "employee");
2227 assert!(!reg.is_symmetric("employee"));
2228 assert!(reg.is_symmetric("spouse"));
2230 assert_eq!(reg.predicate_object("spouse"), "spouse");
2231 let json = reg.to_json();
2233 let arr = json.as_array().unwrap();
2234 assert_eq!(arr.len(), 3);
2235 let employee = arr.iter().find(|t| t["name"] == "employee").unwrap();
2236 assert_eq!(employee["inverse"], "employer");
2237 assert_eq!(employee["label"], "Employee");
2238 assert_eq!(employee["label_plural"], "Employees");
2239 assert_eq!(employee["symmetric"], false);
2240 }
2241
2242 #[test]
2243 fn registry_keeps_configured_types_over_derived_ones() {
2244 let reg = RelationTypeRegistry::from_types(&[
2248 inverse_type("employer", "employee"),
2249 inverse_type("employee", "staff"),
2250 ]);
2251 assert_eq!(reg.inverse_of("employee").as_deref(), Some("staff"));
2252 assert_eq!(reg.predicate_object("employee"), "staff");
2253 assert_eq!(reg.predicate_object("employer"), "employee");
2254 assert_eq!(reg.inverse_of("staff").as_deref(), Some("employee"));
2255 }
2256
2257 #[test]
2258 fn registry_symmetric_wins_over_inverse_and_blank_inverse_ignored() {
2259 let reg = RelationTypeRegistry::from_types(&[
2262 RelationType {
2263 name: "partner".to_string(),
2264 symmetric: true,
2265 inverse: Some("partnered-with".to_string()),
2266 label: None,
2267 label_plural: None,
2268 hierarchy: None,
2269 category: None,
2270 },
2271 inverse_type("boss", " "),
2272 ]);
2273 assert!(reg.get("partnered-with").is_none());
2274 assert_eq!(reg.predicate_object("partner"), "partner");
2275 assert_eq!(reg.predicate_object("boss"), "boss");
2277 assert_eq!(reg.to_json().as_array().unwrap().len(), 2);
2278 }
2279
2280 #[test]
2281 fn registry_coerces_self_inverse_type_to_symmetric() {
2282 let reg = RelationTypeRegistry::from_types(&[inverse_type("sponsor", "sponsor")]);
2285 assert!(reg.is_symmetric("sponsor"));
2286 assert_eq!(reg.inverse_of("sponsor"), None);
2287 assert_eq!(reg.predicate_subject("sponsor"), "sponsor");
2289 assert_eq!(reg.predicate_object("sponsor"), "sponsor");
2290 assert_eq!(reg.to_json().as_array().unwrap().len(), 1);
2292 }
2293
2294 #[test]
2295 fn registry_self_inverse_coercion_reaches_site_json() {
2296 let reg = RelationTypeRegistry::from_types(&[inverse_type("sponsor", "sponsor")]);
2300 let json = reg.to_json();
2301 let sponsor = json
2302 .as_array()
2303 .unwrap()
2304 .iter()
2305 .find(|t| t["name"] == "sponsor")
2306 .expect("type should be present");
2307 assert_eq!(sponsor["symmetric"], true);
2308 assert_eq!(sponsor["inverse"], serde_json::Value::Null);
2309 }
2310
2311 #[test]
2312 fn registry_self_inverse_coercion_is_case_insensitive() {
2313 let reg = RelationTypeRegistry::from_types(&[inverse_type("sponsor", "Sponsor")]);
2316 assert!(reg.is_symmetric("sponsor"));
2317 assert_eq!(reg.inverse_of("sponsor"), None);
2318
2319 let reg = RelationTypeRegistry::from_types(&[inverse_type("Peer", " peer ")]);
2321 assert!(reg.is_symmetric("peer"));
2322 assert_eq!(reg.inverse_of("peer"), None);
2323 }
2324
2325 #[test]
2326 fn registry_self_inverse_coercion_warns_naming_the_type() {
2327 let (_reg, logs) = crate::test_support::capture_tracing(|| {
2328 RelationTypeRegistry::from_types(&[inverse_type("sponsor", "sponsor")])
2329 });
2330 assert!(
2331 logs.contains("`sponsor` names itself as its own `inverse`"),
2332 "the warning must name the offending type, got: {logs}"
2333 );
2334 assert!(
2335 logs.contains("symmetric = true"),
2336 "the warning must say what to do instead, got: {logs}"
2337 );
2338 }
2339
2340 #[test]
2341 fn registry_normal_inverse_pair_is_untouched_by_coercion() {
2342 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2345 assert!(!reg.is_symmetric("parent"));
2346 assert!(!reg.is_symmetric("child"));
2347 assert_eq!(reg.inverse_of("parent").as_deref(), Some("child"));
2348 assert_eq!(reg.inverse_of("child").as_deref(), Some("parent"));
2349
2350 let reg = RelationTypeRegistry::from_types(&half_declared_types());
2351 assert!(!reg.is_symmetric("employer"));
2352 assert_eq!(reg.inverse_of("employer").as_deref(), Some("employee"));
2353 assert_eq!(reg.inverse_of("employee").as_deref(), Some("employer"));
2354
2355 let mut types = genealogy_types();
2357 types.push(inverse_type("sponsor", "sponsor"));
2358 let reg = RelationTypeRegistry::from_types(&types);
2359 assert!(reg.is_symmetric("sponsor"));
2360 assert_eq!(reg.inverse_of("parent").as_deref(), Some("child"));
2361 assert!(!reg.is_symmetric("parent"));
2362 }
2363
2364 #[test]
2365 fn registry_defaults_are_unchanged_by_derivation() {
2366 let defaults = genealogy_types();
2370 let reg = RelationTypeRegistry::from_types(&defaults);
2371 assert_eq!(reg.to_json().as_array().unwrap().len(), defaults.len());
2372 for t in &defaults {
2373 assert_eq!(reg.get(&t.name), Some(t), "{} changed", t.name);
2374 }
2375 }
2376
2377 #[test]
2378 fn registry_to_json_has_labels() {
2379 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2380 let json = reg.to_json();
2381 let arr = json.as_array().unwrap();
2382 assert_eq!(arr.len(), 11);
2383 let child = arr.iter().find(|t| t["name"] == "child").unwrap();
2384 assert_eq!(child["label_plural"], "Children");
2385 assert_eq!(child["inverse"], "parent");
2386 }
2387
2388 #[test]
2391 fn resolve_by_title_and_stem_case_insensitive() {
2392 let notes = vec![
2393 note(
2394 "/people/john/",
2395 "John Doe",
2396 "john",
2397 vec![RawRelationship {
2398 implicit: false,
2399 rel_type: "spouse".into(),
2400 to: Some("[[mary doe]]".into()),
2401 from: None,
2402 label: None,
2403 attributes: BTreeMap::new(),
2404 }],
2405 ),
2406 note("/people/mary/", "Mary Doe", "mary-doe-file", vec![]),
2407 ];
2408 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2409 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2410 let john = map.get("/people/john/").unwrap();
2411 assert_eq!(john[0].neighbor, "/people/mary/");
2412 assert!(john[0].resolved);
2413 let mary = map.get("/people/mary/").unwrap();
2415 assert_eq!(mary[0].neighbor, "/people/john/");
2416 assert_eq!(mary[0].predicate, "spouse");
2417 }
2418
2419 #[test]
2420 fn resolve_by_filename_stem() {
2421 let notes = vec![
2422 note(
2423 "/a/",
2424 "Alpha",
2425 "alpha",
2426 vec![RawRelationship {
2427 implicit: false,
2428 rel_type: "spouse".into(),
2429 to: Some("[[beta-stem]]".into()),
2430 from: None,
2431 label: None,
2432 attributes: BTreeMap::new(),
2433 }],
2434 ),
2435 note("/b/", "Beta Person", "beta-stem", vec![]),
2436 ];
2437 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2438 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2439 assert_eq!(map.get("/a/").unwrap()[0].neighbor, "/b/");
2440 }
2441
2442 #[test]
2443 fn resolve_broken_endpoint_kept_raw() {
2444 let notes = vec![note(
2445 "/a/",
2446 "Alpha",
2447 "alpha",
2448 vec![RawRelationship {
2449 implicit: false,
2450 rel_type: "spouse".into(),
2451 to: Some("[[Ghost]]".into()),
2452 from: None,
2453 label: None,
2454 attributes: BTreeMap::new(),
2455 }],
2456 )];
2457 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2458 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2459 let a = map.get("/a/").unwrap();
2460 assert_eq!(a.len(), 1);
2461 assert!(!a[0].resolved);
2462 assert_eq!(a[0].neighbor, "");
2463 assert_eq!(a[0].neighbor_raw, "[[Ghost]]");
2464 assert_eq!(a[0].neighbor_title, "Ghost");
2465 }
2466
2467 #[test]
2468 fn resolve_ambiguous_title_is_deterministic() {
2469 let notes = vec![
2471 note(
2472 "/z/",
2473 "Zeb",
2474 "zeb",
2475 vec![RawRelationship {
2476 implicit: false,
2477 rel_type: "spouse".into(),
2478 to: Some("[[Sam]]".into()),
2479 from: None,
2480 label: None,
2481 attributes: BTreeMap::new(),
2482 }],
2483 ),
2484 note("/people/a-sam/", "Sam", "a-sam", vec![]),
2485 note("/people/b-sam/", "Sam", "b-sam", vec![]),
2486 ];
2487 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2488 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2489 assert_eq!(map.get("/z/").unwrap()[0].neighbor, "/people/a-sam/");
2490 }
2491
2492 #[test]
2493 fn resolve_path_endpoint() {
2494 let notes = vec![
2495 note(
2496 "/people/john/",
2497 "John",
2498 "john",
2499 vec![RawRelationship {
2500 implicit: false,
2501 rel_type: "spouse".into(),
2502 to: Some("mary.md".into()),
2503 from: None,
2504 label: None,
2505 attributes: BTreeMap::new(),
2506 }],
2507 ),
2508 note("/people/mary/", "Mary", "mary", vec![]),
2509 ];
2510 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2511 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2512 assert_eq!(
2513 map.get("/people/john/").unwrap()[0].neighbor,
2514 "/people/mary/"
2515 );
2516 }
2517
2518 #[test]
2519 fn resolve_by_alias() {
2520 let mut mary = note("/people/mary/", "Mary Smith", "mary", vec![]);
2522 mary.aliases = vec!["Mary Doe".into()];
2523 let notes = vec![
2524 note(
2525 "/z/",
2526 "Zeb",
2527 "zeb",
2528 vec![RawRelationship {
2529 implicit: false,
2530 rel_type: "spouse".into(),
2531 to: Some("[[Mary Doe]]".into()),
2532 from: None,
2533 label: None,
2534 attributes: BTreeMap::new(),
2535 }],
2536 ),
2537 mary,
2538 ];
2539 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2540 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2541 assert_eq!(map.get("/z/").unwrap()[0].neighbor, "/people/mary/");
2542 }
2543
2544 #[test]
2545 fn title_beats_alias() {
2546 let mut alias_holder = note("/people/aka/", "Samuel", "samuel", vec![]);
2549 alias_holder.aliases = vec!["Sam".into()];
2550 let notes = vec![
2551 note(
2552 "/z/",
2553 "Zeb",
2554 "zeb",
2555 vec![RawRelationship {
2556 implicit: false,
2557 rel_type: "spouse".into(),
2558 to: Some("[[Sam]]".into()),
2559 from: None,
2560 label: None,
2561 attributes: BTreeMap::new(),
2562 }],
2563 ),
2564 note("/people/sam/", "Sam", "sam", vec![]),
2565 alias_holder,
2566 ];
2567 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2568 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2569 assert_eq!(map.get("/z/").unwrap()[0].neighbor, "/people/sam/");
2570 }
2571
2572 #[test]
2573 fn alias_case_insensitive() {
2574 let mut mary = note("/people/mary/", "Mary Smith", "mary", vec![]);
2576 mary.aliases = vec!["Mary DOE".into()];
2577 let notes = vec![
2578 note(
2579 "/z/",
2580 "Zeb",
2581 "zeb",
2582 vec![RawRelationship {
2583 implicit: false,
2584 rel_type: "spouse".into(),
2585 to: Some("[[mary doe]]".into()),
2586 from: None,
2587 label: None,
2588 attributes: BTreeMap::new(),
2589 }],
2590 ),
2591 mary,
2592 ];
2593 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2594 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2595 assert_eq!(map.get("/z/").unwrap()[0].neighbor, "/people/mary/");
2596 }
2597
2598 #[test]
2601 fn inverse_parent_child_derivation() {
2602 let notes = vec![
2604 note(
2605 "/john/",
2606 "John",
2607 "john",
2608 vec![RawRelationship {
2609 implicit: false,
2610 rel_type: "child".into(),
2611 to: Some("[[Alice]]".into()),
2612 from: None,
2613 label: None,
2614 attributes: BTreeMap::new(),
2615 }],
2616 ),
2617 note("/alice/", "Alice", "alice", vec![]),
2618 ];
2619 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2620 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2621
2622 let john = map.get("/john/").unwrap();
2623 assert_eq!(john[0].predicate, "child");
2624 assert_eq!(john[0].neighbor, "/alice/");
2625 assert!(!john[0].derived);
2626
2627 let alice = map.get("/alice/").unwrap();
2628 assert_eq!(alice[0].predicate, "parent");
2629 assert_eq!(alice[0].neighbor, "/john/");
2630 assert!(alice[0].derived);
2631 }
2632
2633 #[test]
2634 fn symmetric_spouse_appears_both_sides() {
2635 let notes = vec![
2636 note(
2637 "/john/",
2638 "John",
2639 "john",
2640 vec![RawRelationship {
2641 implicit: false,
2642 rel_type: "spouse".into(),
2643 to: Some("[[Mary]]".into()),
2644 from: None,
2645 label: None,
2646 attributes: BTreeMap::new(),
2647 }],
2648 ),
2649 note("/mary/", "Mary", "mary", vec![]),
2650 ];
2651 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2652 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2653 assert_eq!(map.get("/john/").unwrap()[0].predicate, "spouse");
2654 assert_eq!(map.get("/mary/").unwrap()[0].predicate, "spouse");
2655 }
2656
2657 #[test]
2658 fn reciprocal_declarations_deduped() {
2659 let notes = vec![
2661 note(
2662 "/john/",
2663 "John",
2664 "john",
2665 vec![RawRelationship {
2666 implicit: false,
2667 rel_type: "child".into(),
2668 to: Some("[[Alice]]".into()),
2669 from: None,
2670 label: None,
2671 attributes: BTreeMap::new(),
2672 }],
2673 ),
2674 note(
2675 "/alice/",
2676 "Alice",
2677 "alice",
2678 vec![RawRelationship {
2679 implicit: false,
2680 rel_type: "parent".into(),
2681 to: Some("[[John]]".into()),
2682 from: None,
2683 label: None,
2684 attributes: BTreeMap::new(),
2685 }],
2686 ),
2687 ];
2688 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2689 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2690 assert_eq!(map.get("/john/").unwrap().len(), 1);
2692 assert_eq!(map.get("/alice/").unwrap().len(), 1);
2693 assert!(!map.get("/john/").unwrap()[0].derived);
2695 assert!(!map.get("/alice/").unwrap()[0].derived);
2696 }
2697
2698 #[test]
2699 fn half_declared_inverse_labels_the_object_side() {
2700 let notes = vec![
2704 note("/a/", "A", "a", vec![]),
2705 note("/b/", "B", "b", vec![rel_to("employee", "[[A]]")]),
2706 ];
2707 let reg = RelationTypeRegistry::from_types(&half_declared_types());
2708 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2709
2710 let b = map.get("/b/").unwrap();
2711 assert_eq!(b.len(), 1);
2712 assert_eq!(b[0].predicate, "employee");
2713 assert_eq!(b[0].neighbor, "/a/");
2714 assert!(!b[0].derived);
2715
2716 let a = map.get("/a/").unwrap();
2717 assert_eq!(a.len(), 1);
2718 assert_eq!(a[0].predicate, "employer");
2719 assert_eq!(a[0].neighbor, "/b/");
2720 assert!(a[0].derived);
2721 }
2722
2723 #[test]
2724 fn half_declared_reciprocal_declarations_collapse_to_one_edge() {
2725 let notes = vec![
2730 note("/a/", "A", "a", vec![rel_to("employer", "[[B]]")]),
2731 note("/b/", "B", "b", vec![rel_to("employee", "[[A]]")]),
2732 ];
2733 let reg = RelationTypeRegistry::from_types(&[inverse_type("employee", "employer")]);
2734 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2735
2736 let a = map.get("/a/").unwrap();
2737 assert_eq!(a.len(), 1);
2738 assert_eq!(a[0].predicate, "employer");
2739 assert_eq!(a[0].neighbor, "/b/");
2740 assert!(!a[0].derived);
2742
2743 let b = map.get("/b/").unwrap();
2744 assert_eq!(b.len(), 1);
2745 assert_eq!(b[0].predicate, "employee");
2746 assert_eq!(b[0].neighbor, "/a/");
2747 assert!(!b[0].derived);
2748 }
2749
2750 #[test]
2751 fn half_declared_registry_still_handles_symmetric_types() {
2752 let notes = vec![
2754 note("/john/", "John", "john", vec![rel_to("spouse", "[[Mary]]")]),
2755 note("/mary/", "Mary", "mary", vec![]),
2756 ];
2757 let reg = RelationTypeRegistry::from_types(&half_declared_types());
2758 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2759 let john = map.get("/john/").unwrap();
2760 assert_eq!(john.len(), 1);
2761 assert_eq!(john[0].predicate, "spouse");
2762 let mary = map.get("/mary/").unwrap();
2763 assert_eq!(mary.len(), 1);
2764 assert_eq!(mary[0].predicate, "spouse");
2765 assert!(mary[0].derived);
2766 }
2767
2768 #[test]
2769 fn both_present_edge_between_other_notes() {
2770 let notes = vec![
2772 note(
2773 "/c/",
2774 "C",
2775 "c",
2776 vec![RawRelationship {
2777 implicit: false,
2778 rel_type: "spouse".into(),
2779 from: Some("[[A]]".into()),
2780 to: Some("[[B]]".into()),
2781 label: None,
2782 attributes: BTreeMap::new(),
2783 }],
2784 ),
2785 note("/a/", "A", "a", vec![]),
2786 note("/b/", "B", "b", vec![]),
2787 ];
2788 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2789 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2790 assert!(!map.contains_key("/c/"));
2791 assert!(map.get("/a/").unwrap()[0].derived);
2792 assert!(map.get("/b/").unwrap()[0].derived);
2793 }
2794
2795 #[test]
2796 fn unknown_type_directed_no_relabel() {
2797 let notes = vec![
2798 note(
2799 "/john/",
2800 "John",
2801 "john",
2802 vec![RawRelationship {
2803 implicit: false,
2804 rel_type: "mentor".into(),
2805 to: Some("[[Alice]]".into()),
2806 from: None,
2807 label: None,
2808 attributes: BTreeMap::new(),
2809 }],
2810 ),
2811 note("/alice/", "Alice", "alice", vec![]),
2812 ];
2813 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2814 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2815 assert_eq!(map.get("/john/").unwrap()[0].predicate, "mentor");
2816 assert_eq!(map.get("/alice/").unwrap()[0].predicate, "mentor");
2818 assert_eq!(
2819 map.get("/alice/").unwrap()[0].direction,
2820 Direction::Incoming
2821 );
2822 }
2823
2824 #[test]
2825 fn neither_endpoint_is_skipped() {
2826 let notes = vec![note(
2827 "/john/",
2828 "John",
2829 "john",
2830 vec![RawRelationship {
2831 implicit: false,
2832 rel_type: "spouse".into(),
2833 to: None,
2834 from: None,
2835 label: None,
2836 attributes: BTreeMap::new(),
2837 }],
2838 )];
2839 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2840 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
2841 assert!(map.is_empty());
2842 }
2843
2844 fn build_genealogy(notes: &[NoteRelInput]) -> RelationshipBuild {
2848 let reg = RelationTypeRegistry::from_types(&genealogy_types());
2849 build_relationships(notes, ®, &["md".to_string()])
2850 }
2851
2852 #[test]
2853 fn cycle_detection_finds_two_note_cycle() {
2854 let notes = vec![
2858 note("/a/", "A", "a", vec![rel_to("parent", "[[B]]")]),
2859 note("/b/", "B", "b", vec![rel_to("parent", "[[A]]")]),
2860 ];
2861 let built = build_genealogy(¬es);
2862 assert_eq!(built.cycles.len(), 1, "{:?}", built.cycles);
2863 let cycle = &built.cycles[0];
2864 assert_eq!(cycle.rel_type, "child");
2865 assert_eq!(cycle.members.len(), 2, "{:?}", cycle.members);
2866 assert!(cycle.members.contains(&"/a/".to_string()), "{:?}", cycle);
2867 assert!(cycle.members.contains(&"/b/".to_string()), "{:?}", cycle);
2868 }
2869
2870 #[test]
2871 fn cycle_detection_finds_three_note_cycle() {
2872 let notes = vec![
2873 note("/a/", "A", "a", vec![rel_to("parent", "[[B]]")]),
2874 note("/b/", "B", "b", vec![rel_to("parent", "[[C]]")]),
2875 note("/c/", "C", "c", vec![rel_to("parent", "[[A]]")]),
2876 ];
2877 let built = build_genealogy(¬es);
2878 assert_eq!(built.cycles.len(), 1, "{:?}", built.cycles);
2879 let mut members = built.cycles[0].members.clone();
2880 members.sort();
2881 assert_eq!(members, vec!["/a/", "/b/", "/c/"]);
2882 }
2883
2884 #[test]
2885 fn cycle_detection_ignores_acyclic_tree() {
2886 let notes = vec![
2888 note(
2889 "/root/",
2890 "Root",
2891 "root",
2892 vec![rel_to("child", "[[Kid1]]"), rel_to("child", "[[Kid2]]")],
2893 ),
2894 note("/kid1/", "Kid1", "kid1", vec![rel_to("child", "[[Grand]]")]),
2895 note("/kid2/", "Kid2", "kid2", vec![]),
2896 note("/grand/", "Grand", "grand", vec![]),
2897 ];
2898 let built = build_genealogy(¬es);
2899 assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2900 }
2901
2902 #[test]
2903 fn cycle_detection_ignores_symmetric_only_cycle() {
2904 let notes = vec![
2907 note(
2908 "/a/",
2909 "A",
2910 "a",
2911 vec![rel_to("spouse", "[[B]]"), rel_to("sibling", "[[C]]")],
2912 ),
2913 note("/b/", "B", "b", vec![rel_to("spouse", "[[A]]")]),
2914 note("/c/", "C", "c", vec![rel_to("sibling", "[[A]]")]),
2915 ];
2916 let built = build_genealogy(¬es);
2917 assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2918 }
2919
2920 #[test]
2921 fn cycle_detection_ignores_self_inverse_type() {
2922 let reg = RelationTypeRegistry::from_types(&[inverse_type("rival", "rival")]);
2927 assert!(reg.is_symmetric("rival"), "coercion is what prevents this");
2928
2929 let notes = vec![
2930 note("/a/", "A", "a", vec![rel_to("rival", "[[B]]")]),
2931 note("/b/", "B", "b", vec![]),
2932 ];
2933 let built = build_relationships(¬es, ®, &["md".to_string()]);
2934 assert_eq!(built.by_note.get("/a/").unwrap().len(), 1);
2936 assert_eq!(built.by_note.get("/b/").unwrap().len(), 1);
2937 assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2939 }
2940
2941 #[test]
2942 fn cycle_detection_ignores_mutually_declared_self_inverse_type() {
2943 let reg = RelationTypeRegistry::from_types(&[inverse_type("sponsor", "sponsor")]);
2947 let notes = vec![
2948 note(
2949 "/people/one/",
2950 "One",
2951 "one",
2952 vec![rel_to("sponsor", "[[Two]]")],
2953 ),
2954 note(
2955 "/people/two/",
2956 "Two",
2957 "two",
2958 vec![rel_to("sponsor", "[[One]]")],
2959 ),
2960 ];
2961 let built = build_relationships(¬es, ®, &["md".to_string()]);
2962 assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2963 assert_eq!(built.by_note.get("/people/one/").unwrap().len(), 1);
2966 assert_eq!(built.by_note.get("/people/two/").unwrap().len(), 1);
2967 }
2968
2969 #[test]
2970 fn cycle_detection_ignores_unknown_directed_type() {
2971 let notes = vec![
2974 note("/a/", "A", "a", vec![rel_to("mentor", "[[B]]")]),
2975 note("/b/", "B", "b", vec![rel_to("mentor", "[[A]]")]),
2976 ];
2977 let built = build_genealogy(¬es);
2978 assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2979 }
2980
2981 #[test]
2982 fn cycle_detection_reports_one_cycle_per_component() {
2983 let notes = vec![
2986 note(
2987 "/a/",
2988 "A",
2989 "a",
2990 vec![rel_to("child", "[[B]]"), rel_to("child", "[[C]]")],
2991 ),
2992 note("/b/", "B", "b", vec![rel_to("child", "[[A]]")]),
2993 note("/c/", "C", "c", vec![rel_to("child", "[[A]]")]),
2994 ];
2995 let built = build_genealogy(¬es);
2996 assert_eq!(built.cycles.len(), 1, "{:?}", built.cycles);
2997 assert_eq!(built.cycles[0].members.len(), 2, "{:?}", built.cycles);
2999 }
3000
3001 #[test]
3002 fn cycle_detection_is_deterministic() {
3003 let notes = vec![
3006 note("/a/", "A", "a", vec![rel_to("parent", "[[B]]")]),
3007 note("/b/", "B", "b", vec![rel_to("parent", "[[C]]")]),
3008 note("/c/", "C", "c", vec![rel_to("parent", "[[A]]")]),
3009 note("/x/", "X", "x", vec![rel_to("parent", "[[Y]]")]),
3010 note("/y/", "Y", "y", vec![rel_to("parent", "[[X]]")]),
3011 ];
3012 let first = build_genealogy(¬es);
3013 for _ in 0..5 {
3014 let again = build_genealogy(¬es);
3015 assert_eq!(first.cycles, again.cycles);
3016 }
3017 assert_eq!(first.cycles.len(), 2, "{:?}", first.cycles);
3018 }
3019
3020 #[test]
3021 fn cycle_detection_ignores_unresolved_endpoints() {
3022 let notes = vec![note("/a/", "A", "a", vec![rel_to("parent", "[[Nobody]]")])];
3024 let built = build_genealogy(¬es);
3025 assert!(built.cycles.is_empty(), "{:?}", built.cycles);
3026 }
3027
3028 #[test]
3029 fn cycle_detection_separates_distinct_relation_types() {
3030 let mut types = genealogy_types();
3032 types.push(inverse_type("employer", "employee"));
3033 let reg = RelationTypeRegistry::from_types(&types);
3034 let notes = vec![
3035 note(
3036 "/a/",
3037 "A",
3038 "a",
3039 vec![rel_to("child", "[[B]]"), rel_to("employer", "[[B]]")],
3040 ),
3041 note(
3042 "/b/",
3043 "B",
3044 "b",
3045 vec![rel_to("child", "[[A]]"), rel_to("employer", "[[A]]")],
3046 ),
3047 ];
3048 let built = build_relationships(¬es, ®, &["md".to_string()]);
3049 let mut kinds: Vec<&str> = built
3050 .cycles
3051 .iter()
3052 .map(|cycle| cycle.rel_type.as_str())
3053 .collect();
3054 kinds.sort_unstable();
3055 assert_eq!(kinds, vec!["child", "employee"], "{:?}", built.cycles);
3056 }
3057
3058 #[test]
3061 fn ambiguous_endpoint_is_recorded_against_the_declaring_note() {
3062 let notes = vec![
3064 note("/people/john-jr/", "John Doe", "john-jr", vec![]),
3065 note("/people/john-sr/", "John Doe", "john-sr", vec![]),
3066 note("/z/", "Zeb", "zeb", vec![rel_to("parent", "[[John Doe]]")]),
3067 ];
3068 let built = build_genealogy(¬es);
3069
3070 let found = built
3071 .ambiguous_by_note
3072 .get("/z/")
3073 .expect("declaring note should carry the finding");
3074 assert_eq!(found.len(), 1);
3075 assert_eq!(found[0].raw, "[[John Doe]]");
3076 assert_eq!(found[0].resolved_to, "/people/john-jr/");
3077 assert_eq!(found[0].candidates, vec!["/people/john-sr/".to_string()]);
3078
3079 assert_eq!(
3081 built.by_note.get("/z/").unwrap()[0].neighbor,
3082 "/people/john-jr/"
3083 );
3084 assert!(!built.ambiguous_by_note.contains_key("/people/john-sr/"));
3087 assert!(!built.ambiguous_by_note.contains_key("/people/john-jr/"));
3088 }
3089
3090 #[test]
3091 fn unambiguous_endpoint_records_nothing() {
3092 let notes = vec![
3093 note("/people/mary/", "Mary Doe", "mary", vec![]),
3094 note("/z/", "Zeb", "zeb", vec![rel_to("spouse", "[[Mary Doe]]")]),
3095 ];
3096 let built = build_genealogy(¬es);
3097 assert!(
3098 built.ambiguous_by_note.is_empty(),
3099 "{:?}",
3100 built.ambiguous_by_note
3101 );
3102 }
3103
3104 #[test]
3105 fn note_answering_to_a_name_twice_is_not_ambiguous_with_itself() {
3106 let notes = vec![
3109 note("/a/", "alpha", "alpha", vec![]),
3110 note("/z/", "Zeb", "zeb", vec![rel_to("parent", "[[alpha]]")]),
3111 ];
3112 let built = build_genealogy(¬es);
3113 assert!(
3114 built.ambiguous_by_note.is_empty(),
3115 "{:?}",
3116 built.ambiguous_by_note
3117 );
3118 }
3119
3120 #[test]
3121 fn path_endpoint_is_never_reported_as_ambiguous() {
3122 let notes = vec![
3125 note("/other/mary/", "Mary", "mary-other", vec![]),
3126 note(
3127 "/people/john/",
3128 "John",
3129 "john",
3130 vec![rel_to("parent", "mary.md")],
3131 ),
3132 note("/people/mary/", "Mary", "mary", vec![]),
3133 ];
3134 let built = build_genealogy(¬es);
3135 assert_eq!(
3136 built.by_note.get("/people/john/").unwrap()[0].neighbor,
3137 "/people/mary/"
3138 );
3139 assert!(
3140 built.ambiguous_by_note.is_empty(),
3141 "{:?}",
3142 built.ambiguous_by_note
3143 );
3144 }
3145
3146 #[test]
3147 fn ambiguous_endpoint_via_alias_is_reported() {
3148 let mut alias_holder = note("/people/b-mary/", "Mary Smith", "b-mary", vec![]);
3150 alias_holder.aliases = vec!["Mary Doe".into()];
3151 let notes = vec![
3152 note("/people/a-mary/", "Mary Doe", "a-mary", vec![]),
3153 alias_holder,
3154 note("/z/", "Zeb", "zeb", vec![rel_to("spouse", "[[Mary Doe]]")]),
3155 ];
3156 let built = build_genealogy(¬es);
3157 let found = built.ambiguous_by_note.get("/z/").expect("finding");
3158 assert_eq!(found[0].resolved_to, "/people/a-mary/");
3159 assert_eq!(found[0].candidates, vec!["/people/b-mary/".to_string()]);
3160 }
3161
3162 #[test]
3163 fn index_note_stems_are_not_reported_as_ambiguous() {
3164 let mut docs = note("/docs/", "Docs", "index", vec![]);
3168 docs.is_index = true;
3169 let mut guide = note("/guide/", "Guide", "index", vec![]);
3170 guide.is_index = true;
3171 let notes = vec![
3172 docs,
3173 guide,
3174 note("/z/", "Zeb", "zeb", vec![rel_to("parent", "[[index]]")]),
3175 ];
3176 let built = build_genealogy(¬es);
3177 assert_eq!(built.by_note.get("/z/").unwrap()[0].neighbor, "/docs/");
3179 assert!(
3181 built.ambiguous_by_note.is_empty(),
3182 "{:?}",
3183 built.ambiguous_by_note
3184 );
3185 }
3186
3187 #[test]
3188 fn ambiguous_name_warnings_are_capped_with_a_summary() {
3189 let ambiguous_count = AMBIGUOUS_WARN_CAP + 5;
3192 let mut notes = Vec::new();
3193 for i in 0..ambiguous_count {
3194 notes.push(note(
3196 &format!("/a{i:02}/"),
3197 &format!("Dup {i:02}"),
3198 &format!("a{i:02}"),
3199 vec![],
3200 ));
3201 notes.push(note(
3202 &format!("/b{i:02}/"),
3203 &format!("Dup {i:02}"),
3204 &format!("b{i:02}"),
3205 vec![],
3206 ));
3207 notes.push(note(
3208 &format!("/ref{i:02}/"),
3209 &format!("Ref {i:02}"),
3210 &format!("ref{i:02}"),
3211 vec![rel_to("parent", &format!("[[Dup {i:02}]]"))],
3212 ));
3213 }
3214
3215 let (built, logs) = crate::test_support::capture_tracing(|| build_genealogy(¬es));
3216
3217 assert_eq!(built.ambiguous_by_note.len(), ambiguous_count);
3219
3220 let individual = logs
3221 .matches("ambiguous relationship endpoint name `")
3222 .count();
3223 assert_eq!(individual, AMBIGUOUS_WARN_CAP, "{logs}");
3224 assert!(
3225 logs.contains("and 5 more ambiguous relationship endpoint names"),
3226 "{logs}"
3227 );
3228 }
3229
3230 #[test]
3231 fn cycle_warning_names_every_member_and_the_relation_type() {
3232 let notes = vec![
3233 note("/a/", "A", "a", vec![rel_to("parent", "[[B]]")]),
3234 note("/b/", "B", "b", vec![rel_to("parent", "[[A]]")]),
3235 ];
3236 let (_built, logs) = crate::test_support::capture_tracing(|| build_genealogy(¬es));
3237 assert!(logs.contains("relationship cycle over `child`"), "{logs}");
3238 assert!(logs.contains("/a/"), "{logs}");
3239 assert!(logs.contains("/b/"), "{logs}");
3240 }
3241
3242 #[test]
3245 fn index_exposes_cycles_per_member_note() {
3246 let index = RelationshipIndex::from_relation_types(&genealogy_types());
3247 index.rebuild(
3248 &[
3249 note("/a/", "A", "a", vec![rel_to("parent", "[[B]]")]),
3250 note("/b/", "B", "b", vec![rel_to("parent", "[[A]]")]),
3251 note("/c/", "C", "c", vec![rel_to("parent", "[[A]]")]),
3252 ],
3253 &["md".to_string()],
3254 );
3255
3256 assert!(index.is_in_cycle("/a/"));
3257 assert!(index.is_in_cycle("/b/"));
3258 assert!(!index.is_in_cycle("/c/"));
3260 assert_eq!(index.cycles_for("/a/").len(), 1);
3261 assert_eq!(index.cycles_for("/a/")[0].rel_type, "child");
3262 assert!(index.cycles_for("/c/").is_empty());
3263 assert_eq!(index.cycles_for("a/").len(), 1);
3265
3266 index.rebuild(
3268 &[
3269 note("/a/", "A", "a", vec![rel_to("parent", "[[B]]")]),
3270 note("/b/", "B", "b", vec![]),
3271 ],
3272 &["md".to_string()],
3273 );
3274 assert!(!index.is_in_cycle("/a/"));
3275 assert!(!index.is_in_cycle("/b/"));
3276 }
3277
3278 #[test]
3279 fn index_exposes_ambiguous_endpoints_per_declaring_note() {
3280 let index = RelationshipIndex::from_relation_types(&genealogy_types());
3281 index.rebuild(
3282 &[
3283 note("/people/john-jr/", "John Doe", "john-jr", vec![]),
3284 note("/people/john-sr/", "John Doe", "john-sr", vec![]),
3285 note("/z/", "Zeb", "zeb", vec![rel_to("parent", "[[John Doe]]")]),
3286 ],
3287 &["md".to_string()],
3288 );
3289
3290 let found = index.ambiguous_endpoints_for("/z/");
3291 assert_eq!(found.len(), 1);
3292 assert_eq!(found[0].resolved_to, "/people/john-jr/");
3293 assert_eq!(found[0].candidates, vec!["/people/john-sr/".to_string()]);
3294 assert!(index.ambiguous_endpoints_for("/people/john-sr/").is_empty());
3295
3296 index.clear();
3297 assert!(index.ambiguous_endpoints_for("/z/").is_empty());
3298 assert!(!index.is_in_cycle("/z/"));
3299 }
3300
3301 #[test]
3302 fn index_rebuild_and_get() {
3303 let index = RelationshipIndex::from_relation_types(&genealogy_types());
3304 assert!(index.is_empty());
3305 let notes = vec![
3306 note(
3307 "/john/",
3308 "John",
3309 "john",
3310 vec![RawRelationship {
3311 implicit: false,
3312 rel_type: "spouse".into(),
3313 to: Some("[[Mary]]".into()),
3314 from: None,
3315 label: None,
3316 attributes: BTreeMap::new(),
3317 }],
3318 ),
3319 note("/mary/", "Mary", "mary", vec![]),
3320 ];
3321 index.rebuild(¬es, &["md".to_string()]);
3322 assert_eq!(index.get("/john/").len(), 1);
3323 assert_eq!(index.get("/mary/").len(), 1);
3324 index.rebuild(&[], &["md".to_string()]);
3326 assert!(index.is_empty());
3327 }
3328}
3329
3330#[cfg(test)]
3331mod proptests {
3332 use super::*;
3333 use proptest::prelude::*;
3334
3335 #[derive(Debug, Clone)]
3337 struct GenEdge {
3338 declarer: usize,
3339 target: usize,
3340 symmetric: bool,
3341 }
3342
3343 fn edges_strategy(n: usize) -> impl Strategy<Value = Vec<GenEdge>> {
3344 prop::collection::vec(
3345 (0..n, 0..n, any::<bool>()).prop_map(|(declarer, target, symmetric)| GenEdge {
3346 declarer,
3347 target,
3348 symmetric,
3349 }),
3350 0..12,
3351 )
3352 }
3353
3354 fn url(i: usize) -> String {
3355 format!("/p{i}/")
3356 }
3357
3358 proptest! {
3359 #[test]
3363 fn inverse_and_symmetric_closure(edges in edges_strategy(6)) {
3364 let n = 6;
3365 let reg = RelationTypeRegistry::from_types(&crate::config::default_relationship_types());
3366
3367 let mut per_note: Vec<Vec<RawRelationship>> = vec![Vec::new(); n];
3369 for e in &edges {
3370 if e.declarer == e.target {
3371 continue;
3372 }
3373 let rel_type = if e.symmetric { "spouse" } else { "parent" };
3374 per_note[e.declarer].push(RawRelationship { implicit: false,
3375 rel_type: rel_type.to_string(),
3376 to: Some(format!("[[P{}]]", e.target)),
3377 from: None,
3378 label: None,
3379 attributes: BTreeMap::new(),
3380 });
3381 }
3382
3383 let notes: Vec<NoteRelInput> = (0..n)
3384 .map(|i| NoteRelInput {
3385 url: url(i),
3386 title: format!("P{i}"),
3387 stem: format!("p{i}"),
3388 aliases: Vec::new(),
3389 is_index: false,
3390 relationships: per_note[i].clone(),
3391 })
3392 .collect();
3393
3394 let map = build_relationship_map(¬es, ®, &["md".to_string()]);
3395
3396 for e in &edges {
3397 if e.declarer == e.target {
3398 continue;
3399 }
3400 let a = url(e.declarer);
3401 let b = url(e.target);
3402 let a_rels = map.get(&a).cloned().unwrap_or_default();
3403 let b_rels = map.get(&b).cloned().unwrap_or_default();
3404
3405 if e.symmetric {
3406 prop_assert!(a_rels
3408 .iter()
3409 .any(|r| r.predicate == "spouse" && r.neighbor == b));
3410 prop_assert!(b_rels
3411 .iter()
3412 .any(|r| r.predicate == "spouse" && r.neighbor == a));
3413 } else {
3414 prop_assert!(a_rels
3417 .iter()
3418 .any(|r| r.predicate == "parent" && r.neighbor == b));
3419 prop_assert!(b_rels
3420 .iter()
3421 .any(|r| r.predicate == "child" && r.neighbor == a));
3422 }
3423 }
3424 }
3425 }
3426}