Skip to main content

mbr_core/
relationships.rs

1//! Named, typed relationships between notes.
2//!
3//! Where [`crate::link_index`] tracks *untyped* content links (from markdown
4//! `[text](url)`), this module tracks **named, typed relationships** declared in
5//! YAML frontmatter, e.g. genealogy edges such as `parent`, `child`, `spouse`,
6//! and `sibling`. Each edge can carry its own attributes (marriage/divorce
7//! dates, place, notes...).
8//!
9//! # Model
10//!
11//! A relationship is a directed edge between two notes:
12//!
13//! ```text
14//! subject --rel_type--> object       (read: "object is subject's rel_type")
15//! ```
16//!
17//! - `from` names the **subject** (defaults to the current note when omitted).
18//! - `to` names the **object** (defaults to the current note when omitted).
19//! - `rel_type` labels the neighbour's role from the subject's viewpoint, so
20//!   `type: parent, to: [[Sam]]` reads "Sam is my parent" and puts Sam under
21//!   the current note's *Parents* group, while the reciprocal (derived) edge on
22//!   Sam's note puts the current note under Sam's *Children* group.
23//!
24//! A [`RelationTypeRegistry`] (config-driven) classifies each `rel_type` as
25//! `symmetric` (spouse, sibling) or one half of an `inverse` pair (parent ↔
26//! child), so an author declares each edge **once** and the reverse is derived
27//! automatically — analogous to mbr's bidirectional backlink derivation. Only
28//! one half of an inverse pair need be configured: the registry auto-registers
29//! the reciprocal (see [`RelationTypeRegistry::from_types`]).
30//!
31//! Unknown relation types are tolerated: they are tracked directed with no
32//! relabelling. Unresolved endpoints never fail the build — the raw string is
33//! kept for display and a warning is emitted.
34
35use 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
46/// Reserved edge keys that are not treated as free-form attributes.
47const RESERVED_EDGE_KEYS: &[&str] = &["type", "to", "from", "label"];
48
49/// The direction of a relationship edge from a note's viewpoint.
50///
51/// `Outgoing` means the note is the **subject** of the canonical edge;
52/// `Incoming` means it is the **object**. For symmetric relations the direction
53/// is stable but not semantically meaningful (both endpoints see the same
54/// predicate).
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum Direction {
58    Outgoing,
59    Incoming,
60}
61
62/// A relationship exactly as declared in a note's frontmatter, with endpoints
63/// left unresolved.
64///
65/// Parsed by [`parse_relationships`] straight from the raw YAML — this is the
66/// dedicated typed path that avoids the lossy generic frontmatter converter
67/// (which drops non-string array elements).
68/// Serializable so a repository snapshot (`crate::repo::RepoSnapshot`) can
69/// carry it; nothing serializes it to a client.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct RawRelationship {
72    /// The relation predicate (e.g. "parent", "spouse"). Required.
73    pub rel_type: String,
74    /// The `to` endpoint (object), if present. Omitted => current note.
75    pub to: Option<String>,
76    /// The `from` endpoint (subject), if present. Omitted => current note.
77    pub from: Option<String>,
78    /// Optional explicit label overriding the registry label.
79    pub label: Option<String>,
80    /// Free-form edge attributes (everything except the reserved keys),
81    /// preserved verbatim as JSON. Order-stable via `BTreeMap`.
82    pub attributes: BTreeMap<String, serde_json::Value>,
83    /// True when the edge was *implied* by another frontmatter field rather
84    /// than written in `relationships:` — today only a person's
85    /// `company: "[[Acme]]"`, which implies `employer → Acme`.
86    ///
87    /// An implicit edge does not make its note a declarer, so it surfaces as
88    /// `derived: true` on **both** ends, and it is dropped when its type is not
89    /// in the registry (a repository whose `relationship_types` has no
90    /// `employer` asked for no such edge). An explicit declaration of the same
91    /// edge wins and makes it a normal, declared one.
92    pub implicit: bool,
93}
94
95/// A relationship after endpoint resolution and viewpoint framing, ready for
96/// JSON exposure and rendering in the info panel.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct ResolvedRelationship {
99    /// The base relation type as declared (e.g. "parent").
100    pub rel_type: String,
101    /// The viewpoint-relative predicate used for grouping/labelling (e.g.
102    /// "child" when this note is the target of a declared `parent` edge).
103    pub predicate: String,
104    /// The resolved neighbour URL path (empty string when unresolved).
105    pub neighbor: String,
106    /// The neighbour's display title (or the raw endpoint text when unresolved).
107    pub neighbor_title: String,
108    /// The original raw endpoint string (e.g. "[[Mary Doe]]").
109    pub neighbor_raw: String,
110    /// Whether the neighbour endpoint resolved to a known note.
111    pub resolved: bool,
112    /// Direction of the edge from this note's viewpoint.
113    pub direction: Direction,
114    /// Optional explicit edge label.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub label: Option<String>,
117    /// Free-form edge attributes, preserved verbatim.
118    pub attributes: BTreeMap<String, serde_json::Value>,
119    /// True when this entry was *not* declared on the note it is attached to
120    /// (i.e. produced by inverse/symmetric derivation or declared by a third
121    /// note about two other notes).
122    pub derived: bool,
123    /// True when the note this entry is attached to *implied* the edge through
124    /// another frontmatter field — today only a person's `company: "[[X]]"`
125    /// (see [`RawRelationship::implicit`]).
126    ///
127    /// Survives deduplication: when the organization's own `employee`
128    /// declaration (or the person's explicit `employer`) is aggregated first,
129    /// the edge keeps *that* declaration's endpoint text, so the raw string can
130    /// no longer say which entry the `company` wikilink resolved to. This flag
131    /// can, which is how [`crate::contact::Contact::resolve_company`] links the
132    /// card to the very note the name index chose.
133    ///
134    /// In-memory only: not part of the `site.json` wire format.
135    #[serde(skip)]
136    pub implied: bool,
137}
138
139/// A cycle detected over the **hierarchical** relationship edges.
140///
141/// Only inverse-pair types (`parent` ↔ `child`, `employer` ↔ `employee`)
142/// participate: they describe a hierarchy, so a cycle is data that cannot be
143/// true — and it is what makes `family-chart`'s `d3.hierarchy()` allocate
144/// forever on a person page. Symmetric types (`spouse`, `sibling`) and plain
145/// directed types cycle legitimately and are ignored.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct RelationshipCycle {
148    /// Note URLs forming the cycle, in traversal order: each entry is the
149    /// [`Self::rel_type`] of the entry before it, and the last closes back onto
150    /// the first.
151    pub members: Vec<String>,
152    /// The canonical relation type whose edges close the cycle (e.g. "child").
153    pub rel_type: String,
154}
155
156/// A relationship endpoint that resolved through a name owned by more than one
157/// note.
158///
159/// Resolution itself is unchanged (first-wins, lexicographically-smallest URL);
160/// this only records that the choice was arbitrary so the author can be told.
161/// In a genealogy repository a John Doe Sr/Jr pair makes this the likeliest way
162/// to accidentally close a [`RelationshipCycle`].
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct AmbiguousEndpoint {
165    /// The endpoint exactly as authored, e.g. "[[John Doe]]".
166    pub raw: String,
167    /// The note URL mbr resolved it to.
168    pub resolved_to: String,
169    /// The other notes sharing that name.
170    pub candidates: Vec<String>,
171}
172
173/// Maximum number of individual ambiguous-name warnings logged per index build.
174///
175/// The user repository that motivated this is 585 genealogy notes where
176/// namesakes are the norm, so the ambiguous-name count can run to dozens. One
177/// log line each would scroll every other startup warning off the screen.
178const AMBIGUOUS_WARN_CAP: usize = 20;
179
180/// One ambiguous name, ready for logging.
181pub(crate) struct AmbiguousNameReport<'a> {
182    /// The (normalised) name several notes answer to.
183    pub name: &'a str,
184    /// The note URL that wins resolution.
185    pub winner: &'a str,
186    /// The other note URLs sharing the name.
187    pub others: Vec<&'a str>,
188}
189
190/// Logs ambiguous-name warnings, capped at [`AMBIGUOUS_WARN_CAP`] individual
191/// lines plus one summary line for the remainder.
192///
193/// `kind` names the thing that was ambiguous ("relationship endpoint") and is
194/// interpolated into the message. The per-page `errors.json` payload is
195/// deliberately *not* capped — a reader only ever sees their own page's
196/// problems, so there is nothing to bury there.
197///
198/// # Only authored endpoints belong here
199///
200/// [`crate::wikilink_index`] used to call this too, from `rebuild`, and it was
201/// wrong twice over: `rebuild` sees notes rather than links, so it warned about
202/// every colliding name in the repository whether or not anything referenced
203/// one — repositories containing no `[[wikilinks]]` at all got flooded — and it
204/// named the lexicographically smallest URL as the winner, while
205/// [`WikilinkIndex::resolve_wikilink`] is current-folder-first. Wikilink
206/// ambiguity is a property of a *link site*, not of the repository, so it is
207/// reported per page through `ambiguous_wikilink` in `errors.json`, which is
208/// the only thing that knows the linking page's folder. Do not wire this back
209/// up to an index build.
210///
211/// A relationship endpoint is different, and may legitimately warn from a
212/// build: it is *authored* in frontmatter, so a collision is always a real
213/// reference to something.
214///
215/// [`WikilinkIndex::resolve_wikilink`]: crate::wikilink_index::WikilinkIndex::resolve_wikilink
216pub(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/// Classifies relation types (symmetric / inverse pair / directed) and supplies
237/// display labels. Built from the configured [`RelationType`]s.
238#[derive(Debug, Clone)]
239pub struct RelationTypeRegistry {
240    /// Keyed by lowercased relation-type name.
241    by_name: BTreeMap<String, RelationType>,
242}
243
244impl RelationTypeRegistry {
245    /// Builds a registry from the configured relation types.
246    ///
247    /// Configured types are taken verbatim and **always win**. For every
248    /// non-symmetric type that names an `inverse`, the reciprocal half of the
249    /// pair is auto-registered (with the inverse pointing back) when it is not
250    /// itself configured — so declaring only
251    /// `{ name = "employer", inverse = "employee" }` still relabels *both* sides
252    /// of an edge and still collapses reciprocal declarations into one edge.
253    ///
254    /// When the reciprocal *is* configured but disagrees (`employer` claims
255    /// `employee`, while `employee` claims `staff`), both are kept exactly as
256    /// configured and a warning naming both is logged. A type that is at once
257    /// `symmetric` and `inverse` keeps its symmetric meaning (matching
258    /// [`Self::predicate_object`]) and logs a warning.
259    ///
260    /// A type naming *itself* as its `inverse` is self-contradictory and is
261    /// coerced to symmetric — see [`coerce_self_inverse`].
262    pub fn from_types(types: &[RelationType]) -> Self {
263        // Repair self-contradictory declarations before anything reads them, so
264        // every consumer sees one coherent definition: this registry,
265        // `canonical_key`, cycle detection, and — via the `symmetric` flag in
266        // `to_json` — the frontend's own relationship classifier.
267        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    /// Looks up a relation type by (case-insensitive) name.
278    pub fn get(&self, name: &str) -> Option<&RelationType> {
279        self.by_name.get(&name.to_lowercase())
280    }
281
282    /// Returns true if the named relation type is symmetric (e.g. spouse).
283    pub fn is_symmetric(&self, name: &str) -> bool {
284        self.get(name).is_some_and(|t| t.symmetric)
285    }
286
287    /// Returns the inverse relation-type name for the given type, if any
288    /// (e.g. `parent` -> `child`).
289    ///
290    /// The name is trimmed, and a blank `inverse` reads as no inverse at all —
291    /// matching the auto-registration in [`Self::from_types`], which also
292    /// ignores it. Without that filter `predicate_object` would relabel the
293    /// reverse edge to the empty string.
294    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    /// Returns the canonical (configured) casing for a relation-type name,
303    /// falling back to the input trimmed when the type is unknown.
304    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    /// The predicate shown on the **subject** side of an edge (the note that
311    /// asserts "neighbour is my rel_type"): the declared type, unchanged.
312    fn predicate_subject(&self, rel_type: &str) -> String {
313        self.canonical_name(rel_type)
314    }
315
316    /// The predicate shown on the **object** side of an edge (the derived
317    /// counterpart): the inverse for an inverse pair, the same name for a
318    /// symmetric or unknown/directed type.
319    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    /// Serialises the registry for `site.json` as an array of type descriptors
331    /// with resolved (concrete) labels for the frontend.
332    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                // Omitted rather than `null` when unset: the frontend's
345                // fallbacks key on the field being absent (contract 0.3/0.4).
346                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
361/// Repairs a type that names *itself* as its `inverse`, returning the symmetric
362/// type its author meant. Any other type is returned unchanged.
363///
364/// `inverse` names the **other** half of a pair, so a self-inverse declaration is
365/// self-contradictory. Both endpoints of such an edge derive the *same*
366/// predicate, which is exactly what `symmetric = true` means — so "mutual" is
367/// almost certainly the intent.
368///
369/// Coercing (rather than tolerating) matters because a nominal inverse pair whose
370/// two halves are indistinguishable looks like a **2-cycle** to anything that
371/// orients hierarchical edges by predicate. That includes the frontend's
372/// `classifyRelationship`, which tests `symmetric` before `inverse`: left
373/// uncoerced it would take the hierarchical branch, emit two dedup keys for one
374/// logical edge, drop half of every such relationship, and tell the reader their
375/// notes each claim to be the other's ancestor — blaming their data for a config
376/// mistake. Fixing it here propagates automatically, because `symmetric` is
377/// serialised into `relationship_types` in `site.json`.
378fn 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
401/// Builds the reciprocal half of an inverse pair, ready for auto-registration.
402///
403/// Returns `None` (with a warning) when the declaration cannot yield a usable
404/// reciprocal: a blank `inverse`, or a type that is both `symmetric` and
405/// `inverse` — symmetric wins there, matching
406/// [`RelationTypeRegistry::predicate_object`] and [`canonical_key`].
407fn 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
435/// Makes the hierarchy/category metadata of every inverse pair coherent.
436///
437/// The two halves of a pair describe one edge from opposite ends, so their
438/// `hierarchy` must point opposite ways and their `category` must agree. This
439/// never fails startup (matching the rest of the registry validation): a half
440/// that leaves a field unset inherits it from its partner, and a contradiction
441/// (`parent` and `child` both `up`) is repaired in favour of the type declared
442/// **first** in the config, with a warning naming both. A symmetric type has no
443/// direction, so a `hierarchy` on one is dropped with a warning.
444///
445/// Only pairs whose halves name each other are touched; a pair that disagrees
446/// about its inverse was already reported by [`derive_reciprocal_types`].
447fn 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(&current.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
523/// Completes half-declared inverse pairs by auto-registering the reciprocal of
524/// every configured type that names an `inverse`.
525///
526/// Configured types are never overwritten; a reciprocal that already exists and
527/// disagrees is left alone and logged. See [`RelationTypeRegistry::from_types`].
528fn 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/// Input for building the relationship index: one entry per markdown note.
571#[derive(Debug, Clone)]
572pub struct NoteRelInput {
573    /// The note's site URL path (e.g. "/people/john/").
574    pub url: String,
575    /// The note's display title.
576    pub title: String,
577    /// The note's filename stem (without extension), for `[[stem]]` resolution.
578    pub stem: String,
579    /// Alternate names (e.g. maiden names) that also resolve to this note.
580    pub aliases: Vec<String>,
581    /// Whether the note is an index file (affects relative path resolution).
582    pub is_index: bool,
583    /// The raw relationships declared on the note.
584    pub relationships: Vec<RawRelationship>,
585}
586
587/// A thread-safe index of resolved relationships, keyed by note URL path.
588///
589/// Built once after a scan (when all titles are known) and rebuilt on file
590/// changes in server mode — modelled on [`crate::tag_index::TagIndex`].
591pub struct RelationshipIndex {
592    /// url_path -> resolved relationships (declared + derived), deduped.
593    by_note: ConcurrentHashMap<String, Vec<ResolvedRelationship>>,
594    /// The relation-type registry (types + labels).
595    registry: RelationTypeRegistry,
596    /// Member note url_path -> every hierarchical cycle it participates in.
597    ///
598    /// Keyed by *member* (not by declarer): a cycle is a property of the notes
599    /// caught in it, and those are the notes whose data has to change.
600    /// Recomputed by [`Self::rebuild`], so it always matches `by_note`.
601    cycles_by_note: ConcurrentHashMap<String, Vec<RelationshipCycle>>,
602    /// Declaring note url_path -> its own endpoints that resolved through a name
603    /// shared by several notes. Keyed by *declarer*, because that note's
604    /// frontmatter is what needs disambiguating.
605    ambiguous_by_note: ConcurrentHashMap<String, Vec<AmbiguousEndpoint>>,
606}
607
608impl RelationshipIndex {
609    /// Creates an empty index with the given registry.
610    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    /// Creates an empty index from the configured relation types.
620    pub fn from_relation_types(types: &[RelationType]) -> Self {
621        Self::new(RelationTypeRegistry::from_types(types))
622    }
623
624    /// Returns the relation-type registry.
625    pub fn registry(&self) -> &RelationTypeRegistry {
626        &self.registry
627    }
628
629    /// Normalises a note URL to the canonical index key: exactly one leading
630    /// slash, with the remainder (including any trailing slash) left untouched.
631    ///
632    /// Applied on both insert and lookup so callers may pass URLs with or
633    /// without a leading slash and still resolve to the same entry.
634    fn normalize_url_key(url: &str) -> String {
635        format!("/{}", url.trim_start_matches('/'))
636    }
637
638    /// Rebuilds the index from the given notes.
639    ///
640    /// Resolves endpoints, attributes each edge to both endpoints, applies the
641    /// registry for inverse/symmetric predicates and reverse materialisation,
642    /// and dedupes reciprocal declarations.
643    /// Also detects and stores the data problems the build found (hierarchical
644    /// cycles, ambiguous endpoint names) so they can be queried per note without
645    /// re-walking the graph on the request path.
646    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        // Invert cycles onto their members so a page lookup is a single hit.
656        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    /// Returns the resolved relationships for a note URL (empty when none).
679    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    /// Returns the hierarchical cycles this note is a member of (empty when it
688    /// is not caught in one).
689    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    /// Whether this note is a member of a hierarchical relationship cycle.
698    ///
699    /// Cheaper than [`Self::cycles_for`] (no clone) for callers that only need
700    /// to know whether to warn.
701    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    /// Returns the endpoints *this* note declared that resolved through a name
709    /// shared by several notes (empty when none).
710    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    /// Clears the index, including the detected data problems.
719    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    /// Returns the number of notes with at least one relationship.
726    pub fn len(&self) -> usize {
727        self.by_note.pin().len()
728    }
729
730    /// Returns true when no note has any relationship.
731    pub fn is_empty(&self) -> bool {
732        self.by_note.pin().is_empty()
733    }
734
735    /// Injects relationship data into a serialised `site.json` value.
736    ///
737    /// Adds a top-level `relationship_types` array and a per-note
738    /// `relationships` array to each entry in `markdown_files`.
739    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            // Pin the concurrent map once for the whole loop and look up via the
747            // shared guard, serialising each note's relationships by reference
748            // rather than re-pinning and cloning per entry.
749            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
770/// Parses the `relationships:` array-of-objects faithfully from the raw YAML.
771///
772/// This is the dedicated typed path: the generic frontmatter converter drops
773/// non-string array elements, so relationships must be read straight from the
774/// `yaml_rust2::Yaml` value. Malformed or empty entries are tolerated (skipped
775/// with a warning); the endpoint strings are kept unresolved.
776///
777/// Also yields the implicit `employer` edge a person's `company: "[[X]]"`
778/// stands for (see [`company_employer_relationship`]), so every consumer of the
779/// declared relationships sees it without a second pass over the YAML.
780pub 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
789/// The relation type a person's `company` wikilink implies.
790pub const COMPANY_RELATION_TYPE: &str = "employer";
791
792/// The implicit `employer → X` edge for a `type: person` note whose `company`
793/// is a wikilink.
794///
795/// Wikilink-only on purpose: plain text such as `company: Acme` stays plain
796/// text even when a note happens to be titled "Acme". Auto-resolving free text
797/// would mint edges the author never asked for — and a one-word company name is
798/// exactly the kind of string that collides with an unrelated note.
799fn 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
819/// Parses the explicit `relationships:` array. See [`parse_relationships`].
820fn 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
880/// Converts a `yaml_rust2::Yaml` value to a `serde_json::Value`, preserving
881/// structure (used for free-form edge attributes and the contact model).
882pub(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/// A resolved endpoint of a relationship edge.
909#[derive(Debug, Clone)]
910struct ResolvedEndpoint {
911    /// Site URL path when resolved, else empty.
912    url: String,
913    /// Display title (resolved title, or the raw name when unresolved).
914    title: String,
915    /// Original raw endpoint text.
916    raw: String,
917    /// Whether the endpoint resolved to a known note.
918    resolved: bool,
919}
920
921impl ResolvedEndpoint {
922    /// Builds a resolved endpoint pointing at a known note.
923    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    /// Builds an unresolved endpoint that keeps its raw text for display.
933    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    /// Identity used for dedup/matching: the URL when resolved, otherwise a
943    /// `~`-prefixed sentinel derived from the (normalised) display name so
944    /// distinct unresolved endpoints don't collide with real notes.
945    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
954/// Name index used to resolve `[[Name]]`/stem endpoints to note URLs.
955struct 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    /// Normalised name -> every note URL answering to it (sorted), for names
962    /// owned by **more than one** note. Unambiguous names are absent, so the
963    /// map is empty for the overwhelmingly common case and reporting costs one
964    /// miss per endpoint.
965    ///
966    /// Detection only: resolution still goes through `by_title` / `by_alias` /
967    /// `by_stem` above, so first-wins behaviour is untouched.
968    ambiguous: HashMap<String, Vec<String>>,
969}
970
971impl NameIndex {
972    /// Builds the name index from notes. Ambiguous names resolve
973    /// deterministically to the note with the lexicographically smallest URL
974    /// (notes are visited in sorted URL order and the first insertion wins).
975    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        // Every note answering to each name, deduped by URL so a note whose
982        // title and filename stem normalise alike is not "ambiguous" with
983        // itself. `notes` arrives sorted by URL, so each list is sorted too.
984        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(&note.title))
990                .or_insert_with(|| note.url.clone());
991            for alias in &note.aliases {
992                by_alias
993                    .entry(normalize_name(alias))
994                    .or_insert_with(|| note.url.clone());
995            }
996            by_stem
997                .entry(normalize_name(&note.stem))
998                .or_insert_with(|| note.url.clone());
999
1000            // An index note's stem is a filename artifact ("index"), not a name
1001            // anyone means, and every repository with several `index.md` files
1002            // has one per folder — counting them would report "index" as
1003            // ambiguous in almost every repo. The title still participates, and
1004            // resolution is untouched (`by_stem` above indexes every note).
1005            let stem_as_name = (!note.is_index).then_some(&note.stem);
1006            let names = std::iter::once(&note.title)
1007                .chain(&note.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(&note.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        // Name-shaped endpoints (wikilink or bare name) go through the name
1040        // index; path-shaped ones resolve through the URL space instead.
1041        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    /// The ambiguity record for an endpoint that resolved through a name owned
1059    /// by more than one note, or `None` when the name is unique, the endpoint
1060    /// did not resolve, or it was path-shaped (paths never consult the name
1061    /// index, so they are never ambiguous).
1062    ///
1063    /// Derives the looked-up name with the very same [`lookup_name`] that
1064    /// [`Self::resolve`] uses, so the two can never disagree about which name
1065    /// was consulted.
1066    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        // Resolution order: title -> alias -> filename stem.
1092        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
1111/// The name [`NameIndex::resolve`] looks up for an endpoint, or `None` when the
1112/// endpoint is path-shaped (relative/absolute file path), which resolves through
1113/// the URL space instead of the name index.
1114///
1115/// Handles `[[Name]]` and `[[Target|Alias]]` (the target before the pipe wins),
1116/// falling through to the bare string.
1117fn 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
1127/// An aggregated (deduped) logical edge.
1128struct AggregatedEdge {
1129    rel_type: String,
1130    subject: ResolvedEndpoint,
1131    object: ResolvedEndpoint,
1132    label: Option<String>,
1133    attributes: BTreeMap<String, serde_json::Value>,
1134    /// URLs of notes that declared this edge (for the `derived` flag).
1135    declarers: HashSet<String>,
1136    /// URLs of notes that implied this edge (for the `implied` flag).
1137    implied_by: HashSet<String>,
1138}
1139
1140/// Everything a relationship build produces: the per-note map plus the
1141/// data-quality findings mbr reports back to the author.
1142///
1143/// The findings are computed here rather than on the request path because a
1144/// rebuild already has the whole graph in hand and happens once per scan.
1145pub struct RelationshipBuild {
1146    /// url -> resolved relationships (declared + derived), deduped.
1147    pub by_note: BTreeMap<String, Vec<ResolvedRelationship>>,
1148    /// Hierarchical cycles, one entry per cycle.
1149    pub cycles: Vec<RelationshipCycle>,
1150    /// Declaring note url -> the endpoints it declared that resolved through a
1151    /// name shared by several notes.
1152    pub ambiguous_by_note: BTreeMap<String, Vec<AmbiguousEndpoint>>,
1153}
1154
1155/// Builds the full relationship map (url -> resolved relationships) from notes.
1156///
1157/// Modelled on the build-mode inbound-link inversion: each declared edge is
1158/// attributed to both endpoints, with the reverse materialised via the
1159/// registry. Reciprocal declarations are deduped by a canonical edge key.
1160///
1161/// Convenience wrapper over [`build_relationships`] for callers that only need
1162/// the map.
1163pub 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
1171/// Builds the relationship map *and* the data-quality findings, warning about
1172/// each finding once.
1173///
1174/// See [`build_relationship_map`] for how the map itself is derived. On top of
1175/// that this reports the two ways relationship data goes quietly wrong:
1176///
1177/// - **Hierarchical cycles** ([`detect_relationship_cycles`]) — impossible data
1178///   that hangs the genealogy chart.
1179/// - **Ambiguous endpoint names** — an endpoint that named a title/alias/stem
1180///   shared by several notes and was resolved arbitrarily.
1181///
1182/// Runs once per index rebuild (server scan / file change / static build), never
1183/// on the request path.
1184pub fn build_relationships(
1185    notes: &[NoteRelInput],
1186    registry: &RelationTypeRegistry,
1187    markdown_extensions: &[String],
1188) -> RelationshipBuild {
1189    // Sort notes by URL for deterministic ambiguous-name resolution and stable
1190    // canonical-edge orientation.
1191    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    // Ambiguity findings, collected twice over: per declaring note (for
1198    // `errors.json`) and per name (to warn once regardless of how many notes
1199    // referenced it). Both are `BTreeMap`s so the output order is stable.
1200    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 &note.relationships {
1205            let rel_type = rel.rel_type.trim();
1206            if rel_type.is_empty() {
1207                continue;
1208            }
1209            // An implied edge exists only if the repository knows its type —
1210            // see `RawRelationship::implicit`.
1211            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(), &note.title);
1224
1225            // Only *authored* endpoints can be ambiguous: an omitted `to`/`from`
1226            // means "this note", which is never a name lookup.
1227            let mut resolve_authored = |authored: &str| {
1228                let endpoint =
1229                    name_index.resolve(authored, &note.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                        &note.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            // Merge attributes (first declaration wins on key conflict).
1278            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    // Materialise per-endpoint entries. Both sides share the same shape,
1290    // differing only in the anchor/neighbour roles, the viewpoint predicate,
1291    // and the direction, so one closure handles both.
1292    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    // Deterministic ordering + defensive dedupe within each note.
1331    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            // `a` is the one dropped: keep its `implied` on the survivor.
1345            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
1373/// Records an ambiguous endpoint against the note that declared it *and* against
1374/// the per-name table used for the capped warning summary.
1375///
1376/// Per-note entries are deduped so a note declaring the same ambiguous endpoint
1377/// twice (e.g. as both a `parent` and a `spouse`) reports it once.
1378fn 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
1393/// Logs one warning per detected cycle, naming every member so the author knows
1394/// exactly which notes to open.
1395fn warn_relationship_cycles(cycles: &[RelationshipCycle]) {
1396    for cycle in cycles {
1397        // Repeat the first member at the end so the chain visibly closes.
1398        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
1417/// Detects cycles over the **hierarchical** (inverse-pair) edges of a built
1418/// relationship map.
1419///
1420/// Symmetric types (`spouse`, `sibling`) and plain directed/unknown types are
1421/// excluded: a spouse "cycle" is just a married couple, and an untyped edge
1422/// carries no hierarchy promise. Only inverse pairs describe an ordering that a
1423/// cycle can contradict.
1424///
1425/// Runs iterative Tarjan SCC per canonical relation type, then reduces each
1426/// component to one concrete simple cycle. O(V+E) in the hierarchical edge set,
1427/// with no recursion — a deep ancestor chain in a large repository must not be
1428/// able to blow the stack of a server worker thread.
1429///
1430/// Output is deterministic: edges are collected into `BTreeMap`/`BTreeSet`, so
1431/// node numbering, traversal order, and the reported cycle are stable across
1432/// runs.
1433pub 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            // A component is cyclic when it has several members, or when a lone
1442            // member points at itself. Self-edges should be impossible (the
1443            // build skips self-referential relationships) but cost nothing to
1444            // handle and would otherwise be reported as "no cycle".
1445            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
1465/// Extracts the hierarchical edge set of a built relationship map, grouped by
1466/// canonical relation type.
1467///
1468/// An edge `(subject, object)` reads "object is subject's `<canonical type>`" —
1469/// the same orientation [`canonical_key`] picks. Because both endpoints of one
1470/// logical edge carry a materialised entry, and their viewpoint predicates are
1471/// exact inverses, the two entries always yield the *identical* directed edge and
1472/// collapse in the `BTreeSet`.
1473fn 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            // The viewpoint predicate already names the neighbour's role, so it
1484            // is the right label to classify: for a declared `parent` edge the
1485            // object side sees `child` and vice versa.
1486            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            // `inverse != predicate` is guaranteed here: a self-inverse type is
1495            // coerced to symmetric by `coerce_self_inverse` and so was already
1496            // skipped above. That matters, because such a pair's two halves are
1497            // indistinguishable and would otherwise look like a 2-cycle.
1498            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
1512/// A directed graph over note URLs, interned to `usize` node ids so traversal
1513/// works on flat `Vec`s instead of hashing strings.
1514struct NodeGraph<'a> {
1515    /// Node id -> note URL.
1516    nodes: Vec<&'a str>,
1517    /// Node id -> outgoing neighbour ids.
1518    adjacency: Vec<Vec<usize>>,
1519}
1520
1521impl<'a> NodeGraph<'a> {
1522    /// Interns the endpoints of `edges` in iteration order. `edges` is a
1523    /// `BTreeSet`, so ids (and therefore every traversal below) are assigned
1524    /// deterministically.
1525    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
1544/// Iterative Tarjan strongly-connected components.
1545///
1546/// Iterative on purpose: this runs on the server's index-rebuild path, and a
1547/// recursive version's stack depth is bounded by the longest ancestor chain in
1548/// the repository, which is user data.
1549fn 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    // (node, index of the next outgoing edge to visit) — the explicit call stack.
1557    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            // Every edge explored: `node` is a component root when nothing
1592            // below it reached an earlier node.
1593            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
1614/// Reduces a strongly-connected component to one concrete simple cycle.
1615///
1616/// Reporting the whole component would hand a genealogy author a list of every
1617/// note tangled in the knot; `A -> B -> C -> A` is what they can actually go and
1618/// fix. Iterative depth-first walk restricted to the component, started from its
1619/// smallest node id so the result is deterministic.
1620///
1621/// A strongly-connected component of two or more nodes always contains a cycle,
1622/// so the walk is guaranteed to find a back edge; the component itself is
1623/// returned as a defensive fallback rather than reporting nothing.
1624fn 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    // node -> its position in `path` (the "grey" set of classic cycle detection).
1632    let mut position_on_path: HashMap<usize, usize> = HashMap::from([(start, 0)]);
1633    // Next outgoing edge to try, one entry per `path` entry.
1634    let mut cursors: Vec<usize> = vec![0];
1635    // Nodes already expanded; skipping them keeps the walk O(V+E).
1636    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
1669/// Computes a canonical dedup key for an edge so reciprocal declarations
1670/// collapse to a single logical edge.
1671///
1672/// - Symmetric: endpoints sorted (unordered pair).
1673/// - Inverse pair: normalised to the lexicographically smaller member, swapping
1674///   endpoints when needed.
1675/// - Directed/unknown: `(rel_type, subject, object)` as-is.
1676fn 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        // The canonical member is the lexicographically smaller of the pair;
1697        // when `rel_type` is the *inverse* of it, swap the endpoints so both
1698        // declarations of the pair collapse to one key.
1699        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
1712/// Normalises a name for case-insensitive matching: trimmed and lowercased.
1713///
1714/// Shared with [`crate::wikilink_index`] and the server's rename handling so
1715/// body-wikilink resolution and link rewriting use the exact same
1716/// title/alias/stem matching as typed relationships.
1717pub 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
1737/// Converts a path endpoint to a site URL, stripping a markdown extension and
1738/// resolving relative paths against the source note's URL.
1739fn path_to_url(
1740    path: &str,
1741    source_url: &str,
1742    source_is_index: bool,
1743    markdown_extensions: &[String],
1744) -> String {
1745    // Drop any anchor fragment.
1746    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
1751/// Turns `a/b.md` into `a/b/` (trailing-slash URL convention); leaves paths
1752/// without a markdown extension unchanged.
1753fn 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    /// A relation type with only `name` + `inverse` set, as a user would write
1772    /// `{ name = "employer", inverse = "employee" }` in `.mbr/config.toml`.
1773    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    /// A symmetric relation type with only `name` + `symmetric` set.
1786    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    /// The half-declared registry from the review: only the `employer` side of
1799    /// the pair is configured, plus an unrelated symmetric type.
1800    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    // ----- parse_relationships -----
1838
1839    #[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        // Only the well-formed spouse entry survives.
1907        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    // ----- registry -----
1924
1925    #[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    // ----- hierarchy / category -----
1941
1942    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        // Both halves claim `up`: impossible. The first declared keeps its
1973        // value and the partner is flipped, without failing startup.
1974        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    // ----- company → employer -----
2025
2026    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(&notes, &reg, &["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(&notes, &reg, &["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(&notes, &reg, &["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(&notes, &reg, &["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    /// The URL the contact card links `company` to, after a full build.
2097    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, &reg, &["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    /// Regression: an organization that declares `employee` and sorts before
2110    /// the person owns the aggregated edge, so the person's entry is stored as
2111    /// `employee`/incoming with the org's title as raw text. The card must
2112    /// still link.
2113    #[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(&notes, &reg, &["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(&notes, "/people/jane/", fm).as_deref(),
2141            Some("/people/acme/")
2142        );
2143    }
2144
2145    /// Regression: the person's own explicit `employer` (different casing,
2146    /// an alias) is parsed before the implied edge and wins the raw text.
2147    #[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(&notes, "/people/jane/", fm).as_deref(),
2163            Some("/orgs/acme-corp/")
2164        );
2165    }
2166
2167    /// With several employers, the card links the one `company` names — not
2168    /// whichever `employer` edge happens to sort first.
2169    #[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(&notes, "/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(&notes, &reg, &["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        // Regression: only the `employer` half of the pair is configured, so
2220        // `employee` used to be unknown and `predicate_object("employee")` fell
2221        // back to "employee" — labelling the employer's page "Employees".
2222        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        // The symmetric type is untouched by the derivation.
2229        assert!(reg.is_symmetric("spouse"));
2230        assert_eq!(reg.predicate_object("spouse"), "spouse");
2231        // Derived types are exposed to the frontend with auto-derived labels.
2232        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        // `employer` claims `employee` as its inverse, but `employee` claims
2245        // `staff`. The explicit declarations win (and a warning is logged);
2246        // `staff` is auto-registered because nothing declares it.
2247        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        // `symmetric` and `inverse` are mutually exclusive; symmetric wins, so
2260        // no reciprocal is derived from the stray `inverse`.
2261        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        // A blank `inverse` registers nothing and leaves the type directed.
2276        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        // `inverse` names the *other* half of a pair, so naming yourself is
2283        // self-contradictory. Treat it as the symmetric type it describes.
2284        let reg = RelationTypeRegistry::from_types(&[inverse_type("sponsor", "sponsor")]);
2285        assert!(reg.is_symmetric("sponsor"));
2286        assert_eq!(reg.inverse_of("sponsor"), None);
2287        // Both viewpoints now agree, which is what stops the false 2-cycle.
2288        assert_eq!(reg.predicate_subject("sponsor"), "sponsor");
2289        assert_eq!(reg.predicate_object("sponsor"), "sponsor");
2290        // No phantom reciprocal was auto-registered from the stray `inverse`.
2291        assert_eq!(reg.to_json().as_array().unwrap().len(), 1);
2292    }
2293
2294    #[test]
2295    fn registry_self_inverse_coercion_reaches_site_json() {
2296        // This is the propagation path that fixes the frontend without any TS
2297        // change: `classifyRelationship` tests `symmetric` before `inverse`, so
2298        // the coerced flag makes it take the symmetric branch.
2299        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        // `inverse = "Sponsor"` for `name = "sponsor"` is the same declaration;
2314        // relation-type lookup is case-insensitive everywhere else too.
2315        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        // Also with surrounding whitespace and the casing reversed.
2320        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        // The genealogy defaults and half-declared pairs must be completely
2343        // unaffected: only a *self*-naming inverse is coerced.
2344        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        // And a self-inverse type alongside normal ones only affects itself.
2356        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        // The defaults declare both halves of every pair with coherent
2367        // hierarchy/category, so neither derivation nor reconciliation may
2368        // touch a single field.
2369        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    // ----- endpoint resolution -----
2389
2390    #[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(&notes, &reg, &["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        // Mary sees the reciprocal (symmetric) edge.
2414        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(&notes, &reg, &["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(&notes, &reg, &["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        // Two notes share the title "Sam"; the smaller URL wins.
2470        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(&notes, &reg, &["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(&notes, &reg, &["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        // A references Mary by her maiden name; Mary's note lists it as an alias.
2521        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(&notes, &reg, &["md".to_string()]);
2541        assert_eq!(map.get("/z/").unwrap()[0].neighbor, "/people/mary/");
2542    }
2543
2544    #[test]
2545    fn title_beats_alias() {
2546        // "Sam" is one note's title and another note's alias; the title wins
2547        // (resolution order is title -> alias -> stem).
2548        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(&notes, &reg, &["md".to_string()]);
2569        assert_eq!(map.get("/z/").unwrap()[0].neighbor, "/people/sam/");
2570    }
2571
2572    #[test]
2573    fn alias_case_insensitive() {
2574        // A differently-cased alias still resolves a differently-cased endpoint.
2575        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(&notes, &reg, &["md".to_string()]);
2595        assert_eq!(map.get("/z/").unwrap()[0].neighbor, "/people/mary/");
2596    }
2597
2598    // ----- inverse / symmetric derivation -----
2599
2600    #[test]
2601    fn inverse_parent_child_derivation() {
2602        // John declares "Alice is my child" -> Alice sees John as parent.
2603        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(&notes, &reg, &["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(&notes, &reg, &["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        // John: "Alice is my child"; Alice: "John is my parent". Same edge.
2660        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(&notes, &reg, &["md".to_string()]);
2690        // Exactly one edge on each side (not duplicated).
2691        assert_eq!(map.get("/john/").unwrap().len(), 1);
2692        assert_eq!(map.get("/alice/").unwrap().len(), 1);
2693        // Both declared it, so neither side is "derived".
2694        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        // Regression: with only `{ name = "employer", inverse = "employee" }`
2701        // configured, B declaring "A is my employee" left A's page showing B
2702        // under *Employees* instead of *Employers*.
2703        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(&notes, &reg, &["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        // Regression: A says "B is my employer", B says "A is my employee".
2726        // With only the `employee` half configured the two declarations used to
2727        // land on different canonical keys, so each note carried two entries
2728        // for the same logical edge (one of them mislabelled).
2729        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(&notes, &reg, &["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        // Both notes declared this edge, so neither side is derived.
2741        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        // The derivation must not disturb symmetric types sharing the registry.
2753        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(&notes, &reg, &["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        // C declares an edge between A and B; both get derived entries.
2771        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(&notes, &reg, &["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(&notes, &reg, &["md".to_string()]);
2815        assert_eq!(map.get("/john/").unwrap()[0].predicate, "mentor");
2816        // Counterpart still visible, no relabel.
2817        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(&notes, &reg, &["md".to_string()]);
2841        assert!(map.is_empty());
2842    }
2843
2844    // ----- cycle detection -----
2845
2846    /// Builds with the default genealogy types and returns the findings.
2847    fn build_genealogy(notes: &[NoteRelInput]) -> RelationshipBuild {
2848        let reg = RelationTypeRegistry::from_types(&genealogy_types());
2849        build_relationships(notes, &reg, &["md".to_string()])
2850    }
2851
2852    #[test]
2853    fn cycle_detection_finds_two_note_cycle() {
2854        // A says B is its parent and B says A is its parent. Canonically both
2855        // orient onto `child`, giving B -> A and A -> B: each is the other's
2856        // ancestor, which cannot be.
2857        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(&notes);
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(&notes);
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        // A textbook family: one parent, two children, one grandchild.
2887        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(&notes);
2899        assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2900    }
2901
2902    #[test]
2903    fn cycle_detection_ignores_symmetric_only_cycle() {
2904        // Spouses and siblings reference each other by design; a "cycle" over
2905        // symmetric edges is just a family, not bad data.
2906        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(&notes);
2917        assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2918    }
2919
2920    #[test]
2921    fn cycle_detection_ignores_self_inverse_type() {
2922        // `{ name = "rival", inverse = "rival" }` is self-contradictory. It is
2923        // coerced to symmetric at registry construction, so the symmetric check
2924        // excludes it and no special case is needed here — but left uncoerced its
2925        // two indistinguishable halves would look like a 2-cycle for every pair.
2926        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(&notes, &reg, &["md".to_string()]);
2934        // The edge itself is still tracked, once on each note...
2935        assert_eq!(built.by_note.get("/a/").unwrap().len(), 1);
2936        assert_eq!(built.by_note.get("/b/").unwrap().len(), 1);
2937        // ...but it is not reported as a cycle.
2938        assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2939    }
2940
2941    #[test]
2942    fn cycle_detection_ignores_mutually_declared_self_inverse_type() {
2943        // The shape the coordinator reproduced: both notes declare the type, so
2944        // *both* viewpoints exist. Uncoerced this produced two dedup keys and a
2945        // false "each is the other's ancestor" for every such pair.
2946        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(&notes, &reg, &["md".to_string()]);
2962        assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2963        // Symmetric handling also collapses the reciprocal declarations into a
2964        // single edge per note, rather than keeping two half-edges.
2965        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        // An unregistered type promises no hierarchy, so mutual `mentor` edges
2972        // are not an error.
2973        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(&notes);
2978        assert!(built.cycles.is_empty(), "{:?}", built.cycles);
2979    }
2980
2981    #[test]
2982    fn cycle_detection_reports_one_cycle_per_component() {
2983        // Two loops sharing note A form a single strongly-connected component,
2984        // so the author gets one actionable report rather than one per loop.
2985        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(&notes);
2996        assert_eq!(built.cycles.len(), 1, "{:?}", built.cycles);
2997        // The report is a concrete simple cycle, not the whole component.
2998        assert_eq!(built.cycles[0].members.len(), 2, "{:?}", built.cycles);
2999    }
3000
3001    #[test]
3002    fn cycle_detection_is_deterministic() {
3003        // Same input, byte-identical findings — the warning text and the
3004        // errors.json payload must not reshuffle between runs.
3005        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(&notes);
3013        for _ in 0..5 {
3014            let again = build_genealogy(&notes);
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        // An endpoint that resolved to nothing has no node to close a loop with.
3023        let notes = vec![note("/a/", "A", "a", vec![rel_to("parent", "[[Nobody]]")])];
3024        let built = build_genealogy(&notes);
3025        assert!(built.cycles.is_empty(), "{:?}", built.cycles);
3026    }
3027
3028    #[test]
3029    fn cycle_detection_separates_distinct_relation_types() {
3030        // A `child` loop and an `employer` loop are independent findings.
3031        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(&notes, &reg, &["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    // ----- ambiguous endpoint names -----
3059
3060    #[test]
3061    fn ambiguous_endpoint_is_recorded_against_the_declaring_note() {
3062        // The classic genealogy trap: a John Doe Sr and a John Doe Jr.
3063        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(&notes);
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        // Resolution behaviour is unchanged: the smallest URL still wins.
3080        assert_eq!(
3081            built.by_note.get("/z/").unwrap()[0].neighbor,
3082            "/people/john-jr/"
3083        );
3084        // The namesakes themselves declared nothing, so nothing is attached to
3085        // them.
3086        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(&notes);
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        // "alpha" is both the title and the filename stem of the *same* note:
3107        // one owner, not two.
3108        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(&notes);
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        // Naming the file explicitly is precisely the fix the warning suggests,
3123        // so a path endpoint must never itself be flagged.
3124        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(&notes);
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        // "Mary Doe" is one note's title and another's maiden-name alias.
3149        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(&notes);
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        // Same rationale as the wikilink index: "index" is a filename artifact
3165        // shared by every folder's index note, so it must not be treated as an
3166        // ambiguous name. Resolution is unchanged.
3167        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(&notes);
3177        // Still resolves first-wins...
3178        assert_eq!(built.by_note.get("/z/").unwrap()[0].neighbor, "/docs/");
3179        // ...but is not reported.
3180        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        // A genealogy repo can have dozens of namesakes; the log must stay
3190        // readable while errors.json stays complete.
3191        let ambiguous_count = AMBIGUOUS_WARN_CAP + 5;
3192        let mut notes = Vec::new();
3193        for i in 0..ambiguous_count {
3194            // Two notes share each title, and a third references it.
3195            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(&notes));
3216
3217        // Every affected page still gets its own complete finding.
3218        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(&notes));
3237        assert!(logs.contains("relationship cycle over `child`"), "{logs}");
3238        assert!(logs.contains("/a/"), "{logs}");
3239        assert!(logs.contains("/b/"), "{logs}");
3240    }
3241
3242    // ----- index-level queries -----
3243
3244    #[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        // C points into the cycle but is not caught in it.
3259        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        // Lookups normalise the leading slash like `get` does.
3264        assert_eq!(index.cycles_for("a/").len(), 1);
3265
3266        // A rebuild with corrected data clears the finding.
3267        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(&notes, &["md".to_string()]);
3322        assert_eq!(index.get("/john/").len(), 1);
3323        assert_eq!(index.get("/mary/").len(), 1);
3324        // Rebuild replaces prior contents.
3325        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    /// A generated edge: `declarer` note declares `type` about the `target` note.
3336    #[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        /// Inverse invariant: declaring `type: parent, to: [[Pj]]` on note Pi
3360        /// makes Pi see Pj as "parent" and (by inverse derivation) Pj see Pi as
3361        /// "child". Symmetric closure: `spouse` appears on both endpoints.
3362        #[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            // Build per-note relationship lists (skip self-loops).
3368            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(&notes, &reg, &["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                    // spouse: both endpoints see each other under "spouse".
3407                    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                    // Declarer sees target as "parent"; target sees declarer as
3415                    // the inverse "child".
3416                    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}