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