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 (a `profile` filed under any `records/<folder>/`, not only its
42//!   canonical `records/profiles/`), so the read is layer-wide, not a single
43//!   canonical folder — otherwise off-canonical-folder linkers would be silently
44//!   dropped.
45//!
46//! **Why the scoped path confirms by parsing the candidate, not by trusting the
47//! sidecar's `links` field.** A sidecar record's `links` is the file's
48//! *frontmatter* `links:` list only — it does **not** capture wiki-links written
49//! in the body or inside other typed frontmatter fields (`company: [[…]]`,
50//! `attendees: [ … ]`, `derived_from: [ … ]`). [`forwardlinks`] extracts edges
51//! from the whole file, so to keep the two directions on the **same** edge set
52//! (an incoming edge to X is exactly: some file whose [`forwardlinks`] contains
53//! X) the incoming-edge confirmation re-parses each candidate file the same way.
54//! The sidecar bounds *which* files are candidates; the parse decides whether
55//! each truly links. The unscoped ripgrep path stays on that same edge set by
56//! matching the link text wherever it lives in the file (frontmatter or body).
57//! A node's `summary` / `type` likewise read frontmatter directly (the source of
58//! truth the sidecar is derived from; never stale).
59
60use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
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, 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 `index.jsonl` sidecars (never a
138///   content-tree walk). With a `--in <layer>` the read touches only that layer:
139///   O(entities-in-layer), the sanctioned loop cost. A type-only scope (no `--in`)
140///   reads store-wide sidecars and filters by `type`, exactly as
141///   [`crate::query::Query::execute`] does — so a record of the type filed under a
142///   non-canonical folder of its layer (a `profile` under any `records/<folder>/`)
143///   *and* a **loose file** of the type filed at the *other* layer's root (a `note`
144///   filed directly under `records/`, catalogued in `records/index.jsonl`) are both
145///   candidates. Each candidate is then confirmed by a single-file parse.
146///
147/// **Correctness (one edge set, both paths).** An incoming edge to X is exactly:
148/// some file whose [`forwardlinks`] contains X — a wiki-link in the body or in
149/// *any* frontmatter field (`company: [[…]]`, `attendees: [ … ]`), not just the
150/// sidecar's frontmatter `links:` projection. Both paths honor that:
151/// - The unscoped scan matches the literal `[[<target>]]` text wherever it lives
152///   in a file (frontmatter or body), the same edges [`forwardlinks`] extracts.
153///   [`Store::find_links_to`] returns *every* `.md` carrying the link text
154///   (including `index.md` catalogs); [`backlinks`] is the relationship view, so
155///   the results are filtered to content files ([`is_content_rel`]) and emitted
156///   as canonical bare targets, self-excluded.
157/// - The scoped path confirms each candidate via [`file_links_to`], which
158///   delegates to [`forwardlinks`] (body + every frontmatter field) — so a
159///   body-only or typed-field edge is caught, not just the sidecar's `links:`
160///   list.
161///
162/// Result form (canonical bare paths, deduped, sorted, seed excluded) is
163/// identical on both paths and matches [`backlinks`].
164pub fn backlinks_filtered(
165    store: &Store,
166    path: &Path,
167    types: &[String],
168    layer: Option<Layer>,
169) -> Result<Vec<PathBuf>, StoreError> {
170    let target = normalize_target(path);
171    if target.is_empty() {
172        return Ok(Vec::new());
173    }
174    let target_key = edge_key(&target);
175
176    // Unscoped: one content pass over the store (O(store) scan with early-exit
177    // per file), not a per-candidate read of every content file. `find_links_to`
178    // returns every `.md` carrying an edge to the target (incl. catalog
179    // `index.md`); narrow to content files and canonicalize to the bare target
180    // form `backlinks` emits, dropping the seed's self-link.
181    if types.is_empty() && layer.is_none() {
182        let mut hits: BTreeSet<PathBuf> = BTreeSet::new();
183        for rel in store.find_links_to(path)? {
184            if !is_content_rel(&rel) {
185                continue;
186            }
187            let linker = normalize_target(&rel);
188            if linker.is_empty() || edge_key(&linker) == target_key {
189                // A file never counts as its own backlink (case-folded so a
190                // case-variant self-link is still excluded).
191                continue;
192            }
193            hits.insert(PathBuf::from(linker));
194        }
195        return Ok(hits.into_iter().collect());
196    }
197
198    // Scoped: read only the named folder(s)' sidecars for the candidate set, then
199    // confirm each candidate with a single-file parse — O(folder), the I/O scope
200    // `--type` / `--in` buys.
201    let mut hits: BTreeSet<PathBuf> = BTreeSet::new();
202    for candidate in candidate_records(store, types, layer)? {
203        let rel = &candidate.path;
204        let candidate_target = normalize_target(rel);
205        if candidate_target.is_empty() || edge_key(&candidate_target) == target_key {
206            // A file never counts as its own backlink.
207            continue;
208        }
209        // Confirm the edge by parsing the candidate file the same way
210        // forwardlinks does (body + all frontmatter), so body/typed-field links
211        // are caught — the sidecar's `links` field alone would miss them.
212        if file_links_to(store, rel, &target)? {
213            hits.insert(PathBuf::from(candidate_target));
214        }
215    }
216
217    Ok(hits.into_iter().collect())
218}
219
220/// Outgoing edges from `path`: the wiki-link targets extracted from that single
221/// file. Loop-fast; follow the evidence chain.
222///
223/// `path` is the store-relative path of the file to read. Targets are returned
224/// as store-relative paths (bare, no `.md`), deduped and sorted; the file's
225/// links to itself are dropped. A missing file yields an empty list (a
226/// dangling seed has no outgoing edges to report — broken-link detection is
227/// [`crate::validate`]'s job).
228pub fn forwardlinks(store: &Store, path: &Path) -> Result<Vec<PathBuf>, StoreError> {
229    let self_key = edge_key(&normalize_target(path));
230    let abs = match resolve_existing(store, path) {
231        Some(a) => a,
232        None => return Ok(Vec::new()),
233    };
234    // Decode the body LOSSILY (bytes -> `from_utf8_lossy`): wiki-link syntax
235    // (`[[...]]`) is ASCII, so a non-UTF8 byte elsewhere on a line cannot hide an
236    // edge. This mirrors the unscoped backlink scanner
237    // ([`Store::find_links_to_any`], which reads bytes + lossy by design) so
238    // SCOPED backlinks (which ride `forwardlinks`) agree with unscoped backlinks
239    // on a Latin-1-imported file instead of silently dropping its edges — a
240    // `read_to_string` that errored on `InvalidData` returned NO edges.
241    let body = match std::fs::read(&abs) {
242        Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
243        Err(e) => return Err(StoreError::Io(e)),
244    };
245
246    let mut out: BTreeSet<PathBuf> = BTreeSet::new();
247    for target in extract_link_targets(&body) {
248        // Self-link drop is case-folded so a case-variant self-reference is also
249        // excluded on a case-insensitive filesystem.
250        if target.is_empty() || edge_key(&target) == self_key {
251            continue;
252        }
253        out.insert(PathBuf::from(target));
254    }
255    Ok(out.into_iter().collect())
256}
257
258/// The candidate set for an incoming-edge scan: the sidecar records that could
259/// link to the target, read from the `index.jsonl` sidecars (never a content-tree
260/// walk). `types`/`layer` narrow *which* sidecars are read — the I/O scope that
261/// keeps a typed/layer backlinks O(entities-in-layer) when a layer is named.
262///
263/// - `types` non-empty, `layer` given: read **only that layer's** sidecars
264///   (O(entities-in-layer)) and keep the records whose `type` is in `types`. The
265///   read is *not* short-circuited on a layer that disagrees with a type's
266///   canonical layer, because a record of that type may legitimately be filed
267///   there as a **loose file** (a `note` filed directly at `records/`, catalogued
268///   in `records/index.jsonl`); the `type` filter on the layer read is what keeps
269///   the result correct in either case.
270/// - `types` non-empty, `layer` `None`: read **store-wide** sidecars and keep the
271///   records whose `type` is in `types` — exactly what [`crate::query::Query::execute`]
272///   does for a type-only query. This is complete across every folder *and* every
273///   layer the type is filed under: its canonical-layer records (the common case)
274///   plus any loose file of that type filed at the *other* layer's root.
275/// - `types` empty: every sidecar record under `layer` (or store-wide when
276///   `None`) via [`Store::sidecar_records`].
277///
278/// **Why store-wide (not the type's one canonical layer) for the type-only case.**
279/// [`layer_for_type`](crate::store::layer_for_type) maps a type to exactly ONE
280/// layer (`note` → Sources, `contact`
281/// → Records), but a loose file (SPEC § Loose files) may legitimately be filed at
282/// the *other* layer's root and catalogued in that layer's `index.jsonl`. Reading
283/// only `layer_for_type(T)` would silently drop a records-loose `note` from
284/// `backlinks --type note`, and early-`continue`-ing on `--in records` (because
285/// `records` ≠ `layer_for_type(note)`) would return empty — diverging from the
286/// unscoped scan, from `--type T --in <layer>`, and from `dbmd query --type T`.
287/// Reading store-wide (or the named layer) and filtering by `type` is sidecar-backed
288/// (no content-tree walk) and keeps the scoped edge set equal to the unscoped one.
289/// A `type` can also span several folders within one layer — a conclusion `profile`
290/// filed under any `records/<folder>/`, not only `records/profiles/` — and the
291/// store-wide/layer read covers that too.
292fn candidate_records(
293    store: &Store,
294    types: &[String],
295    layer: Option<Layer>,
296) -> Result<Vec<IndexRecord>, StoreError> {
297    if types.is_empty() {
298        return store.sidecar_records(layer);
299    }
300    let want: HashSet<&str> = types.iter().map(|s| s.as_str()).collect();
301    // A layer scope reads only that layer's sidecars (O(entities-in-layer)); with
302    // no layer, read store-wide so a loose file of the type filed at *either*
303    // layer's root is covered — matching `Query::execute`'s type-only candidate
304    // set. The `type` filter (not a per-type canonical-layer guess) is what makes
305    // both correct, so a loose `note` under `records/` is found and a `note` under
306    // `sources/` is excluded when `--in records`.
307    let mut by_path: std::collections::BTreeMap<PathBuf, IndexRecord> =
308        std::collections::BTreeMap::new();
309    for rec in store.sidecar_records(layer)? {
310        if want.contains(rec.type_.as_str()) {
311            by_path.insert(rec.path.clone(), rec);
312        }
313    }
314    Ok(by_path.into_values().collect())
315}
316
317/// True if the store file at `rel` carries a wiki-link whose canonical target
318/// equals `target`. Delegates to [`forwardlinks`] so the incoming-edge predicate
319/// is *exactly* the outgoing-edge extraction — body + every frontmatter field —
320/// keeping the two directions on one edge set. `forwardlinks` already emits
321/// canonical bare targets, so `target` (likewise normalized by the caller) is
322/// compared directly. A missing/binary file links to nothing.
323fn file_links_to(store: &Store, rel: &Path, target: &str) -> Result<bool, StoreError> {
324    let edges = forwardlinks(store, rel)?;
325    let target_key = edge_key(target);
326    // Compare on the case-folded edge key so a case-variant link (e.g.
327    // `[[records/contacts/Sarah-Chen]]` to `sarah-chen.md`) is confirmed on a
328    // case-insensitive filesystem, agreeing with the unscoped scan and validate.
329    Ok(edges
330        .iter()
331        .any(|e| edge_key(&e.to_string_lossy()) == target_key))
332}
333
334/// **Context hydration.** Bounded BFS from `seed` over backlinks + forwardlinks
335/// out to `hops`, reading each reached file's `summary` + relationship, and
336/// returning a readable [`ContextSlice`]. Optionally filtered by `types` and
337/// `direction`. On-demand; no maintained graph. What the agent reaches for to
338/// assemble a working set in one call.
339///
340/// Traversal semantics:
341/// - **`hops`** bounds true graph distance from the seed. `hops == 0` returns
342///   an empty slice (the seed alone is no context).
343/// - **`direction`** selects which edges are followed: `Incoming` walks
344///   backlinks, `Outgoing` walks forwardlinks, `Both` walks the union.
345/// - **`types`**, when non-empty, filters which reached nodes appear in the
346///   slice — but traversal still passes *through* off-type nodes, so a
347///   `meeting` two hops out is still reachable through a `contact` even when
348///   filtering to `meeting`. (An empty `types` slice imposes no filter.)
349/// - Each node records the lowest hop count at which it is first reached (BFS
350///   order); the seed is never included as a node.
351///
352/// Unbounded traversal: delegates to [`neighborhood_capped`] with no node cap, so
353/// it expands every reachable node within `hops`. For a densely-interlinked store
354/// this is one full-store backlinks scan **per reached node** (O(visited × store))
355/// — prefer [`neighborhood_capped`] with a `max_nodes` cap to bound that work.
356pub fn neighborhood(
357    store: &Store,
358    seed: &Path,
359    hops: u32,
360    types: &[String],
361    direction: Direction,
362) -> Result<ContextSlice, StoreError> {
363    neighborhood_capped(store, seed, hops, types, direction, None)
364}
365
366/// [`neighborhood`] with a hard cap on how many nodes the BFS **traverses**.
367///
368/// `max_nodes` bounds the *traversal*, not just the result: each node the BFS
369/// expands triggers a per-node incoming-edge scan (an unscoped [`backlinks`] is a
370/// full-store ripgrep pass), so an uncapped neighborhood of a hub node costs
371/// O(visited × store). A post-hoc `.take(n)` on the returned nodes caps the
372/// *output* but not that work — the scans still run for every reached node. This
373/// cap stops discovering (and therefore stops scanning) once `max_nodes` distinct
374/// non-seed nodes have entered the BFS, so the expensive per-node scans are bounded
375/// to at most `max_nodes` of them. `None` is unbounded (the [`neighborhood`]
376/// behavior).
377///
378/// The cap is applied at *discovery* in BFS order, so the kept nodes are exactly
379/// the first `max_nodes` reached (closest-first by hop), and each still records its
380/// true minimum hop distance. Type-filtered (off-type) nodes count against the cap
381/// because the BFS must still traverse *through* them to reach deeper on-type
382/// nodes — the scan cost is paid when a node is expanded, on- or off-type alike.
383pub fn neighborhood_capped(
384    store: &Store,
385    seed: &Path,
386    hops: u32,
387    types: &[String],
388    direction: Direction,
389    max_nodes: Option<usize>,
390) -> Result<ContextSlice, StoreError> {
391    let seed_rel = PathBuf::from(normalize_target(seed));
392    let type_filter: HashSet<&str> = types.iter().map(|s| s.as_str()).collect();
393
394    // `discovered` guards against revisiting a node (and against re-adding the
395    // seed). BFS by levels so the first time we reach a node is its true min
396    // hop distance.
397    let mut discovered: HashSet<PathBuf> = HashSet::new();
398    discovered.insert(seed_rel.clone());
399
400    let mut nodes: Vec<ContextNode> = Vec::new();
401    let mut frontier: VecDeque<PathBuf> = VecDeque::new();
402    frontier.push_back(seed_rel.clone());
403
404    // Count of distinct non-seed nodes admitted to the BFS. Once it hits
405    // `max_nodes` we stop discovering new nodes, which stops enqueuing them, which
406    // stops the per-node full-store backlinks scan they would have triggered — the
407    // cap bounds the *traversal cost*, not only the printed result.
408    let mut admitted = 0usize;
409    let cap_reached = |admitted: usize| max_nodes.is_some_and(|cap| admitted >= cap);
410
411    let mut hop = 0u32;
412    while hop < hops && !frontier.is_empty() && !cap_reached(admitted) {
413        hop += 1;
414        let level_size = frontier.len();
415        for _ in 0..level_size {
416            if cap_reached(admitted) {
417                break;
418            }
419            let current = frontier.pop_front().expect("frontier non-empty");
420
421            // Collect this node's edges in the requested direction(s). Each
422            // edge carries the neighbor path + the direction we traversed it.
423            let mut edges: Vec<(PathBuf, Direction)> = Vec::new();
424            if matches!(direction, Direction::Outgoing | Direction::Both) {
425                for nbr in forwardlinks(store, &current)? {
426                    edges.push((nbr, Direction::Outgoing));
427                }
428            }
429            if matches!(direction, Direction::Incoming | Direction::Both) {
430                for nbr in backlinks(store, &current)? {
431                    edges.push((nbr, Direction::Incoming));
432                }
433            }
434
435            for (neighbor, dir) in edges {
436                if cap_reached(admitted) {
437                    break;
438                }
439                // Drop a neighbor that exists on disk but resolves OUTSIDE the
440                // store via a symlinked path component — it is not a real in-store
441                // edge, exactly as a `..` escape is dropped at edge extraction. This
442                // yields no node (and no traversal through it), closing the
443                // `graph neighborhood` disclosure vector at the graph boundary.
444                if target_escapes_store(store, &neighbor) {
445                    continue;
446                }
447                if !discovered.insert(neighbor.clone()) {
448                    continue;
449                }
450                admitted += 1;
451                let (summary, type_) = read_summary_and_type(store, &neighbor);
452                let include = type_filter.is_empty()
453                    || type_
454                        .as_deref()
455                        .map(|t| type_filter.contains(t))
456                        .unwrap_or(false);
457                if include {
458                    nodes.push(ContextNode {
459                        path: neighbor.clone(),
460                        summary,
461                        type_,
462                        hops: hop,
463                        via: Some((current.clone(), dir)),
464                    });
465                }
466                // Off-type nodes are not emitted but still seed the next BFS
467                // level, so the type filter narrows the *result*, not the
468                // reachable graph.
469                frontier.push_back(neighbor);
470            }
471        }
472    }
473
474    Ok(ContextSlice {
475        seed: seed_rel,
476        nodes,
477    })
478}
479
480/// **SWEEP.** Content files with no incoming AND no outgoing wiki-links — the
481/// curation worklist ("ingested but not yet wired into the wiki"). Off the
482/// loop. Optionally scoped to a layer.
483///
484/// A file is an orphan iff it neither links out to another store file nor is
485/// linked to by one. Incoming edges are counted across the *whole* store
486/// (a link from any layer un-orphans a file), even when `layer` scopes the
487/// candidate set. Returns store-relative paths, sorted.
488pub fn orphans(store: &Store, layer: Option<Layer>) -> Result<Vec<PathBuf>, StoreError> {
489    // One walk of the whole store: for every content file, record (a) whether
490    // it has any outgoing link, and (b) accumulate the set of every target any
491    // file links to (its incoming-edge set). Both come from a single read per
492    // file — the SWEEP cost.
493    let all = walk_content_files(store)?;
494
495    // Every walked content file's edge KEY (NFC-folded, `.md`-stripped). A
496    // wiki-link counts as a live incoming/outgoing edge when it resolves on disk
497    // OR its edge key matches a walked file's. The key match is what makes a
498    // cross-NORMALIZATION link a real edge on a byte-exact filesystem: an NFD
499    // link to an NFC-named file (or vice versa) does NOT satisfy
500    // `resolve_existing`'s `is_file` on Linux (the bytes differ), though it does
501    // on macOS/APFS (which folds NFC/NFD). `link_edge_key` NFC-folds both sides,
502    // so the keys agree on every platform — without this, `orphans` flagged a
503    // live cross-normalization target as an orphan on Linux while macOS hid it.
504    let content_keys: HashSet<String> = all
505        .iter()
506        .filter_map(|abs| rel_path(store, abs))
507        .map(|rel| edge_key(&normalize_target(&rel)))
508        .collect();
509
510    // `linked_to` holds case-folded edge KEYS (not raw paths): the link text may
511    // spell a target with different casing than the on-disk file (e.g.
512    // `[[records/contacts/Sarah-Chen]]` → `sarah-chen.md`), and on a
513    // case-insensitive filesystem that is a real incoming edge. Keying on
514    // `edge_key` so the incoming-edge lookup case-folds is what stops the
515    // false-positive orphan (a file with a live case-variant link reported as
516    // orphaned) — and matches validate, which resolves the same link via the
517    // case-insensitive filesystem.
518    let mut linked_to: HashSet<String> = HashSet::new();
519    let mut has_outgoing: HashMap<PathBuf, bool> = HashMap::new();
520
521    for abs in &all {
522        let rel = match rel_path(store, abs) {
523            Some(r) => r,
524            None => continue,
525        };
526        let self_key = edge_key(&normalize_target(&rel));
527
528        // Lossy decode (see `forwardlinks`): a non-UTF8 byte must not hide a
529        // `[[...]]` edge, or `orphans` would over-report BOTH endpoints of a live
530        // edge as orphans (and `stats` would inflate the orphan count) on a file
531        // with a stray Latin-1 byte beside a valid ASCII link line.
532        let body = match std::fs::read(abs) {
533            Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
534            Err(e) => return Err(StoreError::Io(e)),
535        };
536
537        let mut outgoing = false;
538        for target in extract_link_targets(&body) {
539            if target.is_empty() || edge_key(&target) == self_key {
540                continue;
541            }
542            // A live edge: resolves on disk (handles raw `.eml`/`.pdf` sources and
543            // store containment) OR matches a walked content file by NFC-folded
544            // key (the cross-normalization case `resolve_existing` misses on a
545            // byte-exact filesystem).
546            if resolve_existing(store, Path::new(&target)).is_none()
547                && !content_keys.contains(&edge_key(&target))
548            {
549                continue;
550            }
551            outgoing = true;
552            linked_to.insert(edge_key(&target));
553        }
554        has_outgoing.insert(rel, outgoing);
555    }
556
557    let mut out: BTreeSet<PathBuf> = BTreeSet::new();
558    for abs in &all {
559        let rel = match rel_path(store, abs) {
560            Some(r) => r,
561            None => continue,
562        };
563        if let Some(layer) = layer {
564            if path_layer(&rel) != Some(layer) {
565                continue;
566            }
567        }
568        let outgoing = has_outgoing.get(&rel).copied().unwrap_or(false);
569        let incoming = linked_to.contains(&edge_key(&normalize_target(&rel)));
570        if !outgoing && !incoming {
571            out.insert(rel);
572        }
573    }
574
575    Ok(out.into_iter().collect())
576}
577
578/// **Write-side.** Rewrite every incoming `[[old]]` wiki-link in `text` to
579/// `[[new]]`, preserving any `|display` override and emitting the canonical bare
580/// target (no `.md`). The write-side twin of [`backlinks`]: where `backlinks`
581/// *finds* the files carrying an edge to `old`, this *retargets* that edge to
582/// `new` inside one file's contents.
583///
584/// `old` and `new` are store-relative paths in the wiki-link sense — both are
585/// passed through the same [`normalize_target`] the read side keys on, so the
586/// `.md` and bare spellings of `old` collapse to one target and a match here is
587/// exactly a match [`backlinks`] / [`Store::find_links_to`](crate::Store::find_links_to)
588/// would report. A link is rewritten iff its normalized target equals
589/// `normalize_target(old)`; prefix collisions (`old=a/b` vs `[[a/bc]]`) and
590/// short-form links never match. Returns the rewritten text (identical to the
591/// input when nothing matched), so the caller can cheaply detect a no-op.
592///
593/// Operates on the raw text (not a parser round-trip) so a link in frontmatter
594/// or body is retargeted uniformly and nothing else is reflowed — **except** a
595/// `[[...]]` inside a ``` fenced code block, which is a documentation example,
596/// not an edge: `rename` must NOT mutate fenced verbatim content (validate
597/// treats fenced links as non-edges, so rewriting them silently corrupts the
598/// example and makes rename disagree with validate). Matching is fence-aware,
599/// whitespace-trimmed, and case-folded to the filesystem, the exact edge notion
600/// [`backlinks`]/[`forwardlinks`] use — so rename retargets precisely the edges
601/// those report and nothing else.
602pub fn rewrite_links_to(text: &str, old: &Path, new: &Path) -> String {
603    let old_target = normalize_target(old);
604    let new_target = normalize_target(new);
605    if old_target.is_empty() {
606        // No target to match → never rewrite anything.
607        return text.to_string();
608    }
609    let old_key = edge_key(&old_target);
610
611    let mut out = String::with_capacity(text.len());
612
613    // Split off the leading `---`…`---` frontmatter block exactly like the read
614    // side ([`Store::extract_edge_targets`] via `split_frontmatter_raw`): the
615    // frontmatter is YAML, NOT markdown — it has no code fences, and a `[[…]]`
616    // in any frontmatter field is a real edge. So the frontmatter region is
617    // rewrite-scanned WITHOUT fence tracking, and the body is rewrite-scanned
618    // with a FRESH fence state. Without this boundary reset, a stray ``` / `~~~`
619    // inside a frontmatter block scalar opens a fence that persists into the
620    // body, so every body `[[…]]` is treated as fenced and silently skipped —
621    // leaving a dangling link after rename even though `backlinks`/`forwardlinks`
622    // (which DO reset at this boundary) still report the body edge. Returns
623    // byte offsets so the `---` fence lines and everything else are copied
624    // byte-exact; the only mutation is a matched `[[…]]` retarget.
625    let body_start = match frontmatter_body_split(text) {
626        Some(body_offset) => {
627            // Frontmatter prefix = `0..body_offset` (the opening `---` line, the
628            // YAML, and the closing `---` line). Scan it line-by-line with
629            // rewriting on and NO fence state: the literal `---` fence lines
630            // never match link syntax (rewrite is a no-op on them), and any
631            // real `[[…]]` in a YAML field is retargeted.
632            for line in text[..body_offset].split_inclusive('\n') {
633                rewrite_links_in_line(line, &old_key, &new_target, &mut out);
634            }
635            body_offset
636        }
637        // No leading frontmatter block → the whole text is body.
638        None => 0,
639    };
640
641    // Body scan with a FRESH fence state. Track the fence as a `(byte, run
642    // length)` exactly like validate and `extract_edge_targets` (NOT a bool
643    // toggled on any ``` / ~~~ line). The naive toggle flips mid-block on a
644    // nested/indented/long-run fence, so a fenced example link would be
645    // rewritten — corrupting documentation and making rename disagree with
646    // validate's edge notion.
647    let mut fence: Option<(u8, usize)> = None;
648    // `split_inclusive` keeps each line's trailing `\n`, so copying a chunk
649    // verbatim preserves the original line endings exactly.
650    for line in text[body_start..].split_inclusive('\n') {
651        // The fence rules key on line content without trailing `\r`/`\n`; the
652        // full chunk (line endings intact) is what we copy verbatim.
653        let content = line.trim_end_matches('\n').trim_end_matches('\r');
654        if let Some(f) = fence {
655            // Inside a fenced code block: copy verbatim, never rewrite. Only a
656            // matching closing fence ends the block.
657            if fence_closes(content, f) {
658                fence = None;
659            }
660            out.push_str(line);
661            continue;
662        }
663        if let Some(opened) = fence_opens(content) {
664            fence = Some(opened);
665            out.push_str(line);
666            continue;
667        }
668        rewrite_links_in_line(line, &old_key, &new_target, &mut out);
669    }
670    out
671}
672
673/// Byte offset where the body begins after a leading `---`…`---` frontmatter
674/// block — i.e. the first byte past the closing `---` line's `\n`. `None` when
675/// the text does not open with a `---` fence or has no closing fence (the caller
676/// then treats the whole text as body). Local mirror of store's
677/// `split_frontmatter_raw` boundary detection (BOM- and CRLF-tolerant) — kept
678/// in graph.rs so the module stays self-contained, paired with the existing
679/// `frontmatter_block` mirror. Returns an offset (not slices) so
680/// [`rewrite_links_to`] can copy the frontmatter and body regions byte-exact and
681/// scan them with different fence policies.
682fn frontmatter_body_split(text: &str) -> Option<usize> {
683    // Tolerate a single leading UTF-8 BOM, matching parser/store/index/validate.
684    let bom = if text.starts_with('\u{feff}') {
685        '\u{feff}'.len_utf8()
686    } else {
687        0
688    };
689    let after_open = if text[bom..].starts_with("---\n") {
690        bom + 4
691    } else if text[bom..].starts_with("---\r\n") {
692        bom + 5
693    } else {
694        return None;
695    };
696    // Walk lines from just after the opening fence; the body starts right after
697    // the line that is exactly `---`.
698    let mut idx = after_open;
699    for line in text[after_open..].split_inclusive('\n') {
700        let trimmed = line.trim_end_matches(['\r', '\n']);
701        idx += line.len();
702        if trimmed == "---" {
703            return Some(idx);
704        }
705    }
706    None
707}
708
709/// Rewrite every `[[...]]` on a single (non-fenced) line whose target matches
710/// `old_key`, appending the result to `out`. Preserves any `|display` override
711/// verbatim and emits the canonical bare `new_target`. A `[[...]]` whose target
712/// does not match (a prefix sibling, the short form, an unrelated target) is
713/// copied through untouched.
714fn rewrite_links_in_line(line: &str, old_key: &str, new_target: &str, out: &mut String) {
715    let bytes = line.as_bytes();
716    let mut i = 0usize;
717    let mut last = 0usize;
718    while i + 1 < bytes.len() {
719        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
720            if let Some(close) = line[i + 2..].find("]]") {
721                let inner = &line[i + 2..i + 2 + close];
722                // An embedded newline means this isn't a single-line link.
723                if !inner.contains('\n') {
724                    let (raw_target, display) = match inner.split_once('|') {
725                        Some((t, d)) => (t, Some(d)),
726                        None => (inner, None),
727                    };
728                    let raw_target = raw_target.trim();
729                    // Match on the SAME edge key the read side uses, so `[[old]]`,
730                    // `[[old.md]]`, `[[ ./old ]]`, and (case-insensitive FS)
731                    // `[[Old]]` all retarget while `[[old-jr]]` never does.
732                    if !raw_target.is_empty()
733                        && !raw_target.starts_with('[')
734                        && edge_key(&canonical_link_target(raw_target)) == old_key
735                    {
736                        out.push_str(&line[last..i]);
737                        out.push_str("[[");
738                        out.push_str(new_target);
739                        if let Some(display) = display {
740                            out.push('|');
741                            out.push_str(display);
742                        }
743                        out.push_str("]]");
744                        i = i + 2 + close + 2;
745                        last = i;
746                        continue;
747                    }
748                }
749                // Not a matching link: skip past this `]]` so an inner `[[`
750                // isn't re-scanned, but leave the text for the verbatim copy.
751                i = i + 2 + close + 2;
752                continue;
753            }
754        }
755        i += 1;
756    }
757    out.push_str(&line[last..]);
758}
759
760// ── Private helpers ─────────────────────────────────────────────────────────
761
762/// Normalize a store-relative path into the canonical wiki-link target form:
763/// forward slashes, no leading `./` or `/`, and no trailing `.md`. This is the
764/// canonical (case-PRESERVING) identity used for output and rewrites; edge
765/// *comparisons* go through [`edge_key`] so the `.md`/bare forms AND (on a
766/// case-insensitive filesystem) case-variant spellings of a target unify. The
767/// shared [`canonical_link_target`] is the single definition every db.md
768/// link op keys on.
769fn normalize_target(path: &Path) -> String {
770    canonical_link_target(&path.to_string_lossy())
771}
772
773/// The comparison key for an edge: the canonical target case-folded to the
774/// filesystem (identity on a case-sensitive FS, lowercased on macOS/Windows), so
775/// the string-keyed graph compares agree with the filesystem's case-insensitive
776/// `is_file()` resolution. `[[records/contacts/Sarah-Chen]]` and the on-disk
777/// `sarah-chen.md` must be the same edge on a case-insensitive filesystem or
778/// backlinks/orphans/rename silently disagree with validate.
779fn edge_key(canonical_target: &str) -> String {
780    link_edge_key(canonical_target)
781}
782
783/// Extract every wiki-link target from a body, normalized to the canonical
784/// store-relative form. Fence-aware and whitespace-trimmed via the shared
785/// [`extract_edge_targets`] — a `[[...]]` inside a ``` fenced code block is a
786/// documentation example, NOT an edge (matching validate), and `[[ x ]]`
787/// padding resolves identically to `[[x]]`. A target that would escape the store
788/// root (a `..` component) is dropped here too, so an escaping `[[../outside/x]]`
789/// is never reported as a forward edge and never seeds a [`neighborhood`]
790/// traversal out of the store (the disclosure vector validate flags as an
791/// error). Order-preserving; duplicates kept (callers dedup).
792fn extract_link_targets(body: &str) -> Vec<String> {
793    extract_edge_targets(body)
794        .into_iter()
795        .filter(|t| is_within_store_target(t))
796        .collect()
797}
798
799/// True if a canonical target stays inside the store: it has no `..`
800/// (`ParentDir`) component. The canonical form has already stripped any leading
801/// `./` or `/`, so a `Normal`-only path is a safe store-relative key; a `..`
802/// component is an escape and is rejected, mirroring validate's safe-path guard.
803fn is_within_store_target(target: &str) -> bool {
804    Path::new(target)
805        .components()
806        .all(|c| matches!(c, std::path::Component::Normal(_)))
807}
808
809/// Resolve the store root + a store-relative path to the absolute on-disk file,
810/// trying the path as written and then with a `.md` extension. `None` if neither
811/// exists **or if the target resolves outside the store root** — a `..`-laden or
812/// symlink-escaping wiki-link must never turn a graph read/traversal into a read
813/// of an arbitrary file outside the store (the `dbmd graph neighborhood`
814/// disclosure vector). Containment is enforced via the shared
815/// [`ensure_path_within_store`] gate, matching validate's safe-path guard.
816fn resolve_existing(store: &Store, store_relative: &Path) -> Option<PathBuf> {
817    let direct = store.root.join(store_relative);
818    if direct.is_file() && resolves_within_store(store, &direct) {
819        return Some(direct);
820    }
821    let normalized = normalize_target(store_relative);
822    let with_md = store.root.join(format!("{normalized}.md"));
823    if with_md.is_file() && resolves_within_store(store, &with_md) {
824        return Some(with_md);
825    }
826    None
827}
828
829/// True if a store-relative wiki-link target exists on disk but **resolves
830/// outside the store** — i.e. some `Normal` component is a symlink redirecting to
831/// an external dir/file (`records/linkdir/secret` through `records/linkdir ->
832/// /external`, or a directly-symlinked `records/aliased.md -> /external/x.md`).
833///
834/// This is the symlink twin of the `..` escape that [`is_within_store_target`]
835/// drops at edge *extraction*: a `..` target is rejected by its spelling, but a
836/// symlink escape is spelled with only `Normal` components and can only be caught
837/// by resolving the path. [`neighborhood_capped`] uses this to drop such a
838/// neighbor from the traversal entirely, so an escaping symlink yields **no node**
839/// (matching the `..` control) rather than a phantom node whose summary/type are
840/// blanked — closing the `graph neighborhood` disclosure vector at the graph
841/// boundary, not only at the file read.
842///
843/// A genuinely *dangling* in-store link (a target that exists nowhere) is **not**
844/// an escape: it does not resolve on disk at all, so this returns `false` and the
845/// dangling target is still surfaced as a node (existing behavior; broken-link
846/// reporting is [`crate::validate`]'s job).
847fn target_escapes_store(store: &Store, store_relative: &Path) -> bool {
848    // Already in-store-resolvable → not an escape.
849    if resolve_existing(store, store_relative).is_some() {
850        return false;
851    }
852    // Not resolvable in-store: is it because it points OUTSIDE (a symlink escape),
853    // or because it does not exist at all (a dangling link)? It escapes iff the
854    // path (as written or with `.md`) exists on disk yet fails containment.
855    let direct = store.root.join(store_relative);
856    if direct.exists() && !resolves_within_store(store, &direct) {
857        return true;
858    }
859    let normalized = normalize_target(store_relative);
860    let with_md = store.root.join(format!("{normalized}.md"));
861    with_md.exists() && !resolves_within_store(store, &with_md)
862}
863
864/// Containment check for a candidate on-disk path. Always routes through the
865/// authoritative, symlink-resolving [`ensure_path_within_store`] gate — the only
866/// thing that can prove an escaping or symlink-redirected path actually stays
867/// inside the store.
868///
869/// There is deliberately **no** "all-`Normal`-components" fast path that returns
870/// `true` without canonicalizing. A `Normal` component is not safe by spelling:
871/// it can itself be a symlink to a directory or file outside the store
872/// (`records/linkdir -> /etc`, or a directly-symlinked `records/aliased.md ->
873/// ../../outside/secret.md`). `store.root.join(rel)` follows that in-store symlink,
874/// `is_file()` succeeds (it follows symlinks), and without canonicalizing the
875/// resolved target the out-of-store file's `summary`/`type` leak into a
876/// `graph neighborhood` slice. `ensure_path_within_store` canonicalizes `abs`
877/// (resolving every symlink in its chain) and confirms the result is under the
878/// canonicalized root, closing that disclosure vector — the same gate the `..`
879/// path already passes through.
880fn resolves_within_store(store: &Store, abs: &Path) -> bool {
881    ensure_path_within_store(&store.root, abs).is_ok()
882}
883
884/// Convert an absolute path under the store root into its store-relative form.
885fn rel_path(store: &Store, abs: &Path) -> Option<PathBuf> {
886    abs.strip_prefix(&store.root).ok().map(|p| p.to_path_buf())
887}
888
889/// Which layer a store-relative path sits in, by its first component.
890fn path_layer(rel: &Path) -> Option<Layer> {
891    let first = rel.components().next()?;
892    match first.as_os_str().to_str()? {
893        "sources" => Some(Layer::Sources),
894        "records" => Some(Layer::Records),
895        _ => None,
896    }
897}
898
899/// True if a store-relative path is a *content* file: under `sources/` or
900/// `records/`, a `.md` file, and not an `index.md`. Meta files
901/// (`DB.md`, `log.md`, `log/…`, sidecars) are excluded.
902fn is_content_rel(rel: &Path) -> bool {
903    if path_layer(rel).is_none() {
904        return false;
905    }
906    match rel.extension().and_then(|e| e.to_str()) {
907        Some("md") => {}
908        _ => return false,
909    }
910    rel.file_name().and_then(|n| n.to_str()) != Some("index.md")
911}
912
913/// Walk every content `.md` file in the store via the **`ignore`** walker
914/// (the ripgrep directory engine). Only the two layer roots
915/// (`sources/`/`records/`) are descended, so `DB.md`, `log.md`, and
916/// `log/` at the store root are structurally never reached; hidden dirs and
917/// per-folder `index.md` sidecars are filtered out ([`is_content_rel`]). Honors
918/// `.gitignore` the way `rg` does. Returns absolute paths. SWEEP-class.
919fn walk_content_files(store: &Store) -> Result<Vec<PathBuf>, StoreError> {
920    let mut out = Vec::new();
921    for layer in Layer::all() {
922        let dir = store.root.join(layer_dir_name(layer));
923        if !dir.is_dir() {
924            continue;
925        }
926        let store_root = store.root.clone();
927        let mut builder = WalkBuilder::new(&dir);
928        builder
929            .hidden(true)
930            .git_ignore(true)
931            .git_global(false)
932            .require_git(false)
933            // Follow symlinks so a symlinked `.md` content file or a symlinked
934            // type folder is walked like any other content (consistent with the
935            // store SWEEP walker), rather than silently vanishing from orphans.
936            .follow_links(true)
937            .filter_entry(move |entry| {
938                crate::store::ensure_path_within_store(&store_root, entry.path()).is_ok()
939            });
940        let walker = builder.build();
941        for result in walker {
942            let entry = result.map_err(|e| StoreError::Search {
943                root: store.root.clone(),
944                message: format!("walk failed: {e}"),
945            })?;
946            // A followed symlink entry reports its own type as `is_symlink()`, so
947            // also accept a symlink whose target is a regular file.
948            let is_file = match entry.file_type() {
949                Some(ft) if ft.is_file() => true,
950                Some(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
951                    .map(|m| m.is_file())
952                    .unwrap_or(false),
953                _ => false,
954            };
955            if !is_file {
956                continue;
957            }
958            let abs = entry.into_path();
959            if let Some(rel) = rel_path(store, &abs) {
960                if is_content_rel(&rel) {
961                    out.push(abs);
962                }
963            }
964        }
965    }
966    Ok(out)
967}
968
969/// The on-disk folder name for a layer. Mirrors `Layer::dir_name`; kept local
970/// so the graph module owns its own copy rather than coupling to that body.
971fn layer_dir_name(layer: Layer) -> &'static str {
972    match layer {
973        Layer::Sources => "sources",
974        Layer::Records => "records",
975    }
976}
977
978/// Read a reached node's `summary` and `type` from its frontmatter. A missing
979/// file, missing frontmatter, or unparseable YAML degrades to an empty summary
980/// / unknown type rather than failing the whole hydration — `neighborhood` is
981/// best-effort context assembly, not validation.
982fn read_summary_and_type(store: &Store, rel: &Path) -> (String, Option<String>) {
983    let abs = match resolve_existing(store, rel) {
984        Some(a) => a,
985        None => return (String::new(), None),
986    };
987    // Lossy decode so a node's summary/type still resolve when the file carries
988    // a stray non-UTF8 byte (consistent with the edge readers above).
989    let text = match std::fs::read(&abs) {
990        Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
991        Err(_) => return (String::new(), None),
992    };
993    let yaml = match frontmatter_block(&text) {
994        Some(y) => y,
995        None => return (String::new(), None),
996    };
997    let value: serde_norway::Value = match serde_norway::from_str(yaml) {
998        Ok(v) => v,
999        Err(_) => return (String::new(), None),
1000    };
1001    let summary = value
1002        .get("summary")
1003        .and_then(|v| v.as_str())
1004        .unwrap_or("")
1005        .to_string();
1006    let type_ = value
1007        .get("type")
1008        .and_then(|v| v.as_str())
1009        .map(|s| s.to_string());
1010    (summary, type_)
1011}
1012
1013/// Return the YAML between the opening and closing `---` fences (exclusive), or
1014/// `None` if the text has no leading frontmatter block. Local mirror of the
1015/// parser's split so the graph module stays self-contained.
1016fn frontmatter_block(text: &str) -> Option<&str> {
1017    // Tolerate a single leading UTF-8 BOM, matching parser/store/index/validate.
1018    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1019    let rest = text
1020        .strip_prefix("---\n")
1021        .or_else(|| text.strip_prefix("---\r\n"))?;
1022    // Find the closing fence: a line that is exactly `---`.
1023    let mut idx = 0usize;
1024    for line in rest.split_inclusive('\n') {
1025        let trimmed = line.trim_end_matches(['\r', '\n']);
1026        if trimmed == "---" {
1027            return Some(&rest[..idx]);
1028        }
1029        idx += line.len();
1030    }
1031    None
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use std::fs;
1038    use tempfile::TempDir;
1039
1040    use crate::parser::Config;
1041
1042    // ── Fixture builder ─────────────────────────────────────────────────────
1043    //
1044    // A real on-disk store in a tempdir. We write actual files (frontmatter +
1045    // wiki-links) and exercise the real code paths. The fixture constructs the
1046    // `Store` by its public fields rather than `Store::open`, so the graph
1047    // tests stand on their own and do not depend on any other module's
1048    // behavior. Each test asserts the behavior the SPEC promises, derived from
1049    // intent, never from echoing the function's own output.
1050    //
1051    // `backlinks` (and `neighborhood` in any incoming direction) enumerate their
1052    // candidate set from the type-folder `index.jsonl` sidecars — the loop
1053    // contract: never a whole-store content walk. A real db.md store maintains
1054    // those sidecars write-through, so a test that exercises backlinks must call
1055    // [`Fixture::reindex`] after writing its files to build them (the SWEEP that
1056    // `dbmd index rebuild` runs). Forwardlinks/orphans read content directly and
1057    // need no sidecar.
1058
1059    struct Fixture {
1060        _tmp: TempDir,
1061        store: Store,
1062    }
1063
1064    impl Fixture {
1065        fn new() -> Self {
1066            let tmp = TempDir::new().expect("tempdir");
1067            let root = tmp.path().to_path_buf();
1068            fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").expect("DB.md");
1069            let store = Store {
1070                root,
1071                config: Config::default(),
1072            };
1073            Fixture { _tmp: tmp, store }
1074        }
1075
1076        /// Write a content file at a store-relative path with the given type,
1077        /// summary, and body. Creates parent dirs.
1078        fn write(&self, rel: &str, type_: &str, summary: &str, body: &str) {
1079            let abs = self.store.root.join(rel);
1080            fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
1081            let contents = format!(
1082                "---\ntype: {type_}\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: {summary}\n---\n{body}\n"
1083            );
1084            fs::write(&abs, contents).expect("write file");
1085        }
1086
1087        /// Write a raw file verbatim (for frontmatter-shape edge cases).
1088        fn write_raw(&self, rel: &str, contents: &str) {
1089            let abs = self.store.root.join(rel);
1090            fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
1091            fs::write(&abs, contents).expect("write raw");
1092        }
1093
1094        /// Build the type-folder `index.jsonl` sidecars from the content written
1095        /// so far — the state a real store is always in (write-through), and the
1096        /// candidate set `backlinks` reads. Call after writing files in any test
1097        /// that exercises `backlinks` or an incoming-direction `neighborhood`.
1098        fn reindex(&self) {
1099            crate::index::Index::rebuild_all(&self.store).expect("rebuild sidecars");
1100        }
1101
1102        fn p(&self, rel: &str) -> PathBuf {
1103            PathBuf::from(rel)
1104        }
1105    }
1106
1107    fn paths(v: &[PathBuf]) -> Vec<String> {
1108        v.iter()
1109            .map(|p| p.to_string_lossy().replace('\\', "/"))
1110            .collect()
1111    }
1112
1113    // ── normalize_target ────────────────────────────────────────────────────
1114
1115    #[test]
1116    fn normalize_strips_md_and_leading_dotslash() {
1117        assert_eq!(
1118            normalize_target(Path::new("records/contacts/sarah.md")),
1119            "records/contacts/sarah"
1120        );
1121        assert_eq!(
1122            normalize_target(Path::new("./records/profiles/elena")),
1123            "records/profiles/elena"
1124        );
1125        assert_eq!(normalize_target(Path::new("/records/x")), "records/x");
1126        // Bare and `.md` forms must collapse to the same key, or edges won't unify.
1127        assert_eq!(
1128            normalize_target(Path::new("a/b")),
1129            normalize_target(Path::new("a/b.md"))
1130        );
1131    }
1132
1133    // ── extract_link_targets (forwardlinks core) ────────────────────────────
1134
1135    #[test]
1136    fn extract_handles_display_text_and_md_suffix() {
1137        let body = "See [[records/profiles/sarah-chen|Sarah]] and [[records/contacts/elena.md]].";
1138        let got = extract_link_targets(body);
1139        assert_eq!(
1140            got,
1141            vec!["records/profiles/sarah-chen", "records/contacts/elena"]
1142        );
1143    }
1144
1145    #[test]
1146    fn extract_ignores_external_markdown_links() {
1147        // Standard markdown links are NOT wiki-links and must not be extracted
1148        // (SPEC: external refs don't participate in the graph).
1149        let body = "[Acme](https://acme.io) but [[records/companies/acme]] is internal.";
1150        let got = extract_link_targets(body);
1151        assert_eq!(got, vec!["records/companies/acme"]);
1152    }
1153
1154    #[test]
1155    fn extract_display_text_is_not_treated_as_a_target() {
1156        // A `|display` segment that looks path-like must not become a target;
1157        // only the part before `|` is the link target.
1158        let body = "[[records/contacts/sarah|sources/emails/decoy]]";
1159        let got = extract_link_targets(body);
1160        assert_eq!(got, vec!["records/contacts/sarah"]);
1161    }
1162
1163    // ── rewrite_links_to (write-side twin of backlinks) ─────────────────────
1164
1165    #[test]
1166    fn rewrite_plain_link_to_canonical_new_target() {
1167        let got = rewrite_links_to(
1168            "See [[records/contacts/sarah-chen]] today.",
1169            Path::new("records/contacts/sarah-chen"),
1170            Path::new("records/contacts/sarah-chen-acme"),
1171        );
1172        assert_eq!(got, "See [[records/contacts/sarah-chen-acme]] today.");
1173    }
1174
1175    #[test]
1176    fn rewrite_preserves_display_override() {
1177        let got = rewrite_links_to(
1178            "With [[records/contacts/sarah-chen|Sarah]].",
1179            Path::new("records/contacts/sarah-chen"),
1180            Path::new("records/contacts/sarah-chen-acme"),
1181        );
1182        assert_eq!(got, "With [[records/contacts/sarah-chen-acme|Sarah]].");
1183    }
1184
1185    #[test]
1186    fn rewrite_matches_md_suffixed_old_and_emits_bare_new() {
1187        // The `.md` spelling of the old target must match (it normalizes to the
1188        // same key the read side uses), and the new target is emitted bare —
1189        // the writer doctrine validate enforces (`WIKI_LINK_HAS_EXTENSION`).
1190        let got = rewrite_links_to(
1191            "[[records/contacts/sarah-chen.md]]",
1192            Path::new("records/contacts/sarah-chen"),
1193            Path::new("records/contacts/new.md"),
1194        );
1195        assert_eq!(got, "[[records/contacts/new]]");
1196    }
1197
1198    #[test]
1199    fn rewrite_leaves_prefix_collisions_and_short_form_untouched() {
1200        // Boundary correctness, anchored to the SAME normalize_target the read
1201        // side keys on: `records/contacts/sarah-chen` must NOT match the longer
1202        // `[[…-jr]]`, the short-form `[[sarah-chen]]`, or an unrelated target.
1203        let input = "[[records/contacts/sarah-chen-jr]] [[sarah-chen]] [[records/concepts/x]]";
1204        let got = rewrite_links_to(
1205            input,
1206            Path::new("records/contacts/sarah-chen"),
1207            Path::new("records/contacts/new"),
1208        );
1209        assert_eq!(got, input, "no genuine edge to the seed → text unchanged");
1210    }
1211
1212    #[test]
1213    fn rewrite_handles_multiple_occurrences_and_mixed_spellings() {
1214        let got = rewrite_links_to(
1215            "[[records/x]] then [[./records/x]] and [[records/x.md|d]] end",
1216            Path::new("records/x"),
1217            Path::new("records/y"),
1218        );
1219        // All three spellings of the same target retarget; the display survives.
1220        assert_eq!(
1221            got,
1222            "[[records/y]] then [[records/y]] and [[records/y|d]] end"
1223        );
1224    }
1225
1226    #[test]
1227    fn rewrite_retargets_exactly_the_edges_the_core_parser_sees() {
1228        // The load-bearing property of moving the rewrite into core: the write
1229        // side must operate on EXACTLY the edge set the read side recognizes —
1230        // the same `extract_link_targets` / `normalize_target` grammar that
1231        // `forwardlinks` is built on. Anchor the test to that grammar (via
1232        // `forwardlinks` on a real file) rather than re-listing literals, so a
1233        // future divergence between the read parser and the write rewrite fails
1234        // here. (Coupled to `forwardlinks` — the single-file edge extractor —
1235        // not the multi-file `backlinks` traversal, so it tests the grammar, not
1236        // the walk.)
1237        let fx = Fixture::new();
1238        let body = "Met [[records/contacts/sarah.md|Sarah]] and not [[records/contacts/sarah-2]].";
1239        fx.write("records/profiles/bio.md", "profile", "bio", body);
1240
1241        // Read side: the parser sees two outgoing edges, both in canonical bare
1242        // form (the `.md` spelling collapsed). `sarah` is a real edge here.
1243        let edges = forwardlinks(&fx.store, &fx.p("records/profiles/bio.md")).unwrap();
1244        assert_eq!(
1245            paths(&edges),
1246            vec!["records/contacts/sarah", "records/contacts/sarah-2"],
1247            "fixture must contain exactly the two edges this test reasons about"
1248        );
1249
1250        // Write side: rewriting `sarah → sarah-chen` must retarget the edge the
1251        // parser recognized (matching the `.md` spelling), preserve the display,
1252        // and leave the unrelated `sarah-2` edge untouched.
1253        let got = rewrite_links_to(
1254            body,
1255            Path::new("records/contacts/sarah"),
1256            Path::new("records/contacts/sarah-chen"),
1257        );
1258        assert_eq!(
1259            got,
1260            "Met [[records/contacts/sarah-chen|Sarah]] and not [[records/contacts/sarah-2]]."
1261        );
1262
1263        // Cross-check through the parser: the rewritten text's edge set is the
1264        // original with `sarah` swapped for `sarah-chen` — proving the rewrite
1265        // moved exactly one edge, the one the read side keyed on.
1266        fx.write("records/profiles/bio.md", "profile", "bio", &got);
1267        let after = forwardlinks(&fx.store, &fx.p("records/profiles/bio.md")).unwrap();
1268        assert_eq!(
1269            paths(&after),
1270            vec!["records/contacts/sarah-2", "records/contacts/sarah-chen"],
1271            "after rewrite the parser must see the new target and not the old"
1272        );
1273    }
1274
1275    #[test]
1276    fn rewrite_empty_old_target_is_a_no_op() {
1277        // A degenerate `old` (normalizes to empty) must never rewrite anything,
1278        // mirroring backlinks' empty-target guard.
1279        let input = "[[records/x]] [[]] text";
1280        let got = rewrite_links_to(input, Path::new(""), Path::new("records/y"));
1281        assert_eq!(got, input);
1282    }
1283
1284    #[test]
1285    fn rewrite_no_match_returns_input_unchanged() {
1286        let input = "no links, [external](https://x), and [[records/concepts/y]]";
1287        let got = rewrite_links_to(input, Path::new("records/x"), Path::new("records/z"));
1288        assert_eq!(got, input);
1289    }
1290
1291    #[test]
1292    fn rewrite_does_not_corrupt_links_in_nested_or_long_run_fences() {
1293        // Regression for the naive `starts_with("```")/("~~~")` toggle in the
1294        // rewriter: a fenced example documenting wiki-link syntax must be copied
1295        // VERBATIM, never retargeted — matching validate's edge notion. The
1296        // standard nested-fence convention (a ````-run block wrapping a ```
1297        // example) used to flip the bool mid-block, so the example link was
1298        // rewritten (silent documentation corruption).
1299        let body = "\
1300Here is how to write a link:
1301
1302````
1303```
1304[[records/contacts/bob]]
1305```
1306still fenced [[records/contacts/bob]]
1307````
1308
1309Real link: [[records/contacts/bob]].
1310";
1311        let got = rewrite_links_to(
1312            body,
1313            Path::new("records/contacts/bob"),
1314            Path::new("records/contacts/robert"),
1315        );
1316        // The two fenced examples are untouched; only the real link retargets.
1317        let expected = "\
1318Here is how to write a link:
1319
1320````
1321```
1322[[records/contacts/bob]]
1323```
1324still fenced [[records/contacts/bob]]
1325````
1326
1327Real link: [[records/contacts/robert]].
1328";
1329        assert_eq!(
1330            got, expected,
1331            "fenced example links must survive a rename verbatim; only live edges retarget"
1332        );
1333    }
1334
1335    #[test]
1336    fn rewrite_frontmatter_fence_does_not_swallow_body_link() {
1337        // Regression for the frontmatter/body fence-boundary data-loss bug: a
1338        // stray ``` inside a YAML block scalar in frontmatter used to open a code
1339        // fence that persisted into the body, so the rewriter treated every body
1340        // `[[…]]` as fenced and skipped it — leaving a dangling link after rename
1341        // even though `backlinks`/`forwardlinks` (which reset fence state at the
1342        // frontmatter boundary) still report the body edge. The write side must
1343        // split the frontmatter off and scan the body with a FRESH fence state,
1344        // exactly like the read side, so rename and the graph reads agree.
1345        let fx = Fixture::new();
1346        let text = "\
1347---
1348type: meeting
1349created: 2026-05-27T08:00:00-07:00
1350updated: 2026-05-27T08:00:00-07:00
1351summary: Notes
1352note: |
1353  fence with no close:
1354  ```
1355---
1356Met with [[records/contacts/sarah-chen]] yesterday.
1357";
1358        fx.write_raw("records/meeting.md", text);
1359
1360        // Read side: despite the stray fence in frontmatter, the body edge is a
1361        // live forward edge (fence state resets at the frontmatter boundary).
1362        let edges = forwardlinks(&fx.store, &fx.p("records/meeting.md")).unwrap();
1363        assert_eq!(
1364            paths(&edges),
1365            vec!["records/contacts/sarah-chen"],
1366            "read side must report the body edge despite the frontmatter fence"
1367        );
1368
1369        // Write side: rename must retarget that exact body edge — not skip it as
1370        // fenced. Output is byte-exact everywhere else (frontmatter verbatim,
1371        // including the stray ```).
1372        let got = rewrite_links_to(
1373            text,
1374            Path::new("records/contacts/sarah-chen"),
1375            Path::new("records/contacts/sc2"),
1376        );
1377        let expected = "\
1378---
1379type: meeting
1380created: 2026-05-27T08:00:00-07:00
1381updated: 2026-05-27T08:00:00-07:00
1382summary: Notes
1383note: |
1384  fence with no close:
1385  ```
1386---
1387Met with [[records/contacts/sc2]] yesterday.
1388";
1389        assert_eq!(
1390            got, expected,
1391            "the body link the read side reports must be rewritten; frontmatter copied verbatim"
1392        );
1393
1394        // Cross-check through the parser: after rewrite the read side sees the new
1395        // target and no trace of the old — rename and the graph reads agree.
1396        fx.write_raw("records/meeting.md", &got);
1397        let after = forwardlinks(&fx.store, &fx.p("records/meeting.md")).unwrap();
1398        assert_eq!(
1399            paths(&after),
1400            vec!["records/contacts/sc2"],
1401            "after rename the read side must report only the retargeted edge"
1402        );
1403    }
1404
1405    #[test]
1406    fn rewrite_link_genuinely_inside_a_body_fence_is_left_untouched() {
1407        // The boundary reset must not over-correct: a `[[…]]` truly inside a BODY
1408        // code fence is a documentation example, NOT an edge (matching the read
1409        // side), and must survive rename verbatim. This pairs with the
1410        // frontmatter-fence test: the body still gets a fresh, real fence state.
1411        let fx = Fixture::new();
1412        let text = "\
1413---
1414type: meeting
1415created: 2026-05-27T08:00:00-07:00
1416updated: 2026-05-27T08:00:00-07:00
1417summary: Notes
1418---
1419Real link: [[records/contacts/sarah-chen]].
1420
1421```
1422Example: [[records/contacts/sarah-chen]]
1423```
1424";
1425        fx.write_raw("records/meeting.md", text);
1426
1427        // Read side: only the unfenced body link is an edge; the fenced one is not.
1428        let edges = forwardlinks(&fx.store, &fx.p("records/meeting.md")).unwrap();
1429        assert_eq!(
1430            paths(&edges),
1431            vec!["records/contacts/sarah-chen"],
1432            "only the unfenced body link is a live edge"
1433        );
1434
1435        // Write side: the real link retargets; the fenced example is byte-exact.
1436        let got = rewrite_links_to(
1437            text,
1438            Path::new("records/contacts/sarah-chen"),
1439            Path::new("records/contacts/sc2"),
1440        );
1441        let expected = "\
1442---
1443type: meeting
1444created: 2026-05-27T08:00:00-07:00
1445updated: 2026-05-27T08:00:00-07:00
1446summary: Notes
1447---
1448Real link: [[records/contacts/sc2]].
1449
1450```
1451Example: [[records/contacts/sarah-chen]]
1452```
1453";
1454        assert_eq!(
1455            got, expected,
1456            "a link inside a body fence must survive rename; only the live edge retargets"
1457        );
1458    }
1459
1460    // ── forwardlinks ─────────────────────────────────────────────────────────
1461
1462    #[test]
1463    fn forwardlinks_returns_sorted_deduped_targets_excluding_self() {
1464        let fx = Fixture::new();
1465        fx.write(
1466            "records/projects/renewal.md",
1467            "synthesis",
1468            "Renewal project",
1469            "Links: [[records/contacts/sarah]] [[records/companies/acme]] [[records/contacts/sarah]] and itself [[records/projects/renewal]].",
1470        );
1471        // The targets need not exist on disk for forwardlinks (it reads the one
1472        // file only). Self-links are dropped; duplicates collapse; sorted asc.
1473        let got = forwardlinks(&fx.store, &fx.p("records/projects/renewal.md")).unwrap();
1474        assert_eq!(
1475            paths(&got),
1476            vec!["records/companies/acme", "records/contacts/sarah"]
1477        );
1478    }
1479
1480    #[test]
1481    fn forwardlinks_picks_up_wiki_links_in_frontmatter() {
1482        // SPEC: wiki-links appear in scalar + block-sequence frontmatter fields,
1483        // not just the body. forwardlinks must follow those edges too.
1484        let fx = Fixture::new();
1485        fx.write_raw(
1486            "records/meetings/m1.md",
1487            "---\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 [[records/projects/renewal]].\n",
1488        );
1489        let got = forwardlinks(&fx.store, &fx.p("records/meetings/m1.md")).unwrap();
1490        assert_eq!(
1491            paths(&got),
1492            vec![
1493                "records/companies/acme",
1494                "records/contacts/elena",
1495                "records/contacts/sarah",
1496                "records/projects/renewal",
1497            ]
1498        );
1499    }
1500
1501    #[test]
1502    fn forwardlinks_missing_file_is_empty_not_error() {
1503        let fx = Fixture::new();
1504        let got = forwardlinks(&fx.store, &fx.p("records/profiles/ghost.md")).unwrap();
1505        assert!(got.is_empty());
1506    }
1507
1508    #[test]
1509    fn forwardlinks_resolves_seed_given_without_md_extension() {
1510        let fx = Fixture::new();
1511        fx.write(
1512            "records/profiles/sarah.md",
1513            "profile",
1514            "Sarah bio",
1515            "Works at [[records/companies/acme]].",
1516        );
1517        // Seed passed in bare wiki-link form (no `.md`) must still resolve.
1518        let got = forwardlinks(&fx.store, &fx.p("records/profiles/sarah")).unwrap();
1519        assert_eq!(paths(&got), vec!["records/companies/acme"]);
1520    }
1521
1522    // ── backlinks ──────────────────────────────────────────────────────────
1523
1524    #[test]
1525    fn backlinks_finds_incoming_across_layers_and_link_forms() {
1526        let fx = Fixture::new();
1527        // Target.
1528        fx.write("records/contacts/sarah.md", "contact", "Sarah Chen", "");
1529        // Three different incoming-link spellings, all to the same target.
1530        fx.write(
1531            "records/profiles/sarah.md",
1532            "profile",
1533            "bio",
1534            "See [[records/contacts/sarah]].",
1535        );
1536        fx.write(
1537            "records/meetings/m1.md",
1538            "meeting",
1539            "Renewal call",
1540            "Attendee [[records/contacts/sarah|Sarah]].",
1541        );
1542        fx.write(
1543            "sources/emails/e1.md",
1544            "email",
1545            "Hi",
1546            "From [[records/contacts/sarah.md]] today.",
1547        );
1548        // A file that links to a DIFFERENT contact must not be a backlink.
1549        fx.write(
1550            "records/profiles/other.md",
1551            "profile",
1552            "x",
1553            "[[records/contacts/sarah-2]]",
1554        );
1555        fx.reindex();
1556
1557        // All three link forms ([[x]], [[x|d]], [[x.md]]) resolve to the same
1558        // target and are found; the linkers are returned in canonical bare form.
1559        let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1560        assert_eq!(
1561            paths(&got),
1562            vec![
1563                "records/meetings/m1",
1564                "records/profiles/sarah",
1565                "sources/emails/e1",
1566            ]
1567        );
1568    }
1569
1570    #[test]
1571    fn backlinks_and_forwardlinks_round_trip_on_same_key() {
1572        // If A forwardlinks to B, then B backlinks to A — both expressed in the
1573        // identical bare key, so neighborhood can dedup across directions.
1574        let fx = Fixture::new();
1575        fx.write(
1576            "records/profiles/a.md",
1577            "profile",
1578            "A",
1579            "Knows [[records/profiles/b]].",
1580        );
1581        fx.write("records/profiles/b.md", "profile", "B", "");
1582        fx.reindex();
1583        let fwd = forwardlinks(&fx.store, &fx.p("records/profiles/a.md")).unwrap();
1584        let back = backlinks(&fx.store, &fx.p("records/profiles/b.md")).unwrap();
1585        assert_eq!(paths(&fwd), vec!["records/profiles/b"]);
1586        assert_eq!(paths(&back), vec!["records/profiles/a"]);
1587    }
1588
1589    #[test]
1590    fn backlinks_does_not_match_path_prefix_collisions() {
1591        let fx = Fixture::new();
1592        fx.write("records/contacts/sam.md", "contact", "Sam", "");
1593        // `sam-smith` shares the `sam` prefix; must NOT count as a backlink to `sam`.
1594        fx.write(
1595            "records/profiles/x.md",
1596            "profile",
1597            "x",
1598            "[[records/contacts/sam-smith]]",
1599        );
1600        // The genuine backlink.
1601        fx.write(
1602            "records/profiles/y.md",
1603            "profile",
1604            "y",
1605            "[[records/contacts/sam]]",
1606        );
1607        fx.reindex();
1608
1609        let got = backlinks(&fx.store, &fx.p("records/contacts/sam")).unwrap();
1610        assert_eq!(paths(&got), vec!["records/profiles/y"]);
1611    }
1612
1613    #[test]
1614    fn backlinks_excludes_self_reference() {
1615        let fx = Fixture::new();
1616        // A page that links to itself is not its own backlink.
1617        fx.write(
1618            "records/synthesis/overview.md",
1619            "synthesis",
1620            "Overview",
1621            "This page [[records/synthesis/overview]] references itself.",
1622        );
1623        fx.reindex();
1624        let got = backlinks(&fx.store, &fx.p("records/synthesis/overview.md")).unwrap();
1625        assert!(
1626            got.is_empty(),
1627            "self-link must not appear as a backlink, got {got:?}"
1628        );
1629    }
1630
1631    #[test]
1632    fn backlinks_empty_when_nobody_links() {
1633        let fx = Fixture::new();
1634        fx.write("records/contacts/lonely.md", "contact", "Lonely", "");
1635        fx.write(
1636            "records/profiles/unrelated.md",
1637            "profile",
1638            "x",
1639            "[[records/companies/acme]]",
1640        );
1641        fx.reindex();
1642        let got = backlinks(&fx.store, &fx.p("records/contacts/lonely.md")).unwrap();
1643        assert!(got.is_empty());
1644    }
1645
1646    #[test]
1647    fn backlinks_ignores_index_and_meta_files() {
1648        let fx = Fixture::new();
1649        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1650        // An index.md that lists the target must NOT be reported as a backlink
1651        // (indexes are catalog, not relationship edges).
1652        fx.write_raw(
1653            "records/contacts/index.md",
1654            "---\ntype: index\nscope: folder\nfolder: records/contacts\n---\n- [[records/contacts/sarah]] — Sarah\n",
1655        );
1656        fx.reindex();
1657        let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1658        assert!(got.is_empty(), "index.md must be excluded, got {got:?}");
1659    }
1660
1661    #[test]
1662    fn backlinks_finds_body_only_edge_not_in_frontmatter_links_field() {
1663        // REGRESSION: the sidecar's `links` field carries only the file's
1664        // frontmatter `links:` list; it does NOT include wiki-links written in
1665        // the body or in other typed frontmatter fields. Answering backlinks
1666        // from `links[]` alone would silently miss this edge. The candidate set
1667        // is sidecar-bounded, but each candidate's edge is confirmed by parsing
1668        // the file (the same extraction forwardlinks uses), so a body-only link
1669        // must still register as a backlink.
1670        let fx = Fixture::new();
1671        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1672        // `meeting.md` links to sarah ONLY in its body — its frontmatter has no
1673        // `links:` field at all, so the sidecar record's `links` is empty.
1674        fx.write(
1675            "records/meetings/standup.md",
1676            "meeting",
1677            "Standup",
1678            "Discussed renewal with [[records/contacts/sarah]].",
1679        );
1680        fx.reindex();
1681
1682        // Guard the premise: the sidecar record really does carry an empty
1683        // `links` (so this test fails loudly if the index ever starts extracting
1684        // body links — at which point the backlink predicate could be revisited).
1685        let rec = fx
1686            .store
1687            .find_by_type("meeting")
1688            .unwrap()
1689            .into_iter()
1690            .find(|r| r.path == fx.p("records/meetings/standup.md"))
1691            .expect("meeting is catalogued in its sidecar");
1692        assert!(
1693            rec.links.is_empty(),
1694            "premise: the body link is NOT projected into the sidecar `links` field; got {:?}",
1695            rec.links
1696        );
1697
1698        // Yet backlinks still finds it — because it confirms via the file parse,
1699        // not via the sidecar `links` field.
1700        let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1701        assert_eq!(
1702            paths(&got),
1703            vec!["records/meetings/standup"],
1704            "a body-only wiki-link must register as a backlink"
1705        );
1706    }
1707
1708    #[test]
1709    fn backlinks_finds_edge_in_typed_frontmatter_field() {
1710        // A wiki-link inside a *typed* frontmatter field (`company:`) is a real
1711        // edge forwardlinks follows, so backlinks must find it too — even though
1712        // the sidecar's `links` field (the `links:` key only) does not list it.
1713        let fx = Fixture::new();
1714        fx.write("records/companies/acme.md", "company", "Acme", "");
1715        fx.write_raw(
1716            "records/contacts/sarah.md",
1717            "---\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",
1718        );
1719        fx.reindex();
1720        let got = backlinks(&fx.store, &fx.p("records/companies/acme.md")).unwrap();
1721        assert_eq!(
1722            paths(&got),
1723            vec!["records/contacts/sarah"],
1724            "a wiki-link in a typed frontmatter field is an incoming edge"
1725        );
1726    }
1727
1728    #[test]
1729    fn backlinks_unscoped_scans_the_tree_not_only_the_sidecar() {
1730        // REGRESSION (loop budget): an UNSCOPED `backlinks` must resolve incoming
1731        // edges with a SINGLE embedded-ripgrep pass over the tree
1732        // (`Store::find_links_to`), NOT by reading the sidecar candidate set and
1733        // then `read_to_string`-confirming each candidate (which re-opens every
1734        // content file → O(store); the documented >3x budget miss). A ripgrep
1735        // pass is the same scan engine `validate`/`rename`/`dbmd graph backlinks` ride, and
1736        // the tree — not the sidecar — is its ground truth: a linker that is on
1737        // disk but absent from every sidecar (stale / never-built index) is still
1738        // found. We assert that behaviorally, which fails loudly if the unscoped
1739        // path ever reverts to the sidecar-bounded per-candidate confirm loop
1740        // (that loop would NOT find the unindexed linker).
1741        let fx = Fixture::new();
1742        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1743        fx.write(
1744            "records/profiles/indexed.md",
1745            "profile",
1746            "Indexed",
1747            "[[records/contacts/sarah]]",
1748        );
1749        fx.reindex(); // builds sidecars for sarah + the indexed linker
1750
1751        // Now drop a NEW linker on disk WITHOUT reindexing — it is on disk but in
1752        // no sidecar.
1753        fx.write(
1754            "records/profiles/unindexed.md",
1755            "profile",
1756            "Unindexed",
1757            "[[records/contacts/sarah]]",
1758        );
1759
1760        let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1761        assert_eq!(
1762            paths(&got),
1763            vec!["records/profiles/indexed", "records/profiles/unindexed"],
1764            "unscoped backlinks ripgrep-scans the tree, so the on-disk-but-unindexed \
1765             linker is found too — not only the sidecar-catalogued one"
1766        );
1767    }
1768
1769    #[test]
1770    fn backlinks_scoped_candidates_come_from_the_sidecar_not_a_tree_walk() {
1771        // REGRESSION (scale contract): the SCOPED form (`--type` / `--in`) is the
1772        // I/O-scoped path — it enumerates candidates from the relevant type-folder
1773        // `index.jsonl` sidecars and parses only those, NOT a whole-tree walk.
1774        // That is what makes the scope an I/O scope, not just a result filter:
1775        // a linker that is on disk but ABSENT from the sidecar (stale / never-built
1776        // index) is NOT discovered by the scoped call (the sidecar bounds which
1777        // files are candidates). This is the loop-vs-walk distinction the SPEC
1778        // draws, and it is exactly the inverse of the unscoped tree scan above.
1779        let fx = Fixture::new();
1780        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1781        fx.write(
1782            "records/profiles/indexed.md",
1783            "profile",
1784            "Indexed",
1785            "[[records/contacts/sarah]]",
1786        );
1787        fx.reindex(); // builds sidecars for sarah + the indexed linker
1788
1789        // Drop a NEW profile linker on disk WITHOUT reindexing — on disk, in no
1790        // sidecar.
1791        fx.write(
1792            "records/profiles/unindexed.md",
1793            "profile",
1794            "Unindexed",
1795            "[[records/contacts/sarah]]",
1796        );
1797
1798        // Scoped to the `profile` type: the candidate set is the sidecar's, so
1799        // only the catalogued linker is found — the unindexed one is invisible.
1800        let only_profiles = vec!["profile".to_string()];
1801        let got = backlinks_filtered(
1802            &fx.store,
1803            &fx.p("records/contacts/sarah.md"),
1804            &only_profiles,
1805            None,
1806        )
1807        .unwrap();
1808        assert_eq!(
1809            paths(&got),
1810            vec!["records/profiles/indexed"],
1811            "scoped backlinks reads the sidecar candidate set; the on-disk-but-unindexed \
1812             linker is not tree-walked"
1813        );
1814    }
1815
1816    #[test]
1817    fn backlinks_filtered_type_scopes_the_candidate_set() {
1818        // `--type` narrows backlinks to linkers of that type. Two files link to
1819        // the target — one `meeting`, one `profile`; filtering to `meeting`
1820        // returns only the meeting.
1821        let fx = Fixture::new();
1822        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1823        fx.write(
1824            "records/meetings/m1.md",
1825            "meeting",
1826            "Call",
1827            "[[records/contacts/sarah]]",
1828        );
1829        fx.write(
1830            "records/profiles/bio.md",
1831            "profile",
1832            "Bio",
1833            "[[records/contacts/sarah]]",
1834        );
1835        fx.reindex();
1836
1837        let only_meetings = vec!["meeting".to_string()];
1838        let got = backlinks_filtered(
1839            &fx.store,
1840            &fx.p("records/contacts/sarah.md"),
1841            &only_meetings,
1842            None,
1843        )
1844        .unwrap();
1845        assert_eq!(
1846            paths(&got),
1847            vec!["records/meetings/m1"],
1848            "--type meeting must exclude the profile linker"
1849        );
1850
1851        // Unfiltered, both come back — proving the filter (not the data) dropped one.
1852        let all = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1853        assert_eq!(
1854            paths(&all),
1855            vec!["records/meetings/m1", "records/profiles/bio"]
1856        );
1857    }
1858
1859    #[test]
1860    fn backlinks_filtered_layer_scopes_the_candidate_set() {
1861        // `--in <layer>` narrows backlinks to linkers under that layer. The two
1862        // linkers live in different layers (a sources email and a records
1863        // meeting) so the scope genuinely separates them.
1864        let fx = Fixture::new();
1865        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1866        fx.write(
1867            "records/meetings/m1.md",
1868            "meeting",
1869            "Call",
1870            "[[records/contacts/sarah]]",
1871        );
1872        fx.write(
1873            "sources/emails/intro.md",
1874            "email",
1875            "Intro",
1876            "[[records/contacts/sarah]]",
1877        );
1878        fx.reindex();
1879
1880        let got = backlinks_filtered(
1881            &fx.store,
1882            &fx.p("records/contacts/sarah.md"),
1883            &[],
1884            Some(Layer::Sources),
1885        )
1886        .unwrap();
1887        assert_eq!(
1888            paths(&got),
1889            vec!["sources/emails/intro"],
1890            "--in sources must keep only the sources-layer linker"
1891        );
1892
1893        let records_only = backlinks_filtered(
1894            &fx.store,
1895            &fx.p("records/contacts/sarah.md"),
1896            &[],
1897            Some(Layer::Records),
1898        )
1899        .unwrap();
1900        assert_eq!(paths(&records_only), vec!["records/meetings/m1"]);
1901    }
1902
1903    #[test]
1904    fn backlinks_scoped_type_spans_all_topic_folders_in_its_layer() {
1905        // REGRESSION (finding #12): a `type` can legitimately span several folders
1906        // within one layer — a `profile` is filed under its canonical
1907        // `records/profiles/` folder, but an agent may also file a profile under
1908        // another `records/<folder>/` (the type, not the folder, is authoritative).
1909        // The scoped candidate set must read the whole `records/` layer and filter
1910        // by type, NOT just the canonical-guess folder `records/profiles/`. Before
1911        // the fix, `find_by_type("profile")` read ONLY `records/profiles/index.jsonl`
1912        // whenever that sidecar existed, silently dropping every profile linker
1913        // filed under any other folder — so `backlinks --type profile` under-reported
1914        // dependents (a wrong blast-radius check) the moment a `records/profiles/`
1915        // page also existed.
1916        //
1917        // The trigger needs BOTH: a populated `records/profiles/` (so its canonical
1918        // sidecar exists) AND a profile elsewhere in the layer that links the
1919        // target. The earlier
1920        // `backlinks_scoped_candidates_come_from_the_sidecar_not_a_tree_walk` test
1921        // masks this bug precisely because its fixture has no `records/profiles/`.
1922        let fx = Fixture::new();
1923        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1924        // A profile in the CANONICAL type folder, NOT linking the target — its
1925        // only purpose is to make `records/profiles/index.jsonl` exist on disk.
1926        fx.write(
1927            "records/profiles/glossary.md",
1928            "profile",
1929            "Glossary",
1930            "No link to sarah here.",
1931        );
1932        // A profile in a NON-canonical folder that DOES link the target.
1933        fx.write(
1934            "records/people/sarah.md",
1935            "profile",
1936            "Sarah bio",
1937            "Profile of [[records/contacts/sarah]].",
1938        );
1939        fx.reindex(); // builds records/profiles/index.jsonl AND records/people/index.jsonl
1940
1941        // Scoped to `profile`: the off-canonical linker MUST be found. Pre-fix,
1942        // the candidate set was only `records/profiles/`'s sidecar, so this was empty.
1943        let scoped = backlinks_filtered(
1944            &fx.store,
1945            &fx.p("records/contacts/sarah.md"),
1946            &["profile".to_string()],
1947            None,
1948        )
1949        .unwrap();
1950        assert_eq!(
1951            paths(&scoped),
1952            vec!["records/people/sarah"],
1953            "a profile filed outside records/profiles/ must still be a scoped backlink"
1954        );
1955
1956        // Cross-check: the unscoped path (ripgrep tree scan) finds the same single
1957        // linker, proving the scoped result is now complete — not over- or
1958        // under-counting — and that the data was real all along.
1959        let unscoped = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
1960        assert_eq!(
1961            paths(&unscoped),
1962            vec!["records/people/sarah"],
1963            "scoped and unscoped backlinks must agree on the edge set"
1964        );
1965    }
1966
1967    #[test]
1968    fn backlinks_scoped_type_finds_loose_file_at_non_canonical_layer() {
1969        // REGRESSION (spec-conformance, SPEC § Loose files): a loose file (content
1970        // directly at a layer root, no type-folder) may be filed at a layer that is
1971        // NOT the type's canonical layer — e.g. a `note` (canonical layer
1972        // `sources/`) filed as `records/loose-note.md` and catalogued in
1973        // `records/index.jsonl`. A scoped `backlinks --type note` must still find
1974        // it, matching the unscoped scan and `dbmd query --type note`.
1975        //
1976        // Pre-fix, `candidate_records(--type note)` read only `layer_for_type(note)`
1977        // = Sources, so the records-loose note was invisible (`--type note` empty),
1978        // and `--type note --in records` hit the early `continue` (records ≠ the
1979        // note's canonical Sources layer) → also empty. Both diverged from the
1980        // store-wide unscoped scan. The fix reads store-wide (or the named layer)
1981        // sidecars and filters by `type`, never short-circuiting on the canonical
1982        // layer.
1983        let fx = Fixture::new();
1984        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
1985        // A loose `note` directly at the records/ layer root (no type-folder),
1986        // linking the target. Its canonical layer is sources/, so this exercises
1987        // exactly the off-canonical-layer loose-file path.
1988        fx.write_raw(
1989            "records/loose-note.md",
1990            "---\ntype: note\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: Loose\n---\nMentions [[records/contacts/sarah]].\n",
1991        );
1992        fx.reindex(); // catalogs the loose note in records/index.jsonl
1993
1994        let target = fx.p("records/contacts/sarah.md");
1995        let note_type = vec!["note".to_string()];
1996
1997        // Unscoped: the loose note is a backlink (ground truth).
1998        let unscoped = backlinks(&fx.store, &target).unwrap();
1999        assert_eq!(
2000            paths(&unscoped),
2001            vec!["records/loose-note"],
2002            "unscoped backlinks finds the records-loose note"
2003        );
2004
2005        // `--type note` (no layer): must agree with unscoped, NOT empty.
2006        let by_type = backlinks_filtered(&fx.store, &target, &note_type, None).unwrap();
2007        assert_eq!(
2008            paths(&by_type),
2009            vec!["records/loose-note"],
2010            "`--type note` must find the loose note filed at the non-canonical (records) layer"
2011        );
2012
2013        // `--type note --in records`: the note lives in records/, so this must
2014        // find it too — the early `continue` on canonical-layer mismatch is gone.
2015        let by_type_in_records =
2016            backlinks_filtered(&fx.store, &target, &note_type, Some(Layer::Records)).unwrap();
2017        assert_eq!(
2018            paths(&by_type_in_records),
2019            vec!["records/loose-note"],
2020            "`--type note --in records` must find the records-loose note"
2021        );
2022
2023        // Cross-check the same completeness via the structured query path the SPEC
2024        // ties graph reads to: `query --type note` (store-wide) sees the loose note,
2025        // proving the data was real and the scoped graph result now agrees with it.
2026        let q_records: Vec<String> = paths(
2027            &crate::query::Query::new()
2028                .with_type("note")
2029                .execute(&fx.store)
2030                .unwrap()
2031                .into_iter()
2032                .map(|r| r.path)
2033                .collect::<Vec<_>>(),
2034        );
2035        assert_eq!(
2036            q_records,
2037            vec!["records/loose-note.md"],
2038            "query --type note sees the loose note store-wide; scoped backlinks must agree"
2039        );
2040    }
2041
2042    // ── neighborhood ─────────────────────────────────────────────────────────
2043
2044    #[test]
2045    fn neighborhood_hops_zero_is_empty() {
2046        let fx = Fixture::new();
2047        fx.write(
2048            "records/profiles/a.md",
2049            "profile",
2050            "A",
2051            "[[records/profiles/b]]",
2052        );
2053        fx.write("records/profiles/b.md", "profile", "B", "");
2054        let slice = neighborhood(
2055            &fx.store,
2056            &fx.p("records/profiles/a.md"),
2057            0,
2058            &[],
2059            Direction::Both,
2060        )
2061        .unwrap();
2062        assert_eq!(slice.seed, fx.p("records/profiles/a"));
2063        assert!(slice.nodes.is_empty());
2064    }
2065
2066    #[test]
2067    fn neighborhood_outgoing_one_hop_reads_summary_and_type() {
2068        let fx = Fixture::new();
2069        fx.write(
2070            "records/profiles/a.md",
2071            "profile",
2072            "Person A",
2073            "Knows [[records/contacts/b]].",
2074        );
2075        fx.write("records/contacts/b.md", "contact", "Contact B summary", "");
2076        let slice = neighborhood(
2077            &fx.store,
2078            &fx.p("records/profiles/a.md"),
2079            1,
2080            &[],
2081            Direction::Outgoing,
2082        )
2083        .unwrap();
2084        assert_eq!(slice.nodes.len(), 1);
2085        let n = &slice.nodes[0];
2086        assert_eq!(n.path, fx.p("records/contacts/b"));
2087        assert_eq!(n.summary, "Contact B summary");
2088        assert_eq!(n.type_.as_deref(), Some("contact"));
2089        assert_eq!(n.hops, 1);
2090        assert_eq!(
2091            n.via,
2092            Some((fx.p("records/profiles/a"), Direction::Outgoing))
2093        );
2094    }
2095
2096    #[test]
2097    fn neighborhood_incoming_only_walks_backlinks() {
2098        let fx = Fixture::new();
2099        // a -> seed (incoming to seed). seed -> c (outgoing from seed).
2100        fx.write(
2101            "records/profiles/seed.md",
2102            "profile",
2103            "Seed",
2104            "Out to [[records/profiles/c]].",
2105        );
2106        fx.write(
2107            "records/profiles/a.md",
2108            "profile",
2109            "A",
2110            "In to [[records/profiles/seed]].",
2111        );
2112        fx.write("records/profiles/c.md", "profile", "C", "");
2113        fx.reindex();
2114        let slice = neighborhood(
2115            &fx.store,
2116            &fx.p("records/profiles/seed.md"),
2117            1,
2118            &[],
2119            Direction::Incoming,
2120        )
2121        .unwrap();
2122        // Incoming direction: only `a` (which links TO seed), not `c`.
2123        assert_eq!(
2124            paths(
2125                &slice
2126                    .nodes
2127                    .iter()
2128                    .map(|n| n.path.clone())
2129                    .collect::<Vec<_>>()
2130            ),
2131            vec!["records/profiles/a"]
2132        );
2133        assert_eq!(
2134            slice.nodes[0].via,
2135            Some((fx.p("records/profiles/seed"), Direction::Incoming))
2136        );
2137    }
2138
2139    #[test]
2140    fn neighborhood_bounded_bfs_respects_hop_limit_and_min_distance() {
2141        let fx = Fixture::new();
2142        // Chain a -> b -> c -> d, all outgoing.
2143        fx.write("records/c/a.md", "concept", "A", "[[records/c/b]]");
2144        fx.write("records/c/b.md", "concept", "B", "[[records/c/c]]");
2145        fx.write("records/c/c.md", "concept", "C", "[[records/c/d]]");
2146        fx.write("records/c/d.md", "concept", "D", "");
2147        let slice = neighborhood(
2148            &fx.store,
2149            &fx.p("records/c/a.md"),
2150            2,
2151            &[],
2152            Direction::Outgoing,
2153        )
2154        .unwrap();
2155        // 2 hops reaches b (1) and c (2), not d (3).
2156        let by_path: HashMap<String, u32> = slice
2157            .nodes
2158            .iter()
2159            .map(|n| (n.path.to_string_lossy().to_string(), n.hops))
2160            .collect();
2161        assert_eq!(by_path.get("records/c/b").copied(), Some(1));
2162        assert_eq!(by_path.get("records/c/c").copied(), Some(2));
2163        assert_eq!(by_path.get("records/c/d"), None);
2164        assert_eq!(slice.nodes.len(), 2);
2165    }
2166
2167    #[test]
2168    fn neighborhood_records_min_hops_on_diamond() {
2169        let fx = Fixture::new();
2170        // Diamond: a -> b, a -> c, b -> d, c -> d. d is reachable at hop 2 from
2171        // either branch; it must be recorded once, at hop 2.
2172        fx.write(
2173            "records/d/a.md",
2174            "concept",
2175            "A",
2176            "[[records/d/b]] [[records/d/c]]",
2177        );
2178        fx.write("records/d/b.md", "concept", "B", "[[records/d/d]]");
2179        fx.write("records/d/c.md", "concept", "C", "[[records/d/d]]");
2180        fx.write("records/d/d.md", "concept", "D", "");
2181        let slice = neighborhood(
2182            &fx.store,
2183            &fx.p("records/d/a.md"),
2184            3,
2185            &[],
2186            Direction::Outgoing,
2187        )
2188        .unwrap();
2189        let d_nodes: Vec<&ContextNode> = slice
2190            .nodes
2191            .iter()
2192            .filter(|n| n.path == fx.p("records/d/d"))
2193            .collect();
2194        assert_eq!(d_nodes.len(), 1, "d must appear exactly once");
2195        assert_eq!(d_nodes[0].hops, 2, "d's min distance from a is 2");
2196        // b and c at hop 1, d at hop 2 => 3 nodes total, no cycle blowup.
2197        assert_eq!(slice.nodes.len(), 3);
2198    }
2199
2200    #[test]
2201    fn neighborhood_type_filter_narrows_results_but_not_traversal() {
2202        let fx = Fixture::new();
2203        // seed -> contact -> meeting. Filtering to `meeting` must still reach
2204        // the meeting THROUGH the (excluded) contact at hop 2.
2205        fx.write(
2206            "records/profiles/seed.md",
2207            "profile",
2208            "Seed",
2209            "[[records/contacts/sarah]]",
2210        );
2211        fx.write(
2212            "records/contacts/sarah.md",
2213            "contact",
2214            "Sarah",
2215            "[[records/meetings/m1]]",
2216        );
2217        fx.write("records/meetings/m1.md", "meeting", "Renewal call", "");
2218        let only_meetings = vec!["meeting".to_string()];
2219        let slice = neighborhood(
2220            &fx.store,
2221            &fx.p("records/profiles/seed.md"),
2222            2,
2223            &only_meetings,
2224            Direction::Outgoing,
2225        )
2226        .unwrap();
2227        // Only the meeting is returned; the contact is traversed but filtered out.
2228        assert_eq!(slice.nodes.len(), 1);
2229        assert_eq!(slice.nodes[0].path, fx.p("records/meetings/m1"));
2230        assert_eq!(slice.nodes[0].type_.as_deref(), Some("meeting"));
2231        assert_eq!(slice.nodes[0].hops, 2);
2232    }
2233
2234    #[test]
2235    fn neighborhood_capped_bounds_traversal_not_just_output() {
2236        // REGRESSION (finding #16): `neighborhood` expands every reached node, and
2237        // each incoming-edge expansion is a full-store scan, so the per-node cost
2238        // is O(visited × store). The CLI's `--limit` was applied post-hoc as a
2239        // `.take(n)` on the RESULT, which caps printed nodes but NOT the traversal
2240        // — the scans still fire for every reachable node. `neighborhood_capped`
2241        // bounds the traversal itself: once `max_nodes` distinct nodes are
2242        // admitted, the BFS stops discovering (and therefore stops scanning).
2243        //
2244        // Structure proving traversal — not just output — is bounded:
2245        //   seed -> a, b, c   (hop 1, discovered in sorted order: a, b, c)
2246        //   a    -> deep      (hop 2, reachable ONLY by expanding `a`)
2247        // Cap at 2: admit `a` and `b`, stop before `c` and before any hop-2
2248        // expansion. `deep` is therefore unreachable. A post-hoc `.take(2)` would
2249        // have traversed the whole graph (reaching `deep`) and only then truncated
2250        // — so the absence of `deep` is observable proof the traversal stopped.
2251        let fx = Fixture::new();
2252        fx.write(
2253            "records/n/seed.md",
2254            "concept",
2255            "Seed",
2256            "[[records/n/a]] [[records/n/b]] [[records/n/c]]",
2257        );
2258        fx.write("records/n/a.md", "concept", "A", "[[records/n/deep]]");
2259        fx.write("records/n/b.md", "concept", "B", "");
2260        fx.write("records/n/c.md", "concept", "C", "");
2261        fx.write("records/n/deep.md", "concept", "Deep", "");
2262
2263        // Uncapped over 3 hops: all four reachable nodes appear (a, b, c at hop 1,
2264        // deep at hop 2) — the full set the cap is measured against.
2265        let full = neighborhood(
2266            &fx.store,
2267            &fx.p("records/n/seed.md"),
2268            3,
2269            &[],
2270            Direction::Outgoing,
2271        )
2272        .unwrap();
2273        assert_eq!(
2274            paths(
2275                &full
2276                    .nodes
2277                    .iter()
2278                    .map(|n| n.path.clone())
2279                    .collect::<Vec<_>>()
2280            ),
2281            vec![
2282                "records/n/a",
2283                "records/n/b",
2284                "records/n/c",
2285                "records/n/deep"
2286            ],
2287            "uncapped traversal reaches every node within the hop budget"
2288        );
2289
2290        // Capped at 2 over the SAME hop budget: exactly the first two hop-1 nodes,
2291        // and crucially NOT `deep` — the cap halted the BFS before any node was
2292        // expanded into hop 2, so the deep node was never traversed to.
2293        let capped = neighborhood_capped(
2294            &fx.store,
2295            &fx.p("records/n/seed.md"),
2296            3,
2297            &[],
2298            Direction::Outgoing,
2299            Some(2),
2300        )
2301        .unwrap();
2302        assert_eq!(
2303            paths(
2304                &capped
2305                    .nodes
2306                    .iter()
2307                    .map(|n| n.path.clone())
2308                    .collect::<Vec<_>>()
2309            ),
2310            vec!["records/n/a", "records/n/b"],
2311            "the cap bounds traversal: only the first 2 nodes are reached, and the \
2312             hop-2 `deep` node (reachable only by expanding a capped-out node) is \
2313             never traversed"
2314        );
2315
2316        // `max_nodes = None` is exactly the unbounded `neighborhood` behavior.
2317        let uncapped = neighborhood_capped(
2318            &fx.store,
2319            &fx.p("records/n/seed.md"),
2320            3,
2321            &[],
2322            Direction::Outgoing,
2323            None,
2324        )
2325        .unwrap();
2326        assert_eq!(
2327            uncapped.nodes.len(),
2328            full.nodes.len(),
2329            "None cap matches the unbounded neighborhood result"
2330        );
2331    }
2332
2333    #[test]
2334    fn neighborhood_capped_both_direction_caps_the_node_count() {
2335        // The CLI always passes `Direction::Both` (the per-node backlinks scan is
2336        // the expensive path the cap exists to bound). The cap gates discovery in
2337        // any direction, so a hub linked from many nodes is still bounded.
2338        let fx = Fixture::new();
2339        fx.write("records/profiles/hub.md", "profile", "Hub", "");
2340        for n in ["a", "b", "c", "d", "e"] {
2341            fx.write(
2342                &format!("records/profiles/{n}.md"),
2343                "profile",
2344                n,
2345                "[[records/profiles/hub]]",
2346            );
2347        }
2348        fx.reindex();
2349
2350        let capped = neighborhood_capped(
2351            &fx.store,
2352            &fx.p("records/profiles/hub.md"),
2353            1,
2354            &[],
2355            Direction::Both,
2356            Some(3),
2357        )
2358        .unwrap();
2359        assert_eq!(
2360            capped.nodes.len(),
2361            3,
2362            "Both-direction neighborhood is bounded to the node cap"
2363        );
2364
2365        // Without the cap the same call returns all five backlinking nodes,
2366        // proving the cap (not the data) limited the set.
2367        let uncapped = neighborhood(
2368            &fx.store,
2369            &fx.p("records/profiles/hub.md"),
2370            1,
2371            &[],
2372            Direction::Both,
2373        )
2374        .unwrap();
2375        assert_eq!(uncapped.nodes.len(), 5);
2376    }
2377
2378    #[test]
2379    fn neighborhood_cycle_terminates() {
2380        let fx = Fixture::new();
2381        // a <-> b cycle. Must not loop forever; each appears once.
2382        fx.write("records/g/a.md", "concept", "A", "[[records/g/b]]");
2383        fx.write("records/g/b.md", "concept", "B", "[[records/g/a]]");
2384        fx.reindex();
2385        let slice =
2386            neighborhood(&fx.store, &fx.p("records/g/a.md"), 10, &[], Direction::Both).unwrap();
2387        // From a: b is the only other node (a is the seed, excluded).
2388        assert_eq!(
2389            paths(
2390                &slice
2391                    .nodes
2392                    .iter()
2393                    .map(|n| n.path.clone())
2394                    .collect::<Vec<_>>()
2395            ),
2396            vec!["records/g/b"]
2397        );
2398    }
2399
2400    // ── orphans ──────────────────────────────────────────────────────────────
2401
2402    #[test]
2403    fn orphans_finds_files_with_no_edges_either_direction() {
2404        let fx = Fixture::new();
2405        // Wired pair: a links to b (a has outgoing, b has incoming).
2406        fx.write(
2407            "records/profiles/a.md",
2408            "profile",
2409            "A",
2410            "[[records/profiles/b]]",
2411        );
2412        fx.write("records/profiles/b.md", "profile", "B", "");
2413        // Orphan: no links in or out.
2414        fx.write(
2415            "sources/emails/lonely.md",
2416            "email",
2417            "Lonely email",
2418            "Just text, no links.",
2419        );
2420        let got = orphans(&fx.store, None).unwrap();
2421        assert_eq!(paths(&got), vec!["sources/emails/lonely.md"]);
2422    }
2423
2424    #[test]
2425    fn orphans_file_with_only_broken_outgoing_link_is_orphan() {
2426        let fx = Fixture::new();
2427        // Broken targets are validation issues, not graph edges to another
2428        // store file. A file whose only link points nowhere is still an orphan.
2429        fx.write(
2430            "records/profiles/a.md",
2431            "profile",
2432            "A",
2433            "[[records/contacts/ghost]]",
2434        );
2435        let got = orphans(&fx.store, None).unwrap();
2436        assert!(
2437            paths(&got).contains(&"records/profiles/a.md".to_string()),
2438            "broken outgoing links must not wire the graph: {got:?}"
2439        );
2440    }
2441
2442    #[test]
2443    fn orphans_file_with_only_incoming_is_not_orphan() {
2444        let fx = Fixture::new();
2445        // `target` has no outgoing links but IS linked to by `linker` — not an orphan.
2446        fx.write("records/contacts/target.md", "contact", "Target", "");
2447        fx.write(
2448            "records/profiles/linker.md",
2449            "profile",
2450            "Linker",
2451            "[[records/contacts/target]]",
2452        );
2453        let got = orphans(&fx.store, None).unwrap();
2454        assert!(
2455            !paths(&got).contains(&"records/contacts/target.md".to_string()),
2456            "incoming-only is not an orphan: {got:?}"
2457        );
2458        // `linker` has outgoing, so also not an orphan.
2459        assert!(!paths(&got).contains(&"records/profiles/linker.md".to_string()));
2460    }
2461
2462    #[test]
2463    fn orphans_incoming_link_from_other_layer_unorphans() {
2464        let fx = Fixture::new();
2465        // Candidate in records/, only incoming edge comes from sources/ — a
2466        // cross-layer link must still un-orphan it even when scoped to records.
2467        fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
2468        fx.write(
2469            "sources/emails/sarah.md",
2470            "email",
2471            "bio",
2472            "[[records/contacts/sarah]]",
2473        );
2474        // A genuine orphan in records/ to prove the scope still returns something.
2475        fx.write("records/contacts/nemo.md", "contact", "Nemo", "");
2476        let got = orphans(&fx.store, Some(Layer::Records)).unwrap();
2477        assert_eq!(paths(&got), vec!["records/contacts/nemo.md"]);
2478    }
2479
2480    #[test]
2481    fn orphans_layer_scope_filters_candidates() {
2482        let fx = Fixture::new();
2483        // Orphans across both layers: one source, and two records (an atomic
2484        // contact + a conclusion `profile`, the former wiki-page).
2485        fx.write("sources/emails/s.md", "email", "S", "no links");
2486        fx.write("records/contacts/r.md", "contact", "R", "");
2487        fx.write("records/profiles/w.md", "profile", "W", "");
2488        // The records scope keeps only the two records-layer orphans.
2489        let only_records = orphans(&fx.store, Some(Layer::Records)).unwrap();
2490        assert_eq!(
2491            paths(&only_records),
2492            vec!["records/contacts/r.md", "records/profiles/w.md"]
2493        );
2494        let only_sources = orphans(&fx.store, Some(Layer::Sources)).unwrap();
2495        assert_eq!(paths(&only_sources), vec!["sources/emails/s.md"]);
2496        // No scope: all three, sorted (records, records, sources).
2497        let all = orphans(&fx.store, None).unwrap();
2498        assert_eq!(
2499            paths(&all),
2500            vec![
2501                "records/contacts/r.md",
2502                "records/profiles/w.md",
2503                "sources/emails/s.md",
2504            ]
2505        );
2506    }
2507
2508    #[test]
2509    fn orphans_self_link_does_not_count_as_an_edge() {
2510        let fx = Fixture::new();
2511        // A page that only links to itself has no real edges => still an orphan.
2512        fx.write(
2513            "records/synthesis/solo.md",
2514            "synthesis",
2515            "Solo",
2516            "I reference [[records/synthesis/solo]] only.",
2517        );
2518        let got = orphans(&fx.store, None).unwrap();
2519        assert_eq!(paths(&got), vec!["records/synthesis/solo.md"]);
2520    }
2521
2522    #[test]
2523    fn orphans_excludes_index_and_db_files() {
2524        let fx = Fixture::new();
2525        // A lone index.md / DB.md must never be reported as an orphan content file.
2526        fx.write_raw(
2527            "records/index.md",
2528            "---\ntype: index\nscope: layer\nfolder: records\n---\n# records\n",
2529        );
2530        fx.write(
2531            "records/profiles/real-orphan.md",
2532            "profile",
2533            "Real",
2534            "no links",
2535        );
2536        let got = orphans(&fx.store, None).unwrap();
2537        assert_eq!(paths(&got), vec!["records/profiles/real-orphan.md"]);
2538    }
2539
2540    // ── frontmatter_block helper ─────────────────────────────────────────────
2541
2542    #[test]
2543    fn frontmatter_block_extracts_between_fences() {
2544        let text = "---\ntype: contact\nsummary: hi\n---\nbody here\n";
2545        assert_eq!(
2546            frontmatter_block(text),
2547            Some("type: contact\nsummary: hi\n")
2548        );
2549    }
2550
2551    #[test]
2552    fn frontmatter_block_none_without_leading_fence() {
2553        let text = "no frontmatter here\n";
2554        assert_eq!(frontmatter_block(text), None);
2555    }
2556
2557    #[test]
2558    fn frontmatter_block_tolerates_leading_bom() {
2559        // Regression (finding #19 cross-module): a UTF-8 BOM before the opening
2560        // fence must not hide the frontmatter from the graph layer — otherwise a
2561        // BOM-prefixed file the catalog indexes contributes no backlinks/edges.
2562        // Pre-fix the `---\n` strip failed on the BOM and returned None.
2563        let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody here\n";
2564        assert_eq!(
2565            frontmatter_block(text),
2566            Some("type: contact\nsummary: hi\n"),
2567            "a leading BOM must not hide frontmatter from the graph layer"
2568        );
2569    }
2570
2571    // ── shared edge notion: whitespace / fence / case / containment ──────────
2572
2573    /// Padded `[[ x ]]` must be a forward edge AND (after reindex) a backward
2574    /// edge — the two views agreeing on the same edge in a clean store.
2575    #[test]
2576    fn padded_link_is_both_a_forward_and_backward_edge() {
2577        let fx = Fixture::new();
2578        fx.write(
2579            "records/contacts/sarah.md",
2580            "contact",
2581            "Sarah",
2582            "the contact",
2583        );
2584        fx.write(
2585            "records/profiles/a.md",
2586            "profile",
2587            "A",
2588            "See [[ records/contacts/sarah ]] today.",
2589        );
2590        fx.reindex();
2591
2592        assert_eq!(
2593            paths(&forwardlinks(&fx.store, Path::new("records/profiles/a.md")).unwrap()),
2594            vec!["records/contacts/sarah"],
2595            "padded link is a forward edge"
2596        );
2597        assert_eq!(
2598            paths(&backlinks(&fx.store, Path::new("records/contacts/sarah.md")).unwrap()),
2599            vec!["records/profiles/a"],
2600            "padded link is the SAME backward edge (forward and backward agree)"
2601        );
2602    }
2603
2604    /// A `[[...]]` only inside a fenced code block is a documentation example,
2605    /// not an edge: no forward edge, no backward edge, and the source page is an
2606    /// orphan (no real links). Matches validate's fence-aware extractor.
2607    #[test]
2608    fn fenced_link_is_not_an_edge_and_page_is_orphan() {
2609        let fx = Fixture::new();
2610        fx.write(
2611            "records/contacts/sarah.md",
2612            "contact",
2613            "Sarah",
2614            "the contact",
2615        );
2616        fx.write(
2617            "records/synthesis/howto.md",
2618            "synthesis",
2619            "Howto",
2620            "```markdown\n[[records/contacts/sarah]] is how you link.\n```",
2621        );
2622        fx.reindex();
2623
2624        assert!(
2625            forwardlinks(&fx.store, Path::new("records/synthesis/howto.md"))
2626                .unwrap()
2627                .is_empty(),
2628            "a fenced example is not a forward edge"
2629        );
2630        assert!(
2631            backlinks(&fx.store, Path::new("records/contacts/sarah.md"))
2632                .unwrap()
2633                .is_empty(),
2634            "a fenced example is not a backward edge"
2635        );
2636        let orphan_set = paths(&orphans(&fx.store, None).unwrap());
2637        assert!(
2638            orphan_set.contains(&"records/synthesis/howto.md".to_string()),
2639            "a page whose only link is fenced has no real edges => orphan: {orphan_set:?}"
2640        );
2641    }
2642
2643    /// `rename` must NOT rewrite a `[[...]]` inside a fenced code block (it is
2644    /// verbatim documentation, not an edge), while still rewriting a real link.
2645    #[test]
2646    fn rewrite_links_to_leaves_fenced_examples_untouched() {
2647        let input = "\
2648Real [[records/contacts/sarah]] link.
2649
2650```markdown
2651Example: [[records/contacts/sarah]] inside a fence.
2652```
2653
2654Trailing [[records/contacts/sarah]].
2655";
2656        let got = rewrite_links_to(
2657            input,
2658            Path::new("records/contacts/sarah"),
2659            Path::new("records/contacts/sarah-chen"),
2660        );
2661        // The two non-fenced links retarget; the fenced one is verbatim.
2662        assert!(
2663            got.contains("Real [[records/contacts/sarah-chen]] link."),
2664            "real link before the fence must retarget"
2665        );
2666        assert!(
2667            got.contains("Trailing [[records/contacts/sarah-chen]]."),
2668            "real link after the fence must retarget"
2669        );
2670        assert!(
2671            got.contains("Example: [[records/contacts/sarah]] inside a fence."),
2672            "fenced example must stay verbatim, got:\n{got}"
2673        );
2674    }
2675
2676    /// `rewrite_links_to` matches a padded link and preserves the display.
2677    #[test]
2678    fn rewrite_links_to_matches_padded_link() {
2679        let got = rewrite_links_to(
2680            "See [[ records/contacts/sarah |Sarah]] today.",
2681            Path::new("records/contacts/sarah"),
2682            Path::new("records/contacts/sarah-chen"),
2683        );
2684        assert_eq!(got, "See [[records/contacts/sarah-chen|Sarah]] today.");
2685    }
2686
2687    /// On a case-insensitive filesystem a case-variant link is the same edge:
2688    /// backlinks finds it, orphans does NOT falsely orphan the target, and
2689    /// rename rewrites it. On a case-sensitive FS the link is genuinely a
2690    /// different target, so the test is skipped.
2691    #[cfg(unix)]
2692    #[test]
2693    fn case_variant_link_is_one_edge_on_case_insensitive_fs() {
2694        // Probe the filesystem the same way the production code does
2695        // (`link_edge_key` is imported at module scope).
2696        if link_edge_key("A") != link_edge_key("a") {
2697            // case-sensitive filesystem: the case-variant link is a different
2698            // target, so this scenario doesn't apply.
2699            return;
2700        }
2701        let fx = Fixture::new();
2702        fx.write(
2703            "records/contacts/sarah-chen.md",
2704            "contact",
2705            "Sarah",
2706            "the contact",
2707        );
2708        fx.write(
2709            "records/profiles/bio.md",
2710            "profile",
2711            "Bio",
2712            "See [[records/contacts/Sarah-Chen]].",
2713        );
2714        fx.reindex();
2715
2716        assert_eq!(
2717            paths(&backlinks(&fx.store, Path::new("records/contacts/sarah-chen.md")).unwrap()),
2718            vec!["records/profiles/bio"],
2719            "case-variant incoming link must be a backward edge"
2720        );
2721        let orphan_set = paths(&orphans(&fx.store, None).unwrap());
2722        assert!(
2723            !orphan_set.contains(&"records/contacts/sarah-chen.md".to_string()),
2724            "a target with a live case-variant incoming link must NOT be orphaned: {orphan_set:?}"
2725        );
2726
2727        let rewritten = rewrite_links_to(
2728            "See [[records/contacts/Sarah-Chen]].",
2729            Path::new("records/contacts/sarah-chen"),
2730            Path::new("records/contacts/sarah"),
2731        );
2732        assert_eq!(
2733            rewritten, "See [[records/contacts/sarah]].",
2734            "rename must rewrite the case-variant link on a case-insensitive FS"
2735        );
2736    }
2737
2738    /// REGRESSION (Unicode encoding / silent graph break): a file whose name is
2739    /// written in one Unicode normalization form and an incoming link written in
2740    /// the OTHER form must be ONE edge — on macOS/APFS both name the same file
2741    /// (the FS folds NFC/NFD), so the string-keyed graph must agree. Before the
2742    /// fix, `link_edge_key` only case-folded (no NFC), so `backlinks` returned
2743    /// empty and `orphans` flagged the linked-to file as an orphan while
2744    /// `validate` saw the link as live. NFC-keying both sides unifies them.
2745    ///
2746    /// Runs on every platform: the file is written NFC and linked NFD (both
2747    /// representable in any filename), and `link_edge_key` normalizes
2748    /// unconditionally, so the assertion holds regardless of host FS folding.
2749    #[test]
2750    fn nfc_nfd_cross_normalization_link_is_one_edge() {
2751        let fx = Fixture::new();
2752        // File on disk: NFC `josé` (é = U+00E9).
2753        fx.write(
2754            "records/contacts/jos\u{00e9}.md",
2755            "contact",
2756            "Jose",
2757            "the contact",
2758        );
2759        // Incoming link: NFD `josé` (e + U+0301) — byte-different, same name.
2760        fx.write(
2761            "records/profiles/bio.md",
2762            "profile",
2763            "Bio",
2764            "Knows [[records/contacts/jose\u{0301}]].",
2765        );
2766        fx.reindex();
2767
2768        // backlinks: the NFD link must resolve to the NFC file.
2769        assert_eq!(
2770            paths(&backlinks(&fx.store, Path::new("records/contacts/jos\u{00e9}.md")).unwrap()),
2771            vec!["records/profiles/bio"],
2772            "an NFD incoming link must be a backward edge of the NFC-named file"
2773        );
2774
2775        // orphans: the linked-to file must NOT be flagged as an orphan.
2776        let orphan_set = paths(&orphans(&fx.store, None).unwrap());
2777        assert!(
2778            !orphan_set.contains(&"records/contacts/jos\u{00e9}.md".to_string()),
2779            "a target with a live cross-normalization incoming link must NOT be orphaned: \
2780             {orphan_set:?}"
2781        );
2782
2783        // forwardlinks: the body link is a real forward edge. Its emitted target
2784        // is the canonical (normalization-PRESERVING) form — i.e. the NFD bytes
2785        // as written, NOT re-normalized to NFC — because `forwardlinks` output
2786        // feeds byte-faithful rewrites; only the comparison KEY is NFC-folded.
2787        let fwd = paths(&forwardlinks(&fx.store, &fx.p("records/profiles/bio.md")).unwrap());
2788        assert_eq!(
2789            fwd,
2790            vec!["records/contacts/jose\u{0301}"],
2791            "forwardlinks must emit the body link's canonical (NFD-preserving) target"
2792        );
2793    }
2794
2795    /// A `[[../outside/x]]` escaping wiki-link is never a forward edge, and a
2796    /// `neighborhood` from the escaping page never reads or traverses through the
2797    /// external file — closing the disclosure vector.
2798    #[cfg(unix)]
2799    #[test]
2800    fn escaping_link_is_not_an_edge_and_neighborhood_does_not_escape() {
2801        let fx = Fixture::new();
2802        // An external file OUTSIDE the store root, with its own in-store link.
2803        let outside_dir = fx.store.root.parent().unwrap().join("outside");
2804        fs::create_dir_all(&outside_dir).unwrap();
2805        fs::write(
2806            outside_dir.join("secret.md"),
2807            "---\ntype: note\nsummary: TOPSECRET\n---\nLinks [[records/contacts/sarah]].\n",
2808        )
2809        .unwrap();
2810        fx.write(
2811            "records/contacts/sarah.md",
2812            "contact",
2813            "Sarah",
2814            "the contact",
2815        );
2816        fx.write(
2817            "records/concepts/traversal.md",
2818            "concept",
2819            "Traversal",
2820            "See [[../outside/secret]].",
2821        );
2822        fx.reindex();
2823
2824        // The escaping target is not a forward edge.
2825        assert!(
2826            forwardlinks(&fx.store, Path::new("records/concepts/traversal.md"))
2827                .unwrap()
2828                .is_empty(),
2829            "an escaping `[[../outside/secret]]` must not be a forward edge"
2830        );
2831
2832        // Neighborhood from the escaping page reaches nothing through the
2833        // external file (the external file is never read/traversed).
2834        let slice = neighborhood(
2835            &fx.store,
2836            Path::new("records/concepts/traversal.md"),
2837            2,
2838            &[],
2839            Direction::Outgoing,
2840        )
2841        .unwrap();
2842        assert!(
2843            slice
2844                .nodes
2845                .iter()
2846                .all(|n| !n.path.to_string_lossy().contains("outside")),
2847            "neighborhood must not read/traverse the external file: {:?}",
2848            slice.nodes
2849        );
2850    }
2851
2852    /// REGRESSION (path-safety / info-disclosure): a wiki-link target whose path
2853    /// is made entirely of `Normal` components but routes through a **symlink**
2854    /// pointing outside the store must NOT leak the out-of-store file's
2855    /// `summary`/`type` into a `neighborhood` slice. Two shapes:
2856    ///   (a) a symlinked DIRECTORY component (`records/linkdir -> /external/dir`,
2857    ///       link `[[records/linkdir/secret]]`), and
2858    ///   (b) a directly-symlinked `.md` (`records/aliased.md -> /external/secret.md`,
2859    ///       link `[[records/aliased]]`).
2860    /// Both used to slip past the all-`Normal`-components fast path in
2861    /// `resolves_within_store` (which returned `true` without canonicalizing), so
2862    /// `store.root.join(rel)` followed the in-store symlink, `is_file()` succeeded,
2863    /// and the external file was read. The fix routes every candidate through the
2864    /// symlink-resolving `ensure_path_within_store`, so these resolve to NO
2865    /// out-of-store node — exactly like the `..` escape control above. A legitimate
2866    /// in-store link still resolves, proving the gate did not over-block.
2867    #[cfg(unix)]
2868    #[test]
2869    fn symlinked_normal_component_does_not_disclose_out_of_store_file() {
2870        use std::os::unix::fs::symlink;
2871
2872        let fx = Fixture::new();
2873        // The secret lives OUTSIDE the store root, as a sibling of it.
2874        let outside_dir = fx.store.root.parent().unwrap().join("secret");
2875        fs::create_dir_all(&outside_dir).unwrap();
2876        fs::write(
2877            outside_dir.join("secret.md"),
2878            "---\ntype: contact\nsummary: TOP SECRET\n---\n# x\n",
2879        )
2880        .unwrap();
2881
2882        // A legitimate in-store target, to prove the gate does not over-block.
2883        fx.write("records/contacts/real.md", "contact", "Real Contact", "");
2884
2885        // (a) symlinked DIRECTORY component: records/linkdir -> <outside>/secret
2886        symlink(&outside_dir, fx.store.root.join("records/linkdir")).unwrap();
2887        fx.write(
2888            "records/contacts/seed.md",
2889            "contact",
2890            "Seed",
2891            "[[records/linkdir/secret]] and the in-store [[records/contacts/real]].",
2892        );
2893
2894        // (b) directly-symlinked .md: records/aliased.md -> <outside>/secret.md
2895        symlink(
2896            outside_dir.join("secret.md"),
2897            fx.store.root.join("records/aliased.md"),
2898        )
2899        .unwrap();
2900        fx.write(
2901            "records/contacts/seed2.md",
2902            "contact",
2903            "Seed2",
2904            "[[records/aliased]]",
2905        );
2906        fx.reindex();
2907
2908        // (a): the symlinked-dir target must NOT appear; the in-store link must.
2909        let slice = neighborhood(
2910            &fx.store,
2911            &fx.p("records/contacts/seed.md"),
2912            1,
2913            &[],
2914            Direction::Outgoing,
2915        )
2916        .unwrap();
2917        assert!(
2918            !slice.nodes.iter().any(|n| n.summary == "TOP SECRET"),
2919            "a symlinked-dir component must not disclose the out-of-store summary: {:?}",
2920            slice.nodes
2921        );
2922        assert!(
2923            !slice
2924                .nodes
2925                .iter()
2926                .any(|n| n.path.to_string_lossy().contains("linkdir")),
2927            "the symlinked-out-of-store target must not be a node: {:?}",
2928            slice.nodes
2929        );
2930        assert!(
2931            slice
2932                .nodes
2933                .iter()
2934                .any(|n| n.path == fx.p("records/contacts/real")),
2935            "the legitimate in-store link must still resolve (gate did not over-block): {:?}",
2936            slice.nodes
2937        );
2938
2939        // (b): the directly-symlinked .md target must NOT disclose anything.
2940        let slice2 = neighborhood(
2941            &fx.store,
2942            &fx.p("records/contacts/seed2.md"),
2943            1,
2944            &[],
2945            Direction::Outgoing,
2946        )
2947        .unwrap();
2948        assert!(
2949            slice2.nodes.is_empty(),
2950            "a directly-symlinked .md pointing outside the store must yield no node: {:?}",
2951            slice2.nodes
2952        );
2953    }
2954
2955    #[test]
2956    fn regression_non_utf8_linker_edges_survive_scoped_backlinks_and_orphans() {
2957        // Adversarial review #10: a content file with a stray non-UTF8 byte beside
2958        // a valid ASCII `[[...]]` line must still expose its edges. The unscoped
2959        // backlink scanner reads bytes lossily, but `forwardlinks`/`orphans` used
2960        // `read_to_string` and dropped EVERY edge on `InvalidData` — so scoped
2961        // backlinks under-reported vs unscoped, and `orphans` flagged BOTH
2962        // endpoints of a live edge.
2963        let fx = Fixture::new();
2964        fx.write("records/contacts/sarah.md", "contact", "Sarah", "# Sarah");
2965        // bio.md: valid UTF-8 frontmatter, but a BODY line with a 0xE9 byte
2966        // (Latin-1 'é', invalid as standalone UTF-8) beside the link to sarah.
2967        let mut bytes: Vec<u8> = Vec::new();
2968        bytes.extend_from_slice(
2969            b"---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: Bio\n---\n",
2970        );
2971        bytes.extend_from_slice(b"See [[records/contacts/sarah]] caf");
2972        bytes.push(0xE9);
2973        bytes.extend_from_slice(b"\n");
2974        let bio_abs = fx.store.root.join("records/profiles/bio.md");
2975        fs::create_dir_all(bio_abs.parent().unwrap()).unwrap();
2976        fs::write(&bio_abs, &bytes).unwrap();
2977        fx.reindex();
2978
2979        let sarah = fx.p("records/contacts/sarah");
2980
2981        // forwardlinks reads the non-UTF8 file and still finds the edge.
2982        let fwd = paths(&forwardlinks(&fx.store, &fx.p("records/profiles/bio")).unwrap());
2983        assert!(
2984            fwd.iter().any(|p| p.contains("sarah")),
2985            "forwardlinks must extract the edge from a non-UTF8 file: {fwd:?}"
2986        );
2987
2988        // Scoped backlinks (rides `forwardlinks`) must AGREE with unscoped.
2989        let unscoped = paths(&backlinks(&fx.store, &sarah).unwrap());
2990        let scoped =
2991            paths(&backlinks_filtered(&fx.store, &sarah, &["profile".to_string()], None).unwrap());
2992        assert!(
2993            unscoped.iter().any(|p| p.contains("bio")),
2994            "unscoped backlinks must include bio: {unscoped:?}"
2995        );
2996        assert!(
2997            scoped.iter().any(|p| p.contains("bio")),
2998            "scoped backlinks must agree with unscoped on the non-UTF8 linker: {scoped:?}"
2999        );
3000
3001        // Neither endpoint of the live edge may be reported as an orphan.
3002        let orph = paths(&orphans(&fx.store, None).unwrap());
3003        assert!(
3004            !orph
3005                .iter()
3006                .any(|p| p.contains("bio") || p.contains("sarah")),
3007            "neither endpoint of a live edge may be an orphan: {orph:?}"
3008        );
3009    }
3010}