Skip to main content

dbmd_core/
graph.rs

1//! `graph` — the wiki-link **relationship layer**.
2//!
3//! Wiki-links are curated-relevance edges (the LLM wrote them), so the graph's
4//! job is to **assemble the relevant context around a seed**, not to be
5//! analyzed. **All ops are on-demand — there is no maintained graph** (a
6//! persistent graph is the roadmap engine).
7//!
8//! [`backlinks`] / [`forwardlinks`] are loop ops (O(changed), never O(store)).
9//! [`neighborhood`] is the high-value context-hydration op. [`orphans`] is a
10//! SWEEP curation worklist.
11//!
12//! Whole-graph analytics (connected components, cycle detection, shortest
13//! path, sinks/sources, DOT/JSON export) are deliberately **not** here — a
14//! human studying the graph opens the store in Obsidian; broken-link detection
15//! is [`crate::validate`]'s job (`WIKI_LINK_BROKEN`).
16//!
17//! ## Implementation note — two paths for the incoming-edge scan
18//!
19//! The scale contract (SPEC § Tooling, plan: *"the interactive loop is
20//! O(changed), never O(store)"*) is the load-bearing rule here. [`backlinks`]
21//! is a loop op, so it must **not** open and `read_to_string` every content file
22//! in the store on each call. It resolves incoming edges by one of two paths,
23//! chosen by whether the call is scoped:
24//!
25//! - **Unscoped** (`dbmd graph backlinks <x>`, no `--type`/`--in`): one
26//!   embedded-ripgrep pass for the literal `[[<target>]]` over the tree, via
27//!   [`Store::find_links_to`] (`grep` + `ignore`, early-exit per file) — the
28//!   same scan engine [`crate::validate`]'s working-set incoming-linker step
29//!   uses. A single store traversal with cheap presence-only matching, not N
30//!   whole-file parses; that is what keeps the unscoped call inside the loop
31//!   budget. [`backlinks`] then filters the raw hits to content files and emits
32//!   canonical bare targets (its relationship view), where the lower-level
33//!   [`Store::find_links_to`] returns every `.md` the text appears in.
34//! - **Scoped** (`--type` / `--in`): the candidate set is enumerated from the
35//!   relevant layer's `index.jsonl` sidecars — the sidecars of the one layer the
36//!   `--type` belongs to (via [`Store::sidecar_records`]), filtered to that type
37//!   — and each candidate is confirmed by a single-file parse. That is what makes
38//!   `--type` / `--in` an *I/O* scope, not just a result filter: a typed/layer-scoped
39//!   `backlinks` reads only the relevant layer's sidecars (O(entities-in-layer))
40//!   and parses only those files. A type's records can span several folders within
41//!   its layer (`wiki-page` under any `wiki/<topic>/`), so the read is layer-wide,
42//!   not a single canonical folder — otherwise off-canonical-folder linkers would
43//!   be silently dropped.
44//!
45//! **Why the scoped path confirms by parsing the candidate, not by trusting the
46//! sidecar's `links` field.** A sidecar record's `links` is the file's
47//! *frontmatter* `links:` list only — it does **not** capture wiki-links written
48//! in the body or inside other typed frontmatter fields (`company: [[…]]`,
49//! `attendees: [ … ]`, `derived_from: [ … ]`). [`forwardlinks`] extracts edges
50//! from the whole file, so to keep the two directions on the **same** edge set
51//! (an incoming edge to X is exactly: some file whose [`forwardlinks`] contains
52//! X) the incoming-edge confirmation re-parses each candidate file the same way.
53//! The sidecar bounds *which* files are candidates; the parse decides whether
54//! each truly links. The unscoped ripgrep path stays on that same edge set by
55//! matching the link text wherever it lives in the file (frontmatter or body).
56//! A node's `summary` / `type` likewise read frontmatter directly (the source of
57//! truth the sidecar is derived from; never stale).
58
59use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
60use std::io;
61use std::path::{Path, PathBuf};
62
63use ignore::WalkBuilder;
64
65use crate::index::IndexRecord;
66use crate::store::{
67    canonical_link_target, ensure_path_within_store, extract_edge_targets, fence_closes,
68    fence_opens, layer_for_type, link_edge_key, Layer, Store, StoreError,
69};
70
71/// Which edge directions a traversal follows.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Direction {
74    /// Incoming edges only (backlinks).
75    Incoming,
76    /// Outgoing edges only (forwardlinks).
77    Outgoing,
78    /// Both directions.
79    Both,
80}
81
82/// One node reached during a [`neighborhood`] hydration: the file, its
83/// `summary`, and how it connects back toward the seed.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ContextNode {
86    /// The store-relative path of the reached file.
87    pub path: PathBuf,
88    /// The file's `summary` (read from its sidecar entry / frontmatter).
89    pub summary: String,
90    /// The file's `type`, when known.
91    pub type_: Option<String>,
92    /// Hop distance from the seed (the seed itself is 0).
93    pub hops: u32,
94    /// The relationship edge that brought this node into the slice: the path it
95    /// links to/from one hop closer to the seed, and the direction.
96    pub via: Option<(PathBuf, Direction)>,
97}
98
99/// The readable working-set digest [`neighborhood`] returns: the seed plus the
100/// reached nodes with their summaries and connections. The relationship-axis
101/// "turn a seed into context" primitive.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct ContextSlice {
104    /// The seed the slice was hydrated from.
105    pub seed: PathBuf,
106    /// The reached nodes (excluding the seed), in BFS order.
107    pub nodes: Vec<ContextNode>,
108}
109
110/// Incoming edges to `path`: files that wiki-link to it. The blast-radius /
111/// dependents primitive before an edit. Store-wide (every layer / every type);
112/// see [`backlinks_filtered`] for the `--type` / `--in`-scoped form.
113///
114/// `path` is the store-relative target as it would be written inside a
115/// wiki-link (with or without a trailing `.md`; both resolve to the same
116/// target). Returns each linking file as its **canonical bare wiki-link path**
117/// (store-relative, no `.md`) — the same key [`forwardlinks`] emits, so the two
118/// directions round-trip and [`neighborhood`] can use one node identity.
119/// Deduped, sorted, never including the seed itself.
120pub fn backlinks(store: &Store, path: &Path) -> Result<Vec<PathBuf>, StoreError> {
121    backlinks_filtered(store, path, &[], None)
122}
123
124/// Incoming edges to `path`, scoped by the linking file's `type` and/or layer —
125/// the `dbmd graph backlinks --type/--in` surface.
126///
127/// **Scale (the loop contract).** Two paths, by whether the call is scoped:
128///
129/// - **Unscoped** (`types` empty *and* `layer` `None`): one embedded-ripgrep
130///   pass for `[[<target>]]` across the store via [`Store::find_links_to`] — a
131///   single `grep` + `ignore` traversal with early-exit per file, never a
132///   `read_to_string` of every content file. This is the same scan engine
133///   [`crate::validate::validate_working_set`]'s incoming-linker step rides, and
134///   it keeps the unscoped call inside the loop budget (the old per-candidate
135///   confirm-read re-opened every file in the store → O(store)).
136/// - **Scoped** (`types` and/or `layer` set): the candidate set — the files that
137///   *might* link to `path` — is read from the relevant layer's `index.jsonl`
138///   sidecars, so the call touches only the named layer(s): O(entities-in-layer),
139///   the sanctioned loop cost. Each candidate is then confirmed by a single-file
140///   parse. When `types` lists several types, the sidecars of each type's layer
141///   are read and the candidate sets unioned (filtered to the type), so a type
142///   whose records span multiple folders within its layer (e.g. `wiki-page` under
143///   any `wiki/<topic>/`) is fully covered; a `layer` further restricts the
144///   candidate paths to that layer.
145///
146/// **Correctness (one edge set, both paths).** An incoming edge to X is exactly:
147/// some file whose [`forwardlinks`] contains X — a wiki-link in the body or in
148/// *any* frontmatter field (`company: [[…]]`, `attendees: [ … ]`), not just the
149/// sidecar's frontmatter `links:` projection. Both paths honor that:
150/// - The unscoped scan matches the literal `[[<target>]]` text wherever it lives
151///   in a file (frontmatter or body), the same edges [`forwardlinks`] extracts.
152///   [`Store::find_links_to`] returns *every* `.md` carrying the link text
153///   (including `index.md` catalogs); [`backlinks`] is the relationship view, so
154///   the results are filtered to content files ([`is_content_rel`]) and emitted
155///   as canonical bare targets, self-excluded.
156/// - The scoped path confirms each candidate via [`file_links_to`], which
157///   delegates to [`forwardlinks`] (body + every frontmatter field) — so a
158///   body-only or typed-field edge is caught, not just the sidecar's `links:`
159///   list.
160///
161/// Result form (canonical bare paths, deduped, sorted, seed excluded) is
162/// identical on both paths and matches [`backlinks`].
163pub fn backlinks_filtered(
164    store: &Store,
165    path: &Path,
166    types: &[String],
167    layer: Option<Layer>,
168) -> Result<Vec<PathBuf>, StoreError> {
169    let target = normalize_target(path);
170    if target.is_empty() {
171        return Ok(Vec::new());
172    }
173    let target_key = edge_key(&target);
174
175    // Unscoped: one content pass over the store (O(store) scan with early-exit
176    // per file), not a per-candidate read of every content file. `find_links_to`
177    // returns every `.md` carrying an edge to the target (incl. catalog
178    // `index.md`); narrow to content files and canonicalize to the bare target
179    // form `backlinks` emits, dropping the seed's self-link.
180    if types.is_empty() && layer.is_none() {
181        let mut hits: BTreeSet<PathBuf> = BTreeSet::new();
182        for rel in store.find_links_to(path)? {
183            if !is_content_rel(&rel) {
184                continue;
185            }
186            let linker = normalize_target(&rel);
187            if linker.is_empty() || edge_key(&linker) == target_key {
188                // A file never counts as its own backlink (case-folded so a
189                // case-variant self-link is still excluded).
190                continue;
191            }
192            hits.insert(PathBuf::from(linker));
193        }
194        return Ok(hits.into_iter().collect());
195    }
196
197    // Scoped: read only the named folder(s)' sidecars for the candidate set, then
198    // confirm each candidate with a single-file parse — O(folder), the I/O scope
199    // `--type` / `--in` buys.
200    let mut hits: BTreeSet<PathBuf> = BTreeSet::new();
201    for candidate in candidate_records(store, types, layer)? {
202        let rel = &candidate.path;
203        let candidate_target = normalize_target(rel);
204        if candidate_target.is_empty() || edge_key(&candidate_target) == target_key {
205            // A file never counts as its own backlink.
206            continue;
207        }
208        // Confirm the edge by parsing the candidate file the same way
209        // forwardlinks does (body + all frontmatter), so body/typed-field links
210        // are caught — the sidecar's `links` field alone would miss them.
211        if file_links_to(store, rel, &target)? {
212            hits.insert(PathBuf::from(candidate_target));
213        }
214    }
215
216    Ok(hits.into_iter().collect())
217}
218
219/// Outgoing edges from `path`: the wiki-link targets extracted from that single
220/// file. Loop-fast; follow the evidence chain.
221///
222/// `path` is the store-relative path of the file to read. Targets are returned
223/// as store-relative paths (bare, no `.md`), deduped and sorted; the file's
224/// links to itself are dropped. A missing file yields an empty list (a
225/// dangling seed has no outgoing edges to report — broken-link detection is
226/// [`crate::validate`]'s job).
227pub fn forwardlinks(store: &Store, path: &Path) -> Result<Vec<PathBuf>, StoreError> {
228    let self_key = edge_key(&normalize_target(path));
229    let abs = match resolve_existing(store, path) {
230        Some(a) => a,
231        None => return Ok(Vec::new()),
232    };
233    let body = match std::fs::read_to_string(&abs) {
234        Ok(b) => b,
235        // A file that isn't valid UTF-8 (e.g. a binary source) carries no
236        // wiki-links we can extract.
237        Err(e) if e.kind() == io::ErrorKind::InvalidData => return Ok(Vec::new()),
238        Err(e) => return Err(StoreError::Io(e)),
239    };
240
241    let mut out: BTreeSet<PathBuf> = BTreeSet::new();
242    for target in extract_link_targets(&body) {
243        // Self-link drop is case-folded so a case-variant self-reference is also
244        // excluded on a case-insensitive filesystem.
245        if target.is_empty() || edge_key(&target) == self_key {
246            continue;
247        }
248        out.insert(PathBuf::from(target));
249    }
250    Ok(out.into_iter().collect())
251}
252
253/// The candidate set for an incoming-edge scan: the sidecar records that could
254/// link to the target, read from the type-folder `index.jsonl` sidecars (never
255/// a content-tree walk). `types`/`layer` narrow *which* sidecars are read — the
256/// I/O scope that keeps a typed/layer backlinks O(entities-in-layer).
257///
258/// - `types` non-empty: for each type, read **the whole layer** the type belongs
259///   to ([`layer_for_type`] → [`Store::sidecar_records`]) and keep the records of
260///   that `type`, unioned by path across the requested types. A `layer` filter,
261///   when given, intersects with the type's own layer (a type lives in exactly
262///   one layer, so a mismatched `--in` simply yields no candidates).
263/// - `types` empty: every sidecar record under `layer` (or store-wide when
264///   `None`) via [`Store::sidecar_records`].
265///
266/// **Why the whole layer, not just the type's canonical folder.** A `type` can
267/// legitimately span several folders within one layer — `wiki-page` is the
268/// canonical case (SPEC files it under `wiki/<topic>/` for an *arbitrary* topic:
269/// `wiki/topics/`, `wiki/people/`, `wiki/projects/`, …). Reading only the
270/// single canonical-guess folder (`wiki/topics/`) would silently drop every
271/// wiki-page filed elsewhere in the layer, so a scoped `backlinks --type
272/// wiki-page` would under-report dependents the moment that canonical folder
273/// exists — breaking the docstring's promise that the scoped edge set equals the
274/// unscoped one. Reading the type's full layer subtree and filtering by `type`
275/// is complete and still O(entities-in-layer), the sanctioned loop scope.
276fn candidate_records(
277    store: &Store,
278    types: &[String],
279    layer: Option<Layer>,
280) -> Result<Vec<IndexRecord>, StoreError> {
281    if types.is_empty() {
282        return store.sidecar_records(layer);
283    }
284    let mut by_path: std::collections::BTreeMap<PathBuf, IndexRecord> =
285        std::collections::BTreeMap::new();
286    for type_ in types {
287        // A type lives in exactly one layer; read that whole layer's sidecars so
288        // a record filed under a non-canonical folder of the same type (e.g. a
289        // `wiki-page` under `wiki/people/` rather than `wiki/topics/`) is still a
290        // candidate. An explicit `--in` layer that disagrees with the type's
291        // layer can never match the type, so skip the read entirely.
292        let type_layer = layer_for_type(type_);
293        if let Some(scope) = layer {
294            if scope != type_layer {
295                continue;
296            }
297        }
298        for rec in store.sidecar_records(Some(type_layer))? {
299            if rec.type_ == *type_ {
300                by_path.insert(rec.path.clone(), rec);
301            }
302        }
303    }
304    Ok(by_path.into_values().collect())
305}
306
307/// True if the store file at `rel` carries a wiki-link whose canonical target
308/// equals `target`. Delegates to [`forwardlinks`] so the incoming-edge predicate
309/// is *exactly* the outgoing-edge extraction — body + every frontmatter field —
310/// keeping the two directions on one edge set. `forwardlinks` already emits
311/// canonical bare targets, so `target` (likewise normalized by the caller) is
312/// compared directly. A missing/binary file links to nothing.
313fn file_links_to(store: &Store, rel: &Path, target: &str) -> Result<bool, StoreError> {
314    let edges = forwardlinks(store, rel)?;
315    let target_key = edge_key(target);
316    // Compare on the case-folded edge key so a case-variant link (e.g.
317    // `[[records/contacts/Sarah-Chen]]` to `sarah-chen.md`) is confirmed on a
318    // case-insensitive filesystem, agreeing with the unscoped scan and validate.
319    Ok(edges
320        .iter()
321        .any(|e| edge_key(&e.to_string_lossy()) == target_key))
322}
323
324/// **Context hydration.** Bounded BFS from `seed` over backlinks + forwardlinks
325/// out to `hops`, reading each reached file's `summary` + relationship, and
326/// returning a readable [`ContextSlice`]. Optionally filtered by `types` and
327/// `direction`. On-demand; no maintained graph. What the agent reaches for to
328/// assemble a working set in one call.
329///
330/// Traversal semantics:
331/// - **`hops`** bounds true graph distance from the seed. `hops == 0` returns
332///   an empty slice (the seed alone is no context).
333/// - **`direction`** selects which edges are followed: `Incoming` walks
334///   backlinks, `Outgoing` walks forwardlinks, `Both` walks the union.
335/// - **`types`**, when non-empty, filters which reached nodes appear in the
336///   slice — but traversal still passes *through* off-type nodes, so a
337///   `meeting` two hops out is still reachable through a `contact` even when
338///   filtering to `meeting`. (An empty `types` slice imposes no filter.)
339/// - Each node records the lowest hop count at which it is first reached (BFS
340///   order); the seed is never included as a node.
341///
342/// Unbounded traversal: delegates to [`neighborhood_capped`] with no node cap, so
343/// it expands every reachable node within `hops`. For a densely-interlinked store
344/// this is one full-store backlinks scan **per reached node** (O(visited × store))
345/// — prefer [`neighborhood_capped`] with a `max_nodes` cap to bound that work.
346pub fn neighborhood(
347    store: &Store,
348    seed: &Path,
349    hops: u32,
350    types: &[String],
351    direction: Direction,
352) -> Result<ContextSlice, StoreError> {
353    neighborhood_capped(store, seed, hops, types, direction, None)
354}
355
356/// [`neighborhood`] with a hard cap on how many nodes the BFS **traverses**.
357///
358/// `max_nodes` bounds the *traversal*, not just the result: each node the BFS
359/// expands triggers a per-node incoming-edge scan (an unscoped [`backlinks`] is a
360/// full-store ripgrep pass), so an uncapped neighborhood of a hub node costs
361/// O(visited × store). A post-hoc `.take(n)` on the returned nodes caps the
362/// *output* but not that work — the scans still run for every reached node. This
363/// cap stops discovering (and therefore stops scanning) once `max_nodes` distinct
364/// non-seed nodes have entered the BFS, so the expensive per-node scans are bounded
365/// to at most `max_nodes` of them. `None` is unbounded (the [`neighborhood`]
366/// behavior).
367///
368/// The cap is applied at *discovery* in BFS order, so the kept nodes are exactly
369/// the first `max_nodes` reached (closest-first by hop), and each still records its
370/// true minimum hop distance. Type-filtered (off-type) nodes count against the cap
371/// because the BFS must still traverse *through* them to reach deeper on-type
372/// nodes — the scan cost is paid when a node is expanded, on- or off-type alike.
373pub fn neighborhood_capped(
374    store: &Store,
375    seed: &Path,
376    hops: u32,
377    types: &[String],
378    direction: Direction,
379    max_nodes: Option<usize>,
380) -> Result<ContextSlice, StoreError> {
381    let seed_rel = PathBuf::from(normalize_target(seed));
382    let type_filter: HashSet<&str> = types.iter().map(|s| s.as_str()).collect();
383
384    // `discovered` guards against revisiting a node (and against re-adding the
385    // seed). BFS by levels so the first time we reach a node is its true min
386    // hop distance.
387    let mut discovered: HashSet<PathBuf> = HashSet::new();
388    discovered.insert(seed_rel.clone());
389
390    let mut nodes: Vec<ContextNode> = Vec::new();
391    let mut frontier: VecDeque<PathBuf> = VecDeque::new();
392    frontier.push_back(seed_rel.clone());
393
394    // Count of distinct non-seed nodes admitted to the BFS. Once it hits
395    // `max_nodes` we stop discovering new nodes, which stops enqueuing them, which
396    // stops the per-node full-store backlinks scan they would have triggered — the
397    // cap bounds the *traversal cost*, not only the printed result.
398    let mut admitted = 0usize;
399    let cap_reached = |admitted: usize| max_nodes.is_some_and(|cap| admitted >= cap);
400
401    let mut hop = 0u32;
402    while hop < hops && !frontier.is_empty() && !cap_reached(admitted) {
403        hop += 1;
404        let level_size = frontier.len();
405        for _ in 0..level_size {
406            if cap_reached(admitted) {
407                break;
408            }
409            let current = frontier.pop_front().expect("frontier non-empty");
410
411            // Collect this node's edges in the requested direction(s). Each
412            // edge carries the neighbor path + the direction we traversed it.
413            let mut edges: Vec<(PathBuf, Direction)> = Vec::new();
414            if matches!(direction, Direction::Outgoing | Direction::Both) {
415                for nbr in forwardlinks(store, &current)? {
416                    edges.push((nbr, Direction::Outgoing));
417                }
418            }
419            if matches!(direction, Direction::Incoming | Direction::Both) {
420                for nbr in backlinks(store, &current)? {
421                    edges.push((nbr, Direction::Incoming));
422                }
423            }
424
425            for (neighbor, dir) in edges {
426                if cap_reached(admitted) {
427                    break;
428                }
429                if !discovered.insert(neighbor.clone()) {
430                    continue;
431                }
432                admitted += 1;
433                let (summary, type_) = read_summary_and_type(store, &neighbor);
434                let include = type_filter.is_empty()
435                    || type_
436                        .as_deref()
437                        .map(|t| type_filter.contains(t))
438                        .unwrap_or(false);
439                if include {
440                    nodes.push(ContextNode {
441                        path: neighbor.clone(),
442                        summary,
443                        type_,
444                        hops: hop,
445                        via: Some((current.clone(), dir)),
446                    });
447                }
448                // Off-type nodes are not emitted but still seed the next BFS
449                // level, so the type filter narrows the *result*, not the
450                // reachable graph.
451                frontier.push_back(neighbor);
452            }
453        }
454    }
455
456    Ok(ContextSlice {
457        seed: seed_rel,
458        nodes,
459    })
460}
461
462/// **SWEEP.** Content files with no incoming AND no outgoing wiki-links — the
463/// curation worklist ("ingested but not yet wired into the wiki"). Off the
464/// loop. Optionally scoped to a layer.
465///
466/// A file is an orphan iff it neither links out to another store file nor is
467/// linked to by one. Incoming edges are counted across the *whole* store
468/// (a link from any layer un-orphans a file), even when `layer` scopes the
469/// candidate set. Returns store-relative paths, sorted.
470pub fn orphans(store: &Store, layer: Option<Layer>) -> Result<Vec<PathBuf>, StoreError> {
471    // One walk of the whole store: for every content file, record (a) whether
472    // it has any outgoing link, and (b) accumulate the set of every target any
473    // file links to (its incoming-edge set). Both come from a single read per
474    // file — the SWEEP cost.
475    let all = walk_content_files(store)?;
476
477    // `linked_to` holds case-folded edge KEYS (not raw paths): the link text may
478    // spell a target with different casing than the on-disk file (e.g.
479    // `[[records/contacts/Sarah-Chen]]` → `sarah-chen.md`), and on a
480    // case-insensitive filesystem that is a real incoming edge. Keying on
481    // `edge_key` so the incoming-edge lookup case-folds is what stops the
482    // false-positive orphan (a file with a live case-variant link reported as
483    // orphaned) — and matches validate, which resolves the same link via the
484    // case-insensitive filesystem.
485    let mut linked_to: HashSet<String> = HashSet::new();
486    let mut has_outgoing: HashMap<PathBuf, bool> = HashMap::new();
487
488    for abs in &all {
489        let rel = match rel_path(store, abs) {
490            Some(r) => r,
491            None => continue,
492        };
493        let self_key = edge_key(&normalize_target(&rel));
494
495        let body = match std::fs::read_to_string(abs) {
496            Ok(b) => b,
497            Err(e) if e.kind() == io::ErrorKind::InvalidData => String::new(),
498            Err(e) => return Err(StoreError::Io(e)),
499        };
500
501        let mut outgoing = false;
502        for target in extract_link_targets(&body) {
503            if target.is_empty() || edge_key(&target) == self_key {
504                continue;
505            }
506            if resolve_existing(store, Path::new(&target)).is_none() {
507                continue;
508            }
509            outgoing = true;
510            linked_to.insert(edge_key(&target));
511        }
512        has_outgoing.insert(rel, outgoing);
513    }
514
515    let mut out: BTreeSet<PathBuf> = BTreeSet::new();
516    for abs in &all {
517        let rel = match rel_path(store, abs) {
518            Some(r) => r,
519            None => continue,
520        };
521        if let Some(layer) = layer {
522            if path_layer(&rel) != Some(layer) {
523                continue;
524            }
525        }
526        let outgoing = has_outgoing.get(&rel).copied().unwrap_or(false);
527        let incoming = linked_to.contains(&edge_key(&normalize_target(&rel)));
528        if !outgoing && !incoming {
529            out.insert(rel);
530        }
531    }
532
533    Ok(out.into_iter().collect())
534}
535
536/// **Write-side.** Rewrite every incoming `[[old]]` wiki-link in `text` to
537/// `[[new]]`, preserving any `|display` override and emitting the canonical bare
538/// target (no `.md`). The write-side twin of [`backlinks`]: where `backlinks`
539/// *finds* the files carrying an edge to `old`, this *retargets* that edge to
540/// `new` inside one file's contents.
541///
542/// `old` and `new` are store-relative paths in the wiki-link sense — both are
543/// passed through the same [`normalize_target`] the read side keys on, so the
544/// `.md` and bare spellings of `old` collapse to one target and a match here is
545/// exactly a match [`backlinks`] / [`Store::find_links_to`](crate::Store::find_links_to)
546/// would report. A link is rewritten iff its normalized target equals
547/// `normalize_target(old)`; prefix collisions (`old=a/b` vs `[[a/bc]]`) and
548/// short-form links never match. Returns the rewritten text (identical to the
549/// input when nothing matched), so the caller can cheaply detect a no-op.
550///
551/// Operates on the raw text (not a parser round-trip) so a link in frontmatter
552/// or body is retargeted uniformly and nothing else is reflowed — **except** a
553/// `[[...]]` inside a ``` fenced code block, which is a documentation example,
554/// not an edge: `rename` must NOT mutate fenced verbatim content (validate
555/// treats fenced links as non-edges, so rewriting them silently corrupts the
556/// example and makes rename disagree with validate). Matching is fence-aware,
557/// whitespace-trimmed, and case-folded to the filesystem, the exact edge notion
558/// [`backlinks`]/[`forwardlinks`] use — so rename retargets precisely the edges
559/// those report and nothing else.
560pub fn rewrite_links_to(text: &str, old: &Path, new: &Path) -> String {
561    let old_target = normalize_target(old);
562    let new_target = normalize_target(new);
563    if old_target.is_empty() {
564        // No target to match → never rewrite anything.
565        return text.to_string();
566    }
567    let old_key = edge_key(&old_target);
568
569    let mut out = String::with_capacity(text.len());
570    // Track the fence as a `(char, run length)` exactly like validate and
571    // `extract_edge_targets` (NOT a bool toggled on any ``` / ~~~ line). The
572    // naive toggle flips mid-block on a nested/indented/long-run fence, so a
573    // fenced example link would be rewritten — corrupting documentation and
574    // making rename disagree with validate's edge notion.
575    let mut fence: Option<(u8, usize)> = None;
576    // `split_inclusive` keeps each line's trailing `\n`, so copying a chunk
577    // verbatim preserves the original line endings exactly.
578    for line in text.split_inclusive('\n') {
579        // The fence rules key on line content without trailing `\r`/`\n`; the
580        // full chunk (line endings intact) is what we copy verbatim.
581        let content = line.trim_end_matches('\n').trim_end_matches('\r');
582        if let Some(f) = fence {
583            // Inside a fenced code block: copy verbatim, never rewrite. Only a
584            // matching closing fence ends the block.
585            if fence_closes(content, f) {
586                fence = None;
587            }
588            out.push_str(line);
589            continue;
590        }
591        if let Some(opened) = fence_opens(content) {
592            fence = Some(opened);
593            out.push_str(line);
594            continue;
595        }
596        rewrite_links_in_line(line, &old_key, &new_target, &mut out);
597    }
598    out
599}
600
601/// Rewrite every `[[...]]` on a single (non-fenced) line whose target matches
602/// `old_key`, appending the result to `out`. Preserves any `|display` override
603/// verbatim and emits the canonical bare `new_target`. A `[[...]]` whose target
604/// does not match (a prefix sibling, the short form, an unrelated target) is
605/// copied through untouched.
606fn rewrite_links_in_line(line: &str, old_key: &str, new_target: &str, out: &mut String) {
607    let bytes = line.as_bytes();
608    let mut i = 0usize;
609    let mut last = 0usize;
610    while i + 1 < bytes.len() {
611        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
612            if let Some(close) = line[i + 2..].find("]]") {
613                let inner = &line[i + 2..i + 2 + close];
614                // An embedded newline means this isn't a single-line link.
615                if !inner.contains('\n') {
616                    let (raw_target, display) = match inner.split_once('|') {
617                        Some((t, d)) => (t, Some(d)),
618                        None => (inner, None),
619                    };
620                    let raw_target = raw_target.trim();
621                    // Match on the SAME edge key the read side uses, so `[[old]]`,
622                    // `[[old.md]]`, `[[ ./old ]]`, and (case-insensitive FS)
623                    // `[[Old]]` all retarget while `[[old-jr]]` never does.
624                    if !raw_target.is_empty()
625                        && !raw_target.starts_with('[')
626                        && edge_key(&canonical_link_target(raw_target)) == old_key
627                    {
628                        out.push_str(&line[last..i]);
629                        out.push_str("[[");
630                        out.push_str(new_target);
631                        if let Some(display) = display {
632                            out.push('|');
633                            out.push_str(display);
634                        }
635                        out.push_str("]]");
636                        i = i + 2 + close + 2;
637                        last = i;
638                        continue;
639                    }
640                }
641                // Not a matching link: skip past this `]]` so an inner `[[`
642                // isn't re-scanned, but leave the text for the verbatim copy.
643                i = i + 2 + close + 2;
644                continue;
645            }
646        }
647        i += 1;
648    }
649    out.push_str(&line[last..]);
650}
651
652// ── Private helpers ─────────────────────────────────────────────────────────
653
654/// Normalize a store-relative path into the canonical wiki-link target form:
655/// forward slashes, no leading `./` or `/`, and no trailing `.md`. This is the
656/// canonical (case-PRESERVING) identity used for output and rewrites; edge
657/// *comparisons* go through [`edge_key`] so the `.md`/bare forms AND (on a
658/// case-insensitive filesystem) case-variant spellings of a target unify. The
659/// shared [`canonical_link_target`] is the single definition every db.md
660/// link op keys on.
661fn normalize_target(path: &Path) -> String {
662    canonical_link_target(&path.to_string_lossy())
663}
664
665/// The comparison key for an edge: the canonical target case-folded to the
666/// filesystem (identity on a case-sensitive FS, lowercased on macOS/Windows), so
667/// the string-keyed graph compares agree with the filesystem's case-insensitive
668/// `is_file()` resolution. `[[records/contacts/Sarah-Chen]]` and the on-disk
669/// `sarah-chen.md` must be the same edge on a case-insensitive filesystem or
670/// backlinks/orphans/rename silently disagree with validate.
671fn edge_key(canonical_target: &str) -> String {
672    link_edge_key(canonical_target)
673}
674
675/// Extract every wiki-link target from a body, normalized to the canonical
676/// store-relative form. Fence-aware and whitespace-trimmed via the shared
677/// [`extract_edge_targets`] — a `[[...]]` inside a ``` fenced code block is a
678/// documentation example, NOT an edge (matching validate), and `[[ x ]]`
679/// padding resolves identically to `[[x]]`. A target that would escape the store
680/// root (a `..` component) is dropped here too, so an escaping `[[../outside/x]]`
681/// is never reported as a forward edge and never seeds a [`neighborhood`]
682/// traversal out of the store (the disclosure vector validate flags as an
683/// error). Order-preserving; duplicates kept (callers dedup).
684fn extract_link_targets(body: &str) -> Vec<String> {
685    extract_edge_targets(body)
686        .into_iter()
687        .filter(|t| is_within_store_target(t))
688        .collect()
689}
690
691/// True if a canonical target stays inside the store: it has no `..`
692/// (`ParentDir`) component. The canonical form has already stripped any leading
693/// `./` or `/`, so a `Normal`-only path is a safe store-relative key; a `..`
694/// component is an escape and is rejected, mirroring validate's safe-path guard.
695fn is_within_store_target(target: &str) -> bool {
696    Path::new(target)
697        .components()
698        .all(|c| matches!(c, std::path::Component::Normal(_)))
699}
700
701/// Resolve the store root + a store-relative path to the absolute on-disk file,
702/// trying the path as written and then with a `.md` extension. `None` if neither
703/// exists **or if the target resolves outside the store root** — a `..`-laden or
704/// symlink-escaping wiki-link must never turn a graph read/traversal into a read
705/// of an arbitrary file outside the store (the `dbmd graph neighborhood`
706/// disclosure vector). Containment is enforced via the shared
707/// [`ensure_path_within_store`] gate, matching validate's safe-path guard.
708fn resolve_existing(store: &Store, store_relative: &Path) -> Option<PathBuf> {
709    let direct = store.root.join(store_relative);
710    if direct.is_file() && resolves_within_store(store, store_relative, &direct) {
711        return Some(direct);
712    }
713    let normalized = normalize_target(store_relative);
714    let with_md = store.root.join(format!("{normalized}.md"));
715    if with_md.is_file() && resolves_within_store(store, Path::new(&normalized), &with_md) {
716        return Some(with_md);
717    }
718    None
719}
720
721/// Containment check for a candidate on-disk path, with a cheap fast path. A
722/// store-relative path made of only `Normal` components (no `..`, no absolute /
723/// platform prefix) is trivially inside the root, so the common case avoids the
724/// `canonicalize` syscalls entirely. Anything with a `..`/absolute/prefix
725/// component falls through to the authoritative [`ensure_path_within_store`]
726/// gate (symlink-resolving), which is the only thing that can prove an escaping
727/// or symlink-redirected path actually stays inside the store.
728fn resolves_within_store(store: &Store, store_relative: &Path, abs: &Path) -> bool {
729    let plain_relative = !store_relative.is_absolute()
730        && store_relative
731            .components()
732            .all(|c| matches!(c, std::path::Component::Normal(_)));
733    if plain_relative {
734        return true;
735    }
736    ensure_path_within_store(&store.root, abs).is_ok()
737}
738
739/// Convert an absolute path under the store root into its store-relative form.
740fn rel_path(store: &Store, abs: &Path) -> Option<PathBuf> {
741    abs.strip_prefix(&store.root).ok().map(|p| p.to_path_buf())
742}
743
744/// Which layer a store-relative path sits in, by its first component.
745fn path_layer(rel: &Path) -> Option<Layer> {
746    let first = rel.components().next()?;
747    match first.as_os_str().to_str()? {
748        "sources" => Some(Layer::Sources),
749        "records" => Some(Layer::Records),
750        "wiki" => Some(Layer::Wiki),
751        _ => None,
752    }
753}
754
755/// True if a store-relative path is a *content* file: under `sources/`,
756/// `records/`, or `wiki/`, a `.md` file, and not an `index.md`. Meta files
757/// (`DB.md`, `log.md`, `log/…`, sidecars) are excluded.
758fn is_content_rel(rel: &Path) -> bool {
759    if path_layer(rel).is_none() {
760        return false;
761    }
762    match rel.extension().and_then(|e| e.to_str()) {
763        Some("md") => {}
764        _ => return false,
765    }
766    rel.file_name().and_then(|n| n.to_str()) != Some("index.md")
767}
768
769/// Walk every content `.md` file in the store via the **`ignore`** walker
770/// (the ripgrep directory engine). Only the three layer roots
771/// (`sources/`/`records/`/`wiki/`) are descended, so `DB.md`, `log.md`, and
772/// `log/` at the store root are structurally never reached; hidden dirs and
773/// per-folder `index.md` sidecars are filtered out ([`is_content_rel`]). Honors
774/// `.gitignore` the way `rg` does. Returns absolute paths. SWEEP-class.
775fn walk_content_files(store: &Store) -> Result<Vec<PathBuf>, StoreError> {
776    let mut out = Vec::new();
777    for layer in Layer::all() {
778        let dir = store.root.join(layer_dir_name(layer));
779        if !dir.is_dir() {
780            continue;
781        }
782        let walker = WalkBuilder::new(&dir)
783            .hidden(true)
784            .git_ignore(true)
785            .git_global(false)
786            .require_git(false)
787            // Follow symlinks so a symlinked `.md` content file or a symlinked
788            // type folder is walked like any other content (consistent with the
789            // store SWEEP walker), rather than silently vanishing from orphans.
790            .follow_links(true)
791            .build();
792        for result in walker {
793            let entry = result.map_err(|e| StoreError::Search {
794                root: store.root.clone(),
795                message: format!("walk failed: {e}"),
796            })?;
797            // A followed symlink entry reports its own type as `is_symlink()`, so
798            // also accept a symlink whose target is a regular file.
799            let is_file = match entry.file_type() {
800                Some(ft) if ft.is_file() => true,
801                Some(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
802                    .map(|m| m.is_file())
803                    .unwrap_or(false),
804                _ => false,
805            };
806            if !is_file {
807                continue;
808            }
809            let abs = entry.into_path();
810            if let Some(rel) = rel_path(store, &abs) {
811                if is_content_rel(&rel) {
812                    out.push(abs);
813                }
814            }
815        }
816    }
817    Ok(out)
818}
819
820/// The on-disk folder name for a layer. Mirrors `Layer::dir_name`; kept local
821/// so the graph module owns its own copy rather than coupling to that body.
822fn layer_dir_name(layer: Layer) -> &'static str {
823    match layer {
824        Layer::Sources => "sources",
825        Layer::Records => "records",
826        Layer::Wiki => "wiki",
827    }
828}
829
830/// Read a reached node's `summary` and `type` from its frontmatter. A missing
831/// file, missing frontmatter, or unparseable YAML degrades to an empty summary
832/// / unknown type rather than failing the whole hydration — `neighborhood` is
833/// best-effort context assembly, not validation.
834fn read_summary_and_type(store: &Store, rel: &Path) -> (String, Option<String>) {
835    let abs = match resolve_existing(store, rel) {
836        Some(a) => a,
837        None => return (String::new(), None),
838    };
839    let text = match std::fs::read_to_string(&abs) {
840        Ok(t) => t,
841        Err(_) => return (String::new(), None),
842    };
843    let yaml = match frontmatter_block(&text) {
844        Some(y) => y,
845        None => return (String::new(), None),
846    };
847    let value: serde_norway::Value = match serde_norway::from_str(yaml) {
848        Ok(v) => v,
849        Err(_) => return (String::new(), None),
850    };
851    let summary = value
852        .get("summary")
853        .and_then(|v| v.as_str())
854        .unwrap_or("")
855        .to_string();
856    let type_ = value
857        .get("type")
858        .and_then(|v| v.as_str())
859        .map(|s| s.to_string());
860    (summary, type_)
861}
862
863/// Return the YAML between the opening and closing `---` fences (exclusive), or
864/// `None` if the text has no leading frontmatter block. Local mirror of the
865/// parser's split so the graph module stays self-contained.
866fn frontmatter_block(text: &str) -> Option<&str> {
867    // Tolerate a single leading UTF-8 BOM, matching parser/store/index/validate.
868    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
869    let rest = text
870        .strip_prefix("---\n")
871        .or_else(|| text.strip_prefix("---\r\n"))?;
872    // Find the closing fence: a line that is exactly `---`.
873    let mut idx = 0usize;
874    for line in rest.split_inclusive('\n') {
875        let trimmed = line.trim_end_matches(['\r', '\n']);
876        if trimmed == "---" {
877            return Some(&rest[..idx]);
878        }
879        idx += line.len();
880    }
881    None
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use std::fs;
888    use tempfile::TempDir;
889
890    use crate::parser::Config;
891
892    // ── Fixture builder ─────────────────────────────────────────────────────
893    //
894    // A real on-disk store in a tempdir. We write actual files (frontmatter +
895    // wiki-links) and exercise the real code paths. The fixture constructs the
896    // `Store` by its public fields rather than `Store::open`, so the graph
897    // tests stand on their own and do not depend on any other module's
898    // behavior. Each test asserts the behavior the SPEC promises, derived from
899    // intent, never from echoing the function's own output.
900    //
901    // `backlinks` (and `neighborhood` in any incoming direction) enumerate their
902    // candidate set from the type-folder `index.jsonl` sidecars — the loop
903    // contract: never a whole-store content walk. A real db.md store maintains
904    // those sidecars write-through, so a test that exercises backlinks must call
905    // [`Fixture::reindex`] after writing its files to build them (the SWEEP that
906    // `dbmd index rebuild` runs). Forwardlinks/orphans read content directly and
907    // need no sidecar.
908
909    struct Fixture {
910        _tmp: TempDir,
911        store: Store,
912    }
913
914    impl Fixture {
915        fn new() -> Self {
916            let tmp = TempDir::new().expect("tempdir");
917            let root = tmp.path().to_path_buf();
918            fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").expect("DB.md");
919            let store = Store {
920                root,
921                config: Config::default(),
922            };
923            Fixture { _tmp: tmp, store }
924        }
925
926        /// Write a content file at a store-relative path with the given type,
927        /// summary, and body. Creates parent dirs.
928        fn write(&self, rel: &str, type_: &str, summary: &str, body: &str) {
929            let abs = self.store.root.join(rel);
930            fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
931            let contents = format!(
932                "---\ntype: {type_}\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: {summary}\n---\n{body}\n"
933            );
934            fs::write(&abs, contents).expect("write file");
935        }
936
937        /// Write a raw file verbatim (for frontmatter-shape edge cases).
938        fn write_raw(&self, rel: &str, contents: &str) {
939            let abs = self.store.root.join(rel);
940            fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
941            fs::write(&abs, contents).expect("write raw");
942        }
943
944        /// Build the type-folder `index.jsonl` sidecars from the content written
945        /// so far — the state a real store is always in (write-through), and the
946        /// candidate set `backlinks` reads. Call after writing files in any test
947        /// that exercises `backlinks` or an incoming-direction `neighborhood`.
948        fn reindex(&self) {
949            crate::index::Index::rebuild_all(&self.store).expect("rebuild sidecars");
950        }
951
952        fn p(&self, rel: &str) -> PathBuf {
953            PathBuf::from(rel)
954        }
955    }
956
957    fn paths(v: &[PathBuf]) -> Vec<String> {
958        v.iter()
959            .map(|p| p.to_string_lossy().replace('\\', "/"))
960            .collect()
961    }
962
963    // ── normalize_target ────────────────────────────────────────────────────
964
965    #[test]
966    fn normalize_strips_md_and_leading_dotslash() {
967        assert_eq!(
968            normalize_target(Path::new("records/contacts/sarah.md")),
969            "records/contacts/sarah"
970        );
971        assert_eq!(
972            normalize_target(Path::new("./wiki/people/elena")),
973            "wiki/people/elena"
974        );
975        assert_eq!(normalize_target(Path::new("/records/x")), "records/x");
976        // Bare and `.md` forms must collapse to the same key, or edges won't unify.
977        assert_eq!(
978            normalize_target(Path::new("a/b")),
979            normalize_target(Path::new("a/b.md"))
980        );
981    }
982
983    // ── extract_link_targets (forwardlinks core) ────────────────────────────
984
985    #[test]
986    fn extract_handles_display_text_and_md_suffix() {
987        let body = "See [[wiki/people/sarah-chen|Sarah]] and [[records/contacts/elena.md]].";
988        let got = extract_link_targets(body);
989        assert_eq!(
990            got,
991            vec!["wiki/people/sarah-chen", "records/contacts/elena"]
992        );
993    }
994
995    #[test]
996    fn extract_ignores_external_markdown_links() {
997        // Standard markdown links are NOT wiki-links and must not be extracted
998        // (SPEC: external refs don't participate in the graph).
999        let body = "[Acme](https://acme.io) but [[records/companies/acme]] is internal.";
1000        let got = extract_link_targets(body);
1001        assert_eq!(got, vec!["records/companies/acme"]);
1002    }
1003
1004    #[test]
1005    fn extract_display_text_is_not_treated_as_a_target() {
1006        // A `|display` segment that looks path-like must not become a target;
1007        // only the part before `|` is the link target.
1008        let body = "[[records/contacts/sarah|sources/emails/decoy]]";
1009        let got = extract_link_targets(body);
1010        assert_eq!(got, vec!["records/contacts/sarah"]);
1011    }
1012
1013    // ── rewrite_links_to (write-side twin of backlinks) ─────────────────────
1014
1015    #[test]
1016    fn rewrite_plain_link_to_canonical_new_target() {
1017        let got = rewrite_links_to(
1018            "See [[records/contacts/sarah-chen]] today.",
1019            Path::new("records/contacts/sarah-chen"),
1020            Path::new("records/contacts/sarah-chen-acme"),
1021        );
1022        assert_eq!(got, "See [[records/contacts/sarah-chen-acme]] today.");
1023    }
1024
1025    #[test]
1026    fn rewrite_preserves_display_override() {
1027        let got = rewrite_links_to(
1028            "With [[records/contacts/sarah-chen|Sarah]].",
1029            Path::new("records/contacts/sarah-chen"),
1030            Path::new("records/contacts/sarah-chen-acme"),
1031        );
1032        assert_eq!(got, "With [[records/contacts/sarah-chen-acme|Sarah]].");
1033    }
1034
1035    #[test]
1036    fn rewrite_matches_md_suffixed_old_and_emits_bare_new() {
1037        // The `.md` spelling of the old target must match (it normalizes to the
1038        // same key the read side uses), and the new target is emitted bare —
1039        // the writer doctrine validate enforces (`WIKI_LINK_HAS_EXTENSION`).
1040        let got = rewrite_links_to(
1041            "[[records/contacts/sarah-chen.md]]",
1042            Path::new("records/contacts/sarah-chen"),
1043            Path::new("records/contacts/new.md"),
1044        );
1045        assert_eq!(got, "[[records/contacts/new]]");
1046    }
1047
1048    #[test]
1049    fn rewrite_leaves_prefix_collisions_and_short_form_untouched() {
1050        // Boundary correctness, anchored to the SAME normalize_target the read
1051        // side keys on: `records/contacts/sarah-chen` must NOT match the longer
1052        // `[[…-jr]]`, the short-form `[[sarah-chen]]`, or an unrelated target.
1053        let input = "[[records/contacts/sarah-chen-jr]] [[sarah-chen]] [[wiki/topics/x]]";
1054        let got = rewrite_links_to(
1055            input,
1056            Path::new("records/contacts/sarah-chen"),
1057            Path::new("records/contacts/new"),
1058        );
1059        assert_eq!(got, input, "no genuine edge to the seed → text unchanged");
1060    }
1061
1062    #[test]
1063    fn rewrite_handles_multiple_occurrences_and_mixed_spellings() {
1064        let got = rewrite_links_to(
1065            "[[records/x]] then [[./records/x]] and [[records/x.md|d]] end",
1066            Path::new("records/x"),
1067            Path::new("records/y"),
1068        );
1069        // All three spellings of the same target retarget; the display survives.
1070        assert_eq!(
1071            got,
1072            "[[records/y]] then [[records/y]] and [[records/y|d]] end"
1073        );
1074    }
1075
1076    #[test]
1077    fn rewrite_retargets_exactly_the_edges_the_core_parser_sees() {
1078        // The load-bearing property of moving the rewrite into core: the write
1079        // side must operate on EXACTLY the edge set the read side recognizes —
1080        // the same `extract_link_targets` / `normalize_target` grammar that
1081        // `forwardlinks` is built on. Anchor the test to that grammar (via
1082        // `forwardlinks` on a real file) rather than re-listing literals, so a
1083        // future divergence between the read parser and the write rewrite fails
1084        // here. (Coupled to `forwardlinks` — the single-file edge extractor —
1085        // not the multi-file `backlinks` traversal, so it tests the grammar, not
1086        // the walk.)
1087        let fx = Fixture::new();
1088        let body = "Met [[records/contacts/sarah.md|Sarah]] and not [[records/contacts/sarah-2]].";
1089        fx.write("wiki/people/bio.md", "wiki-page", "bio", body);
1090
1091        // Read side: the parser sees two outgoing edges, both in canonical bare
1092        // form (the `.md` spelling collapsed). `sarah` is a real edge here.
1093        let edges = forwardlinks(&fx.store, &fx.p("wiki/people/bio.md")).unwrap();
1094        assert_eq!(
1095            paths(&edges),
1096            vec!["records/contacts/sarah", "records/contacts/sarah-2"],
1097            "fixture must contain exactly the two edges this test reasons about"
1098        );
1099
1100        // Write side: rewriting `sarah → sarah-chen` must retarget the edge the
1101        // parser recognized (matching the `.md` spelling), preserve the display,
1102        // and leave the unrelated `sarah-2` edge untouched.
1103        let got = rewrite_links_to(
1104            body,
1105            Path::new("records/contacts/sarah"),
1106            Path::new("records/contacts/sarah-chen"),
1107        );
1108        assert_eq!(
1109            got,
1110            "Met [[records/contacts/sarah-chen|Sarah]] and not [[records/contacts/sarah-2]]."
1111        );
1112
1113        // Cross-check through the parser: the rewritten text's edge set is the
1114        // original with `sarah` swapped for `sarah-chen` — proving the rewrite
1115        // moved exactly one edge, the one the read side keyed on.
1116        fx.write("wiki/people/bio.md", "wiki-page", "bio", &got);
1117        let after = forwardlinks(&fx.store, &fx.p("wiki/people/bio.md")).unwrap();
1118        assert_eq!(
1119            paths(&after),
1120            vec!["records/contacts/sarah-2", "records/contacts/sarah-chen"],
1121            "after rewrite the parser must see the new target and not the old"
1122        );
1123    }
1124
1125    #[test]
1126    fn rewrite_empty_old_target_is_a_no_op() {
1127        // A degenerate `old` (normalizes to empty) must never rewrite anything,
1128        // mirroring backlinks' empty-target guard.
1129        let input = "[[records/x]] [[]] text";
1130        let got = rewrite_links_to(input, Path::new(""), Path::new("records/y"));
1131        assert_eq!(got, input);
1132    }
1133
1134    #[test]
1135    fn rewrite_no_match_returns_input_unchanged() {
1136        let input = "no links, [external](https://x), and [[wiki/topics/y]]";
1137        let got = rewrite_links_to(input, Path::new("records/x"), Path::new("records/z"));
1138        assert_eq!(got, input);
1139    }
1140
1141    #[test]
1142    fn rewrite_does_not_corrupt_links_in_nested_or_long_run_fences() {
1143        // Regression for the naive `starts_with("```")/("~~~")` toggle in the
1144        // rewriter: a fenced example documenting wiki-link syntax must be copied
1145        // VERBATIM, never retargeted — matching validate's edge notion. The
1146        // standard nested-fence convention (a ````-run block wrapping a ```
1147        // example) used to flip the bool mid-block, so the example link was
1148        // rewritten (silent documentation corruption).
1149        let body = "\
1150Here is how to write a link:
1151
1152````
1153```
1154[[records/contacts/bob]]
1155```
1156still fenced [[records/contacts/bob]]
1157````
1158
1159Real link: [[records/contacts/bob]].
1160";
1161        let got = rewrite_links_to(
1162            body,
1163            Path::new("records/contacts/bob"),
1164            Path::new("records/contacts/robert"),
1165        );
1166        // The two fenced examples are untouched; only the real link retargets.
1167        let expected = "\
1168Here is how to write a link:
1169
1170````
1171```
1172[[records/contacts/bob]]
1173```
1174still fenced [[records/contacts/bob]]
1175````
1176
1177Real link: [[records/contacts/robert]].
1178";
1179        assert_eq!(
1180            got, expected,
1181            "fenced example links must survive a rename verbatim; only live edges retarget"
1182        );
1183    }
1184
1185    // ── forwardlinks ─────────────────────────────────────────────────────────
1186
1187    #[test]
1188    fn forwardlinks_returns_sorted_deduped_targets_excluding_self() {
1189        let fx = Fixture::new();
1190        fx.write(
1191            "wiki/projects/renewal.md",
1192            "wiki-page",
1193            "Renewal project",
1194            "Links: [[records/contacts/sarah]] [[records/companies/acme]] [[records/contacts/sarah]] and itself [[wiki/projects/renewal]].",
1195        );
1196        // The targets need not exist on disk for forwardlinks (it reads the one
1197        // file only). Self-links are dropped; duplicates collapse; sorted asc.
1198        let got = forwardlinks(&fx.store, &fx.p("wiki/projects/renewal.md")).unwrap();
1199        assert_eq!(
1200            paths(&got),
1201            vec!["records/companies/acme", "records/contacts/sarah"]
1202        );
1203    }
1204
1205    #[test]
1206    fn forwardlinks_picks_up_wiki_links_in_frontmatter() {
1207        // SPEC: wiki-links appear in scalar + block-sequence frontmatter fields,
1208        // not just the body. forwardlinks must follow those edges too.
1209        let fx = Fixture::new();
1210        fx.write_raw(
1211            "records/meetings/m1.md",
1212            "---\ntype: meeting\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: Renewal sync\ncompany: [[records/companies/acme]]\nattendees:\n  - [[records/contacts/sarah]]\n  - [[records/contacts/elena]]\n---\nNotes about [[wiki/projects/renewal]].\n",
1213        );
1214        let got = forwardlinks(&fx.store, &fx.p("records/meetings/m1.md")).unwrap();
1215        assert_eq!(
1216            paths(&got),
1217            vec![
1218                "records/companies/acme",
1219                "records/contacts/elena",
1220                "records/contacts/sarah",
1221                "wiki/projects/renewal",
1222            ]
1223        );
1224    }
1225
1226    #[test]
1227    fn forwardlinks_missing_file_is_empty_not_error() {
1228        let fx = Fixture::new();
1229        let got = forwardlinks(&fx.store, &fx.p("wiki/people/ghost.md")).unwrap();
1230        assert!(got.is_empty());
1231    }
1232
1233    #[test]
1234    fn forwardlinks_resolves_seed_given_without_md_extension() {
1235        let fx = Fixture::new();
1236        fx.write(
1237            "wiki/people/sarah.md",
1238            "wiki-page",
1239            "Sarah bio",
1240            "Works at [[records/companies/acme]].",
1241        );
1242        // Seed passed in bare wiki-link form (no `.md`) must still resolve.
1243        let got = forwardlinks(&fx.store, &fx.p("wiki/people/sarah")).unwrap();
1244        assert_eq!(paths(&got), vec!["records/companies/acme"]);
1245    }
1246
1247    // ── backlinks ──────────────────────────────────────────────────────────
1248
1249    #[test]
1250    fn backlinks_finds_incoming_across_layers_and_link_forms() {
1251        let fx = Fixture::new();
1252        // Target.
1253        fx.write("records/contacts/sarah.md", "contact", "Sarah Chen", "");
1254        // Three different incoming-link spellings, all to the same target.
1255        fx.write(
1256            "wiki/people/sarah.md",
1257            "wiki-page",
1258            "bio",
1259            "See [[records/contacts/sarah]].",
1260        );
1261        fx.write(
1262            "records/meetings/m1.md",
1263            "meeting",
1264            "Renewal call",
1265            "Attendee [[records/contacts/sarah|Sarah]].",
1266        );
1267        fx.write(
1268            "sources/emails/e1.md",
1269            "email",
1270            "Hi",
1271            "From [[records/contacts/sarah.md]] today.",
1272        );
1273        // A file that links to a DIFFERENT contact must not be a backlink.
1274        fx.write(
1275            "wiki/people/other.md",
1276            "wiki-page",
1277            "x",
1278            "[[records/contacts/sarah-2]]",
1279        );
1280        fx.reindex();
1281
1282        // All three link forms ([[x]], [[x|d]], [[x.md]]) resolve to the same
1283        // target and are found; the linkers are returned in canonical bare form.
1284        let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1285        assert_eq!(
1286            paths(&got),
1287            vec![
1288                "records/meetings/m1",
1289                "sources/emails/e1",
1290                "wiki/people/sarah",
1291            ]
1292        );
1293    }
1294
1295    #[test]
1296    fn backlinks_and_forwardlinks_round_trip_on_same_key() {
1297        // If A forwardlinks to B, then B backlinks to A — both expressed in the
1298        // identical bare key, so neighborhood can dedup across directions.
1299        let fx = Fixture::new();
1300        fx.write(
1301            "wiki/people/a.md",
1302            "wiki-page",
1303            "A",
1304            "Knows [[wiki/people/b]].",
1305        );
1306        fx.write("wiki/people/b.md", "wiki-page", "B", "");
1307        fx.reindex();
1308        let fwd = forwardlinks(&fx.store, &fx.p("wiki/people/a.md")).unwrap();
1309        let back = backlinks(&fx.store, &fx.p("wiki/people/b.md")).unwrap();
1310        assert_eq!(paths(&fwd), vec!["wiki/people/b"]);
1311        assert_eq!(paths(&back), vec!["wiki/people/a"]);
1312    }
1313
1314    #[test]
1315    fn backlinks_does_not_match_path_prefix_collisions() {
1316        let fx = Fixture::new();
1317        fx.write("records/contacts/sam.md", "contact", "Sam", "");
1318        // `sam-smith` shares the `sam` prefix; must NOT count as a backlink to `sam`.
1319        fx.write(
1320            "wiki/people/x.md",
1321            "wiki-page",
1322            "x",
1323            "[[records/contacts/sam-smith]]",
1324        );
1325        // The genuine backlink.
1326        fx.write(
1327            "wiki/people/y.md",
1328            "wiki-page",
1329            "y",
1330            "[[records/contacts/sam]]",
1331        );
1332        fx.reindex();
1333
1334        let got = backlinks(&fx.store, &fx.p("records/contacts/sam")).unwrap();
1335        assert_eq!(paths(&got), vec!["wiki/people/y"]);
1336    }
1337
1338    #[test]
1339    fn backlinks_excludes_self_reference() {
1340        let fx = Fixture::new();
1341        // A page that links to itself is not its own backlink.
1342        fx.write(
1343            "wiki/synthesis/overview.md",
1344            "wiki-page",
1345            "Overview",
1346            "This page [[wiki/synthesis/overview]] references itself.",
1347        );
1348        fx.reindex();
1349        let got = backlinks(&fx.store, &fx.p("wiki/synthesis/overview.md")).unwrap();
1350        assert!(
1351            got.is_empty(),
1352            "self-link must not appear as a backlink, got {got:?}"
1353        );
1354    }
1355
1356    #[test]
1357    fn backlinks_empty_when_nobody_links() {
1358        let fx = Fixture::new();
1359        fx.write("records/contacts/lonely.md", "contact", "Lonely", "");
1360        fx.write(
1361            "wiki/people/unrelated.md",
1362            "wiki-page",
1363            "x",
1364            "[[records/companies/acme]]",
1365        );
1366        fx.reindex();
1367        let got = backlinks(&fx.store, &fx.p("records/contacts/lonely.md")).unwrap();
1368        assert!(got.is_empty());
1369    }
1370
1371    #[test]
1372    fn backlinks_ignores_index_and_meta_files() {
1373        let fx = Fixture::new();
1374        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1375        // An index.md that lists the target must NOT be reported as a backlink
1376        // (indexes are catalog, not relationship edges).
1377        fx.write_raw(
1378            "records/contacts/index.md",
1379            "---\ntype: index\nscope: folder\nfolder: records/contacts\n---\n- [[records/contacts/sarah]] — Sarah\n",
1380        );
1381        fx.reindex();
1382        let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1383        assert!(got.is_empty(), "index.md must be excluded, got {got:?}");
1384    }
1385
1386    #[test]
1387    fn backlinks_finds_body_only_edge_not_in_frontmatter_links_field() {
1388        // REGRESSION: the sidecar's `links` field carries only the file's
1389        // frontmatter `links:` list; it does NOT include wiki-links written in
1390        // the body or in other typed frontmatter fields. Answering backlinks
1391        // from `links[]` alone would silently miss this edge. The candidate set
1392        // is sidecar-bounded, but each candidate's edge is confirmed by parsing
1393        // the file (the same extraction forwardlinks uses), so a body-only link
1394        // must still register as a backlink.
1395        let fx = Fixture::new();
1396        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1397        // `meeting.md` links to sarah ONLY in its body — its frontmatter has no
1398        // `links:` field at all, so the sidecar record's `links` is empty.
1399        fx.write(
1400            "records/meetings/standup.md",
1401            "meeting",
1402            "Standup",
1403            "Discussed renewal with [[records/contacts/sarah]].",
1404        );
1405        fx.reindex();
1406
1407        // Guard the premise: the sidecar record really does carry an empty
1408        // `links` (so this test fails loudly if the index ever starts extracting
1409        // body links — at which point the backlink predicate could be revisited).
1410        let rec = fx
1411            .store
1412            .find_by_type("meeting")
1413            .unwrap()
1414            .into_iter()
1415            .find(|r| r.path == fx.p("records/meetings/standup.md"))
1416            .expect("meeting is catalogued in its sidecar");
1417        assert!(
1418            rec.links.is_empty(),
1419            "premise: the body link is NOT projected into the sidecar `links` field; got {:?}",
1420            rec.links
1421        );
1422
1423        // Yet backlinks still finds it — because it confirms via the file parse,
1424        // not via the sidecar `links` field.
1425        let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1426        assert_eq!(
1427            paths(&got),
1428            vec!["records/meetings/standup"],
1429            "a body-only wiki-link must register as a backlink"
1430        );
1431    }
1432
1433    #[test]
1434    fn backlinks_finds_edge_in_typed_frontmatter_field() {
1435        // A wiki-link inside a *typed* frontmatter field (`company:`) is a real
1436        // edge forwardlinks follows, so backlinks must find it too — even though
1437        // the sidecar's `links` field (the `links:` key only) does not list it.
1438        let fx = Fixture::new();
1439        fx.write("records/companies/acme.md", "company", "Acme", "");
1440        fx.write_raw(
1441            "records/contacts/sarah.md",
1442            "---\ntype: contact\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: Sarah\ncompany: [[records/companies/acme]]\n---\nBody with no links.\n",
1443        );
1444        fx.reindex();
1445        let got = backlinks(&fx.store, &fx.p("records/companies/acme.md")).unwrap();
1446        assert_eq!(
1447            paths(&got),
1448            vec!["records/contacts/sarah"],
1449            "a wiki-link in a typed frontmatter field is an incoming edge"
1450        );
1451    }
1452
1453    #[test]
1454    fn backlinks_unscoped_scans_the_tree_not_only_the_sidecar() {
1455        // REGRESSION (loop budget): an UNSCOPED `backlinks` must resolve incoming
1456        // edges with a SINGLE embedded-ripgrep pass over the tree
1457        // (`Store::find_links_to`), NOT by reading the sidecar candidate set and
1458        // then `read_to_string`-confirming each candidate (which re-opens every
1459        // content file → O(store); the documented >3x budget miss). A ripgrep
1460        // pass is the same scan engine `validate`/`rename`/`dbmd links` ride, and
1461        // the tree — not the sidecar — is its ground truth: a linker that is on
1462        // disk but absent from every sidecar (stale / never-built index) is still
1463        // found. We assert that behaviorally, which fails loudly if the unscoped
1464        // path ever reverts to the sidecar-bounded per-candidate confirm loop
1465        // (that loop would NOT find the unindexed linker).
1466        let fx = Fixture::new();
1467        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1468        fx.write(
1469            "wiki/people/indexed.md",
1470            "wiki-page",
1471            "Indexed",
1472            "[[records/contacts/sarah]]",
1473        );
1474        fx.reindex(); // builds sidecars for sarah + the indexed linker
1475
1476        // Now drop a NEW linker on disk WITHOUT reindexing — it is on disk but in
1477        // no sidecar.
1478        fx.write(
1479            "wiki/people/unindexed.md",
1480            "wiki-page",
1481            "Unindexed",
1482            "[[records/contacts/sarah]]",
1483        );
1484
1485        let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1486        assert_eq!(
1487            paths(&got),
1488            vec!["wiki/people/indexed", "wiki/people/unindexed"],
1489            "unscoped backlinks ripgrep-scans the tree, so the on-disk-but-unindexed \
1490             linker is found too — not only the sidecar-catalogued one"
1491        );
1492    }
1493
1494    #[test]
1495    fn backlinks_scoped_candidates_come_from_the_sidecar_not_a_tree_walk() {
1496        // REGRESSION (scale contract): the SCOPED form (`--type` / `--in`) is the
1497        // I/O-scoped path — it enumerates candidates from the relevant type-folder
1498        // `index.jsonl` sidecars and parses only those, NOT a whole-tree walk.
1499        // That is what makes the scope an I/O scope, not just a result filter:
1500        // a linker that is on disk but ABSENT from the sidecar (stale / never-built
1501        // index) is NOT discovered by the scoped call (the sidecar bounds which
1502        // files are candidates). This is the loop-vs-walk distinction the SPEC
1503        // draws, and it is exactly the inverse of the unscoped tree scan above.
1504        let fx = Fixture::new();
1505        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1506        fx.write(
1507            "wiki/people/indexed.md",
1508            "wiki-page",
1509            "Indexed",
1510            "[[records/contacts/sarah]]",
1511        );
1512        fx.reindex(); // builds sidecars for sarah + the indexed linker
1513
1514        // Drop a NEW wiki-page linker on disk WITHOUT reindexing — on disk, in no
1515        // sidecar.
1516        fx.write(
1517            "wiki/people/unindexed.md",
1518            "wiki-page",
1519            "Unindexed",
1520            "[[records/contacts/sarah]]",
1521        );
1522
1523        // Scoped to the `wiki-page` type: the candidate set is the sidecar's, so
1524        // only the catalogued linker is found — the unindexed one is invisible.
1525        let only_wiki_pages = vec!["wiki-page".to_string()];
1526        let got = backlinks_filtered(
1527            &fx.store,
1528            &fx.p("records/contacts/sarah.md"),
1529            &only_wiki_pages,
1530            None,
1531        )
1532        .unwrap();
1533        assert_eq!(
1534            paths(&got),
1535            vec!["wiki/people/indexed"],
1536            "scoped backlinks reads the sidecar candidate set; the on-disk-but-unindexed \
1537             linker is not tree-walked"
1538        );
1539    }
1540
1541    #[test]
1542    fn backlinks_filtered_type_scopes_the_candidate_set() {
1543        // `--type` narrows backlinks to linkers of that type. Two files link to
1544        // the target — one `meeting`, one `wiki-page`; filtering to `meeting`
1545        // returns only the meeting.
1546        let fx = Fixture::new();
1547        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1548        fx.write(
1549            "records/meetings/m1.md",
1550            "meeting",
1551            "Call",
1552            "[[records/contacts/sarah]]",
1553        );
1554        fx.write(
1555            "wiki/people/bio.md",
1556            "wiki-page",
1557            "Bio",
1558            "[[records/contacts/sarah]]",
1559        );
1560        fx.reindex();
1561
1562        let only_meetings = vec!["meeting".to_string()];
1563        let got = backlinks_filtered(
1564            &fx.store,
1565            &fx.p("records/contacts/sarah.md"),
1566            &only_meetings,
1567            None,
1568        )
1569        .unwrap();
1570        assert_eq!(
1571            paths(&got),
1572            vec!["records/meetings/m1"],
1573            "--type meeting must exclude the wiki-page linker"
1574        );
1575
1576        // Unfiltered, both come back — proving the filter (not the data) dropped one.
1577        let all = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1578        assert_eq!(paths(&all), vec!["records/meetings/m1", "wiki/people/bio"]);
1579    }
1580
1581    #[test]
1582    fn backlinks_filtered_layer_scopes_the_candidate_set() {
1583        // `--in <layer>` narrows backlinks to linkers under that layer.
1584        let fx = Fixture::new();
1585        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1586        fx.write(
1587            "records/meetings/m1.md",
1588            "meeting",
1589            "Call",
1590            "[[records/contacts/sarah]]",
1591        );
1592        fx.write(
1593            "wiki/people/bio.md",
1594            "wiki-page",
1595            "Bio",
1596            "[[records/contacts/sarah]]",
1597        );
1598        fx.reindex();
1599
1600        let got = backlinks_filtered(
1601            &fx.store,
1602            &fx.p("records/contacts/sarah.md"),
1603            &[],
1604            Some(Layer::Wiki),
1605        )
1606        .unwrap();
1607        assert_eq!(
1608            paths(&got),
1609            vec!["wiki/people/bio"],
1610            "--in wiki must keep only the wiki-layer linker"
1611        );
1612
1613        let records_only = backlinks_filtered(
1614            &fx.store,
1615            &fx.p("records/contacts/sarah.md"),
1616            &[],
1617            Some(Layer::Records),
1618        )
1619        .unwrap();
1620        assert_eq!(paths(&records_only), vec!["records/meetings/m1"]);
1621    }
1622
1623    #[test]
1624    fn backlinks_scoped_type_spans_all_topic_folders_in_its_layer() {
1625        // REGRESSION (finding #12): a `type` can legitimately span several folders
1626        // within one layer — `wiki-page` is filed under `wiki/<topic>/` for an
1627        // arbitrary topic (SPEC). The scoped candidate set must read the whole
1628        // `wiki/` layer and filter by type, NOT just the canonical-guess folder
1629        // `wiki/topics/`. Before the fix, `find_by_type("wiki-page")` read ONLY
1630        // `wiki/topics/index.jsonl` whenever that sidecar existed, silently
1631        // dropping every wiki-page linker filed under any other topic folder — so
1632        // `backlinks --type wiki-page` under-reported dependents (a wrong
1633        // blast-radius check) the moment a `wiki/topics/` page also existed.
1634        //
1635        // The trigger needs BOTH: a populated `wiki/topics/` (so its canonical
1636        // sidecar exists) AND a wiki-page elsewhere in the layer that links the
1637        // target. The earlier
1638        // `backlinks_scoped_candidates_come_from_the_sidecar_not_a_tree_walk` test
1639        // masks this bug precisely because its fixture has no `wiki/topics/`.
1640        let fx = Fixture::new();
1641        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1642        // A wiki-page in the CANONICAL topic folder, NOT linking the target — its
1643        // only purpose is to make `wiki/topics/index.jsonl` exist on disk.
1644        fx.write(
1645            "wiki/topics/glossary.md",
1646            "wiki-page",
1647            "Glossary",
1648            "No link to sarah here.",
1649        );
1650        // A wiki-page in a NON-canonical topic folder that DOES link the target.
1651        fx.write(
1652            "wiki/people/sarah.md",
1653            "wiki-page",
1654            "Sarah bio",
1655            "Profile of [[records/contacts/sarah]].",
1656        );
1657        fx.reindex(); // builds wiki/topics/index.jsonl AND wiki/people/index.jsonl
1658
1659        // Scoped to `wiki-page`: the off-canonical linker MUST be found. Pre-fix,
1660        // the candidate set was only `wiki/topics/`'s sidecar, so this was empty.
1661        let scoped = backlinks_filtered(
1662            &fx.store,
1663            &fx.p("records/contacts/sarah.md"),
1664            &["wiki-page".to_string()],
1665            None,
1666        )
1667        .unwrap();
1668        assert_eq!(
1669            paths(&scoped),
1670            vec!["wiki/people/sarah"],
1671            "a wiki-page filed outside wiki/topics/ must still be a scoped backlink"
1672        );
1673
1674        // Cross-check: the unscoped path (ripgrep tree scan) finds the same single
1675        // linker, proving the scoped result is now complete — not over- or
1676        // under-counting — and that the data was real all along.
1677        let unscoped = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1678        assert_eq!(
1679            paths(&unscoped),
1680            vec!["wiki/people/sarah"],
1681            "scoped and unscoped backlinks must agree on the edge set"
1682        );
1683    }
1684
1685    // ── neighborhood ─────────────────────────────────────────────────────────
1686
1687    #[test]
1688    fn neighborhood_hops_zero_is_empty() {
1689        let fx = Fixture::new();
1690        fx.write("wiki/people/a.md", "wiki-page", "A", "[[wiki/people/b]]");
1691        fx.write("wiki/people/b.md", "wiki-page", "B", "");
1692        let slice = neighborhood(
1693            &fx.store,
1694            &fx.p("wiki/people/a.md"),
1695            0,
1696            &[],
1697            Direction::Both,
1698        )
1699        .unwrap();
1700        assert_eq!(slice.seed, fx.p("wiki/people/a"));
1701        assert!(slice.nodes.is_empty());
1702    }
1703
1704    #[test]
1705    fn neighborhood_outgoing_one_hop_reads_summary_and_type() {
1706        let fx = Fixture::new();
1707        fx.write(
1708            "wiki/people/a.md",
1709            "wiki-page",
1710            "Person A",
1711            "Knows [[records/contacts/b]].",
1712        );
1713        fx.write("records/contacts/b.md", "contact", "Contact B summary", "");
1714        let slice = neighborhood(
1715            &fx.store,
1716            &fx.p("wiki/people/a.md"),
1717            1,
1718            &[],
1719            Direction::Outgoing,
1720        )
1721        .unwrap();
1722        assert_eq!(slice.nodes.len(), 1);
1723        let n = &slice.nodes[0];
1724        assert_eq!(n.path, fx.p("records/contacts/b"));
1725        assert_eq!(n.summary, "Contact B summary");
1726        assert_eq!(n.type_.as_deref(), Some("contact"));
1727        assert_eq!(n.hops, 1);
1728        assert_eq!(n.via, Some((fx.p("wiki/people/a"), Direction::Outgoing)));
1729    }
1730
1731    #[test]
1732    fn neighborhood_incoming_only_walks_backlinks() {
1733        let fx = Fixture::new();
1734        // a -> seed (incoming to seed). seed -> c (outgoing from seed).
1735        fx.write(
1736            "wiki/people/seed.md",
1737            "wiki-page",
1738            "Seed",
1739            "Out to [[wiki/people/c]].",
1740        );
1741        fx.write(
1742            "wiki/people/a.md",
1743            "wiki-page",
1744            "A",
1745            "In to [[wiki/people/seed]].",
1746        );
1747        fx.write("wiki/people/c.md", "wiki-page", "C", "");
1748        fx.reindex();
1749        let slice = neighborhood(
1750            &fx.store,
1751            &fx.p("wiki/people/seed.md"),
1752            1,
1753            &[],
1754            Direction::Incoming,
1755        )
1756        .unwrap();
1757        // Incoming direction: only `a` (which links TO seed), not `c`.
1758        assert_eq!(
1759            paths(
1760                &slice
1761                    .nodes
1762                    .iter()
1763                    .map(|n| n.path.clone())
1764                    .collect::<Vec<_>>()
1765            ),
1766            vec!["wiki/people/a"]
1767        );
1768        assert_eq!(
1769            slice.nodes[0].via,
1770            Some((fx.p("wiki/people/seed"), Direction::Incoming))
1771        );
1772    }
1773
1774    #[test]
1775    fn neighborhood_bounded_bfs_respects_hop_limit_and_min_distance() {
1776        let fx = Fixture::new();
1777        // Chain a -> b -> c -> d, all outgoing.
1778        fx.write("wiki/c/a.md", "wiki-page", "A", "[[wiki/c/b]]");
1779        fx.write("wiki/c/b.md", "wiki-page", "B", "[[wiki/c/c]]");
1780        fx.write("wiki/c/c.md", "wiki-page", "C", "[[wiki/c/d]]");
1781        fx.write("wiki/c/d.md", "wiki-page", "D", "");
1782        let slice =
1783            neighborhood(&fx.store, &fx.p("wiki/c/a.md"), 2, &[], Direction::Outgoing).unwrap();
1784        // 2 hops reaches b (1) and c (2), not d (3).
1785        let by_path: HashMap<String, u32> = slice
1786            .nodes
1787            .iter()
1788            .map(|n| (n.path.to_string_lossy().to_string(), n.hops))
1789            .collect();
1790        assert_eq!(by_path.get("wiki/c/b").copied(), Some(1));
1791        assert_eq!(by_path.get("wiki/c/c").copied(), Some(2));
1792        assert_eq!(by_path.get("wiki/c/d"), None);
1793        assert_eq!(slice.nodes.len(), 2);
1794    }
1795
1796    #[test]
1797    fn neighborhood_records_min_hops_on_diamond() {
1798        let fx = Fixture::new();
1799        // Diamond: a -> b, a -> c, b -> d, c -> d. d is reachable at hop 2 from
1800        // either branch; it must be recorded once, at hop 2.
1801        fx.write("wiki/d/a.md", "wiki-page", "A", "[[wiki/d/b]] [[wiki/d/c]]");
1802        fx.write("wiki/d/b.md", "wiki-page", "B", "[[wiki/d/d]]");
1803        fx.write("wiki/d/c.md", "wiki-page", "C", "[[wiki/d/d]]");
1804        fx.write("wiki/d/d.md", "wiki-page", "D", "");
1805        let slice =
1806            neighborhood(&fx.store, &fx.p("wiki/d/a.md"), 3, &[], Direction::Outgoing).unwrap();
1807        let d_nodes: Vec<&ContextNode> = slice
1808            .nodes
1809            .iter()
1810            .filter(|n| n.path == fx.p("wiki/d/d"))
1811            .collect();
1812        assert_eq!(d_nodes.len(), 1, "d must appear exactly once");
1813        assert_eq!(d_nodes[0].hops, 2, "d's min distance from a is 2");
1814        // b and c at hop 1, d at hop 2 => 3 nodes total, no cycle blowup.
1815        assert_eq!(slice.nodes.len(), 3);
1816    }
1817
1818    #[test]
1819    fn neighborhood_type_filter_narrows_results_but_not_traversal() {
1820        let fx = Fixture::new();
1821        // seed -> contact -> meeting. Filtering to `meeting` must still reach
1822        // the meeting THROUGH the (excluded) contact at hop 2.
1823        fx.write(
1824            "wiki/people/seed.md",
1825            "wiki-page",
1826            "Seed",
1827            "[[records/contacts/sarah]]",
1828        );
1829        fx.write(
1830            "records/contacts/sarah.md",
1831            "contact",
1832            "Sarah",
1833            "[[records/meetings/m1]]",
1834        );
1835        fx.write("records/meetings/m1.md", "meeting", "Renewal call", "");
1836        let only_meetings = vec!["meeting".to_string()];
1837        let slice = neighborhood(
1838            &fx.store,
1839            &fx.p("wiki/people/seed.md"),
1840            2,
1841            &only_meetings,
1842            Direction::Outgoing,
1843        )
1844        .unwrap();
1845        // Only the meeting is returned; the contact is traversed but filtered out.
1846        assert_eq!(slice.nodes.len(), 1);
1847        assert_eq!(slice.nodes[0].path, fx.p("records/meetings/m1"));
1848        assert_eq!(slice.nodes[0].type_.as_deref(), Some("meeting"));
1849        assert_eq!(slice.nodes[0].hops, 2);
1850    }
1851
1852    #[test]
1853    fn neighborhood_capped_bounds_traversal_not_just_output() {
1854        // REGRESSION (finding #16): `neighborhood` expands every reached node, and
1855        // each incoming-edge expansion is a full-store scan, so the per-node cost
1856        // is O(visited × store). The CLI's `--limit` was applied post-hoc as a
1857        // `.take(n)` on the RESULT, which caps printed nodes but NOT the traversal
1858        // — the scans still fire for every reachable node. `neighborhood_capped`
1859        // bounds the traversal itself: once `max_nodes` distinct nodes are
1860        // admitted, the BFS stops discovering (and therefore stops scanning).
1861        //
1862        // Structure proving traversal — not just output — is bounded:
1863        //   seed -> a, b, c   (hop 1, discovered in sorted order: a, b, c)
1864        //   a    -> deep      (hop 2, reachable ONLY by expanding `a`)
1865        // Cap at 2: admit `a` and `b`, stop before `c` and before any hop-2
1866        // expansion. `deep` is therefore unreachable. A post-hoc `.take(2)` would
1867        // have traversed the whole graph (reaching `deep`) and only then truncated
1868        // — so the absence of `deep` is observable proof the traversal stopped.
1869        let fx = Fixture::new();
1870        fx.write(
1871            "wiki/n/seed.md",
1872            "wiki-page",
1873            "Seed",
1874            "[[wiki/n/a]] [[wiki/n/b]] [[wiki/n/c]]",
1875        );
1876        fx.write("wiki/n/a.md", "wiki-page", "A", "[[wiki/n/deep]]");
1877        fx.write("wiki/n/b.md", "wiki-page", "B", "");
1878        fx.write("wiki/n/c.md", "wiki-page", "C", "");
1879        fx.write("wiki/n/deep.md", "wiki-page", "Deep", "");
1880
1881        // Uncapped over 3 hops: all four reachable nodes appear (a, b, c at hop 1,
1882        // deep at hop 2) — the full set the cap is measured against.
1883        let full = neighborhood(
1884            &fx.store,
1885            &fx.p("wiki/n/seed.md"),
1886            3,
1887            &[],
1888            Direction::Outgoing,
1889        )
1890        .unwrap();
1891        assert_eq!(
1892            paths(
1893                &full
1894                    .nodes
1895                    .iter()
1896                    .map(|n| n.path.clone())
1897                    .collect::<Vec<_>>()
1898            ),
1899            vec!["wiki/n/a", "wiki/n/b", "wiki/n/c", "wiki/n/deep"],
1900            "uncapped traversal reaches every node within the hop budget"
1901        );
1902
1903        // Capped at 2 over the SAME hop budget: exactly the first two hop-1 nodes,
1904        // and crucially NOT `deep` — the cap halted the BFS before any node was
1905        // expanded into hop 2, so the deep node was never traversed to.
1906        let capped = neighborhood_capped(
1907            &fx.store,
1908            &fx.p("wiki/n/seed.md"),
1909            3,
1910            &[],
1911            Direction::Outgoing,
1912            Some(2),
1913        )
1914        .unwrap();
1915        assert_eq!(
1916            paths(
1917                &capped
1918                    .nodes
1919                    .iter()
1920                    .map(|n| n.path.clone())
1921                    .collect::<Vec<_>>()
1922            ),
1923            vec!["wiki/n/a", "wiki/n/b"],
1924            "the cap bounds traversal: only the first 2 nodes are reached, and the \
1925             hop-2 `deep` node (reachable only by expanding a capped-out node) is \
1926             never traversed"
1927        );
1928
1929        // `max_nodes = None` is exactly the unbounded `neighborhood` behavior.
1930        let uncapped = neighborhood_capped(
1931            &fx.store,
1932            &fx.p("wiki/n/seed.md"),
1933            3,
1934            &[],
1935            Direction::Outgoing,
1936            None,
1937        )
1938        .unwrap();
1939        assert_eq!(
1940            uncapped.nodes.len(),
1941            full.nodes.len(),
1942            "None cap matches the unbounded neighborhood result"
1943        );
1944    }
1945
1946    #[test]
1947    fn neighborhood_capped_both_direction_caps_the_node_count() {
1948        // The CLI always passes `Direction::Both` (the per-node backlinks scan is
1949        // the expensive path the cap exists to bound). The cap gates discovery in
1950        // any direction, so a hub linked from many nodes is still bounded.
1951        let fx = Fixture::new();
1952        fx.write("wiki/h/hub.md", "wiki-page", "Hub", "");
1953        for n in ["a", "b", "c", "d", "e"] {
1954            fx.write(&format!("wiki/h/{n}.md"), "wiki-page", n, "[[wiki/h/hub]]");
1955        }
1956        fx.reindex();
1957
1958        let capped = neighborhood_capped(
1959            &fx.store,
1960            &fx.p("wiki/h/hub.md"),
1961            1,
1962            &[],
1963            Direction::Both,
1964            Some(3),
1965        )
1966        .unwrap();
1967        assert_eq!(
1968            capped.nodes.len(),
1969            3,
1970            "Both-direction neighborhood is bounded to the node cap"
1971        );
1972
1973        // Without the cap the same call returns all five backlinking nodes,
1974        // proving the cap (not the data) limited the set.
1975        let uncapped =
1976            neighborhood(&fx.store, &fx.p("wiki/h/hub.md"), 1, &[], Direction::Both).unwrap();
1977        assert_eq!(uncapped.nodes.len(), 5);
1978    }
1979
1980    #[test]
1981    fn neighborhood_cycle_terminates() {
1982        let fx = Fixture::new();
1983        // a <-> b cycle. Must not loop forever; each appears once.
1984        fx.write("wiki/g/a.md", "wiki-page", "A", "[[wiki/g/b]]");
1985        fx.write("wiki/g/b.md", "wiki-page", "B", "[[wiki/g/a]]");
1986        fx.reindex();
1987        let slice =
1988            neighborhood(&fx.store, &fx.p("wiki/g/a.md"), 10, &[], Direction::Both).unwrap();
1989        // From a: b is the only other node (a is the seed, excluded).
1990        assert_eq!(
1991            paths(
1992                &slice
1993                    .nodes
1994                    .iter()
1995                    .map(|n| n.path.clone())
1996                    .collect::<Vec<_>>()
1997            ),
1998            vec!["wiki/g/b"]
1999        );
2000    }
2001
2002    // ── orphans ──────────────────────────────────────────────────────────────
2003
2004    #[test]
2005    fn orphans_finds_files_with_no_edges_either_direction() {
2006        let fx = Fixture::new();
2007        // Wired pair: a links to b (a has outgoing, b has incoming).
2008        fx.write("wiki/people/a.md", "wiki-page", "A", "[[wiki/people/b]]");
2009        fx.write("wiki/people/b.md", "wiki-page", "B", "");
2010        // Orphan: no links in or out.
2011        fx.write(
2012            "sources/emails/lonely.md",
2013            "email",
2014            "Lonely email",
2015            "Just text, no links.",
2016        );
2017        let got = orphans(&fx.store, None).unwrap();
2018        assert_eq!(paths(&got), vec!["sources/emails/lonely.md"]);
2019    }
2020
2021    #[test]
2022    fn orphans_file_with_only_broken_outgoing_link_is_orphan() {
2023        let fx = Fixture::new();
2024        // Broken targets are validation issues, not graph edges to another
2025        // store file. A file whose only link points nowhere is still an orphan.
2026        fx.write(
2027            "wiki/people/a.md",
2028            "wiki-page",
2029            "A",
2030            "[[records/contacts/ghost]]",
2031        );
2032        let got = orphans(&fx.store, None).unwrap();
2033        assert!(
2034            paths(&got).contains(&"wiki/people/a.md".to_string()),
2035            "broken outgoing links must not wire the graph: {got:?}"
2036        );
2037    }
2038
2039    #[test]
2040    fn orphans_file_with_only_incoming_is_not_orphan() {
2041        let fx = Fixture::new();
2042        // `target` has no outgoing links but IS linked to by `linker` — not an orphan.
2043        fx.write("records/contacts/target.md", "contact", "Target", "");
2044        fx.write(
2045            "wiki/people/linker.md",
2046            "wiki-page",
2047            "Linker",
2048            "[[records/contacts/target]]",
2049        );
2050        let got = orphans(&fx.store, None).unwrap();
2051        assert!(
2052            !paths(&got).contains(&"records/contacts/target.md".to_string()),
2053            "incoming-only is not an orphan: {got:?}"
2054        );
2055        // `linker` has outgoing, so also not an orphan.
2056        assert!(!paths(&got).contains(&"wiki/people/linker.md".to_string()));
2057    }
2058
2059    #[test]
2060    fn orphans_incoming_link_from_other_layer_unorphans() {
2061        let fx = Fixture::new();
2062        // Candidate in records/, only incoming edge comes from wiki/ — a
2063        // cross-layer link must still un-orphan it even when scoped to records.
2064        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
2065        fx.write(
2066            "wiki/people/sarah.md",
2067            "wiki-page",
2068            "bio",
2069            "[[records/contacts/sarah]]",
2070        );
2071        // A genuine orphan in records/ to prove the scope still returns something.
2072        fx.write("records/contacts/nemo.md", "contact", "Nemo", "");
2073        let got = orphans(&fx.store, Some(Layer::Records)).unwrap();
2074        assert_eq!(paths(&got), vec!["records/contacts/nemo.md"]);
2075    }
2076
2077    #[test]
2078    fn orphans_layer_scope_filters_candidates() {
2079        let fx = Fixture::new();
2080        // One orphan per layer.
2081        fx.write("sources/emails/s.md", "email", "S", "no links");
2082        fx.write("records/contacts/r.md", "contact", "R", "");
2083        fx.write("wiki/people/w.md", "wiki-page", "W", "");
2084        let only_wiki = orphans(&fx.store, Some(Layer::Wiki)).unwrap();
2085        assert_eq!(paths(&only_wiki), vec!["wiki/people/w.md"]);
2086        let only_sources = orphans(&fx.store, Some(Layer::Sources)).unwrap();
2087        assert_eq!(paths(&only_sources), vec!["sources/emails/s.md"]);
2088        // No scope: all three, sorted (records, sources, wiki).
2089        let all = orphans(&fx.store, None).unwrap();
2090        assert_eq!(
2091            paths(&all),
2092            vec![
2093                "records/contacts/r.md",
2094                "sources/emails/s.md",
2095                "wiki/people/w.md",
2096            ]
2097        );
2098    }
2099
2100    #[test]
2101    fn orphans_self_link_does_not_count_as_an_edge() {
2102        let fx = Fixture::new();
2103        // A page that only links to itself has no real edges => still an orphan.
2104        fx.write(
2105            "wiki/synthesis/solo.md",
2106            "wiki-page",
2107            "Solo",
2108            "I reference [[wiki/synthesis/solo]] only.",
2109        );
2110        let got = orphans(&fx.store, None).unwrap();
2111        assert_eq!(paths(&got), vec!["wiki/synthesis/solo.md"]);
2112    }
2113
2114    #[test]
2115    fn orphans_excludes_index_and_db_files() {
2116        let fx = Fixture::new();
2117        // A lone index.md / DB.md must never be reported as an orphan content file.
2118        fx.write_raw(
2119            "wiki/index.md",
2120            "---\ntype: index\nscope: layer\nfolder: wiki\n---\n# wiki\n",
2121        );
2122        fx.write(
2123            "wiki/people/real-orphan.md",
2124            "wiki-page",
2125            "Real",
2126            "no links",
2127        );
2128        let got = orphans(&fx.store, None).unwrap();
2129        assert_eq!(paths(&got), vec!["wiki/people/real-orphan.md"]);
2130    }
2131
2132    // ── frontmatter_block helper ─────────────────────────────────────────────
2133
2134    #[test]
2135    fn frontmatter_block_extracts_between_fences() {
2136        let text = "---\ntype: contact\nsummary: hi\n---\nbody here\n";
2137        assert_eq!(
2138            frontmatter_block(text),
2139            Some("type: contact\nsummary: hi\n")
2140        );
2141    }
2142
2143    #[test]
2144    fn frontmatter_block_none_without_leading_fence() {
2145        let text = "no frontmatter here\n";
2146        assert_eq!(frontmatter_block(text), None);
2147    }
2148
2149    #[test]
2150    fn frontmatter_block_tolerates_leading_bom() {
2151        // Regression (finding #19 cross-module): a UTF-8 BOM before the opening
2152        // fence must not hide the frontmatter from the graph layer — otherwise a
2153        // BOM-prefixed file the catalog indexes contributes no backlinks/edges.
2154        // Pre-fix the `---\n` strip failed on the BOM and returned None.
2155        let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody here\n";
2156        assert_eq!(
2157            frontmatter_block(text),
2158            Some("type: contact\nsummary: hi\n"),
2159            "a leading BOM must not hide frontmatter from the graph layer"
2160        );
2161    }
2162
2163    // ── shared edge notion: whitespace / fence / case / containment ──────────
2164
2165    /// Padded `[[ x ]]` must be a forward edge AND (after reindex) a backward
2166    /// edge — the two views agreeing on the same edge in a clean store.
2167    #[test]
2168    fn padded_link_is_both_a_forward_and_backward_edge() {
2169        let fx = Fixture::new();
2170        fx.write(
2171            "records/contacts/sarah.md",
2172            "contact",
2173            "Sarah",
2174            "the contact",
2175        );
2176        fx.write(
2177            "wiki/people/a.md",
2178            "wiki-page",
2179            "A",
2180            "See [[ records/contacts/sarah ]] today.",
2181        );
2182        fx.reindex();
2183
2184        assert_eq!(
2185            paths(&forwardlinks(&fx.store, Path::new("wiki/people/a.md")).unwrap()),
2186            vec!["records/contacts/sarah"],
2187            "padded link is a forward edge"
2188        );
2189        assert_eq!(
2190            paths(&backlinks(&fx.store, Path::new("records/contacts/sarah.md")).unwrap()),
2191            vec!["wiki/people/a"],
2192            "padded link is the SAME backward edge (forward and backward agree)"
2193        );
2194    }
2195
2196    /// A `[[...]]` only inside a fenced code block is a documentation example,
2197    /// not an edge: no forward edge, no backward edge, and the source page is an
2198    /// orphan (no real links). Matches validate's fence-aware extractor.
2199    #[test]
2200    fn fenced_link_is_not_an_edge_and_page_is_orphan() {
2201        let fx = Fixture::new();
2202        fx.write(
2203            "records/contacts/sarah.md",
2204            "contact",
2205            "Sarah",
2206            "the contact",
2207        );
2208        fx.write(
2209            "wiki/topics/howto.md",
2210            "wiki-page",
2211            "Howto",
2212            "```markdown\n[[records/contacts/sarah]] is how you link.\n```",
2213        );
2214        fx.reindex();
2215
2216        assert!(
2217            forwardlinks(&fx.store, Path::new("wiki/topics/howto.md"))
2218                .unwrap()
2219                .is_empty(),
2220            "a fenced example is not a forward edge"
2221        );
2222        assert!(
2223            backlinks(&fx.store, Path::new("records/contacts/sarah.md"))
2224                .unwrap()
2225                .is_empty(),
2226            "a fenced example is not a backward edge"
2227        );
2228        let orphan_set = paths(&orphans(&fx.store, None).unwrap());
2229        assert!(
2230            orphan_set.contains(&"wiki/topics/howto.md".to_string()),
2231            "a page whose only link is fenced has no real edges => orphan: {orphan_set:?}"
2232        );
2233    }
2234
2235    /// `rename` must NOT rewrite a `[[...]]` inside a fenced code block (it is
2236    /// verbatim documentation, not an edge), while still rewriting a real link.
2237    #[test]
2238    fn rewrite_links_to_leaves_fenced_examples_untouched() {
2239        let input = "\
2240Real [[records/contacts/sarah]] link.
2241
2242```markdown
2243Example: [[records/contacts/sarah]] inside a fence.
2244```
2245
2246Trailing [[records/contacts/sarah]].
2247";
2248        let got = rewrite_links_to(
2249            input,
2250            Path::new("records/contacts/sarah"),
2251            Path::new("records/contacts/sarah-chen"),
2252        );
2253        // The two non-fenced links retarget; the fenced one is verbatim.
2254        assert!(
2255            got.contains("Real [[records/contacts/sarah-chen]] link."),
2256            "real link before the fence must retarget"
2257        );
2258        assert!(
2259            got.contains("Trailing [[records/contacts/sarah-chen]]."),
2260            "real link after the fence must retarget"
2261        );
2262        assert!(
2263            got.contains("Example: [[records/contacts/sarah]] inside a fence."),
2264            "fenced example must stay verbatim, got:\n{got}"
2265        );
2266    }
2267
2268    /// `rewrite_links_to` matches a padded link and preserves the display.
2269    #[test]
2270    fn rewrite_links_to_matches_padded_link() {
2271        let got = rewrite_links_to(
2272            "See [[ records/contacts/sarah |Sarah]] today.",
2273            Path::new("records/contacts/sarah"),
2274            Path::new("records/contacts/sarah-chen"),
2275        );
2276        assert_eq!(got, "See [[records/contacts/sarah-chen|Sarah]] today.");
2277    }
2278
2279    /// On a case-insensitive filesystem a case-variant link is the same edge:
2280    /// backlinks finds it, orphans does NOT falsely orphan the target, and
2281    /// rename rewrites it. On a case-sensitive FS the link is genuinely a
2282    /// different target, so the test is skipped.
2283    #[cfg(unix)]
2284    #[test]
2285    fn case_variant_link_is_one_edge_on_case_insensitive_fs() {
2286        // Probe the filesystem the same way the production code does
2287        // (`link_edge_key` is imported at module scope).
2288        if link_edge_key("A") != link_edge_key("a") {
2289            // case-sensitive filesystem: the case-variant link is a different
2290            // target, so this scenario doesn't apply.
2291            return;
2292        }
2293        let fx = Fixture::new();
2294        fx.write(
2295            "records/contacts/sarah-chen.md",
2296            "contact",
2297            "Sarah",
2298            "the contact",
2299        );
2300        fx.write(
2301            "wiki/people/bio.md",
2302            "wiki-page",
2303            "Bio",
2304            "See [[records/contacts/Sarah-Chen]].",
2305        );
2306        fx.reindex();
2307
2308        assert_eq!(
2309            paths(&backlinks(&fx.store, Path::new("records/contacts/sarah-chen.md")).unwrap()),
2310            vec!["wiki/people/bio"],
2311            "case-variant incoming link must be a backward edge"
2312        );
2313        let orphan_set = paths(&orphans(&fx.store, None).unwrap());
2314        assert!(
2315            !orphan_set.contains(&"records/contacts/sarah-chen.md".to_string()),
2316            "a target with a live case-variant incoming link must NOT be orphaned: {orphan_set:?}"
2317        );
2318
2319        let rewritten = rewrite_links_to(
2320            "See [[records/contacts/Sarah-Chen]].",
2321            Path::new("records/contacts/sarah-chen"),
2322            Path::new("records/contacts/sarah"),
2323        );
2324        assert_eq!(
2325            rewritten, "See [[records/contacts/sarah]].",
2326            "rename must rewrite the case-variant link on a case-insensitive FS"
2327        );
2328    }
2329
2330    /// A `[[../outside/x]]` escaping wiki-link is never a forward edge, and a
2331    /// `neighborhood` from the escaping page never reads or traverses through the
2332    /// external file — closing the disclosure vector.
2333    #[cfg(unix)]
2334    #[test]
2335    fn escaping_link_is_not_an_edge_and_neighborhood_does_not_escape() {
2336        let fx = Fixture::new();
2337        // An external file OUTSIDE the store root, with its own in-store link.
2338        let outside_dir = fx.store.root.parent().unwrap().join("outside");
2339        fs::create_dir_all(&outside_dir).unwrap();
2340        fs::write(
2341            outside_dir.join("secret.md"),
2342            "---\ntype: note\nsummary: TOPSECRET\n---\nLinks [[records/contacts/sarah]].\n",
2343        )
2344        .unwrap();
2345        fx.write(
2346            "records/contacts/sarah.md",
2347            "contact",
2348            "Sarah",
2349            "the contact",
2350        );
2351        fx.write(
2352            "wiki/topics/traversal.md",
2353            "wiki-page",
2354            "Traversal",
2355            "See [[../outside/secret]].",
2356        );
2357        fx.reindex();
2358
2359        // The escaping target is not a forward edge.
2360        assert!(
2361            forwardlinks(&fx.store, Path::new("wiki/topics/traversal.md"))
2362                .unwrap()
2363                .is_empty(),
2364            "an escaping `[[../outside/secret]]` must not be a forward edge"
2365        );
2366
2367        // Neighborhood from the escaping page reaches nothing through the
2368        // external file (the external file is never read/traversed).
2369        let slice = neighborhood(
2370            &fx.store,
2371            Path::new("wiki/topics/traversal.md"),
2372            2,
2373            &[],
2374            Direction::Outgoing,
2375        )
2376        .unwrap();
2377        assert!(
2378            slice
2379                .nodes
2380                .iter()
2381                .all(|n| !n.path.to_string_lossy().contains("outside")),
2382            "neighborhood must not read/traverse the external file: {:?}",
2383            slice.nodes
2384        );
2385    }
2386}