Skip to main content

dbmd_core/
store.rs

1//! `store` — walk, locate, and shard a db.md store.
2//!
3//! A db.md store is one directory marked by an uppercase `DB.md` at its root.
4//! [`Store::open`] is the single gate every store-walking subcommand goes
5//! through; a missing `DB.md` is the [`NotAStore`] error (`NOT_A_STORE`). The
6//! toolkit never guesses a store root.
7//!
8//! Scale discipline lives here: [`Store::walk`] and the layer/type-folder
9//! walks are **SWEEP** primitives used only by `validate --all`,
10//! `index rebuild`, and `stats`. The interactive loop instead uses
11//! [`Store::find_links_to`] / [`Store::find_links_to_any`] (a single
12//! presence-only content scan) and the `index.jsonl` sidecar readers
13//! ([`Store::find_by_type`] / [`Store::find_by_where`] /
14//! [`Store::read_type_index`]) — never a whole-store parse. The batch
15//! [`Store::find_links_to_any`] is what keeps the working-set validate's
16//! incoming-linker discovery a single store scan rather than one scan per
17//! changed object.
18//!
19//! Link edges are defined once, here, by the shared [`extract_edge_targets`] /
20//! [`canonical_link_target`] / [`link_edge_key`] helpers (fence-aware,
21//! whitespace-trimmed, case-folded to the filesystem), so the forward view
22//! (`graph::forwardlinks`), the backward view ([`Store::find_links_to_any`]),
23//! `rename`, and `validate` all agree on exactly which `[[...]]` is an edge.
24//! [`ensure_path_within_store`] is the within-store containment gate every
25//! caller-influenced path passes through before it is read or traversed.
26
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use chrono::{DateTime, Datelike, FixedOffset};
32use ignore::WalkBuilder;
33
34use crate::index::IndexRecord;
35use crate::parser::{parse_db_md, Config, Frontmatter};
36
37/// Basenames that are never content files: the config marker and the two
38/// curator-maintained catalogs. The store walks skip these so a SWEEP over the
39/// content layers never mistakes a catalog for a record.
40///
41/// Only `index.md` is excluded by basename. A descendant `DB.md` is not content:
42/// it marks a foreign nested-store boundary, and the walker prunes the entire
43/// directory before reaching that marker. The root `DB.md` / `log.md` (and the
44/// `log/` archive) live outside every layer walk.
45const NON_CONTENT_BASENAMES: [&str; 1] = ["index.md"];
46
47/// The complete machine-twin sidecar that backs every structured read.
48const TYPE_INDEX_FILE: &str = "index.jsonl";
49
50/// Returned when a path is opened as a store but has no `DB.md` at its root.
51/// Surfaced as the structured code `NOT_A_STORE` with a non-zero exit.
52#[derive(Debug, thiserror::Error)]
53#[error("not a db.md store: {path} has no DB.md")]
54pub struct NotAStore {
55    /// The path that was inspected.
56    pub path: PathBuf,
57}
58
59/// Errors from store-level operations (walk, locate, shard, sidecar read).
60#[derive(Debug, thiserror::Error)]
61pub enum StoreError {
62    /// A sidecar `index.jsonl` could not be read or parsed.
63    #[error("failed to read type index {path}: {message}")]
64    BadTypeIndex {
65        /// The sidecar file.
66        path: PathBuf,
67        /// What went wrong.
68        message: String,
69    },
70
71    /// A required date field for sharding was absent or unparseable, and there
72    /// was no usable fallback.
73    #[error("cannot compute shard path for {file}: no usable date field")]
74    NoShardDate {
75        /// The file being placed.
76        file: PathBuf,
77    },
78
79    /// An embedded-ripgrep scan failed to start or run.
80    #[error("search failed under {root}: {message}")]
81    Search {
82        /// The root the scan ran under.
83        root: PathBuf,
84        /// What went wrong.
85        message: String,
86    },
87
88    /// An underlying I/O failure.
89    #[error(transparent)]
90    Io(#[from] std::io::Error),
91}
92
93/// The three canonical layers of a db.md store.
94///
95/// `Ord`/`PartialOrd` are derived (additively) because sibling modules key
96/// `BTreeMap`s on `Layer` (e.g. `stats::Stats::files_per_layer`); the canonical
97/// declaration order (`Sources` < `Records`) is the sort order.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub enum Layer {
100    /// `sources/` — raw evidence (documentary + testimonial); immutable; date-sharded at scale.
101    Sources,
102    /// `records/` — everything the agent authors; meta-typed fact/operational/conclusion; entity types flat, event types sharded.
103    Records,
104}
105
106impl Layer {
107    /// The on-disk folder name for this layer (`"sources"` / `"records"`).
108    pub fn dir_name(self) -> &'static str {
109        match self {
110            Layer::Sources => "sources",
111            Layer::Records => "records",
112        }
113    }
114
115    /// Parse a layer from its folder name; `None` for anything else.
116    pub fn from_dir_name(name: &str) -> Option<Self> {
117        match name {
118            "sources" => Some(Layer::Sources),
119            "records" => Some(Layer::Records),
120            _ => None,
121        }
122    }
123
124    /// Every layer, in canonical order.
125    pub fn all() -> [Layer; 2] {
126        [Layer::Sources, Layer::Records]
127    }
128}
129
130/// An opened db.md store: its root path plus the parsed `DB.md` [`Config`].
131///
132/// Construct via [`Store::open`]; that is the only path in, and it validates
133/// the `DB.md` marker so downstream code can assume a real store.
134#[derive(Debug, Clone)]
135pub struct Store {
136    /// The store root (the directory containing `DB.md`).
137    pub root: PathBuf,
138    /// The parsed `DB.md` config (agent instructions, policies, schemas).
139    pub config: Config,
140}
141
142impl Store {
143    /// True if `path` is a db.md store root: an uppercase `DB.md` file exists
144    /// at `path`. On case-sensitive filesystems a lowercase `db.md` must NOT
145    /// count (the lowercase name refers to the project/spec, not the marker).
146    pub fn is_db_md_store(path: &Path) -> bool {
147        // Fast negative path. Nearly every directory in a large store lacks the
148        // marker; one lstat avoids enumerating a potentially 10k-entry folder
149        // for every containment check. On a case-insensitive filesystem this
150        // probe can also match `db.md`, so a positive still goes through the
151        // exact-name read_dir check below.
152        let probe = path.join("DB.md");
153        let Ok(probe_meta) = std::fs::symlink_metadata(&probe) else {
154            return false;
155        };
156        if probe_meta.is_dir() {
157            return false;
158        }
159
160        // Read the directory and match the *stored* filename byte-for-byte.
161        // `path.join("DB.md").exists()` would lie on a case-insensitive
162        // filesystem (macOS default), where a lowercase `db.md` answers a
163        // `DB.md` probe. `read_dir` returns the real on-disk name, so the
164        // exact-match check is correct on both case-sensitive (Linux) and
165        // case-insensitive filesystems.
166        let entries = match std::fs::read_dir(path) {
167            Ok(entries) => entries,
168            Err(_) => return false,
169        };
170        for entry in entries.flatten() {
171            if entry.file_name() == "DB.md" {
172                // A directory literally named `DB.md` is not the marker.
173                let Ok(ft) = entry.file_type() else {
174                    return false;
175                };
176                if ft.is_dir() {
177                    return false;
178                }
179                // A marker symlink is legal only when it resolves inside this
180                // root. Otherwise merely opening a hostile store would read an
181                // arbitrary external file as configuration.
182                let Ok(root) = path.canonicalize() else {
183                    return false;
184                };
185                let Ok(marker) = entry.path().canonicalize() else {
186                    return false;
187                };
188                return marker.is_file() && marker.starts_with(root);
189            }
190        }
191        false
192    }
193
194    /// Open `path` as a db.md store and require `DB.md` to be readable and
195    /// parseable. Normal commands should enter through this strict gate so a
196    /// damaged config cannot silently disable schema or policy rules.
197    pub fn open_strict(path: &Path) -> crate::Result<Store> {
198        if !Store::is_db_md_store(path) {
199            return Err(NotAStore {
200                path: path.to_path_buf(),
201            }
202            .into());
203        }
204        let db_md = path.join("DB.md");
205        let text = std::fs::read_to_string(&db_md)?;
206        let config = parse_db_md(&text, &db_md)?;
207        Ok(Store {
208            root: path.to_path_buf(),
209            config,
210        })
211    }
212
213    /// Open `path` as a db.md store: confirm the `DB.md` marker (else
214    /// [`NotAStore`]) and parse the `DB.md` config when possible. This is the
215    /// lenient validation-oriented open path: a damaged `DB.md` still marks the
216    /// directory as a store so `dbmd validate` can report the config error as an
217    /// issue. Normal CLI commands should use [`Store::open_strict`] instead.
218    pub fn open(path: &Path) -> Result<Store, NotAStore> {
219        if !Store::is_db_md_store(path) {
220            return Err(NotAStore {
221                path: path.to_path_buf(),
222            });
223        }
224        let db_md = path.join("DB.md");
225        // The marker exists; parse its config. A read or parse failure leaves
226        // the store openable with default config rather than masquerading as
227        // NOT_A_STORE — the marker is present, so this *is* a store; a damaged
228        // DB.md is `dbmd validate`'s job to report, not `open`'s.
229        let config = match std::fs::read_to_string(&db_md) {
230            Ok(text) => parse_db_md(&text, &db_md).unwrap_or_default(),
231            Err(_) => Config::default(),
232        };
233        Ok(Store {
234            root: path.to_path_buf(),
235            config,
236        })
237    }
238
239    /// **SWEEP.** Recursively iterate every `.md` content file across
240    /// `sources/` and `records/`, skipping hidden dirs and `log/`.
241    /// Used only by `validate --all`, `index rebuild`, and `stats` — never on
242    /// the interactive loop.
243    pub fn walk(&self) -> Result<Vec<PathBuf>, StoreError> {
244        // Only the three content layers — never root meta files (`DB.md`,
245        // `index.md`, `log.md`) and never `log/`, which live at root and are
246        // outside every layer dir.
247        let mut out = Vec::new();
248        for layer in Layer::all() {
249            out.extend(self.walk_layer(layer)?);
250        }
251        out.sort();
252        Ok(out)
253    }
254
255    /// **SWEEP.** Like [`Store::walk`] but scoped to a single layer.
256    pub fn walk_layer(&self, layer: Layer) -> Result<Vec<PathBuf>, StoreError> {
257        let layer_root = self.root.join(layer.dir_name());
258        if !layer_root.is_dir() {
259            return Ok(Vec::new());
260        }
261        self.walk_content_md(&layer_root)
262    }
263
264    /// Enumerate every `.md` file in a single type-folder, **recursing through
265    /// its date-shards** (`sources/emails/**/*.md`). The unit the index builder
266    /// and per-folder rebuild operate on. SWEEP-class (scoped to one folder).
267    pub fn walk_type_folder(&self, type_folder: &Path) -> Result<Vec<PathBuf>, StoreError> {
268        let abs = self.resolve_under_root(type_folder);
269        if !abs.is_dir() {
270            return Ok(Vec::new());
271        }
272        self.walk_content_md(&abs)
273    }
274
275    /// Return visible descendant db.md store roots, stopping at each boundary.
276    ///
277    /// A db.md store cannot own another store. This discovery primitive exists
278    /// for validation: normal walkers prune the boundary and never read through
279    /// it, while `validate` reports the structural error against its `DB.md`.
280    /// Hidden directories stay outside the db.md content model and are skipped.
281    pub fn nested_store_roots(&self) -> Result<Vec<PathBuf>, StoreError> {
282        let canonical_root = self.root.canonicalize()?;
283        let mut out = Vec::new();
284        let mut walker = walkdir::WalkDir::new(&self.root)
285            .follow_links(true)
286            .into_iter();
287
288        while let Some(result) = walker.next() {
289            let entry = match result {
290                Ok(entry) => entry,
291                // A symlink loop is not a nested store. The ordinary store walk
292                // has the same loop protection; skip the broken branch.
293                Err(err) if err.loop_ancestor().is_some() => continue,
294                Err(err) => {
295                    return Err(StoreError::Search {
296                        root: self.root.clone(),
297                        message: err.to_string(),
298                    });
299                }
300            };
301            if entry.depth() == 0 {
302                continue;
303            }
304            let name = entry.file_name().to_string_lossy();
305            if name.starts_with('.') {
306                if entry.path().is_dir() {
307                    walker.skip_current_dir();
308                }
309                continue;
310            }
311
312            let resolved = match resolve_within(&canonical_root, &self.root, entry.path()) {
313                Ok(path) => path,
314                Err(_) => {
315                    if entry.path().is_dir() {
316                        walker.skip_current_dir();
317                    }
318                    continue;
319                }
320            };
321            if resolved.is_dir() && Store::is_db_md_store(&resolved) {
322                if let Ok(rel) = entry.path().strip_prefix(&self.root) {
323                    out.push(rel.to_path_buf());
324                }
325                walker.skip_current_dir();
326            }
327        }
328        out.sort();
329        out.dedup();
330        Ok(out)
331    }
332
333    /// Whether `candidate` resolves to content owned by this store.
334    ///
335    /// Ownership requires both filesystem containment and that the path does
336    /// not cross a descendant store boundary.
337    pub fn owns_path(&self, candidate: &Path) -> bool {
338        ensure_path_within_store(&self.root, candidate).is_ok()
339    }
340
341    /// Visible symlinks whose targets are outside this store's ownership
342    /// boundary. The scan never follows a symlink or reads its target bytes;
343    /// mutating callers use it to surface that ignored content exists instead
344    /// of silently implying the whole visible tree was processed.
345    pub fn unowned_symlinks(&self) -> Result<Vec<PathBuf>, StoreError> {
346        let mut out = Vec::new();
347        let mut walker = walkdir::WalkDir::new(&self.root)
348            .follow_links(false)
349            .into_iter();
350        while let Some(result) = walker.next() {
351            let entry = result.map_err(|err| StoreError::Search {
352                root: self.root.clone(),
353                message: err.to_string(),
354            })?;
355            if entry.depth() == 0 {
356                continue;
357            }
358            let name = entry.file_name().to_string_lossy();
359            if name.starts_with('.') {
360                if entry.file_type().is_dir() {
361                    walker.skip_current_dir();
362                }
363                continue;
364            }
365            if entry.file_type().is_symlink() && !self.owns_path(entry.path()) {
366                if let Some(rel) = self.rel_path(entry.path()) {
367                    out.push(rel);
368                }
369            }
370        }
371        out.sort();
372        out.dedup();
373        Ok(out)
374    }
375
376    /// The ≤`n` most-recent files in a type-folder by frontmatter `updated`
377    /// (descending), ties broken by store-relative path (ascending) — a total
378    /// order, so write-through and rebuild never disagree on #500 vs #501.
379    ///
380    /// Reads `updated` across the folder's shards — a SWEEP cost absorbed into
381    /// `index rebuild`. The write-through path never calls this. The
382    /// cap-selection primitive for the 500-entry `index.md` browse view.
383    pub fn recent_in_type_folder(
384        &self,
385        type_folder: &Path,
386        n: usize,
387    ) -> Result<Vec<PathBuf>, StoreError> {
388        let files = self.walk_type_folder(type_folder)?;
389        // (updated, rel-path) for each file. Files missing/unparseable
390        // `updated` sort *after* dated ones (None last), then by path — so they
391        // are deterministically the lowest-priority candidates for the cap, not
392        // dropped silently. The total order (updated desc, path asc) is what
393        // keeps write-through and rebuild agreeing on #500 vs #501.
394        let mut keyed: Vec<(Option<DateTime<FixedOffset>>, PathBuf)> = files
395            .into_iter()
396            .map(|rel| {
397                let updated = self.read_updated(&self.abs_path(&rel));
398                (updated, rel)
399            })
400            .collect();
401        keyed.sort_by(|a, b| {
402            // `updated` descending: newest first. `None` is treated as the
403            // oldest possible, so dated files always win a cap slot over
404            // undated ones.
405            let by_updated = b.0.cmp(&a.0);
406            by_updated.then_with(|| a.1.cmp(&b.1))
407        });
408        keyed.truncate(n);
409        Ok(keyed.into_iter().map(|(_, rel)| rel).collect())
410    }
411
412    /// The shard/flat predicate: true if the type date-shards, false if it
413    /// stays flat. True for source types and event record types
414    /// (`expense`/`invoice`/`meeting` + custom `order`/`ticket`/`transaction`),
415    /// or when `DB.md ## Schemas` declares `shard: by-date`. False for
416    /// dedup-bounded entity types (`contact`/`company`/`decision`) and
417    /// conclusion records (`profile`/`concept`/`synthesis`).
418    pub fn type_shards(&self, type_: &str) -> bool {
419        // A `DB.md ## Schemas` `### <type>` block with a `shard:` directive is
420        // authoritative — it is the v0.2 generic-model way to declare sharding,
421        // so it overrides the built-in default below (in either direction).
422        if let Some(shard) = self.config.schemas.get(type_).and_then(|s| s.shard) {
423            return shard;
424        }
425        // Built-in default for the example types. Sharding is a property of the
426        // *type*:
427        //  - source types carry a primary date field and shard;
428        //  - event record types track business volume and shard;
429        //  - dedup-bounded entity types and curation-bounded conclusion
430        //    records (`profile`/`concept`/`synthesis`) stay flat.
431        // Any type can override this via a `shard:` directive (above).
432        matches!(
433            type_,
434            // source types (documentary + testimonial)
435            "email" | "transcript" | "pdf-source" | "note"
436            // event record types (canonical)
437            | "expense" | "invoice" | "meeting"
438            // event record types (recognized custom, per the plan)
439            | "order" | "ticket" | "transaction"
440        )
441    }
442
443    /// Compute the canonical write path for a new file. For a sharding type
444    /// (per [`Store::type_shards`]) insert `<YYYY>/<MM>/` from the type's
445    /// primary date field (`email.date`, `expense.date`, … fallback `created`)
446    /// under the type folder; flat types (entity + conclusion records) get no
447    /// shard segment.
448    /// Deterministic + stable: same input → same path, so a record never moves
449    /// once written.
450    pub fn shard_path_for(
451        &self,
452        type_: &str,
453        frontmatter: &Frontmatter,
454        name: &str,
455    ) -> Result<PathBuf, StoreError> {
456        self.shard_path_in(&default_type_folder(type_), type_, frontmatter, name)
457    }
458
459    /// Like [`Store::shard_path_for`], but compute the path under an explicit,
460    /// caller-resolved type-folder rather than the canonical default. This lets a
461    /// write surface honour an agent-supplied conforming sub-folder — e.g. a
462    /// conclusion record filed under `records/profiles/`, `records/concepts/`, or
463    /// `records/synthesis/` (a conclusion record may be filed under ANY
464    /// `records/<folder>/`, not only its canonical one) — while still applying
465    /// date-sharding for sharding types. The folder must be a conforming
466    /// `<layer>/<type-folder>` (2
467    /// components, recognized layer); the caller is responsible for that (see the
468    /// CLI's `resolve_write_path`), so it is taken as given here.
469    ///
470    /// Sharding is still a property of the *type*: a sharding type gets the
471    /// `<YYYY>/<MM>` segment under `folder`; a flat type lands directly in it.
472    pub fn shard_path_in(
473        &self,
474        folder: &Path,
475        type_: &str,
476        frontmatter: &Frontmatter,
477        name: &str,
478    ) -> Result<PathBuf, StoreError> {
479        let folder = folder.to_path_buf();
480        let filename = ensure_md_extension(name);
481
482        if !self.type_shards(type_) {
483            // Flat type (entity records, conclusion records, decisions): no
484            // shard segment.
485            return Ok(folder.join(filename));
486        }
487
488        // Sharding type: derive <YYYY>/<MM> from the primary date field, with
489        // `created` as the universal fallback. Reading the public `Frontmatter`
490        // fields directly (typed `created`/`updated` + raw `extra`) avoids the
491        // not-yet-implemented `Frontmatter::get`/`parse` and keeps this pure.
492        let (year, month) = self
493            .primary_shard_segment(type_, frontmatter)
494            .ok_or_else(|| StoreError::NoShardDate {
495                file: folder.join(&filename),
496            })?;
497
498        Ok(folder.join(year).join(month).join(filename))
499    }
500
501    /// Find files with an incoming wiki-link to `target` via a **single
502    /// presence-only content scan** for an edge to `target` across all layers,
503    /// using the shared fence-aware/whitespace-trimmed/case-folded edge notion
504    /// ([`extract_edge_targets`]). Loop-fast; no whole-graph build. Returns
505    /// store-relative paths.
506    pub fn find_links_to(&self, target: &Path) -> Result<Vec<PathBuf>, StoreError> {
507        // A single target is just the degenerate batch case — one key, one store
508        // scan. Routing through `find_links_to_any` keeps the
509        // pattern construction and the scan loop in exactly one place. The
510        // batch API takes `&[PathBuf]`, so the one-element slice is owned (a
511        // single alloc on this single-target convenience path; the batch path
512        // validate.rs rides is untouched).
513        self.find_links_to_any(&[target.to_path_buf()])
514    }
515
516    /// Find every file with an incoming wiki-link to **any** of `targets`, in a
517    /// **single content pass** over the store (one `.md` walk, one presence-only
518    /// edge scan per file). This is the batch incoming-linker finder the
519    /// working-set [`crate::validate::validate_working_set`] sits on: it must find
520    /// the linkers for the *whole* changed set without paying a full store read
521    /// per changed object. Cost is therefore one store scan (O(store)), NOT
522    /// `targets.len() × store` — calling [`find_links_to`](Self::find_links_to)
523    /// in a loop would reread every `.md` once per target and is the exact
524    /// `O(changed × store)` blow-up this method exists to prevent. Returns
525    /// store-relative paths (deduped, sorted).
526    ///
527    /// **One edge notion with `forwardlinks`/`rename`/`validate`.** A file links
528    /// to a target iff [`extract_edge_targets`] (fence-aware, whitespace-trimmed)
529    /// of its content yields a target whose [`link_edge_key`] equals the target's
530    /// — the *same* definition the forward view and the rename rewriter use. The
531    /// previous implementation used a literal-adjacency ripgrep regex that (a)
532    /// matched `[[...]]` text inside fenced code examples (which validate treats
533    /// as non-edges), (b) missed inner-whitespace padding (`[[ x ]]`), and (c)
534    /// compared case-sensitively even where the filesystem resolves links
535    /// case-insensitively — so backlinks/links/rename silently disagreed with
536    /// forwardlinks and validate. Reading content and routing through the shared
537    /// extractor removes all three divergences.
538    ///
539    /// Why content scan and not the sidecar `links` field: the sidecar projects
540    /// only the frontmatter `links:` array, so it misses edges written in the
541    /// body or in typed fields (`company: [[…]]`). Finding an incoming link to an
542    /// arbitrary path therefore requires reading file content.
543    pub fn find_links_to_any(&self, targets: &[PathBuf]) -> Result<Vec<PathBuf>, StoreError> {
544        // Build the set of comparison keys for the requested targets, in the
545        // canonical (case-folded where the filesystem is case-insensitive) form
546        // the edge extractor emits. An empty key (a target that renders to no
547        // link text, e.g. `""` or `"./"`) contributes nothing — and crucially the
548        // empty set short-circuits below so we never report every file.
549        let want: std::collections::HashSet<String> = targets
550            .iter()
551            .filter_map(|t| {
552                let canonical = canonical_link_target(&t.to_string_lossy());
553                if canonical.is_empty() {
554                    None
555                } else {
556                    Some(link_edge_key(&canonical))
557                }
558            })
559            .collect();
560        if want.is_empty() {
561            return Ok(Vec::new());
562        }
563
564        let mut hits = std::collections::BTreeSet::new();
565        // Scan every `.md` file in the store (skip hidden + `log/`), including
566        // `index.md` catalogs — an incoming reference is wherever the link text
567        // lives; the caller decides relevance. ONE walk for the whole target set;
568        // per file we stop at the first matching edge (presence is all we need),
569        // so a file that links to several targets is read once, not once per
570        // target.
571        for rel in self.walk_all_md()? {
572            let abs = self.abs_path(&rel);
573            // Read lossily: a `.md` verbatim-ingested into `sources/` can carry a
574            // stray non-UTF-8 byte (a mis-decoded Latin-1 import). Decoding
575            // lossily substitutes replacement characters instead of erroring, so
576            // one bad byte on a link-bearing line no longer aborts the whole
577            // store scan (the historical `UTF8`-sink failure). The link syntax is
578            // ASCII, so a replacement char elsewhere on the line never hides a
579            // `[[...]]`. A read error (not a decode error) is genuine I/O trouble
580            // and propagates.
581            let bytes = match std::fs::read(&abs) {
582                Ok(b) => b,
583                Err(e) => {
584                    return Err(StoreError::Search {
585                        root: self.root.clone(),
586                        message: format!("read failed in {}: {e}", abs.display()),
587                    })
588                }
589            };
590            let text = String::from_utf8_lossy(&bytes);
591            for target in extract_edge_targets(&text) {
592                if want.contains(&link_edge_key(&target)) {
593                    hits.insert(rel);
594                    break;
595                }
596            }
597        }
598        Ok(hits.into_iter().collect())
599    }
600
601    /// Candidate set for a `type` query: read every type-folder `index.jsonl`
602    /// sidecar in the type's single layer and return the records of that
603    /// `type`. Complete and cold-cache-proof — NOT a walk-and-parse or a
604    /// frontmatter ripgrep scan, and **never a store-wide read**.
605    ///
606    /// The read is bounded to the type's one layer subtree
607    /// (O(entities-in-layer)): a type lives in exactly one layer, and
608    /// `default_type_folder` always encodes it (recognized → its SPEC layer;
609    /// unrecognized → `records/`), so the walk never fans out across every
610    /// sidecar in the store and stays inside the interactive loop's
611    /// O(entities) contract.
612    ///
613    /// The whole-layer read — rather than reading only the type's canonical
614    /// folder sidecar when it happens to exist — is what makes the result
615    /// *complete*. A single `type` can legitimately be filed across several
616    /// folders within its layer: a conclusion `profile` filed under any
617    /// `records/<folder>/`, or a `contact` filed in `records/clients/` alongside
618    /// the canonical `records/contacts/`. The previous code read only the
619    /// canonical-guess sidecar whenever it was a file, which silently dropped
620    /// those non-canonical records the moment the canonical sidecar existed —
621    /// returning an incomplete set, and a *different* set as the store grew
622    /// (the omission flipped on once one canonical record was added). That
623    /// broke the dedup/enumeration premise this primitive backs and disagreed
624    /// with `find_by_where_in`, which already walks the whole layer. Filtering
625    /// the layer read by `type` keeps the result complete regardless of how the
626    /// type's records are foldered.
627    pub fn find_by_type(&self, type_: &str) -> Result<Vec<IndexRecord>, StoreError> {
628        let canonical_folder = default_type_folder(type_);
629        let records = self.read_all_type_indexes_in(layer_of_folder(&canonical_folder))?;
630        Ok(records.into_iter().filter(|r| r.type_ == type_).collect())
631    }
632
633    /// Candidate set for a `key=value` frontmatter query, **store-wide**: read
634    /// every type-folder `index.jsonl` sidecar and filter their records. The
635    /// unscoped pre-write dedup primitive; prefer [`Store::find_by_where_in`]
636    /// with a layer scope to stay O(entities-in-layer) on the interactive loop.
637    pub fn find_by_where(&self, key: &str, value: &str) -> Result<Vec<IndexRecord>, StoreError> {
638        self.find_by_where_in(key, value, None)
639    }
640
641    /// Candidate set for a `key=value` frontmatter query, **scoped to one
642    /// layer** when `layer` is `Some`: the sidecar walk is confined to that
643    /// layer's subtree (`<root>/<layer>/`), so the I/O is O(entities-in-layer),
644    /// not O(store records). `None` keeps the store-wide read.
645    ///
646    /// This is what makes `--in <layer>` an I/O scope, not just a result
647    /// filter: a `--where`-only query (no `--type`) used to read every sidecar
648    /// in the store and narrow by layer in memory, breaking the O(entities)
649    /// contract the interactive loop depends on. With a layer in hand we walk
650    /// only that layer's sidecars.
651    pub fn find_by_where_in(
652        &self,
653        key: &str,
654        value: &str,
655        layer: Option<Layer>,
656    ) -> Result<Vec<IndexRecord>, StoreError> {
657        // A `key=value` query can target any frontmatter field across any type,
658        // so within the chosen subtree we still read every type-folder sidecar
659        // and filter. The layer (when given) bounds *which* subtree, turning a
660        // whole-store walk into a single-layer walk.
661        let records = self.read_all_type_indexes_in(layer)?;
662        Ok(records
663            .into_iter()
664            .filter(|r| record_matches_field(r, key, value))
665            .collect())
666    }
667
668    /// Every record across the type-folder `index.jsonl` sidecars, scoped to one
669    /// layer when `layer` is `Some` (the walk is confined to `<root>/<layer>/`)
670    /// else store-wide. Sequential, complete sidecar reads — never a
671    /// walk-and-parse of the content tree.
672    ///
673    /// This is the unfiltered sidecar-enumeration primitive the relationship
674    /// loop sits on: [`crate::graph::backlinks_filtered`] uses it to bound its
675    /// candidate set to the relevant layer (or the whole store) without opening
676    /// the content tree, then confirms each candidate's edge by parsing the file.
677    pub fn sidecar_records(&self, layer: Option<Layer>) -> Result<Vec<IndexRecord>, StoreError> {
678        self.read_all_type_indexes_in(layer)
679    }
680
681    /// Parse a type-folder's `index.jsonl` into [`IndexRecord`]s, applying
682    /// last-write-wins by `path` over any un-compacted lines. The sidecar-read
683    /// primitive every structured query sits on.
684    pub fn read_type_index(&self, index_jsonl: &Path) -> Result<Vec<IndexRecord>, StoreError> {
685        let text = std::fs::read_to_string(index_jsonl).map_err(|e| StoreError::BadTypeIndex {
686            path: index_jsonl.to_path_buf(),
687            message: e.to_string(),
688        })?;
689
690        // Last-write-wins by `path` over un-compacted lines: a later line for
691        // the same path supersedes an earlier one (the jsonl is append-mostly
692        // and only compacted on rebuild). Blank lines are skipped; a non-blank
693        // line that is not a valid IndexRecord is a hard parse error.
694        let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
695        for (i, line) in text.lines().enumerate() {
696            let trimmed = line.trim();
697            if trimmed.is_empty() {
698                continue;
699            }
700            let record: IndexRecord =
701                serde_json::from_str(trimmed).map_err(|e| StoreError::BadTypeIndex {
702                    path: index_jsonl.to_path_buf(),
703                    message: format!("line {}: {e}", i + 1),
704                })?;
705            by_path.insert(record.path.clone(), record);
706        }
707        // BTreeMap keyed by path → records emerge sorted by path ascending,
708        // a deterministic order independent of line order in the file.
709        Ok(by_path.into_values().collect())
710    }
711
712    /// Resolve a store-relative path to its absolute on-disk path under
713    /// [`root`](Store::root).
714    pub fn abs_path(&self, store_relative: &Path) -> PathBuf {
715        // `Path::join` returns `store_relative` unchanged if it is already
716        // absolute, so passing an absolute path through is a no-op.
717        self.root.join(store_relative)
718    }
719
720    /// Convert an absolute path under the store into its store-relative form.
721    pub fn rel_path(&self, abs: &Path) -> Option<PathBuf> {
722        abs.strip_prefix(&self.root).ok().map(|p| p.to_path_buf())
723    }
724
725    // ── Private helpers ─────────────────────────────────────────────────────
726
727    /// Resolve a caller-supplied folder path (store-relative or absolute) to an
728    /// absolute path under the store root.
729    fn resolve_under_root(&self, folder: &Path) -> PathBuf {
730        if folder.is_absolute() {
731            folder.to_path_buf()
732        } else {
733            self.root.join(folder)
734        }
735    }
736
737    /// Walk a subtree for content `.md` files (skip hidden dirs, skip `index.md`
738    /// / `DB.md` / `log.md`), returning store-relative paths. Used by the layer
739    /// and type-folder walks.
740    fn walk_content_md(&self, root: &Path) -> Result<Vec<PathBuf>, StoreError> {
741        let mut out = Vec::new();
742        for entry in self.md_walker(root).build() {
743            let entry = entry.map_err(|e| StoreError::Search {
744                root: root.to_path_buf(),
745                message: e.to_string(),
746            })?;
747            if !is_file_entry(&entry) {
748                continue;
749            }
750            let path = entry.path();
751            if !self.owns_path(path) {
752                continue;
753            }
754            if !has_md_extension(path) {
755                continue;
756            }
757            if is_non_content_basename(path) {
758                continue;
759            }
760            if let Some(rel) = self.rel_path(path) {
761                out.push(rel);
762            }
763        }
764        out.sort();
765        Ok(out)
766    }
767
768    /// Walk the whole store for **every** `.md` file (including `index.md`),
769    /// skipping hidden dirs and the `log/` archive tree. Used by the backlink
770    /// scan, where the literal link text can live in any markdown file.
771    fn walk_all_md(&self) -> Result<Vec<PathBuf>, StoreError> {
772        let mut out = Vec::new();
773        for entry in self.md_walker(&self.root).build() {
774            let entry = entry.map_err(|e| StoreError::Search {
775                root: self.root.clone(),
776                message: e.to_string(),
777            })?;
778            if !is_file_entry(&entry) {
779                continue;
780            }
781            let path = entry.path();
782            if !self.owns_path(path) {
783                continue;
784            }
785            if !has_md_extension(path) {
786                continue;
787            }
788            if self.is_in_log_dir(path) {
789                continue;
790            }
791            if let Some(rel) = self.rel_path(path) {
792                out.push(rel);
793            }
794        }
795        out.sort();
796        Ok(out)
797    }
798
799    /// Read and merge every type-folder `index.jsonl` sidecar under `layer`
800    /// when given, else the whole store (skip hidden + `log/`). Each sidecar is
801    /// read with last-write-wins by path; across sidecars, paths are disjoint by
802    /// construction (one sidecar per folder), so a plain concatenation preserves
803    /// completeness. A layer scope confines the walk to `<root>/<layer>/`, which
804    /// is what keeps `find_by_where_in` O(entities-in-layer).
805    fn read_all_type_indexes_in(
806        &self,
807        layer: Option<Layer>,
808    ) -> Result<Vec<IndexRecord>, StoreError> {
809        let mut out = Vec::new();
810        for sidecar in self.find_type_index_files_in(layer)? {
811            out.extend(self.read_type_index(&self.abs_path(&sidecar))?);
812        }
813        Ok(out)
814    }
815
816    /// Locate every `index.jsonl` sidecar under `layer` (when given) else the
817    /// whole store (skip hidden + `log/`), returning store-relative paths. A
818    /// scoped read walks `<root>/<layer>/`; the store-wide read enumerates the
819    /// two canonical layer subtrees (`sources/`, `records/`) — the
820    /// same store model [`Store::walk`] uses — rather than walking from
821    /// `self.root`. Walking from root would descend into non-layer top-level
822    /// dirs (`EXPECTED/` test goldens, an `archive/` of frozen index copies,
823    /// any sibling folder holding store-relative `path`s), pulling their
824    /// sidecars in and returning every record twice. A non-existent layer
825    /// subtree yields no sidecars rather than walking a missing path.
826    fn find_type_index_files_in(&self, layer: Option<Layer>) -> Result<Vec<PathBuf>, StoreError> {
827        // Store-wide read: union the per-layer scoped reads so only the three
828        // content layers are walked (never root meta files or non-layer dirs),
829        // matching `Store::walk`. The per-layer paths are disjoint by folder, so
830        // a plain concatenation preserves completeness.
831        let Some(layer) = layer else {
832            let mut out = Vec::new();
833            for l in Layer::all() {
834                out.extend(self.find_type_index_files_in(Some(l))?);
835            }
836            out.sort();
837            return Ok(out);
838        };
839        let walk_root = self.root.join(layer.dir_name());
840        // A scoped walk over a layer folder that does not exist yet must be an
841        // empty result, mirroring `walk_layer`'s missing-dir guard — not a walk
842        // error from `ignore` over a nonexistent path.
843        if !walk_root.is_dir() {
844            return Ok(Vec::new());
845        }
846        let mut out = Vec::new();
847        let mut builder = WalkBuilder::new(&walk_root);
848        let store_root = self.root.clone();
849        builder
850            .standard_filters(false)
851            .hidden(true)
852            .follow_links(true)
853            .filter_entry(move |entry| ensure_path_within_store(&store_root, entry.path()).is_ok());
854        for entry in builder.build() {
855            let entry = entry.map_err(|e| StoreError::Search {
856                root: walk_root.clone(),
857                message: e.to_string(),
858            })?;
859            if !is_file_entry(&entry) {
860                continue;
861            }
862            let path = entry.path();
863            if !self.owns_path(path) {
864                continue;
865            }
866            if path.file_name().and_then(|n| n.to_str()) != Some(TYPE_INDEX_FILE) {
867                continue;
868            }
869            if self.is_in_log_dir(path) {
870                continue;
871            }
872            if let Some(rel) = self.rel_path(path) {
873                out.push(rel);
874            }
875        }
876        out.sort();
877        Ok(out)
878    }
879
880    /// A `WalkBuilder` configured for db.md SWEEPs: gitignore/global-ignore are
881    /// OFF (a SWEEP must see every file even if the store is a git repo with a
882    /// `.gitignore`), but hidden files/dirs are skipped. Symlinks are
883    /// **followed** (`follow_links(true)`) so an in-store alias to an in-store
884    /// `.md` file or type folder is walked like ordinary content rather than
885    /// silently vanishing. The ownership filter prunes aliases that escape the
886    /// root or cross a descendant-store boundary.
887    fn md_walker(&self, root: &Path) -> WalkBuilder {
888        let mut builder = WalkBuilder::new(root);
889        let store_root = self.root.clone();
890        builder
891            .standard_filters(false)
892            .hidden(true)
893            .follow_links(true)
894            .filter_entry(move |entry| ensure_path_within_store(&store_root, entry.path()).is_ok());
895        builder
896    }
897
898    /// True if an absolute path lives under the store's root-level `log/`
899    /// rotation-archive directory.
900    fn is_in_log_dir(&self, abs: &Path) -> bool {
901        match self.rel_path(abs) {
902            Some(rel) => rel.components().next().map(|c| c.as_os_str()) == Some("log".as_ref()),
903            None => false,
904        }
905    }
906
907    /// Read a file's frontmatter `updated` field as an RFC3339 timestamp,
908    /// returning `None` when absent/unparseable. A self-contained reader (does
909    /// not depend on the not-yet-implemented `parser::read_file`); parses the
910    /// leading `---`-fenced YAML block with the same engine the parser uses.
911    fn read_updated(&self, abs: &Path) -> Option<DateTime<FixedOffset>> {
912        let text = std::fs::read_to_string(abs).ok()?;
913        let yaml = frontmatter_block(&text)?;
914        let value: serde_norway::Value = serde_norway::from_str(yaml).ok()?;
915        let raw = value.get("updated")?;
916        value_to_datetime(raw)
917    }
918
919    /// The `<YYYY>/<MM>` shard segment for a sharding type, from its primary
920    /// date field with a `created` fallback. Reads the public `Frontmatter`
921    /// fields directly. `None` when no usable date is present.
922    fn primary_shard_segment(&self, type_: &str, fm: &Frontmatter) -> Option<(String, String)> {
923        // Try the type's primary date field first.
924        if let Some(field) = primary_date_field(type_) {
925            if let Some(v) = fm.extra.get(field) {
926                if let Some(seg) = value_to_year_month(v) {
927                    return Some(seg);
928                }
929            }
930        }
931        // Universal fallback: the typed `created` timestamp.
932        fm.created
933            .map(|dt| (format!("{:04}", dt.year()), format!("{:02}", dt.month())))
934    }
935}
936
937// ── Path containment (security) ─────────────────────────────────────────────
938
939/// Canonicalize `candidate` (resolving symlinks; for a not-yet-existing leaf,
940/// canonicalize its existing parent chain and re-append the leaf) and return it
941/// only if it resolves inside `store_root`; otherwise `Err`.
942///
943/// This is the single within-store containment gate. A wiki-link target, a
944/// rename destination, or any other caller-influenced path must pass through
945/// here before it is read or traversed, so a `..`-laden or symlink-escaping
946/// target can never turn a store operation into a read of an arbitrary file
947/// outside the store. `store_root` itself is canonicalized first so the
948/// `starts_with` comparison is symlink-stable on both sides (e.g. macOS's
949/// `/tmp` → `/private/tmp`).
950pub fn ensure_path_within_store(store_root: &Path, candidate: &Path) -> std::io::Result<PathBuf> {
951    reject_parent_components(store_root, candidate)?;
952
953    // Canonicalize the root so both sides of the containment check are in the
954    // same (fully-resolved) namespace. This also resolves any `..` the root
955    // itself carries (the user-supplied `--dir`), which the tail-only lexical
956    // check deliberately leaves in place.
957    let root = store_root.canonicalize()?;
958    let resolved = resolve_within(&root, store_root, candidate)?;
959    reject_nested_store_boundary(&root, &resolved, candidate, store_root)?;
960    Ok(resolved)
961}
962
963/// Reject a path owned by a descendant store. Every directory between the
964/// canonical root and the resolved candidate is checked, including the
965/// candidate itself when it is a directory.
966fn reject_nested_store_boundary(
967    root: &Path,
968    resolved: &Path,
969    candidate: &Path,
970    store_root: &Path,
971) -> std::io::Result<()> {
972    let Ok(rel) = resolved.strip_prefix(root) else {
973        return Err(outside_store_err(candidate, store_root));
974    };
975    if rel != Path::new("DB.md")
976        && resolved.file_name().and_then(|name| name.to_str()) == Some("DB.md")
977    {
978        return Err(std::io::Error::new(
979            std::io::ErrorKind::PermissionDenied,
980            format!(
981                "path {} would create or address a nested db.md store marker",
982                candidate.display()
983            ),
984        ));
985    }
986    let mut cursor = root.to_path_buf();
987    for component in rel.components() {
988        cursor.push(component.as_os_str());
989        if cursor.is_dir() && Store::is_db_md_store(&cursor) {
990            return Err(std::io::Error::new(
991                std::io::ErrorKind::PermissionDenied,
992                format!(
993                    "path {} crosses nested db.md store boundary {}",
994                    candidate.display(),
995                    cursor.display()
996                ),
997            ));
998        }
999    }
1000    Ok(())
1001}
1002
1003/// The lexical half of the containment gate: reject any `..` component in the
1004/// caller-influenced tail of `candidate` (the part beyond the trusted
1005/// `store_root` prefix).
1006fn reject_parent_components(store_root: &Path, candidate: &Path) -> std::io::Result<()> {
1007    // The `..` rejection below must apply only to the *caller-influenced* tail of
1008    // the candidate — never to a `..` the trusted `store_root` itself carries.
1009    // Callers build the candidate as `store_root.join(rel)`, so a user-supplied
1010    // `--dir ../../some/store` legitimately seeds every candidate with leading
1011    // `..` components that belong to the root, not to the sidecar/link target.
1012    // Strip the trusted `store_root` prefix lexically and scrutinize only what
1013    // remains; the root's own `..` is resolved safely by `canonicalize()` just
1014    // below. A candidate that does NOT begin with `store_root` (an absolute
1015    // out-of-store path, a CWD-relative target) keeps the whole path under
1016    // scrutiny — there is no trusted prefix to exempt.
1017    let scrutinized = candidate.strip_prefix(store_root).unwrap_or(candidate);
1018
1019    // Reject any `..` component in the scrutinized tail. A `ParentDir` can never
1020    // be resolved safely by lexical normalization: once a symlink sits earlier in
1021    // the path, `foo/../bar` does NOT equal `bar`, and canonicalizing the existing
1022    // prefix (below) would silently collapse `records/contacts/../../outside` down
1023    // to a path that *appears* inside the root, masking the traversal. There is no
1024    // legitimate in-store caller that needs `..` in the tail — wiki-link targets,
1025    // rename destinations, and graph reads are all forward (`Normal`-only) paths —
1026    // so a tail `..` is always either an escape attempt or a malformed target.
1027    if scrutinized
1028        .components()
1029        .any(|c| matches!(c, std::path::Component::ParentDir))
1030    {
1031        return Err(std::io::Error::new(
1032            std::io::ErrorKind::PermissionDenied,
1033            format!(
1034                "path {} contains a `..` component beyond the store root {} and cannot be contained",
1035                candidate.display(),
1036                store_root.display()
1037            ),
1038        ));
1039    }
1040    Ok(())
1041}
1042
1043/// The resolution half of the containment gate, against a pre-canonicalized
1044/// `root`: canonicalize `candidate` as far as it exists (peeling a virtual
1045/// tail), reassemble, and require the result to stay under `root`.
1046fn resolve_within(root: &Path, store_root: &Path, candidate: &Path) -> std::io::Result<PathBuf> {
1047    // Resolve the candidate as far as it exists on disk. `canonicalize` fails on
1048    // a not-yet-existing leaf, so peel trailing components until the remaining
1049    // prefix exists, canonicalize that, then re-append the peeled tail. This
1050    // resolves any symlink in the existing parent chain (an escape vector) while
1051    // still working for a target that does not exist yet (a rename destination).
1052    let mut existing = candidate.to_path_buf();
1053    let mut tail: Vec<std::ffi::OsString> = Vec::new();
1054    let resolved_prefix = loop {
1055        match existing.canonicalize() {
1056            Ok(p) => break p,
1057            Err(_) => {
1058                // No existing prefix left to canonicalize → resolve relative to
1059                // the canonical root (the candidate is somewhere under, or
1060                // escaping from, the store) and let the containment check below
1061                // decide. Pop one component and keep peeling.
1062                match existing.file_name() {
1063                    Some(name) => {
1064                        tail.push(name.to_os_string());
1065                        if !existing.pop() {
1066                            // Ran out of components without finding an existing
1067                            // prefix: anchor the un-resolvable remainder at the
1068                            // canonical root so a relative candidate is judged
1069                            // against the store, not the process CWD.
1070                            break root.to_path_buf();
1071                        }
1072                    }
1073                    None => {
1074                        // A root/prefix component with no file name and no
1075                        // on-disk existence: anchor at the canonical root.
1076                        break root.to_path_buf();
1077                    }
1078                }
1079            }
1080        }
1081    };
1082
1083    // Reassemble: canonical existing prefix + the peeled (still-virtual) tail,
1084    // in original order (the peel pushed them reversed).
1085    let mut resolved = resolved_prefix;
1086    for name in tail.into_iter().rev() {
1087        resolved.push(name);
1088    }
1089
1090    if resolved.starts_with(root) {
1091        Ok(resolved)
1092    } else {
1093        Err(outside_store_err(candidate, store_root))
1094    }
1095}
1096
1097fn outside_store_err(candidate: &Path, store_root: &Path) -> std::io::Error {
1098    std::io::Error::new(
1099        std::io::ErrorKind::PermissionDenied,
1100        format!(
1101            "path {} resolves outside the store root {}",
1102            candidate.display(),
1103            store_root.display()
1104        ),
1105    )
1106}
1107
1108/// Hot-loop companion to [`ensure_path_within_store`]: identical per-candidate
1109/// semantics, amortized cost. The single-shot gate re-canonicalizes the store
1110/// root and walks the candidate's whole parent chain via `canonicalize` on
1111/// every call — two realpath(3) chains per candidate, which at a 10k-file scan
1112/// set dominates the scan itself. This helper canonicalizes the root ONCE at
1113/// construction and memoizes each distinct parent directory's canonical form
1114/// (scan candidates cluster into a few dozen type/shard folders), so the
1115/// common candidate — an existing, non-symlink file in a known folder — costs
1116/// one `lstat(2)` and a prefix check. Symlink leaves, missing files, and other
1117/// corners fall back to the same full peel-resolution the single-shot gate
1118/// runs, so no candidate gets a weaker check: a poisoned path still resolves
1119/// (or fails) exactly as before.
1120pub struct StoreContainment {
1121    store_root: PathBuf,
1122    /// The store root, canonicalized once at construction.
1123    root: PathBuf,
1124    /// Parent dir → its canonical form (memoized realpath).
1125    dirs: BTreeMap<PathBuf, PathBuf>,
1126}
1127
1128impl StoreContainment {
1129    /// Canonicalize the store root once. Errs only if the root itself cannot
1130    /// resolve (deleted mid-operation) — the same condition that would fail
1131    /// every single-shot gate call.
1132    pub fn new(store_root: &Path) -> std::io::Result<Self> {
1133        Ok(Self {
1134            store_root: store_root.to_path_buf(),
1135            root: store_root.canonicalize()?,
1136            dirs: BTreeMap::new(),
1137        })
1138    }
1139
1140    /// [`ensure_path_within_store`], amortized: same acceptance set, same
1141    /// rejection set (see the struct doc).
1142    pub fn resolve(&mut self, candidate: &Path) -> std::io::Result<PathBuf> {
1143        reject_parent_components(&self.store_root, candidate)?;
1144
1145        // Fast path: an existing, non-symlink leaf under a memoizable parent.
1146        // `symlink_metadata` (lstat, no path resolution) both proves existence
1147        // and rules out a symlink leaf; the parent's canonical form resolves
1148        // every symlink earlier in the chain, so `canonical(parent) + leaf` is
1149        // exactly what `canonicalize(candidate)` would return.
1150        if let (Ok(meta), Some(parent), Some(name)) = (
1151            std::fs::symlink_metadata(candidate),
1152            candidate.parent(),
1153            candidate.file_name(),
1154        ) {
1155            if !meta.file_type().is_symlink() {
1156                let canon_parent = match self.dirs.get(parent) {
1157                    Some(p) => p.clone(),
1158                    None => {
1159                        let p = parent.canonicalize()?;
1160                        self.dirs.insert(parent.to_path_buf(), p.clone());
1161                        p
1162                    }
1163                };
1164                let resolved = canon_parent.join(name);
1165                if !resolved.starts_with(&self.root) {
1166                    return Err(outside_store_err(candidate, &self.store_root));
1167                }
1168                reject_nested_store_boundary(&self.root, &resolved, candidate, &self.store_root)?;
1169                return Ok(resolved);
1170            }
1171        }
1172
1173        // Slow path — symlink leaf, missing file, no parent: the full peel,
1174        // against the already-canonical root.
1175        let resolved = resolve_within(&self.root, &self.store_root, candidate)?;
1176        reject_nested_store_boundary(&self.root, &resolved, candidate, &self.store_root)?;
1177        Ok(resolved)
1178    }
1179}
1180
1181// ── The shared wiki-link edge notion (graph / stats / validate / rename) ─────
1182//
1183// One definition of "what `[[...]]` text is a real edge" that every relationship
1184// op keys on, so `forwardlinks`, `backlinks`, `links`, `stats`, and `rename`
1185// never disagree with each other (or with `validate`'s body extractor):
1186//
1187//   1. **Fence-aware.** A `[[...]]` inside a ``` / ~~~ fenced code block is a
1188//      documentation example, not an edge — exactly `validate`'s rule. Counting
1189//      it as an edge over-reports backlinks, falsely un-orphans the page, and
1190//      (worst) lets `rename` rewrite verbatim example text.
1191//   2. **Whitespace-trimmed.** `[[ records/contacts/sarah ]]` is the same edge
1192//      as `[[records/contacts/sarah]]`. The inner padding is cosmetic; both the
1193//      forward and the backward view must resolve it identically.
1194//   3. **Case-folded to the filesystem.** Link *resolution* is `is_file()`,
1195//      which is case-insensitive on macOS/Windows. So on a case-insensitive
1196//      filesystem `[[records/contacts/Sarah-Chen]]` and the on-disk
1197//      `sarah-chen.md` are the SAME edge; the comparison key must case-fold to
1198//      match, or backlinks/rename silently miss the link while validate (which
1199//      resolves via the filesystem) considers it fine.
1200
1201/// Canonicalize a raw `[[...]]` inner target into the wiki-link key: forward
1202/// slashes, no leading `./` or `/`, no trailing `.md`, inner whitespace trimmed.
1203/// The single key forward and backward edges are compared on. Pairs with
1204/// [`link_edge_key`] for the case-fold step.
1205pub fn canonical_link_target(raw: &str) -> String {
1206    let mut s = raw.trim().replace('\\', "/");
1207    while let Some(rest) = s.strip_prefix("./") {
1208        s = rest.to_string();
1209    }
1210    let s = s.trim_start_matches('/');
1211    let s = s.strip_suffix(".md").unwrap_or(s);
1212    s.trim().to_string()
1213}
1214
1215/// The comparison key for a canonical link target. Two normalizations, applied
1216/// in order, so the string-keyed edge comparison agrees with how the filesystem
1217/// resolves the same link:
1218///
1219///   1. **Unicode NFC, always.** macOS/APFS folds NFC and NFD forms of a name to
1220///      the same file, so a file `records/contacts/josé.md` written NFC
1221///      (`é` = U+00E9) and a link `[[records/contacts/josé]]` written NFD
1222///      (`e` + U+0301) name the *same* file on disk — yet their raw UTF-8 bytes
1223///      differ. Without normalization the graph keys them as two different
1224///      targets, so `backlinks`/`forwardlinks` miss the edge and `orphans` flags
1225///      a linked-to file as an orphan, while `validate` (which resolves through
1226///      the filesystem) sees the link as live: the surfaces silently disagree.
1227///      Normalizing BOTH sides to NFC here makes the comparison
1228///      normalization-insensitive, matching the filesystem. This lives in the
1229///      comparison key — not in [`canonical_link_target`] — so the canonical
1230///      form stays byte/normalization-preserving (rename REWRITE output is never
1231///      silently re-normalized); both the link target and the file path pass
1232///      through this function, so NFC here is sufficient to unify them.
1233///   2. **ASCII case-fold on a case-insensitive filesystem.** Identity on a
1234///      case-sensitive FS, ASCII-lowercased on macOS/Windows, so the comparison
1235///      also agrees with the filesystem's case-folding `is_file()` resolution.
1236///
1237/// Callers compare `link_edge_key(a) == link_edge_key(b)`.
1238pub fn link_edge_key(canonical_target: &str) -> String {
1239    use unicode_normalization::UnicodeNormalization;
1240    // NFC first — always, on every platform: the graph must agree across hosts,
1241    // and the comparison must be normalization-insensitive regardless of which
1242    // host's filesystem folded the on-disk name.
1243    let nfc: String = canonical_target.nfc().collect();
1244    if fs_is_case_insensitive() {
1245        nfc.to_ascii_lowercase()
1246    } else {
1247        nfc
1248    }
1249}
1250
1251/// Extract every wiki-link edge target from a markdown body, fence-aware and
1252/// whitespace-trimmed, in document order (duplicates kept — callers dedup).
1253/// Returns canonical targets (see [`canonical_link_target`]); the case-fold for
1254/// comparison is applied separately via [`link_edge_key`] so the canonical form
1255/// (used for rewrites/output) stays case-preserving.
1256///
1257/// Scans line-by-line tracking the fence state inline (no whole-body
1258/// allocation), exactly mirroring validate's `extract_wiki_links`: the fence
1259/// state is a `(fence char, run length)` tracked via [`fence_opens`] /
1260/// [`fence_closes`] — NOT a bool toggled on any ``` / `~~~` line. The naive
1261/// toggle inverts mid-block when a `~~~` block legally contains a ```` ``` ````
1262/// line (the standard way to document a backtick fence), or when a `>3`-space-
1263/// indented ``` is mistaken for a fence — both of which would let a fenced
1264/// example `[[…]]` leak out as a live edge (a false dependent for
1265/// backlinks/rename). Fenced lines never yield edges. Within a line, the text
1266/// before the first `|` is the target; a target whose trimmed form starts with
1267/// `[` is the rejected triple-bracket flow-form list mis-encoding
1268/// (`[[[a]], [[b]]]`), not a real link — skipped, matching validate.
1269///
1270/// Accepts a whole file's text *or* a body-only fragment. A leading `---`
1271/// frontmatter block is YAML, not markdown: it has no code fences, and a
1272/// `[[…]]` in any frontmatter field is a real edge. The frontmatter is therefore
1273/// scanned WITHOUT fence tracking, and the body is scanned with a FRESH fence
1274/// state — so a stray ``` / `~~~` inside a frontmatter value can never open a
1275/// fence that swallows the body's real wiki-links. (Callers `search_by_link`,
1276/// `forwardlinks`, and `dbmd graph backlinks` all pass full file text; without this
1277/// boundary reset a fenced frontmatter value silently dropped every subsequent
1278/// body edge — under-reporting backlinks/forwardlinks/`links`.) A fragment with
1279/// no leading frontmatter takes the body path unchanged.
1280pub fn extract_edge_targets(text: &str) -> Vec<String> {
1281    let mut out = Vec::new();
1282    // Split off a leading `---`…`---` frontmatter block (raw — no YAML parse, so
1283    // a malformed file is still fully scanned). Frontmatter links are edges but
1284    // must not participate in code-fence state.
1285    let body = match split_frontmatter_raw(text) {
1286        Some((frontmatter, body)) => {
1287            for line in frontmatter.lines() {
1288                push_edges_in_line(line, &mut out);
1289            }
1290            body
1291        }
1292        None => text,
1293    };
1294    let mut fence: Option<(u8, usize)> = None;
1295    for line in body.lines() {
1296        let content = line.trim_end_matches('\r');
1297        if let Some(f) = fence {
1298            if fence_closes(content, f) {
1299                fence = None;
1300            }
1301            continue;
1302        }
1303        if let Some(opened) = fence_opens(content) {
1304            fence = Some(opened);
1305            continue;
1306        }
1307        push_edges_in_line(line, &mut out);
1308    }
1309    out
1310}
1311
1312/// Push every `[[target]]` on one line into `out`, alias-stripped (`[[a|b]]` →
1313/// `a`), trimmed, and canonicalized. The triple-bracket flow-form mis-encoding
1314/// (`[[[a]], …]`) is skipped, matching validate. Shared by both the frontmatter
1315/// and body scans in [`extract_edge_targets`] so they honor one link grammar.
1316/// One wiki-link OCCURRENCE in a body, with the byte span it covers.
1317///
1318/// [`extract_edge_targets`] answers "what does this file link to" — deduped,
1319/// order-insensitive, the graph's view. This answers "where, exactly, are the
1320/// link tokens" — the view a RENDERER needs, because rewriting `[[…]]` into
1321/// presentation markup is a splice at a position, not a set operation.
1322///
1323/// Exposing it is what keeps the grammar in one place. A host that must render
1324/// wiki-links otherwise has to re-find the tokens itself, which means a second
1325/// implementation of `[[`/`]]`/`|` scanning and — the part that always rots —
1326/// a second implementation of fence tracking. (Observed in the wild: a hub
1327/// whose renderer rewrote fenced example links into live links, corrupting the
1328/// code samples on exactly the pages that documented the syntax.)
1329#[derive(Debug, Clone, PartialEq, Eq)]
1330pub struct EdgeSpan {
1331    /// The canonical target, byte-identical to the string
1332    /// [`extract_edge_targets`] yields for this occurrence — so the extension
1333    /// is NOT appended here (callers that want a store path add `.md`, exactly
1334    /// as `emit` does).
1335    pub target: String,
1336    /// The inner text verbatim (between `[[` and `]]`), untrimmed and unsplit,
1337    /// so a host with its own conventions can reinterpret it.
1338    pub raw: String,
1339    /// The `|alias` label, if the occurrence carries one.
1340    ///
1341    /// A `#fragment` is deliberately NOT split out: fragments are not in the
1342    /// format (they ride inside the target — see `canonical_link_target`), so
1343    /// splitting one is a host convention, not db.md grammar.
1344    pub alias: Option<String>,
1345    /// Byte offsets into the body passed in — `[start, end)` covers the whole
1346    /// `[[…]]` token including both bracket pairs.
1347    pub start: usize,
1348    pub end: usize,
1349}
1350
1351/// Every wiki-link occurrence in a BODY, in document order, with byte spans.
1352///
1353/// Body-only by design: this exists for renderers, which format bodies. A
1354/// `[[…]]` in a frontmatter VALUE is a real edge (and
1355/// [`extract_edge_targets`] reports it), but it is data being displayed by a
1356/// field, never markdown being rendered in place, so it has no useful span
1357/// here. Pass the body — a full file's text works too, but its frontmatter
1358/// block is then treated as body text.
1359///
1360/// Fence tracking is identical to [`extract_edge_targets`]'s body pass: a
1361/// `[[…]]` inside ``` or `~~~` is a documentation example and yields nothing.
1362pub fn extract_edge_spans(body: &str) -> Vec<EdgeSpan> {
1363    let mut out = Vec::new();
1364    let mut fence: Option<(u8, usize)> = None;
1365    // `lines()` discards offsets, so walk the byte ranges directly and keep the
1366    // running base — spans must index the caller's original string.
1367    let mut base = 0usize;
1368    for line in body.split_inclusive('\n') {
1369        let trimmed_len = line.trim_end_matches('\n').len();
1370        let content = line[..trimmed_len].trim_end_matches('\r');
1371        if let Some(f) = fence {
1372            if fence_closes(content, f) {
1373                fence = None;
1374            }
1375        } else if let Some(opened) = fence_opens(content) {
1376            fence = Some(opened);
1377        } else {
1378            push_edge_spans_in_line(content, base, &mut out);
1379        }
1380        base += line.len();
1381    }
1382    out
1383}
1384
1385fn push_edge_spans_in_line(line: &str, base: usize, out: &mut Vec<EdgeSpan>) {
1386    let bytes = line.as_bytes();
1387    let mut i = 0usize;
1388    while i + 1 < bytes.len() {
1389        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
1390            if let Some(close) = line[i + 2..].find("]]") {
1391                let inner = &line[i + 2..i + 2 + close];
1392                let end = i + 2 + close + 2;
1393                let mut parts = inner.splitn(2, '|');
1394                let raw_target = parts.next().unwrap_or(inner).trim();
1395                let alias = parts.next().map(str::trim).filter(|a| !a.is_empty());
1396                if !raw_target.is_empty() && !raw_target.starts_with('[') {
1397                    let canonical = canonical_link_target(raw_target);
1398                    if !canonical.is_empty() {
1399                        out.push(EdgeSpan {
1400                            target: canonical,
1401                            raw: inner.to_string(),
1402                            alias: alias.map(str::to_string),
1403                            start: base + i,
1404                            end: base + end,
1405                        });
1406                    }
1407                }
1408                i = end;
1409                continue;
1410            }
1411        }
1412        i += 1;
1413    }
1414}
1415
1416fn push_edges_in_line(line: &str, out: &mut Vec<String>) {
1417    let bytes = line.as_bytes();
1418    let mut i = 0usize;
1419    while i + 1 < bytes.len() {
1420        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
1421            if let Some(close) = line[i + 2..].find("]]") {
1422                let inner = &line[i + 2..i + 2 + close];
1423                let raw_target = inner.split('|').next().unwrap_or(inner).trim();
1424                if !raw_target.is_empty() && !raw_target.starts_with('[') {
1425                    let canonical = canonical_link_target(raw_target);
1426                    if !canonical.is_empty() {
1427                        out.push(canonical);
1428                    }
1429                }
1430                i = i + 2 + close + 2;
1431                continue;
1432            }
1433        }
1434        i += 1;
1435    }
1436}
1437
1438/// If `line` opens a fenced code block, return `(fence byte, run length)`. The
1439/// single fence-open rule shared by [`extract_edge_targets`] and graph's
1440/// `rewrite_links_to`, mirroring validate's `fence_opens` and the parser's
1441/// `opening_fence` so every link op tracks fences identically: a fence is
1442/// ```` ``` ```` or `~~~` (run ≥ 3) at ≤ 3 spaces of indent, and a backtick
1443/// fence's info string may not itself contain a backtick.
1444pub fn fence_opens(line: &str) -> Option<(u8, usize)> {
1445    let indent = line.len() - line.trim_start_matches(' ').len();
1446    if indent > 3 {
1447        return None;
1448    }
1449    let rest = &line[indent..];
1450    let byte = rest.bytes().next()?;
1451    if byte != b'`' && byte != b'~' {
1452        return None;
1453    }
1454    let run = rest.len() - rest.trim_start_matches(byte as char).len();
1455    if run < 3 {
1456        return None;
1457    }
1458    // A backtick fence's info string may not itself contain a backtick.
1459    if byte == b'`' && rest[run..].contains('`') {
1460        return None;
1461    }
1462    Some((byte, run))
1463}
1464
1465/// True if `line` closes the currently open `fence`: same char, run at least as
1466/// long, nothing but trailing whitespace after. Mirrors validate's
1467/// `fence_closes` / the parser's `is_closing_fence`, so an inner fence of the
1468/// *other* character (a ```` ``` ```` line inside a `~~~` block) does NOT close
1469/// the outer fence.
1470pub fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
1471    let (byte, open_len) = fence;
1472    let indent = line.len() - line.trim_start_matches(' ').len();
1473    if indent > 3 {
1474        return false;
1475    }
1476    let rest = &line[indent..];
1477    let run = rest.len() - rest.trim_start_matches(byte as char).len();
1478    if run < open_len {
1479        return false;
1480    }
1481    rest[run..].trim().is_empty()
1482}
1483
1484/// True when the host filesystem resolves paths case-insensitively (macOS/
1485/// Windows default). Probed once per process against the OS temp dir by creating
1486/// a lowercase marker and stat-ing its uppercase spelling. A probe failure
1487/// conservatively reports `false` (case-sensitive) — the historical behavior —
1488/// so a transient temp-dir issue never silently widens matching.
1489fn fs_is_case_insensitive() -> bool {
1490    use std::sync::OnceLock;
1491    static CASE_INSENSITIVE: OnceLock<bool> = OnceLock::new();
1492    *CASE_INSENSITIVE.get_or_init(|| {
1493        let dir = std::env::temp_dir();
1494        let pid = std::process::id();
1495        let nanos = SystemTime::now()
1496            .duration_since(UNIX_EPOCH)
1497            .map(|d| d.as_nanos())
1498            .unwrap_or(0);
1499        let lower = dir.join(format!(".dbmd-case-probe-{pid}-{nanos}"));
1500        let upper = dir.join(format!(".DBMD-CASE-PROBE-{pid}-{nanos}"));
1501        // Create the lowercase marker; if its uppercase spelling then resolves to
1502        // a file, the filesystem folded the case → case-insensitive.
1503        let result = match std::fs::File::create(&lower) {
1504            Ok(_) => upper.is_file(),
1505            Err(_) => false,
1506        };
1507        let _ = std::fs::remove_file(&lower);
1508        result
1509    })
1510}
1511
1512// ── Free helpers (no `self`) ────────────────────────────────────────────────
1513
1514/// True if a walk entry is a regular file, **following symlinks** so a
1515/// symlinked `.md` content file (or a file inside a symlinked type folder) is
1516/// counted like any other content file.
1517///
1518/// The store walks enable `follow_links(true)`, so a symlink entry's
1519/// `file_type()` still reports `is_symlink()` (the `ignore` walker does not
1520/// rewrite the entry's own type), not the followed target's type. Treat a
1521/// symlink whose target is a regular file as a file: `stat` (follow) the path
1522/// and check. A broken symlink (no target) is not a file.
1523fn is_file_entry(entry: &ignore::DirEntry) -> bool {
1524    match entry.file_type() {
1525        Some(ft) if ft.is_file() => true,
1526        Some(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
1527            .map(|m| m.is_file())
1528            .unwrap_or(false),
1529        // A `None` file type (the walk root itself) or a non-file/non-symlink
1530        // entry is not a content file.
1531        _ => false,
1532    }
1533}
1534
1535/// True if the path ends in a `.md` extension (case-sensitive — db.md files are
1536/// lowercase `.md`).
1537fn has_md_extension(path: &Path) -> bool {
1538    path.extension().and_then(|e| e.to_str()) == Some("md")
1539}
1540
1541/// True if the basename is a non-content meta file (`DB.md`, `index.md`,
1542/// `log.md`) that the content walks must skip.
1543fn is_non_content_basename(path: &Path) -> bool {
1544    match path.file_name().and_then(|n| n.to_str()) {
1545        Some(name) => NON_CONTENT_BASENAMES.contains(&name),
1546        None => false,
1547    }
1548}
1549
1550/// Append `.md` to a bare name; leave an existing `.md` untouched.
1551fn ensure_md_extension(name: &str) -> String {
1552    if name.ends_with(".md") {
1553        name.to_string()
1554    } else {
1555        format!("{name}.md")
1556    }
1557}
1558
1559/// The canonical default folder for a recognized type, per the SPEC type table
1560/// (`email → sources/emails`, `expense → records/expenses`, …). Unrecognized
1561/// types fall back to `records/<type>` (the bare type name, no pluralization
1562/// guess) — see the store findings on the docstring's looser `<type>` phrasing.
1563fn default_type_folder(type_: &str) -> PathBuf {
1564    let path = match type_ {
1565        // sources — documentary
1566        "email" => "sources/emails",
1567        "transcript" => "sources/transcripts",
1568        "pdf-source" => "sources/docs",
1569        // sources — testimonial (a human told the agent X)
1570        "note" => "sources/notes",
1571        // records — entities
1572        "contact" => "records/contacts",
1573        "company" => "records/companies",
1574        // records — events
1575        "expense" => "records/expenses",
1576        "meeting" => "records/meetings",
1577        "decision" => "records/decisions",
1578        "invoice" => "records/invoices",
1579        // unrecognized: bare type name under records/ (conclusions and any
1580        // custom type land here, e.g. `concept` → `records/concept`).
1581        other => return PathBuf::from("records").join(other),
1582    };
1583    PathBuf::from(path)
1584}
1585
1586/// The canonical [`Layer`] a `type_` belongs to, derived from its default
1587/// type-folder (`email` → `Sources`, `contact` → `Records`, a conclusion
1588/// `profile` → `Records`, unrecognized → `Records`). The write path uses this to decide whether
1589/// an agent-supplied folder is in the *right* layer for the type before honouring
1590/// its sub-folder choice.
1591pub fn layer_for_type(type_: &str) -> Layer {
1592    layer_of_folder(&default_type_folder(type_)).unwrap_or(Layer::Records)
1593}
1594
1595/// The [`Layer`] a type-folder path lives in, read from its first component
1596/// (`sources/` → `Sources`, `records/` → `Records`). Used to
1597/// bound [`Store::find_by_type`]'s whole-layer sidecar read to a single layer
1598/// subtree. Returns `None` for a path with no recognized layer prefix; every
1599/// value [`default_type_folder`] produces has one, so in practice this is
1600/// always `Some` on the call path — `None` degrades to a store-wide read.
1601fn layer_of_folder(folder: &Path) -> Option<Layer> {
1602    let first = folder.components().next()?.as_os_str().to_str()?;
1603    Layer::from_dir_name(first)
1604}
1605
1606/// True if a store-relative path is a db.md **content** file: rooted in a real
1607/// layer (`sources/` or `records/` as its FIRST component), with a `.md`
1608/// extension, and not an `index.md` sidecar. This is the SPEC's "content files =
1609/// everything under `sources/` and `records/` only" predicate (SPEC § content
1610/// files), keyed on the *first* component so a non-layer top-level dir is never
1611/// content even if a deeper component happens to be named `records`/`sources`
1612/// (e.g. `EXPECTED/records/x.md`, `archive/sources/y.md`).
1613///
1614/// It mirrors the graph engine's content filter so the surfaces that READ the
1615/// store (`graph backlinks`) and the surface that MUTATES it (`rename`) agree on
1616/// exactly which files are content. `rename` uses it to restrict its
1617/// link-rewrite set: a store-root file, a non-layer dir (`scratch/`,
1618/// `EXPECTED/`, `archive/`), or an `index.md` is NEVER rewritten — `rename` does
1619/// not own those bytes. The broad store scan ([`Store::find_links_to_any`],
1620/// shared with the read-only working-set validate) is left untouched; the filter
1621/// is applied at the point of mutation.
1622pub fn is_content_path(rel: &Path) -> bool {
1623    if layer_of_folder(rel).is_none() {
1624        return false;
1625    }
1626    if rel.extension().and_then(|e| e.to_str()) != Some("md") {
1627        return false;
1628    }
1629    rel.file_name().and_then(|n| n.to_str()) != Some("index.md")
1630}
1631
1632/// Infer a content file's canonical `type` from its store-relative path — the
1633/// inverse of [`default_type_folder`] and the single source of truth for
1634/// path→type inference (the CLI's `fm init` calls this, never re-derives it).
1635///
1636/// Requires the canonical `<layer>/<type-folder>/<file>` 3-component shape; a
1637/// shorter path (a file directly under a layer) or an unknown leading layer
1638/// yields `None`.
1639///
1640/// Recognized `(layer, folder)` pairs map back to their canonical type. For an
1641/// unrecognized folder the fallback is the **bare folder name verbatim** (no
1642/// pluralization/singularization) so it round-trips with `default_type_folder`,
1643/// whose unrecognized fallback is the bare type name (`task` ⇄ `records/task`).
1644/// Singularizing here would break that round-trip (`records/tasks` → `task`
1645/// while `default_type_folder("task")` → `records/task`). A conclusion record's
1646/// folder (e.g. `records/profiles/`) infers its bare folder name (`profiles`),
1647/// the same custom-type fallback as any other unrecognized folder.
1648pub fn infer_type_from_path(rel: &Path) -> Option<String> {
1649    let mut comps = rel.components().filter_map(|c| c.as_os_str().to_str());
1650    let layer = comps.next()?;
1651    if !matches!(layer, "sources" | "records") {
1652        return None;
1653    }
1654    let folder = comps.next()?;
1655    // The file itself must be a third component (a real type-folder, not the
1656    // file sitting directly under the layer).
1657    comps.next()?;
1658
1659    let mapped = match (layer, folder) {
1660        ("sources", "emails") => "email",
1661        ("sources", "transcripts") => "transcript",
1662        ("sources", "docs") => "pdf-source",
1663        ("sources", "notes") => "note",
1664        ("records", "contacts") => "contact",
1665        ("records", "companies") => "company",
1666        ("records", "expenses") => "expense",
1667        ("records", "meetings") => "meeting",
1668        ("records", "decisions") => "decision",
1669        ("records", "invoices") => "invoice",
1670        // Unrecognized folder: the bare name, verbatim. This is the inverse of
1671        // `default_type_folder`'s unrecognized fallback (`other → records/other`)
1672        // and the round-trip would break if we pluralized/singularized here.
1673        (_, other) => other,
1674    };
1675    Some(mapped.to_string())
1676}
1677
1678/// The primary date field name for a sharding type (the field whose value
1679/// drives `<YYYY>/<MM>`). `None` means "use the `created` fallback only".
1680fn primary_date_field(type_: &str) -> Option<&'static str> {
1681    match type_ {
1682        "email" => Some("date"),
1683        "transcript" => Some("recorded_at"),
1684        "pdf-source" => Some("received_at"),
1685        "note" => Some("told_at"),
1686        "expense" | "invoice" | "meeting" => Some("date"),
1687        // recognized custom event types have no canonical date field name; they
1688        // fall back to `created`.
1689        _ => None,
1690    }
1691}
1692
1693/// Parse a YAML value into an RFC3339 [`DateTime`], accepting both an explicit
1694/// string and a YAML-native scalar rendered to string.
1695fn value_to_datetime(value: &serde_norway::Value) -> Option<DateTime<FixedOffset>> {
1696    let s = yaml_scalar_string(value)?;
1697    DateTime::parse_from_rfc3339(s.trim()).ok()
1698}
1699
1700/// Extract `(YYYY, MM)` from a YAML date/timestamp value. Lenient: matches a
1701/// leading `YYYY-MM` so a bare `2026-05-22` date and a full
1702/// `2026-05-22T10:00:00-07:00` timestamp both work.
1703fn value_to_year_month(value: &serde_norway::Value) -> Option<(String, String)> {
1704    let s = yaml_scalar_string(value)?;
1705    year_month_from_str(s.trim())
1706}
1707
1708/// `(YYYY, MM)` from the leading `YYYY-M` or `YYYY-MM` of a date string, with
1709/// the month returned zero-padded to two digits.
1710///
1711/// The month may be single- OR double-digit so that `2026-1-15` and its
1712/// zero-padded twin `2026-01-15` shard to the *same* `2026/01` folder. This
1713/// matches the lenient `date`-shape validator (`is_iso8601_date_or_datetime`,
1714/// chrono `%Y-%m-%d`), which accepts an unpadded month — without this, a value
1715/// the validator treats as a valid date is silently mis-filed under the
1716/// `created`-fallback month. Genuinely non-date input still returns `None`.
1717fn year_month_from_str(s: &str) -> Option<(String, String)> {
1718    // Hand-roll the leading-`YYYY-M[M]` parse to avoid a regex compile on the
1719    // write path. Split on '-': require a 4-digit year, then a 1-or-2-digit
1720    // numeric month in 1..=12. Anything after the month (a `-DD` day, a `T...`
1721    // time) is ignored — the day field never separates the leading date.
1722    let mut parts = s.splitn(3, '-');
1723    let year = parts.next()?;
1724    let month_part = parts.next()?;
1725
1726    // Year: exactly 4 ASCII digits.
1727    if year.len() != 4 || !year.bytes().all(|b| b.is_ascii_digit()) {
1728        return None;
1729    }
1730
1731    // Month: 1 or 2 ASCII digits, value 1..=12. Padded to two digits on output.
1732    if month_part.is_empty()
1733        || month_part.len() > 2
1734        || !month_part.bytes().all(|b| b.is_ascii_digit())
1735    {
1736        return None;
1737    }
1738    let month: u8 = month_part.parse().ok()?;
1739    if !(1..=12).contains(&month) {
1740        return None;
1741    }
1742
1743    Some((year.to_string(), format!("{month:02}")))
1744}
1745
1746/// Render a YAML scalar as a string: a real `String` verbatim, otherwise the
1747/// value's compact YAML serialization (covers timestamps that the YAML engine
1748/// may surface as a non-string scalar).
1749fn yaml_scalar_string(value: &serde_norway::Value) -> Option<String> {
1750    if let Some(s) = value.as_str() {
1751        return Some(s.to_string());
1752    }
1753    match value {
1754        serde_norway::Value::Null => None,
1755        serde_norway::Value::Mapping(_) | serde_norway::Value::Sequence(_) => None,
1756        other => serde_norway::to_string(other)
1757            .ok()
1758            .map(|s| s.trim().to_string()),
1759    }
1760}
1761
1762/// The YAML frontmatter block of a file: the text between a leading `---` fence
1763/// and the next `---` fence, exclusive. `None` if the file does not open with a
1764/// `---` fence on its first line.
1765fn frontmatter_block(text: &str) -> Option<&str> {
1766    // Tolerate a UTF-8 BOM and CRLF, but the fence must be the very first line.
1767    let body = text.strip_prefix('\u{feff}').unwrap_or(text);
1768    let mut rest = body;
1769    // First line must be exactly `---`, tolerating trailing whitespace (CR, but
1770    // also spaces/tabs) — matching the canonical parser (`parser.rs` /
1771    // `index.rs`'s `extract_frontmatter_block`). A strict `\r`-only trim missed a
1772    // `--- ` fence, so `read_updated` returned None and date-sharding silently
1773    // fell back, disagreeing with the sidecar the rest of the toolkit builds.
1774    let (first, after_first) = split_first_line(rest);
1775    if first.trim_end() != "---" {
1776        return None;
1777    }
1778    rest = after_first;
1779    let block_start = rest;
1780    let mut scanned = 0usize;
1781    loop {
1782        let (line, after) = split_first_line(rest);
1783        if line.trim_end() == "---" {
1784            return Some(&block_start[..scanned]);
1785        }
1786        if after.is_empty() && line.is_empty() {
1787            // Reached end of input without a closing fence.
1788            return None;
1789        }
1790        scanned += line.len() + 1; // +1 for the consumed '\n'
1791        if after.is_empty() {
1792            return None;
1793        }
1794        rest = after;
1795    }
1796}
1797
1798/// Split a file's text into `(frontmatter, body)` at the leading `---`…`---`
1799/// fence — raw (no YAML parse), so a file with malformed frontmatter is still
1800/// split and fully scanned. `frontmatter` is the text between the fences
1801/// (exclusive); `body` is everything after the closing fence's line. Returns
1802/// `None` when the text does not open with a `---` fence or has no closing
1803/// fence — the caller then treats the whole text as body. Mirrors
1804/// [`frontmatter_block`]'s boundary detection (BOM- and CRLF-tolerant).
1805fn split_frontmatter_raw(text: &str) -> Option<(&str, &str)> {
1806    let stripped = text.strip_prefix('\u{feff}').unwrap_or(text);
1807    let (first, after_first) = split_first_line(stripped);
1808    if first.trim_end() != "---" {
1809        return None;
1810    }
1811    let block_start = after_first;
1812    let mut scanned = 0usize;
1813    let mut rest = after_first;
1814    loop {
1815        let (line, after) = split_first_line(rest);
1816        if line.trim_end() == "---" {
1817            // `after` is the body: everything past the closing fence line.
1818            return Some((&block_start[..scanned], after));
1819        }
1820        if after.is_empty() && line.is_empty() {
1821            return None; // reached EOF with no closing fence
1822        }
1823        scanned += line.len() + 1; // +1 for the consumed '\n'
1824        if after.is_empty() {
1825            return None; // closing fence never found
1826        }
1827        rest = after;
1828    }
1829}
1830
1831/// Split a string into (first line without its trailing `\n`, remainder after
1832/// the `\n`). If there is no newline, the whole string is the line and the
1833/// remainder is empty.
1834fn split_first_line(s: &str) -> (&str, &str) {
1835    match s.find('\n') {
1836        Some(i) => (&s[..i], &s[i + 1..]),
1837        None => (s, ""),
1838    }
1839}
1840
1841/// True if an [`IndexRecord`] has a field `key` equal to `value`, checking the
1842/// typed columns first and then the flattened `fields` map.
1843fn record_matches_field(record: &IndexRecord, key: &str, value: &str) -> bool {
1844    match key {
1845        "type" => record.type_ == value,
1846        "summary" => record.summary == value,
1847        "path" => record.path.to_string_lossy() == value,
1848        "created" => timestamp_matches(record.created, value),
1849        "updated" => timestamp_matches(record.updated, value),
1850        "tags" => record.tags.iter().any(|t| t == value),
1851        "links" => record.links.iter().any(|l| l == value),
1852        other => record
1853            .fields
1854            .get(other)
1855            .map(|v| json_value_matches(v, value))
1856            .unwrap_or(false),
1857    }
1858}
1859
1860/// Compare a record's `created`/`updated` instant against a query `value`.
1861///
1862/// db.md files write timestamps in several equivalent RFC3339 spellings — most
1863/// commonly the `Z` UTC designator (`2026-05-01T00:00:00Z`) but also an explicit
1864/// offset (`...+00:00`, `...-07:00`). A naive `record.created.to_rfc3339() ==
1865/// value` reformats only one side: chrono renders a UTC instant as `+00:00`, so
1866/// the `Z` form an agent reads straight out of the file would never match. We
1867/// instead parse `value` as RFC3339 and compare instants, where `Z` and `+00:00`
1868/// (and any same-instant offset) are equal. A `value` that is not valid RFC3339
1869/// can never equal a real timestamp, so it falls through to `false`.
1870fn timestamp_matches(stored: Option<DateTime<FixedOffset>>, value: &str) -> bool {
1871    match (stored, DateTime::parse_from_rfc3339(value)) {
1872        (Some(stored), Ok(queried)) => stored == queried,
1873        _ => false,
1874    }
1875}
1876
1877/// Match a JSON number against a query string.
1878///
1879/// A FLOAT-valued field is compared NUMERICALLY, not textually: the sidecar
1880/// stores a YAML float through serde_json's canonical f64 rendering, which
1881/// discards the file's source spelling (`1234.00` -> `1234.0`, `12.50` ->
1882/// `12.5`, `1e3` -> `1000.0`). A raw `to_string()` compare therefore made the
1883/// spelling a human reads in the file fail to match (and disagreed with
1884/// free-text `search`), while requiring a canonical form often absent from the
1885/// file. We parse the query as f64 and compare values. Restricted to the float
1886/// case so a large INTEGER field never loses exactness to f64 rounding (integers
1887/// render canonically and round-trip exactly through the textual compare).
1888/// Mirrors the parse-then-compare pattern [`timestamp_matches`] already uses.
1889fn number_matches(n: &serde_json::Number, value: &str) -> bool {
1890    if n.to_string() == value {
1891        return true;
1892    }
1893    if n.is_f64() {
1894        if let (Some(stored), Ok(q)) = (n.as_f64(), value.parse::<f64>()) {
1895            return stored == q;
1896        }
1897    }
1898    false
1899}
1900
1901/// Compare a JSON field value against a query string. A string matches
1902/// verbatim; scalars match their textual form; an array matches if any element
1903/// matches (so a list-valued frontmatter field is membership-queried).
1904fn json_value_matches(v: &serde_json::Value, value: &str) -> bool {
1905    match v {
1906        serde_json::Value::String(s) => s == value,
1907        serde_json::Value::Bool(b) => b.to_string() == value,
1908        serde_json::Value::Number(n) => number_matches(n, value),
1909        serde_json::Value::Array(items) => items.iter().any(|i| json_value_matches(i, value)),
1910        // A present-but-null field never matches — consistent with the in-memory
1911        // post-filter (`query::json_value_matches`, which the first `where`
1912        // clause is NOT re-checked against, so the two must agree here or a
1913        // `--where field=` query would return different rows than `--type X
1914        // --where field=`).
1915        serde_json::Value::Null => false,
1916        serde_json::Value::Object(_) => false,
1917    }
1918}
1919
1920#[cfg(test)]
1921mod tests {
1922    use super::*;
1923    use std::fs;
1924    use tempfile::{tempdir, TempDir};
1925
1926    // ── Fixtures ────────────────────────────────────────────────────────────
1927
1928    /// Write `contents` to `<root>/<rel>`, creating parent dirs. Returns the
1929    /// store-relative path for convenient assertions.
1930    fn write(root: &Path, rel: &str, contents: &str) -> PathBuf {
1931        let abs = root.join(rel);
1932        fs::create_dir_all(abs.parent().unwrap()).unwrap();
1933        fs::write(&abs, contents).unwrap();
1934        PathBuf::from(rel)
1935    }
1936
1937    /// A minimal content file with the given `updated` timestamp in frontmatter.
1938    fn content_md(updated: &str) -> String {
1939        format!(
1940            "---\ntype: note\ncreated: {updated}\nupdated: {updated}\nsummary: a note\n---\n\nbody\n"
1941        )
1942    }
1943
1944    /// A bare directory with a `DB.md` marker (valid `db-md` frontmatter so the
1945    /// real parser is exercised).
1946    fn empty_store() -> TempDir {
1947        let dir = tempdir().unwrap();
1948        fs::write(
1949            dir.path().join("DB.md"),
1950            "---\ntype: db-md\nscope: company\nowner: Test\n---\n\n# Store\n",
1951        )
1952        .unwrap();
1953        dir
1954    }
1955
1956    /// Open a store rooted at a TempDir; panics if `open` rejects it.
1957    fn open(dir: &TempDir) -> Store {
1958        Store::open(dir.path()).expect("fixture should be a valid store")
1959    }
1960
1961    fn rels(paths: &[PathBuf]) -> Vec<String> {
1962        paths
1963            .iter()
1964            .map(|p| p.to_string_lossy().replace('\\', "/"))
1965            .collect()
1966    }
1967
1968    // ── Layer ───────────────────────────────────────────────────────────────
1969
1970    #[test]
1971    fn layer_dir_name_and_parse_are_inverse() {
1972        for layer in Layer::all() {
1973            assert_eq!(Layer::from_dir_name(layer.dir_name()), Some(layer));
1974        }
1975        assert_eq!(Layer::Sources.dir_name(), "sources");
1976        assert_eq!(Layer::Records.dir_name(), "records");
1977        // `wiki` is no longer a layer (the wiki/ layer was removed); it parses to None.
1978        assert_eq!(Layer::from_dir_name("wiki"), None);
1979        assert_eq!(Layer::from_dir_name("log"), None);
1980        assert_eq!(Layer::from_dir_name("Sources"), None); // case-sensitive
1981    }
1982
1983    #[test]
1984    fn layer_order_is_canonical() {
1985        // stats keys a BTreeMap on Layer; the sort order must be sources<records.
1986        let mut v = [Layer::Records, Layer::Sources];
1987        v.sort();
1988        assert_eq!(v, [Layer::Sources, Layer::Records]);
1989    }
1990
1991    #[test]
1992    fn is_content_path_is_layer_rooted_and_excludes_non_layer_files() {
1993        // Real content: a `.md` file rooted in a layer's FIRST component.
1994        assert!(is_content_path(Path::new("records/contacts/alice.md")));
1995        assert!(is_content_path(Path::new("sources/emails/2026/05/x.md")));
1996        // Store-root meta files and a bare top-level note are NOT content.
1997        assert!(!is_content_path(Path::new("DB.md")));
1998        assert!(!is_content_path(Path::new("log.md")));
1999        assert!(!is_content_path(Path::new("NOTES.md")));
2000        // Non-layer top-level dirs are NEVER content — even if a DEEPER
2001        // component is named `records`/`sources` (the rename data-loss case).
2002        assert!(!is_content_path(Path::new("scratch/draft.md")));
2003        assert!(!is_content_path(Path::new("EXPECTED/snapshot.md")));
2004        assert!(!is_content_path(Path::new("archive/old.md")));
2005        assert!(!is_content_path(Path::new(
2006            "EXPECTED/records/contacts/x.md"
2007        )));
2008        assert!(!is_content_path(Path::new("archive/sources/emails/y.md")));
2009        // An `index.md` sidecar inside a layer is a catalog, not content.
2010        assert!(!is_content_path(Path::new("records/contacts/index.md")));
2011        // A non-`.md` file inside a layer (e.g. the jsonl sidecar) is not content.
2012        assert!(!is_content_path(Path::new("records/contacts/index.jsonl")));
2013    }
2014
2015    // ── is_db_md_store / open ────────────────────────────────────────────────
2016
2017    #[test]
2018    fn is_store_true_only_with_uppercase_marker() {
2019        let dir = tempdir().unwrap();
2020        assert!(
2021            !Store::is_db_md_store(dir.path()),
2022            "no marker → not a store"
2023        );
2024
2025        fs::write(dir.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
2026        assert!(Store::is_db_md_store(dir.path()), "uppercase DB.md → store");
2027    }
2028
2029    #[test]
2030    fn is_store_false_for_lowercase_db_md() {
2031        // The case-sensitivity contract: a lowercase db.md is the spec name, not
2032        // a marker — even on a case-insensitive filesystem where Path::exists
2033        // would lie. This test must pass on macOS (case-insensitive) too.
2034        let dir = tempdir().unwrap();
2035        fs::write(dir.path().join("db.md"), "---\ntype: db-md\n---\n").unwrap();
2036        assert!(
2037            !Store::is_db_md_store(dir.path()),
2038            "lowercase db.md must NOT be treated as a store marker"
2039        );
2040        assert!(Store::open(dir.path()).is_err());
2041    }
2042
2043    #[test]
2044    fn is_store_false_when_db_md_is_a_directory() {
2045        let dir = tempdir().unwrap();
2046        fs::create_dir(dir.path().join("DB.md")).unwrap();
2047        assert!(
2048            !Store::is_db_md_store(dir.path()),
2049            "a directory named DB.md is not the file marker"
2050        );
2051    }
2052
2053    #[cfg(unix)]
2054    #[test]
2055    fn is_store_false_when_db_md_symlink_escapes_root() {
2056        use std::os::unix::fs::symlink;
2057
2058        let dir = tempdir().unwrap();
2059        let external = tempdir().unwrap();
2060        let marker = external.path().join("outside.md");
2061        fs::write(
2062            &marker,
2063            "---\ntype: db-md\nscope: personal\nowner: Outside\n---\n",
2064        )
2065        .unwrap();
2066        symlink(&marker, dir.path().join("DB.md")).unwrap();
2067
2068        assert!(
2069            !Store::is_db_md_store(dir.path()),
2070            "opening a store must not read an external DB.md symlink"
2071        );
2072    }
2073
2074    #[test]
2075    fn open_rejects_non_store_with_path() {
2076        let dir = tempdir().unwrap();
2077        let err = Store::open(dir.path()).unwrap_err();
2078        assert_eq!(err.path, dir.path());
2079    }
2080
2081    #[test]
2082    fn open_succeeds_and_parses_config() {
2083        let dir = tempdir().unwrap();
2084        // A DB.md whose ## Policies declares a frozen page — proves open()
2085        // actually parsed the config rather than substituting a default.
2086        fs::write(
2087            dir.path().join("DB.md"),
2088            "---\ntype: db-md\nscope: company\nowner: Test\n---\n\n# Store\n\n\
2089             ## Policies\n\n### Frozen pages\n- records/decisions/q1.md\n",
2090        )
2091        .unwrap();
2092        let store = Store::open(dir.path()).unwrap();
2093        assert_eq!(store.root, dir.path());
2094        assert!(
2095            store
2096                .config
2097                .frozen_pages
2098                .iter()
2099                .any(|p| p == Path::new("records/decisions/q1.md")),
2100            "open() must surface DB.md ## Policies, got {:?}",
2101            store.config.frozen_pages
2102        );
2103    }
2104
2105    // ── walk / walk_layer / walk_type_folder ─────────────────────────────────
2106
2107    #[test]
2108    fn walk_collects_content_across_layers_skipping_meta_and_log() {
2109        let dir = empty_store();
2110        let root = dir.path();
2111        write(
2112            root,
2113            "sources/emails/2026/05/a.md",
2114            &content_md("2026-05-01T00:00:00Z"),
2115        );
2116        write(
2117            root,
2118            "records/contacts/sarah.md",
2119            &content_md("2026-05-02T00:00:00Z"),
2120        );
2121        write(
2122            root,
2123            "records/profiles/sarah.md",
2124            &content_md("2026-05-03T00:00:00Z"),
2125        );
2126        // Things walk() must SKIP:
2127        write(root, "sources/emails/index.md", "---\ntype: index\n---\n"); // catalog
2128        write(root, "index.md", "---\ntype: index\n---\n"); // root catalog
2129        write(root, "log.md", "---\ntype: log\n---\n"); // log
2130        write(root, "log/2026-04.md", "---\ntype: log\n---\n"); // rotated log archive
2131        write(
2132            root,
2133            "sources/.hidden/secret.md",
2134            &content_md("2026-05-09T00:00:00Z"),
2135        ); // hidden dir
2136        write(root, "records/contacts/notes.txt", "not markdown"); // non-md
2137
2138        let store = open(&dir);
2139        let got = rels(&store.walk().unwrap());
2140        assert_eq!(
2141            got,
2142            vec![
2143                "records/contacts/sarah.md".to_string(),
2144                "records/profiles/sarah.md".to_string(),
2145                "sources/emails/2026/05/a.md".to_string(),
2146            ]
2147        );
2148    }
2149
2150    #[test]
2151    fn walk_includes_log_md_but_prunes_nested_store() {
2152        let dir = empty_store();
2153        let root = dir.path();
2154        // log.md is reserved only at the store root.
2155        write(
2156            root,
2157            "records/configs/log.md",
2158            &content_md("2026-05-01T00:00:00Z"),
2159        );
2160        // A descendant DB.md starts a foreign store boundary. Nothing beneath
2161        // it belongs to the outer store.
2162        write(
2163            root,
2164            "sources/docs/DB.md",
2165            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
2166        );
2167        write(
2168            root,
2169            "sources/docs/records/notes/secret.md",
2170            &content_md("2026-05-02T00:00:00Z"),
2171        );
2172        // The derived catalog twin is still skipped at any depth.
2173        write(root, "records/configs/index.md", "---\ntype: index\n---\n");
2174        let store = open(&dir);
2175        let got = rels(&store.walk().unwrap());
2176        assert!(
2177            got.contains(&"records/configs/log.md".to_string()),
2178            "layer-internal log.md is content: {got:?}"
2179        );
2180        assert!(
2181            !got.iter().any(|path| path.starts_with("sources/docs/")),
2182            "nested store content must be pruned: {got:?}"
2183        );
2184        assert!(
2185            !got.iter().any(|p| p.ends_with("index.md")),
2186            "index.md is still skipped: {got:?}"
2187        );
2188        assert_eq!(
2189            store.nested_store_roots().unwrap(),
2190            vec![PathBuf::from("sources/docs")]
2191        );
2192        assert!(
2193            ensure_path_within_store(root, &root.join("sources/docs/records/notes/secret.md"))
2194                .is_err(),
2195            "outer-store containment must reject a nested-store path"
2196        );
2197        let nested = Store::open_strict(&root.join("sources/docs")).unwrap();
2198        assert!(
2199            nested.owns_path(&root.join("sources/docs/records/notes/secret.md")),
2200            "the same path is owned when the nested store is opened directly"
2201        );
2202    }
2203
2204    #[cfg(unix)]
2205    #[test]
2206    fn walk_never_reads_external_file_or_directory_symlinks() {
2207        use std::os::unix::fs::symlink;
2208
2209        let dir = empty_store();
2210        let external = tempdir().unwrap();
2211        fs::write(
2212            external.path().join("secret.md"),
2213            content_md("2026-05-03T00:00:00Z"),
2214        )
2215        .unwrap();
2216        fs::create_dir(external.path().join("folder")).unwrap();
2217        fs::write(
2218            external.path().join("folder").join("deeper.md"),
2219            content_md("2026-05-04T00:00:00Z"),
2220        )
2221        .unwrap();
2222
2223        fs::create_dir_all(dir.path().join("records/notes")).unwrap();
2224        symlink(
2225            external.path().join("secret.md"),
2226            dir.path().join("records/notes/aliased.md"),
2227        )
2228        .unwrap();
2229        symlink(
2230            external.path().join("folder"),
2231            dir.path().join("records/external"),
2232        )
2233        .unwrap();
2234
2235        let store = open(&dir);
2236        assert!(
2237            store.walk().unwrap().is_empty(),
2238            "external symlink targets must be pruned from every store sweep"
2239        );
2240        assert!(!store.owns_path(&dir.path().join("records/notes/aliased.md")));
2241        assert!(!store.owns_path(&dir.path().join("records/external")));
2242        assert_eq!(
2243            rels(&store.unowned_symlinks().unwrap()),
2244            vec![
2245                "records/external".to_string(),
2246                "records/notes/aliased.md".to_string(),
2247            ],
2248            "ignored external aliases remain observable without reading targets"
2249        );
2250    }
2251
2252    #[test]
2253    fn walk_layer_is_scoped() {
2254        let dir = empty_store();
2255        let root = dir.path();
2256        write(
2257            root,
2258            "sources/emails/2026/05/a.md",
2259            &content_md("2026-05-01T00:00:00Z"),
2260        );
2261        write(
2262            root,
2263            "records/contacts/sarah.md",
2264            &content_md("2026-05-02T00:00:00Z"),
2265        );
2266        let store = open(&dir);
2267
2268        assert_eq!(
2269            rels(&store.walk_layer(Layer::Sources).unwrap()),
2270            vec!["sources/emails/2026/05/a.md".to_string()]
2271        );
2272        assert_eq!(
2273            rels(&store.walk_layer(Layer::Records).unwrap()),
2274            vec!["records/contacts/sarah.md".to_string()]
2275        );
2276        // A layer with no directory is empty, not an error: a store with only a
2277        // sources/ tree has no records/ dir, so walking Records is empty.
2278        let only_sources = empty_store();
2279        write(
2280            only_sources.path(),
2281            "sources/emails/2026/05/a.md",
2282            &content_md("2026-05-01T00:00:00Z"),
2283        );
2284        let s2 = open(&only_sources);
2285        assert!(s2.walk_layer(Layer::Records).unwrap().is_empty());
2286    }
2287
2288    #[test]
2289    fn walk_type_folder_recurses_shards_and_accepts_abs_or_rel() {
2290        let dir = empty_store();
2291        let root = dir.path();
2292        write(
2293            root,
2294            "sources/emails/2026/05/a.md",
2295            &content_md("2026-05-01T00:00:00Z"),
2296        );
2297        write(
2298            root,
2299            "sources/emails/2026/06/b.md",
2300            &content_md("2026-06-01T00:00:00Z"),
2301        );
2302        write(root, "sources/emails/index.md", "---\ntype: index\n---\n"); // skipped
2303                                                                           // A different type folder must not leak in.
2304        write(
2305            root,
2306            "sources/docs/2026/05/c.md",
2307            &content_md("2026-05-04T00:00:00Z"),
2308        );
2309        let store = open(&dir);
2310
2311        let expected = vec![
2312            "sources/emails/2026/05/a.md".to_string(),
2313            "sources/emails/2026/06/b.md".to_string(),
2314        ];
2315        // Relative folder arg.
2316        assert_eq!(
2317            rels(&store.walk_type_folder(Path::new("sources/emails")).unwrap()),
2318            expected
2319        );
2320        // Absolute folder arg under the store resolves identically.
2321        assert_eq!(
2322            rels(
2323                &store
2324                    .walk_type_folder(&root.join("sources/emails"))
2325                    .unwrap()
2326            ),
2327            expected
2328        );
2329    }
2330
2331    // ── recent_in_type_folder ────────────────────────────────────────────────
2332
2333    #[test]
2334    fn recent_orders_by_updated_desc_then_path_and_caps() {
2335        let dir = empty_store();
2336        let root = dir.path();
2337        // newest
2338        write(
2339            root,
2340            "records/meetings/2026/05/c.md",
2341            &content_md("2026-05-03T00:00:00Z"),
2342        );
2343        // tie on updated — path asc decides (a before b)
2344        write(
2345            root,
2346            "records/meetings/2026/05/a.md",
2347            &content_md("2026-05-02T00:00:00Z"),
2348        );
2349        write(
2350            root,
2351            "records/meetings/2026/05/b.md",
2352            &content_md("2026-05-02T00:00:00Z"),
2353        );
2354        // oldest
2355        write(
2356            root,
2357            "records/meetings/2026/04/z.md",
2358            &content_md("2026-04-01T00:00:00Z"),
2359        );
2360        let store = open(&dir);
2361
2362        let all = rels(
2363            &store
2364                .recent_in_type_folder(Path::new("records/meetings"), 10)
2365                .unwrap(),
2366        );
2367        assert_eq!(
2368            all,
2369            vec![
2370                "records/meetings/2026/05/c.md".to_string(), // newest
2371                "records/meetings/2026/05/a.md".to_string(), // tie, path asc
2372                "records/meetings/2026/05/b.md".to_string(),
2373                "records/meetings/2026/04/z.md".to_string(), // oldest
2374            ]
2375        );
2376
2377        // Cap takes the n most-recent.
2378        let top2 = rels(
2379            &store
2380                .recent_in_type_folder(Path::new("records/meetings"), 2)
2381                .unwrap(),
2382        );
2383        assert_eq!(
2384            top2,
2385            vec![
2386                "records/meetings/2026/05/c.md".to_string(),
2387                "records/meetings/2026/05/a.md".to_string(),
2388            ]
2389        );
2390    }
2391
2392    #[test]
2393    fn recent_sorts_undated_files_last() {
2394        let dir = empty_store();
2395        let root = dir.path();
2396        write(
2397            root,
2398            "records/contacts/dated.md",
2399            &content_md("2026-05-01T00:00:00Z"),
2400        );
2401        // No `updated` field at all.
2402        write(
2403            root,
2404            "records/contacts/undated.md",
2405            "---\ntype: contact\nsummary: x\n---\nbody\n",
2406        );
2407        let store = open(&dir);
2408        let got = rels(
2409            &store
2410                .recent_in_type_folder(Path::new("records/contacts"), 10)
2411                .unwrap(),
2412        );
2413        assert_eq!(
2414            got,
2415            vec![
2416                "records/contacts/dated.md".to_string(),
2417                "records/contacts/undated.md".to_string(),
2418            ],
2419            "a file with a real `updated` must outrank one with none"
2420        );
2421    }
2422
2423    // ── type_shards ──────────────────────────────────────────────────────────
2424
2425    #[test]
2426    fn type_shards_classification() {
2427        let dir = empty_store();
2428        let store = open(&dir);
2429        for t in [
2430            "email",
2431            "transcript",
2432            "pdf-source",
2433            "expense",
2434            "invoice",
2435            "meeting",
2436            "order",
2437            "ticket",
2438            "transaction",
2439        ] {
2440            assert!(store.type_shards(t), "{t} should shard");
2441        }
2442        for t in [
2443            "contact", "company", "decision", "profile", "index", "log", "db-md", "proposal",
2444        ] {
2445            assert!(!store.type_shards(t), "{t} should stay flat");
2446        }
2447    }
2448
2449    #[test]
2450    fn type_shards_respects_schema_directive_both_directions() {
2451        use crate::parser::{Config, Schema};
2452        let dir = empty_store();
2453        let mut store = open(&dir);
2454        let mut config = Config::default();
2455        // A CUSTOM type (not in the built-in list) opts into date-sharding —
2456        // without the schema override `type_shards` would return false for it.
2457        config.schemas.insert(
2458            "shipment".to_string(),
2459            Schema {
2460                shard: Some(true),
2461                ..Schema::default()
2462            },
2463        );
2464        // A BUILT-IN event type opts OUT (flat) — the override wins over the
2465        // built-in default.
2466        config.schemas.insert(
2467            "expense".to_string(),
2468            Schema {
2469                shard: Some(false),
2470                ..Schema::default()
2471            },
2472        );
2473        // A schema with no `shard:` directive leaves the built-in default intact.
2474        config
2475            .schemas
2476            .insert("meeting".to_string(), Schema::default());
2477        store.config = config;
2478
2479        assert!(
2480            store.type_shards("shipment"),
2481            "custom type with `shard: by-date` must shard"
2482        );
2483        assert!(
2484            !store.type_shards("expense"),
2485            "built-in event type with `shard: flat` must go flat"
2486        );
2487        assert!(
2488            store.type_shards("meeting"),
2489            "schema without a `shard:` directive keeps the built-in default"
2490        );
2491        assert!(
2492            !store.type_shards("contact"),
2493            "unconfigured entity type stays flat"
2494        );
2495    }
2496
2497    // ── year_month_from_str ──────────────────────────────────────────────────
2498
2499    #[test]
2500    fn year_month_from_str_accepts_unpadded_month() {
2501        // A single-digit month shards to the same zero-padded folder as its twin,
2502        // matching the lenient `date`-shape validator (chrono `%Y-%m-%d`).
2503        let ym = year_month_from_str;
2504        assert_eq!(
2505            ym("2026-1-15"),
2506            Some(("2026".to_string(), "01".to_string())),
2507        );
2508        assert_eq!(
2509            ym("2026-01-15"),
2510            Some(("2026".to_string(), "01".to_string())),
2511        );
2512        assert_eq!(
2513            ym("2026-12-5"),
2514            Some(("2026".to_string(), "12".to_string())),
2515        );
2516        assert_eq!(ym("2026-1"), Some(("2026".to_string(), "01".to_string())));
2517        // Full timestamps still parse off the leading date.
2518        assert_eq!(
2519            ym("2026-3-22T10:00:00-07:00"),
2520            Some(("2026".to_string(), "03".to_string())),
2521        );
2522    }
2523
2524    #[test]
2525    fn year_month_from_str_rejects_non_dates() {
2526        // Genuinely non-date input still returns None (behavior unchanged).
2527        assert_eq!(year_month_from_str(""), None);
2528        assert_eq!(year_month_from_str("not-a-date"), None);
2529        assert_eq!(year_month_from_str("2026"), None); // no month part
2530        assert_eq!(year_month_from_str("26-1-15"), None); // year not 4 digits
2531        assert_eq!(year_month_from_str("2026-13-01"), None); // month out of range
2532        assert_eq!(year_month_from_str("2026-0-01"), None); // month zero
2533        assert_eq!(year_month_from_str("2026-001-01"), None); // month over 2 digits
2534        assert_eq!(year_month_from_str("2026-x-01"), None); // non-numeric month
2535        assert_eq!(year_month_from_str("20a6-1-15"), None); // non-numeric year
2536    }
2537
2538    #[test]
2539    fn shard_path_accepts_unpadded_month_same_as_padded() {
2540        // End-to-end: an unpadded `date` shards to its real month, identically to
2541        // its zero-padded twin — not to the `created`-fallback month.
2542        let dir = empty_store();
2543        let store = open(&dir);
2544
2545        let padded = store
2546            .shard_path_for("expense", &fm_with_extra("date", "2026-01-15"), "padded")
2547            .unwrap();
2548        assert_eq!(padded, PathBuf::from("records/expenses/2026/01/padded.md"));
2549
2550        let single = store
2551            .shard_path_for("expense", &fm_with_extra("date", "2026-1-15"), "single")
2552            .unwrap();
2553        assert_eq!(single, PathBuf::from("records/expenses/2026/01/single.md"));
2554    }
2555
2556    // ── shard_path_for ───────────────────────────────────────────────────────
2557
2558    fn fm_with_extra(key: &str, value: &str) -> Frontmatter {
2559        let mut fm = Frontmatter::default();
2560        fm.extra.insert(
2561            key.to_string(),
2562            serde_norway::Value::String(value.to_string()),
2563        );
2564        fm
2565    }
2566
2567    fn fm_with_created(rfc3339: &str) -> Frontmatter {
2568        Frontmatter {
2569            created: Some(DateTime::parse_from_rfc3339(rfc3339).unwrap()),
2570            ..Default::default()
2571        }
2572    }
2573
2574    #[test]
2575    fn shard_path_uses_primary_date_field_per_type() {
2576        let dir = empty_store();
2577        let store = open(&dir);
2578
2579        // expense.date → records/expenses/<YYYY>/<MM>/
2580        let p = store
2581            .shard_path_for("expense", &fm_with_extra("date", "2026-05-22"), "lunch")
2582            .unwrap();
2583        assert_eq!(p, PathBuf::from("records/expenses/2026/05/lunch.md"));
2584
2585        // email.date → sources/emails/<YYYY>/<MM>/
2586        let p = store
2587            .shard_path_for(
2588                "email",
2589                &fm_with_extra("date", "2026-11-02T09:00:00-07:00"),
2590                "e1",
2591            )
2592            .unwrap();
2593        assert_eq!(p, PathBuf::from("sources/emails/2026/11/e1.md"));
2594
2595        // transcript.recorded_at → sources/transcripts/<YYYY>/<MM>/
2596        let p = store
2597            .shard_path_for(
2598                "transcript",
2599                &fm_with_extra("recorded_at", "2025-01-15T12:00:00Z"),
2600                "t1",
2601            )
2602            .unwrap();
2603        assert_eq!(p, PathBuf::from("sources/transcripts/2025/01/t1.md"));
2604    }
2605
2606    #[test]
2607    fn shard_path_falls_back_to_created() {
2608        let dir = empty_store();
2609        let store = open(&dir);
2610        // meeting with no `date` field but a `created` timestamp.
2611        let p = store
2612            .shard_path_for(
2613                "meeting",
2614                &fm_with_created("2024-07-09T08:30:00-04:00"),
2615                "sync",
2616            )
2617            .unwrap();
2618        assert_eq!(p, PathBuf::from("records/meetings/2024/07/sync.md"));
2619    }
2620
2621    #[test]
2622    fn shard_path_primary_field_wins_over_created() {
2623        let dir = empty_store();
2624        let store = open(&dir);
2625        let mut fm = fm_with_created("2020-01-01T00:00:00Z");
2626        fm.extra.insert(
2627            "date".into(),
2628            serde_norway::Value::String("2026-05-22".into()),
2629        );
2630        let p = store.shard_path_for("expense", &fm, "x").unwrap();
2631        // The primary `date` (2026/05), not `created` (2020/01), drives the shard.
2632        assert_eq!(p, PathBuf::from("records/expenses/2026/05/x.md"));
2633    }
2634
2635    #[test]
2636    fn shard_path_flat_types_have_no_shard_segment() {
2637        let dir = empty_store();
2638        let store = open(&dir);
2639        // A contact has a `created` date, but contacts stay flat.
2640        let p = store
2641            .shard_path_for(
2642                "contact",
2643                &fm_with_created("2026-05-22T00:00:00Z"),
2644                "sarah-chen",
2645            )
2646            .unwrap();
2647        assert_eq!(p, PathBuf::from("records/contacts/sarah-chen.md"));
2648
2649        // A conclusion `profile` is a custom (non-built-in) type: it is flat (no
2650        // date shard) and lands under the records-layer fallback folder
2651        // `records/<type>` — `records/profile/<name>.md`, a conforming 3-component
2652        // `<layer>/<type-folder>/<file>` path. A 2-component path would be
2653        // invisible to the index/validate type-folder model.
2654        let p = store
2655            .shard_path_for("profile", &Frontmatter::default(), "renewal-theme")
2656            .unwrap();
2657        assert_eq!(p, PathBuf::from("records/profile/renewal-theme.md"));
2658    }
2659
2660    /// Regression: a type written through the toolkit's own path computation
2661    /// must land at a path the index + validate type-folder model accepts. A
2662    /// 2-component `<layer>/<file>` path is one `type_folder_of` (in both `index`
2663    /// and `validate`) treats as "no type-folder" — it would either crash
2664    /// `Index::on_write` (it tried to create `index.md` inside a file) or be
2665    /// silently dropped from every catalog by `Index::rebuild_all`. A custom
2666    /// (non-built-in) type like a conclusion `profile` falls back to
2667    /// `records/<type>` — still a conforming 3-component
2668    /// `<layer>/<type-folder>/<file>` path.
2669    #[test]
2670    fn shard_path_custom_type_is_indexable_three_component_path() {
2671        let dir = empty_store();
2672        let store = open(&dir);
2673        let p = store
2674            .shard_path_for("profile", &Frontmatter::default(), "renewal-theme")
2675            .unwrap();
2676        // First two components are a layer + a non-empty type-folder segment;
2677        // the file is the third. This is exactly the shape `type_folder_of`
2678        // (`comps.len() >= 3`, `comps[0]` a known layer) requires.
2679        let comps: Vec<&str> = p.iter().filter_map(|c| c.to_str()).collect();
2680        assert_eq!(
2681            comps.len(),
2682            3,
2683            "custom-type path must be <layer>/<type-folder>/<file>, got {p:?}"
2684        );
2685        assert_eq!(
2686            comps[0], "records",
2687            "first component must be the records layer (a custom type is \
2688             filed under the records fallback)"
2689        );
2690        assert!(
2691            !comps[1].is_empty() && comps[1] != "renewal-theme.md",
2692            "second component must be a real type-folder, not the file: {p:?}"
2693        );
2694        assert!(
2695            comps[2].ends_with(".md"),
2696            "third component must be the .md file: {p:?}"
2697        );
2698    }
2699
2700    #[test]
2701    fn shard_path_preserves_and_adds_md_extension() {
2702        let dir = empty_store();
2703        let store = open(&dir);
2704        let with = store
2705            .shard_path_for("contact", &Frontmatter::default(), "sarah.md")
2706            .unwrap();
2707        let without = store
2708            .shard_path_for("contact", &Frontmatter::default(), "sarah")
2709            .unwrap();
2710        assert_eq!(with, PathBuf::from("records/contacts/sarah.md"));
2711        assert_eq!(without, PathBuf::from("records/contacts/sarah.md"));
2712    }
2713
2714    #[test]
2715    fn shard_path_errors_when_sharding_type_has_no_date() {
2716        let dir = empty_store();
2717        let store = open(&dir);
2718        // expense shards, but no `date` and no `created` → NoShardDate.
2719        let err = store
2720            .shard_path_for("expense", &Frontmatter::default(), "mystery")
2721            .unwrap_err();
2722        match err {
2723            StoreError::NoShardDate { file } => {
2724                assert_eq!(file, PathBuf::from("records/expenses/mystery.md"));
2725            }
2726            other => panic!("expected NoShardDate, got {other:?}"),
2727        }
2728    }
2729
2730    // ── find_links_to ────────────────────────────────────────────────────────
2731
2732    #[test]
2733    fn find_links_to_matches_all_accepted_spellings() {
2734        let dir = empty_store();
2735        let root = dir.path();
2736        let target = "records/contacts/sarah-chen";
2737
2738        // Plain link.
2739        write(
2740            root,
2741            "records/profiles/sarah.md",
2742            &format!(
2743                "---\ntype: profile\nmeta-type: conclusion\nsummary: s\n---\nSee [[{target}]].\n"
2744            ),
2745        );
2746        // Link with display text.
2747        write(
2748            root,
2749            "records/meetings/2026/05/m.md",
2750            &format!("---\ntype: meeting\nsummary: s\n---\nWith [[{target}|Sarah]].\n"),
2751        );
2752        // Link with .md extension (accepted, warned by validate).
2753        write(
2754            root,
2755            "records/concepts/t.md",
2756            &format!(
2757                "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\n[[{target}.md]]\n"
2758            ),
2759        );
2760        // A catalog/index file also contains the link literally — included.
2761        write(
2762            root,
2763            "records/contacts/index.md",
2764            &format!("---\ntype: index\n---\n- [[{target}]] — Sarah\n"),
2765        );
2766        // No link to the target.
2767        write(
2768            root,
2769            "records/profiles/elena.md",
2770            "---\ntype: profile\nmeta-type: conclusion\nsummary: s\n---\nNo links here.\n",
2771        );
2772        // Short-form link must NOT match the full-path target.
2773        write(
2774            root,
2775            "records/profiles/bob.md",
2776            "---\ntype: profile\nmeta-type: conclusion\nsummary: s\n---\n[[sarah-chen]]\n",
2777        );
2778        // A longer path that merely starts with the target must NOT match
2779        // (boundary correctness): target `sarah-chen` vs `sarah-chen-jr`.
2780        write(
2781            root,
2782            "records/profiles/jr.md",
2783            &format!(
2784                "---\ntype: profile\nmeta-type: conclusion\nsummary: s\n---\n[[{target}-jr]]\n"
2785            ),
2786        );
2787
2788        let store = open(&dir);
2789        let got = rels(&store.find_links_to(Path::new(target)).unwrap());
2790        assert_eq!(
2791            got,
2792            vec![
2793                "records/concepts/t.md".to_string(),
2794                "records/contacts/index.md".to_string(),
2795                "records/meetings/2026/05/m.md".to_string(),
2796                "records/profiles/sarah.md".to_string(),
2797            ]
2798        );
2799    }
2800
2801    #[test]
2802    fn find_links_to_distinguishes_sibling_paths() {
2803        // Two contacts whose paths share a prefix; a link to one must not be
2804        // reported as a link to the other.
2805        let dir = empty_store();
2806        let root = dir.path();
2807        write(
2808            root,
2809            "records/concepts/a.md",
2810            "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\n[[records/contacts/sarah]]\n",
2811        );
2812        write(
2813            root,
2814            "records/concepts/b.md",
2815            "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\n[[records/contacts/sarah-chen]]\n",
2816        );
2817        let store = open(&dir);
2818
2819        assert_eq!(
2820            rels(
2821                &store
2822                    .find_links_to(Path::new("records/contacts/sarah"))
2823                    .unwrap()
2824            ),
2825            vec!["records/concepts/a.md".to_string()]
2826        );
2827        assert_eq!(
2828            rels(
2829                &store
2830                    .find_links_to(Path::new("records/contacts/sarah-chen"))
2831                    .unwrap()
2832            ),
2833            vec!["records/concepts/b.md".to_string()]
2834        );
2835    }
2836
2837    #[test]
2838    fn regression_find_links_to_tolerates_invalid_utf8_on_a_matched_line() {
2839        // Regression: a `.md` file can carry a stray non-UTF-8 byte on the SAME
2840        // line as a `[[target]]` link (a verbatim-ingested `sources/` artifact,
2841        // e.g. a mis-decoded Latin-1 import). The scan must still report the
2842        // link — `find_links_to` / `find_links_to_any` (and `graph backlinks` +
2843        // the working-set validate incoming-linker pass) must not error out and
2844        // drop the legitimate UTF-8 linkers. The content scan reads the file
2845        // with `String::from_utf8_lossy`, so the invalid byte becomes a
2846        // replacement char and the ASCII `[[target]]` link is still extracted.
2847        let dir = empty_store();
2848        let root = dir.path();
2849        let target = "records/contacts/sarah-chen";
2850
2851        // A clean, fully-UTF-8 linker that MUST be returned regardless.
2852        write(
2853            root,
2854            "records/profiles/clean.md",
2855            &format!(
2856                "---\ntype: profile\nmeta-type: conclusion\nsummary: s\n---\nSee [[{target}]].\n"
2857            ),
2858        );
2859
2860        // A linker whose link line ALSO carries a stray 0xFF byte (a mis-decoded
2861        // Latin-1 import). Write raw bytes so the invalid byte survives — a
2862        // `&str` fixture could not express it. The byte-level regex still
2863        // matches `[[target]]` on this line; pre-fix the UTF8 sink aborted here.
2864        let mut bytes: Vec<u8> =
2865            b"---\ntype: email\nsummary: s\n---\nSee [[records/contacts/sarah-chen]] \xFF here\n"
2866                .to_vec();
2867        let dirty_abs = root.join("sources/emails/2026/05/raw.md");
2868        fs::create_dir_all(dirty_abs.parent().unwrap()).unwrap();
2869        fs::write(&dirty_abs, &bytes).unwrap();
2870        // Defensive: confirm the fixture really is invalid UTF-8 (so the test
2871        // exercises the bug, not a coincidentally-valid file).
2872        assert!(
2873            std::str::from_utf8(&bytes).is_err(),
2874            "fixture must contain invalid UTF-8 to exercise the regression"
2875        );
2876        bytes.clear();
2877
2878        let store = open(&dir);
2879        let got = rels(
2880            &store
2881                .find_links_to(Path::new(target))
2882                .expect("a stray non-UTF-8 byte must not abort the backlink scan"),
2883        );
2884        assert_eq!(
2885            got,
2886            vec![
2887                "records/profiles/clean.md".to_string(),
2888                "sources/emails/2026/05/raw.md".to_string(),
2889            ],
2890            "both the clean linker and the one with an invalid byte on the link \
2891             line are reported; the scan degrades, it does not fail"
2892        );
2893    }
2894
2895    // ── find_links_to_any (batch — the O(changed × store) fix) ─────────────────
2896
2897    /// The working-set validate's incoming-linker discovery runs through
2898    /// `find_links_to_any` over the WHOLE changed set in one pass. This pins the
2899    /// batch contract that makes that single-pass behavior correct: the result is
2900    /// the union of incoming linkers across every target, with per-target
2901    /// boundary correctness preserved (no alternation arm bleeds into a
2902    /// prefix-sharing sibling). If a regression reverts the batch finder to a
2903    /// per-object loop, the union below would still hold — but the boundary +
2904    /// union-equivalence assertions are what guard the *correctness* of folding N
2905    /// scans into one regex.
2906    #[test]
2907    fn find_links_to_any_returns_the_union_with_boundary_correctness() {
2908        let dir = empty_store();
2909        let root = dir.path();
2910
2911        // Two distinct targets, each with its own linker.
2912        write(
2913            root,
2914            "records/concepts/links-sarah.md",
2915            "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\n[[records/contacts/sarah-chen]]\n",
2916        );
2917        write(
2918            root,
2919            "records/concepts/links-acme.md",
2920            "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\nDeal with [[records/companies/acme|Acme]].\n",
2921        );
2922        // One file links to BOTH targets — must appear exactly once (deduped),
2923        // proving the per-file early-exit folds multiple-target hits into a
2924        // single result row rather than one row per matched target.
2925        write(
2926            root,
2927            "records/meetings/2026/05/m.md",
2928            "---\ntype: meeting\nsummary: s\n---\n[[records/contacts/sarah-chen]] re \
2929             [[records/companies/acme]]\n",
2930        );
2931        // A prefix-sharing sibling of a target: a link to `sarah-chen-jr` must NOT
2932        // be reported as a link to `sarah-chen` even though the alternation now
2933        // carries `sarah-chen` as one arm.
2934        write(
2935            root,
2936            "records/concepts/links-jr.md",
2937            "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\n[[records/contacts/sarah-chen-jr]]\n",
2938        );
2939        // A file that links to neither requested target.
2940        write(
2941            root,
2942            "records/concepts/unrelated.md",
2943            "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\n[[records/concepts/spend]]\n",
2944        );
2945
2946        let store = open(&dir);
2947        let targets = vec![
2948            PathBuf::from("records/contacts/sarah-chen"),
2949            PathBuf::from("records/companies/acme"),
2950        ];
2951
2952        let got = rels(&store.find_links_to_any(&targets).unwrap());
2953        assert_eq!(
2954            got,
2955            vec![
2956                "records/concepts/links-acme.md".to_string(),
2957                "records/concepts/links-sarah.md".to_string(),
2958                "records/meetings/2026/05/m.md".to_string(),
2959            ],
2960            "batch finder must return the deduped union of linkers across all \
2961             targets, excluding the prefix-sibling and the unrelated file"
2962        );
2963
2964        // Equivalence: the batch result must equal the union of the per-target
2965        // single finder. This is the property the working-set path relies on
2966        // when it folds one-scan-per-object into one scan for the whole set.
2967        let mut union: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
2968        for t in &targets {
2969            for linker in store.find_links_to(t).unwrap() {
2970                union.insert(linker);
2971            }
2972        }
2973        assert_eq!(
2974            rels(&union.into_iter().collect::<Vec<_>>()),
2975            got,
2976            "find_links_to_any must equal the union of per-target find_links_to"
2977        );
2978    }
2979
2980    /// An empty target set must scan nothing and find nothing — and crucially
2981    /// must NOT compile to a match-everything empty regex (which would report
2982    /// every `.md` as a linker). This is the empty-working-set fast path the
2983    /// `validate` loop hits when nothing changed.
2984    #[test]
2985    fn find_links_to_any_empty_targets_matches_nothing() {
2986        let dir = empty_store();
2987        let root = dir.path();
2988        write(
2989            root,
2990            "records/concepts/a.md",
2991            "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\n[[records/contacts/sarah-chen]]\n",
2992        );
2993        let store = open(&dir);
2994
2995        assert!(
2996            store.find_links_to_any(&[]).unwrap().is_empty(),
2997            "no targets ⇒ no linkers (an empty pattern must not match every file)"
2998        );
2999        // A set of only empty/non-link targets is likewise a no-op, not a
3000        // match-everything.
3001        assert!(
3002            store
3003                .find_links_to_any(&[PathBuf::from(""), PathBuf::from("./")])
3004                .unwrap()
3005                .is_empty(),
3006            "targets that render to empty link text contribute no alternation arm"
3007        );
3008    }
3009
3010    // ── read_type_index ──────────────────────────────────────────────────────
3011
3012    #[test]
3013    fn read_type_index_parses_records_and_flattens_fields() {
3014        let dir = empty_store();
3015        let root = dir.path();
3016        let jsonl = "\
3017{\"path\":\"records/expenses/2026/05/a.md\",\"type\":\"expense\",\"summary\":\"lunch\",\"tags\":[\"meals\"],\"links\":[\"records/companies/acme\"],\"created\":\"2026-05-01T00:00:00Z\",\"updated\":\"2026-05-01T00:00:00Z\",\"vendor\":\"acme\",\"amount\":42}
3018{\"path\":\"records/expenses/2026/05/b.md\",\"type\":\"expense\",\"summary\":\"taxi\",\"created\":null,\"updated\":null,\"vendor\":\"yellow\"}
3019";
3020        let p = write(root, "records/expenses/index.jsonl", jsonl);
3021        let store = open(&dir);
3022        let recs = store.read_type_index(&store.abs_path(&p)).unwrap();
3023
3024        assert_eq!(recs.len(), 2);
3025        // Sorted by path asc.
3026        assert_eq!(recs[0].path, PathBuf::from("records/expenses/2026/05/a.md"));
3027        assert_eq!(recs[0].type_, "expense");
3028        assert_eq!(recs[0].summary, "lunch");
3029        assert_eq!(recs[0].tags, vec!["meals".to_string()]);
3030        assert_eq!(recs[0].links, vec!["records/companies/acme".to_string()]);
3031        assert!(recs[0].created.is_some());
3032        // Extra (non-typed) frontmatter flattens into `fields`.
3033        assert_eq!(
3034            recs[0].fields.get("vendor"),
3035            Some(&serde_json::json!("acme"))
3036        );
3037        assert_eq!(recs[0].fields.get("amount"), Some(&serde_json::json!(42)));
3038        // Defaults: missing tags/links → empty.
3039        assert!(recs[1].tags.is_empty());
3040        assert!(recs[1].links.is_empty());
3041    }
3042
3043    #[test]
3044    fn read_type_index_last_write_wins_and_skips_blanks() {
3045        let dir = empty_store();
3046        let root = dir.path();
3047        // Same path twice; the second line supersedes the first. A blank line
3048        // in between must be ignored, not error.
3049        let jsonl = "\
3050{\"path\":\"records/contacts/sarah.md\",\"type\":\"contact\",\"summary\":\"old\",\"created\":null,\"updated\":null}
3051
3052{\"path\":\"records/contacts/sarah.md\",\"type\":\"contact\",\"summary\":\"new\",\"created\":null,\"updated\":null}
3053";
3054        let p = write(root, "records/contacts/index.jsonl", jsonl);
3055        let store = open(&dir);
3056        let recs = store.read_type_index(&store.abs_path(&p)).unwrap();
3057        assert_eq!(recs.len(), 1, "duplicate path collapses to one record");
3058        assert_eq!(recs[0].summary, "new", "later line must win");
3059    }
3060
3061    #[test]
3062    fn read_type_index_errors_on_malformed_line() {
3063        let dir = empty_store();
3064        let root = dir.path();
3065        let p = write(root, "records/contacts/index.jsonl", "{not valid json}\n");
3066        let store = open(&dir);
3067        let err = store.read_type_index(&store.abs_path(&p)).unwrap_err();
3068        assert!(matches!(err, StoreError::BadTypeIndex { .. }));
3069    }
3070
3071    // ── find_by_type / find_by_where ─────────────────────────────────────────
3072
3073    fn jsonl_line(path: &str, type_: &str, summary: &str, extra: &str) -> String {
3074        format!(
3075            "{{\"path\":\"{path}\",\"type\":\"{type_}\",\"summary\":\"{summary}\",\"created\":null,\"updated\":null{extra}}}\n"
3076        )
3077    }
3078
3079    #[test]
3080    fn find_by_type_reads_canonical_folder_sidecar() {
3081        let dir = empty_store();
3082        let root = dir.path();
3083        // Canonical folder for `contact` is records/contacts.
3084        write(
3085            root,
3086            "records/contacts/index.jsonl",
3087            &(jsonl_line("records/contacts/sarah.md", "contact", "Sarah", "")
3088                + &jsonl_line("records/contacts/elena.md", "contact", "Elena", "")),
3089        );
3090        // A different type's sidecar must not leak into a contact query.
3091        write(
3092            root,
3093            "records/companies/index.jsonl",
3094            &jsonl_line("records/companies/acme.md", "company", "Acme", ""),
3095        );
3096        let store = open(&dir);
3097        let recs = store.find_by_type("contact").unwrap();
3098        let names: Vec<_> = recs.iter().map(|r| r.summary.clone()).collect();
3099        assert_eq!(names, vec!["Elena".to_string(), "Sarah".to_string()]); // path-sorted
3100        assert!(recs.iter().all(|r| r.type_ == "contact"));
3101    }
3102
3103    #[test]
3104    fn regression_find_by_type_includes_non_canonical_folder_when_canonical_exists() {
3105        // Regression for the silent-incompleteness bug: once the canonical
3106        // type-folder sidecar exists, `find_by_type` used to read ONLY that
3107        // sidecar and drop same-type records filed in a non-canonical folder in
3108        // the SAME layer — so the result flipped to incomplete the moment a
3109        // canonical record was added. The write path actively enables such a
3110        // layout (`records/clients/` for a `contact`, any `records/<folder>/`
3111        // for a conclusion `profile`), so this is a reachable, dedup-breaking
3112        // omission.
3113        let dir = empty_store();
3114        let root = dir.path();
3115
3116        // CANONICAL folder sidecar exists (`records/contacts/` for `contact`),
3117        // which is exactly the condition that triggered the bug.
3118        write(
3119            root,
3120            "records/contacts/index.jsonl",
3121            &jsonl_line("records/contacts/sarah.md", "contact", "Sarah", ""),
3122        );
3123        // A `contact` filed in a NON-canonical folder within the same (Records)
3124        // layer. Pre-fix this was silently dropped because the canonical
3125        // sidecar existed; it must now come back.
3126        write(
3127            root,
3128            "records/clients/index.jsonl",
3129            &jsonl_line("records/clients/elena.md", "contact", "Elena", ""),
3130        );
3131        // A different type in the same layer must NOT leak in (proves the read
3132        // is type-filtered, not just a blind whole-layer dump).
3133        write(
3134            root,
3135            "records/companies/index.jsonl",
3136            &jsonl_line("records/companies/acme.md", "company", "Acme", ""),
3137        );
3138
3139        let store = open(&dir);
3140        let got: std::collections::BTreeSet<String> = store
3141            .find_by_type("contact")
3142            .unwrap()
3143            .into_iter()
3144            .map(|r| r.path.to_string_lossy().into_owned())
3145            .collect();
3146        assert_eq!(
3147            got,
3148            ["records/clients/elena.md", "records/contacts/sarah.md"]
3149                .into_iter()
3150                .map(String::from)
3151                .collect::<std::collections::BTreeSet<_>>(),
3152            "both the canonical-folder and the non-canonical-folder contact must \
3153             be returned; the company record must be excluded"
3154        );
3155    }
3156
3157    #[test]
3158    fn regression_find_by_type_profile_spans_multiple_topic_folders() {
3159        // Regression for the scoped-backlinks variant of the same bug
3160        // (`graph backlinks --type <conclusion-type>`): a conclusion type like
3161        // `profile` has the canonical fallback folder `records/profile`, but the
3162        // agent may file profiles under ANY records topic folder
3163        // (`records/people/`, `records/clients/`, …). With a
3164        // `records/profile/index.jsonl` present, the old code read only that
3165        // folder and dropped profiles in the other topic folders —
3166        // under-reporting dependents in a blast-radius check. The
3167        // whole-`records/`-layer read must surface all of them.
3168        let dir = empty_store();
3169        let root = dir.path();
3170        write(
3171            root,
3172            "records/profile/index.jsonl",
3173            &jsonl_line("records/profile/billing.md", "profile", "Billing", ""),
3174        );
3175        write(
3176            root,
3177            "records/people/index.jsonl",
3178            &jsonl_line("records/people/sarah-chen.md", "profile", "Sarah Chen", ""),
3179        );
3180        write(
3181            root,
3182            "records/clients/index.jsonl",
3183            &jsonl_line("records/clients/atlas.md", "profile", "Atlas", ""),
3184        );
3185
3186        let store = open(&dir);
3187        let got: std::collections::BTreeSet<String> = store
3188            .find_by_type("profile")
3189            .unwrap()
3190            .into_iter()
3191            .map(|r| r.path.to_string_lossy().into_owned())
3192            .collect();
3193        assert_eq!(
3194            got,
3195            [
3196                "records/clients/atlas.md",
3197                "records/people/sarah-chen.md",
3198                "records/profile/billing.md",
3199            ]
3200            .into_iter()
3201            .map(String::from)
3202            .collect::<std::collections::BTreeSet<_>>(),
3203            "a profile query must return records from every topic folder, not \
3204             just the canonical records/profile/"
3205        );
3206    }
3207
3208    #[test]
3209    fn find_by_type_canonical_absent_falls_back_within_the_layer_only() {
3210        let dir = empty_store();
3211        let root = dir.path();
3212        // A custom `proposal` record filed under a non-canonical folder NAME
3213        // (the natural plural `records/proposals/`) inside the records layer.
3214        // `default_type_folder("proposal")` = `records/proposal` (bare type, no
3215        // pluralization guess), so the canonical sidecar does not exist and
3216        // `find_by_type` falls back. The fallback is bounded to the type's
3217        // layer (records), so this record — same layer, non-canonical folder —
3218        // is still found: completeness within the layer holds.
3219        write(
3220            root,
3221            "records/proposals/index.jsonl",
3222            &jsonl_line("records/proposals/p1.md", "proposal", "Q3 proposal", ""),
3223        );
3224        // A DECOY of the SAME type sitting in a DIFFERENT layer (sources/). The
3225        // old whole-store fallback read every sidecar in the store and would
3226        // have leaked this into the result; the layer-bounded fallback must not.
3227        // It also pins that the fallback is O(entities-in-layer), never O(store).
3228        write(
3229            root,
3230            "sources/proposals/index.jsonl",
3231            &jsonl_line(
3232                "sources/proposals/leak.md",
3233                "proposal",
3234                "cross-layer decoy",
3235                "",
3236            ),
3237        );
3238        let store = open(&dir);
3239        let recs = store.find_by_type("proposal").unwrap();
3240        assert_eq!(
3241            recs.len(),
3242            1,
3243            "only the records-layer proposal, not the sources decoy"
3244        );
3245        assert_eq!(recs[0].summary, "Q3 proposal");
3246        assert_eq!(recs[0].path, PathBuf::from("records/proposals/p1.md"));
3247    }
3248
3249    #[test]
3250    fn find_by_type_canonical_absent_does_not_read_other_layers() {
3251        let dir = empty_store();
3252        let root = dir.path();
3253        // `email`'s canonical folder is `sources/emails` (layer Sources). No
3254        // sidecar there yet, so `find_by_type("email")` falls back — but only
3255        // within the Sources layer. A populated sidecar in the Records layer
3256        // must never be touched: the fallback is layer-bounded, not store-wide.
3257        // Under the old `read_all_type_indexes_in(None)` fallback this records
3258        // sidecar would have been read and filtered (wasted O(store) I/O); now
3259        // it is outside the walk root entirely.
3260        write(
3261            root,
3262            "records/contacts/index.jsonl",
3263            &jsonl_line("records/contacts/sarah.md", "contact", "Sarah", ""),
3264        );
3265        let store = open(&dir);
3266        // No email anywhere ⇒ empty, and the records layer was not in scope.
3267        assert!(store.find_by_type("email").unwrap().is_empty());
3268    }
3269
3270    #[test]
3271    fn find_by_where_matches_typed_columns_and_flat_fields() {
3272        let dir = empty_store();
3273        let root = dir.path();
3274        write(
3275            root,
3276            "records/expenses/index.jsonl",
3277            &(jsonl_line(
3278                "records/expenses/a.md",
3279                "expense",
3280                "lunch",
3281                ",\"vendor\":\"acme\",\"tags\":[\"meals\"]",
3282            ) + &jsonl_line(
3283                "records/expenses/b.md",
3284                "expense",
3285                "taxi",
3286                ",\"vendor\":\"yellow\"",
3287            )),
3288        );
3289        write(
3290            root,
3291            "records/contacts/index.jsonl",
3292            &jsonl_line(
3293                "records/contacts/sarah.md",
3294                "contact",
3295                "Sarah",
3296                ",\"tags\":[\"customer\"]",
3297            ),
3298        );
3299        let store = open(&dir);
3300
3301        // Flat field in `fields`.
3302        let by_vendor = store.find_by_where("vendor", "acme").unwrap();
3303        assert_eq!(by_vendor.len(), 1);
3304        assert_eq!(by_vendor[0].path, PathBuf::from("records/expenses/a.md"));
3305
3306        // Typed column: type (spans both expense records).
3307        assert_eq!(store.find_by_where("type", "expense").unwrap().len(), 2);
3308
3309        // Typed list column: tags membership.
3310        let customers = store.find_by_where("tags", "customer").unwrap();
3311        assert_eq!(customers.len(), 1);
3312        assert_eq!(
3313            customers[0].path,
3314            PathBuf::from("records/contacts/sarah.md")
3315        );
3316
3317        // No match → empty.
3318        assert!(store.find_by_where("vendor", "nobody").unwrap().is_empty());
3319    }
3320
3321    #[test]
3322    fn find_by_where_matches_timestamps_across_rfc3339_spellings() {
3323        let dir = empty_store();
3324        let root = dir.path();
3325        // db.md files most commonly carry the `Z` UTC spelling. The index.jsonl
3326        // serialized from such a file preserves it verbatim.
3327        write(
3328            root,
3329            "records/meetings/index.jsonl",
3330            "{\"path\":\"records/meetings/kickoff.md\",\"type\":\"meeting\",\
3331\"summary\":\"kickoff\",\"created\":\"2026-05-01T00:00:00Z\",\
3332\"updated\":\"2026-05-02T09:30:00-07:00\"}\n",
3333        );
3334        let store = open(&dir);
3335
3336        // The exact value an agent reads out of the file (`Z` form) must match.
3337        let by_z = store
3338            .find_by_where("created", "2026-05-01T00:00:00Z")
3339            .unwrap();
3340        assert_eq!(by_z.len(), 1);
3341        assert_eq!(by_z[0].path, PathBuf::from("records/meetings/kickoff.md"));
3342
3343        // The equivalent explicit-offset spelling of the same instant matches too.
3344        assert_eq!(
3345            store
3346                .find_by_where("created", "2026-05-01T00:00:00+00:00")
3347                .unwrap()
3348                .len(),
3349            1
3350        );
3351
3352        // A non-UTC stored value matches both its own offset spelling and the
3353        // same instant expressed as `Z` (instant comparison, not string compare).
3354        assert_eq!(
3355            store
3356                .find_by_where("updated", "2026-05-02T09:30:00-07:00")
3357                .unwrap()
3358                .len(),
3359            1
3360        );
3361        assert_eq!(
3362            store
3363                .find_by_where("updated", "2026-05-02T16:30:00Z")
3364                .unwrap()
3365                .len(),
3366            1
3367        );
3368
3369        // A different instant does not match.
3370        assert!(store
3371            .find_by_where("created", "2026-05-01T00:00:01Z")
3372            .unwrap()
3373            .is_empty());
3374        // A non-RFC3339 query value never matches a real timestamp.
3375        assert!(store
3376            .find_by_where("created", "2026-05-01")
3377            .unwrap()
3378            .is_empty());
3379    }
3380
3381    #[test]
3382    fn find_by_where_matches_floats_across_serialized_spellings() {
3383        // Adversarial review #5: a float field is stored in index.jsonl via
3384        // serde_json's canonical f64 render, which DISCARDS the file's source
3385        // spelling (`1234.00` -> `1234.0`, `1e3` -> `1000.0`). A textual compare
3386        // made the spelling a human reads in the file miss (and disagree with
3387        // free-text `search`); numeric compare fixes it. `query`
3388        // is the SPEC pre-write dedup primitive, so a miss here silently writes a
3389        // duplicate record.
3390        let dir = empty_store();
3391        let root = dir.path();
3392        write(
3393            root,
3394            "records/invoices/index.jsonl",
3395            "{\"path\":\"records/invoices/inv.md\",\"type\":\"invoice\",\
3396\"summary\":\"inv\",\"amount\":1234.0,\"score\":1000.0,\"count\":42}\n",
3397        );
3398        let store = open(&dir);
3399
3400        // Every spelling of the same numeric value matches the canonical-f64 store.
3401        for spelling in ["1234.00", "1234.0", "1234"] {
3402            assert_eq!(
3403                store.find_by_where("amount", spelling).unwrap().len(),
3404                1,
3405                "amount spelling `{spelling}` must match the stored 1234.0"
3406            );
3407        }
3408        for spelling in ["1e3", "1000", "1000.0"] {
3409            assert_eq!(
3410                store.find_by_where("score", spelling).unwrap().len(),
3411                1,
3412                "score spelling `{spelling}` must match the stored 1000.0"
3413            );
3414        }
3415        // A genuinely different value does not match.
3416        assert!(store.find_by_where("amount", "1234.5").unwrap().is_empty());
3417        // Integer fields keep exact textual matching (unaffected by the fix).
3418        assert_eq!(store.find_by_where("count", "42").unwrap().len(), 1);
3419    }
3420
3421    #[test]
3422    fn number_matches_is_numeric_for_floats_but_exact_for_integers() {
3423        use serde_json::Number;
3424        // Float-valued field: any equal spelling matches (the bug fix).
3425        let f: Number = serde_json::from_str("1234.0").unwrap();
3426        assert!(number_matches(&f, "1234.00"));
3427        assert!(number_matches(&f, "1234"));
3428        assert!(number_matches(&f, "1234.0"));
3429        assert!(!number_matches(&f, "1234.5"));
3430        // Integer-valued field: EXACT textual compare, never f64-rounded — two
3431        // adjacent large integers that round to the same f64 must NOT collide
3432        // (the safety property that motivates restricting numeric compare to
3433        // floats).
3434        let big: Number = serde_json::from_str("18446744073709551615").unwrap(); // u64::MAX
3435        assert!(number_matches(&big, "18446744073709551615"));
3436        assert!(!number_matches(&big, "18446744073709551614"));
3437    }
3438
3439    #[test]
3440    fn find_by_where_in_layer_reads_only_that_layers_sidecars() {
3441        // The O(entities-in-layer) contract: a layer-scoped where read must walk
3442        // ONLY the named layer's subtree. Proven structurally — a *malformed*
3443        // sidecar in another layer would make `read_type_index` error if it were
3444        // read, so a scoped read that succeeds (and excludes that record) is
3445        // proof the other layer's I/O never happened.
3446        let dir = empty_store();
3447        let root = dir.path();
3448        write(
3449            root,
3450            "records/companies/index.jsonl",
3451            &jsonl_line(
3452                "records/companies/acme.md",
3453                "company",
3454                "Acme",
3455                ",\"domain\":\"acme.com\"",
3456            ),
3457        );
3458        // Same field/value in the sources layer — but the sidecar is corrupt.
3459        write(
3460            root,
3461            "sources/emails/index.jsonl",
3462            "{ this is not valid json and would error if read }\n",
3463        );
3464        let store = open(&dir);
3465
3466        // Scoped to records: the corrupt sources sidecar is out of scope, so the
3467        // read succeeds and returns only the records-layer match.
3468        let in_records = store
3469            .find_by_where_in("domain", "acme.com", Some(Layer::Records))
3470            .expect("a records-scoped read must not touch the sources sidecar");
3471        assert_eq!(
3472            rels(
3473                &in_records
3474                    .iter()
3475                    .map(|r| r.path.clone())
3476                    .collect::<Vec<_>>()
3477            ),
3478            vec!["records/companies/acme.md".to_string()]
3479        );
3480
3481        // The store-wide read DOES reach the corrupt sidecar and surfaces it as
3482        // a parse error — confirming the corrupt file is genuinely in the tree
3483        // and that only the layer scope spares it.
3484        let store_wide = store.find_by_where("domain", "acme.com");
3485        assert!(
3486            matches!(store_wide, Err(StoreError::BadTypeIndex { .. })),
3487            "unscoped read walks every layer and hits the corrupt sidecar"
3488        );
3489
3490        // Scoping to the layer that holds only the corrupt sidecar still errors
3491        // (the scope includes it), proving the scope is a real subtree bound and
3492        // not a silent "skip anything that fails".
3493        let in_sources = store.find_by_where_in("domain", "acme.com", Some(Layer::Sources));
3494        assert!(matches!(in_sources, Err(StoreError::BadTypeIndex { .. })));
3495    }
3496
3497    #[test]
3498    fn find_by_where_in_missing_layer_is_empty_not_an_error() {
3499        // A layer-scoped read over a layer folder that does not exist yet must
3500        // return empty (mirrors `walk_layer`'s missing-dir guard), never a walk
3501        // error from `ignore` over a nonexistent path.
3502        let dir = empty_store();
3503        let root = dir.path();
3504        write(
3505            root,
3506            "records/contacts/index.jsonl",
3507            &jsonl_line(
3508                "records/contacts/sarah.md",
3509                "contact",
3510                "Sarah",
3511                ",\"city\":\"denver\"",
3512            ),
3513        );
3514        let store = open(&dir);
3515
3516        // `sources/` was never created.
3517        let in_sources = store
3518            .find_by_where_in("city", "denver", Some(Layer::Sources))
3519            .expect("missing layer subtree is empty, not an error");
3520        assert!(in_sources.is_empty());
3521
3522        // Same query scoped to the layer that has the record still finds it.
3523        let in_records = store
3524            .find_by_where_in("city", "denver", Some(Layer::Records))
3525            .unwrap();
3526        assert_eq!(in_records.len(), 1);
3527    }
3528
3529    // ── abs_path / rel_path ──────────────────────────────────────────────────
3530
3531    #[test]
3532    fn abs_and_rel_path_roundtrip() {
3533        let dir = empty_store();
3534        let store = open(&dir);
3535        let rel = Path::new("records/contacts/sarah.md");
3536        let abs = store.abs_path(rel);
3537        assert_eq!(abs, dir.path().join(rel));
3538        assert_eq!(store.rel_path(&abs).as_deref(), Some(rel));
3539
3540        // An absolute path is passed through unchanged by abs_path.
3541        assert_eq!(store.abs_path(&abs), abs);
3542
3543        // A path outside the store has no store-relative form.
3544        assert_eq!(store.rel_path(Path::new("/somewhere/else.md")), None);
3545    }
3546
3547    // ── infer_type_from_path (inverse of default_type_folder) ────────────────
3548
3549    #[test]
3550    fn infer_type_maps_every_recognized_folder_back_to_its_type() {
3551        let cases = [
3552            ("sources/emails/x.md", "email"),
3553            ("sources/transcripts/x.md", "transcript"),
3554            ("sources/docs/x.md", "pdf-source"),
3555            ("sources/notes/x.md", "note"),
3556            ("records/contacts/x.md", "contact"),
3557            ("records/companies/x.md", "company"),
3558            ("records/expenses/x.md", "expense"),
3559            ("records/meetings/x.md", "meeting"),
3560            ("records/decisions/x.md", "decision"),
3561            ("records/invoices/x.md", "invoice"),
3562        ];
3563        for (path, expected) in cases {
3564            assert_eq!(
3565                infer_type_from_path(Path::new(path)).as_deref(),
3566                Some(expected),
3567                "path {path} should infer type {expected}"
3568            );
3569        }
3570    }
3571
3572    #[test]
3573    fn infer_type_round_trips_with_default_type_folder() {
3574        // The canonical invariant: inference is the inverse of the forward map.
3575        // Every recognized type, routed through `default_type_folder` and then
3576        // back through `infer_type_from_path`, must return the original type.
3577        let recognized = [
3578            "email",
3579            "transcript",
3580            "pdf-source",
3581            "contact",
3582            "company",
3583            "expense",
3584            "meeting",
3585            "decision",
3586            "invoice",
3587        ];
3588        for type_ in recognized {
3589            let folder = default_type_folder(type_);
3590            let file = folder.join("x.md");
3591            assert_eq!(
3592                infer_type_from_path(&file).as_deref(),
3593                Some(type_),
3594                "recognized type {type_} (folder {folder:?}) must round-trip"
3595            );
3596        }
3597    }
3598
3599    #[test]
3600    fn infer_type_round_trips_custom_types_verbatim_no_singularization() {
3601        // Regression guard for the CLI/core divergence: `default_type_folder`'s
3602        // unrecognized fallback is the BARE type name (`task → records/task`,
3603        // `tasks → records/tasks`). Inference must NOT singularize, or a custom
3604        // type would not round-trip (e.g. `records/tasks` → `task` would clash
3605        // with `default_type_folder("task") → records/task`).
3606        for custom in ["task", "tasks", "playbook", "process", "okrs", "ticket"] {
3607            let folder = default_type_folder(custom);
3608            assert_eq!(folder, PathBuf::from("records").join(custom));
3609            let file = folder.join("x.md");
3610            assert_eq!(
3611                infer_type_from_path(&file).as_deref(),
3612                Some(custom),
3613                "custom type {custom} must round-trip verbatim (no singularization)"
3614            );
3615        }
3616
3617        // The specific case named in the finding: a plural custom folder keeps
3618        // its trailing `s`; it is NOT singularized to `task`.
3619        assert_eq!(
3620            infer_type_from_path(Path::new("records/tasks/x.md")).as_deref(),
3621            Some("tasks"),
3622            "records/tasks must infer `tasks`, not `task`"
3623        );
3624    }
3625
3626    #[test]
3627    fn infer_type_requires_three_component_layer_folder_file_shape() {
3628        // Fewer than 3 components: a file directly under a layer has no
3629        // type-folder, so inference yields None (matches the old CLI contract).
3630        assert_eq!(infer_type_from_path(Path::new("records/x.md")), None);
3631        assert_eq!(infer_type_from_path(Path::new("sources/x.md")), None);
3632        assert_eq!(infer_type_from_path(Path::new("x.md")), None);
3633        // Unknown leading layer is never inferred.
3634        assert_eq!(infer_type_from_path(Path::new("foo/bar/x.md")), None);
3635        // Deeper paths still infer from the first type-folder segment (e.g. a
3636        // sharded record under records/expenses/2026/05/x.md).
3637        assert_eq!(
3638            infer_type_from_path(Path::new("records/expenses/2026/05/x.md")).as_deref(),
3639            Some("expense"),
3640        );
3641    }
3642
3643    // ── ensure_path_within_store (containment) ───────────────────────────────
3644
3645    #[test]
3646    fn ensure_path_within_store_accepts_in_store_and_rejects_escape() {
3647        let dir = tempdir().unwrap();
3648        let root = dir.path();
3649        fs::create_dir_all(root.join("records/contacts")).unwrap();
3650        fs::write(root.join("records/contacts/sarah.md"), "x").unwrap();
3651
3652        // An existing in-store file resolves and is accepted.
3653        let inside = root.join("records/contacts/sarah.md");
3654        let got = ensure_path_within_store(root, &inside).expect("in-store path accepted");
3655        // Canonical, but still under the (canonical) root.
3656        assert!(got.starts_with(root.canonicalize().unwrap()));
3657
3658        // A not-yet-existing in-store leaf is accepted (rename destination).
3659        let new_leaf = root.join("records/contacts/sarah-chen.md");
3660        assert!(
3661            ensure_path_within_store(root, &new_leaf).is_ok(),
3662            "a non-existent in-store leaf must be accepted"
3663        );
3664
3665        // A `..`-escaping path is rejected even though its prefix exists.
3666        let escape = root.join("records/contacts/../../outside/secret.md");
3667        assert!(
3668            ensure_path_within_store(root, &escape).is_err(),
3669            "a `..`-escaping path must be rejected"
3670        );
3671    }
3672
3673    #[test]
3674    fn ensure_path_within_store_rejects_symlink_escape() {
3675        let dir = tempdir().unwrap();
3676        let root = dir.path().join("store");
3677        fs::create_dir_all(&root).unwrap();
3678        let outside_dir = dir.path().join("outside");
3679        fs::create_dir_all(&outside_dir).unwrap();
3680        let secret = outside_dir.join("secret.md");
3681        fs::write(&secret, "TOPSECRET").unwrap();
3682
3683        // A symlink inside the store that points OUTSIDE it must be rejected:
3684        // resolving the symlink lands outside the canonical root.
3685        #[cfg(unix)]
3686        {
3687            use std::os::unix::fs::symlink;
3688            let link = root.join("escape.md");
3689            symlink(&secret, &link).unwrap();
3690            assert!(
3691                ensure_path_within_store(&root, &link).is_err(),
3692                "a symlink resolving outside the store must be rejected"
3693            );
3694        }
3695    }
3696
3697    /// The amortized gate accepts and rejects exactly what the single-shot
3698    /// gate does — same resolved paths, same failures — across every candidate
3699    /// class: existing file (fast path), second file in the same folder
3700    /// (memoized parent), missing leaf (slow-path peel), `..` tail, symlink
3701    /// leaf escaping the store, and a symlinked PARENT dir escaping the store.
3702    #[test]
3703    fn store_containment_matches_single_shot_gate() {
3704        let dir = tempdir().unwrap();
3705        let root = dir.path().join("store");
3706        fs::create_dir_all(root.join("records/contacts")).unwrap();
3707        fs::write(root.join("records/contacts/sarah.md"), "x").unwrap();
3708        fs::write(root.join("records/contacts/jules.md"), "y").unwrap();
3709        let outside_dir = dir.path().join("outside");
3710        fs::create_dir_all(&outside_dir).unwrap();
3711        fs::write(outside_dir.join("secret.md"), "TOPSECRET").unwrap();
3712
3713        let mut gate = StoreContainment::new(&root).expect("root canonicalizes");
3714        let same = |cand: &Path, label: &str, gate: &mut StoreContainment| {
3715            let single = ensure_path_within_store(&root, cand);
3716            let amortized = gate.resolve(cand);
3717            match (single, amortized) {
3718                (Ok(a), Ok(b)) => assert_eq!(a, b, "{label}: resolved paths differ"),
3719                (Err(_), Err(_)) => {}
3720                (s, a) => panic!("{label}: verdicts differ — single-shot {s:?} vs amortized {a:?}"),
3721            }
3722        };
3723
3724        same(
3725            &root.join("records/contacts/sarah.md"),
3726            "existing file",
3727            &mut gate,
3728        );
3729        same(
3730            &root.join("records/contacts/jules.md"),
3731            "memoized parent",
3732            &mut gate,
3733        );
3734        same(
3735            &root.join("records/contacts/new-leaf.md"),
3736            "missing leaf",
3737            &mut gate,
3738        );
3739        same(
3740            &root.join("records/contacts/../../outside/secret.md"),
3741            "`..` tail",
3742            &mut gate,
3743        );
3744
3745        #[cfg(unix)]
3746        {
3747            use std::os::unix::fs::symlink;
3748            // Symlink LEAF out of the store: slow path, rejected by both.
3749            let link = root.join("records/contacts/escape.md");
3750            symlink(outside_dir.join("secret.md"), &link).unwrap();
3751            same(&link, "symlink leaf escape", &mut gate);
3752            assert!(
3753                gate.resolve(&link).is_err(),
3754                "symlink leaf must be rejected"
3755            );
3756
3757            // Symlinked PARENT dir out of the store: the fast path's parent
3758            // canonicalize resolves it outside the root — rejected by both.
3759            let linked_dir = root.join("records/linked");
3760            symlink(&outside_dir, &linked_dir).unwrap();
3761            let through = linked_dir.join("secret.md");
3762            same(&through, "symlinked parent escape", &mut gate);
3763            assert!(
3764                gate.resolve(&through).is_err(),
3765                "a candidate under a symlinked-out parent must be rejected"
3766            );
3767        }
3768    }
3769
3770    // ── shared link-edge notion (fence / whitespace / case) ──────────────────
3771
3772    #[test]
3773    fn extract_edge_targets_trims_inner_whitespace() {
3774        // Padded `[[ x ]]` is the same edge as `[[x]]`.
3775        assert_eq!(
3776            extract_edge_targets("See [[ records/contacts/sarah ]] today."),
3777            vec!["records/contacts/sarah".to_string()]
3778        );
3779    }
3780
3781    #[test]
3782    fn extract_edge_targets_skips_fenced_code_blocks() {
3783        // A `[[...]]` inside a ``` fence is a doc example, NOT an edge — matching
3784        // validate's body extractor.
3785        let body = "\
3786Real [[records/contacts/sarah]] link.
3787
3788```markdown
3789[[records/contacts/ghost-example]] is how you link.
3790```
3791
3792After fence [[records/companies/acme]].
3793";
3794        let got = extract_edge_targets(body);
3795        assert_eq!(
3796            got,
3797            vec![
3798                "records/contacts/sarah".to_string(),
3799                "records/companies/acme".to_string(),
3800            ],
3801            "fenced example link must not be an edge"
3802        );
3803    }
3804
3805    #[test]
3806    fn edge_spans_agree_with_edge_targets_on_every_body_shape() {
3807        // THE anti-drift guarantee. Two extractors over one grammar is exactly
3808        // the duplication this type exists to prevent elsewhere, so the pair
3809        // must never disagree: same links, same order, same fence decisions.
3810        // Every hostile body shape the target tests cover, in one corpus.
3811        let bodies = [
3812            "Plain [[records/contacts/sarah]] link.",
3813            "See [[ records/contacts/sarah ]] and [[records/companies/acme|Acme Inc]].",
3814            "Fenced:\n\n```markdown\n[[records/ghost]]\n```\n\nAfter [[records/real]].",
3815            "~~~\n[[records/tilde-ghost]]\n~~~\n[[records/after-tilde]]",
3816            "   ```\n[[records/indented-fence-ghost]]\n   ```\n[[records/after]]",
3817            "````\n```\n[[records/nested-ghost]]\n```\n````\n[[records/after-long]]",
3818            "Mis-encoded [[[a]], [[b]]] and real [[records/x]].",
3819            "Unclosed [[records/never-closed and then [[records/ok]].",
3820            "Empty [[]] and blank [[   ]] and real [[records/y]].",
3821            "Multi [[a]] on [[b]] one [[c]] line.",
3822            "Anchored [[records/x#section]] and aliased [[records/y#s|Label]].",
3823            "Trailing newline body [[records/z]]\n",
3824            "", // degenerate
3825        ];
3826        for body in bodies {
3827            let spans = extract_edge_spans(body);
3828            let targets = extract_edge_targets(body);
3829            assert_eq!(
3830                spans.iter().map(|s| s.target.clone()).collect::<Vec<_>>(),
3831                targets,
3832                "span targets diverged from edge targets for body:\n{body}"
3833            );
3834            // And every span must actually index the `[[…]]` token it claims.
3835            for s in &spans {
3836                let slice = &body[s.start..s.end];
3837                assert!(
3838                    slice.starts_with("[[") && slice.ends_with("]]"),
3839                    "span {}..{} is not a wiki-link token (got {slice:?}) in:\n{body}",
3840                    s.start,
3841                    s.end
3842                );
3843                assert_eq!(
3844                    &slice[2..slice.len() - 2],
3845                    s.raw,
3846                    "span raw text must be the token's inner text"
3847                );
3848            }
3849        }
3850    }
3851
3852    #[test]
3853    fn edge_spans_carry_alias_and_keep_fragments_in_the_target() {
3854        let spans = extract_edge_spans("Go [[records/notes/x#setup|Read the setup]] now.");
3855        assert_eq!(spans.len(), 1);
3856        assert_eq!(spans[0].alias.as_deref(), Some("Read the setup"));
3857        // The fragment stays IN the target — fragments are not in the format.
3858        assert_eq!(spans[0].target, "records/notes/x#setup");
3859        assert_eq!(spans[0].raw, "records/notes/x#setup|Read the setup");
3860        // A splice over the span replaces exactly the token.
3861        let body = "Go [[records/notes/x#setup|Read the setup]] now.";
3862        let out = format!("{}LINK{}", &body[..spans[0].start], &body[spans[0].end..]);
3863        assert_eq!(out, "Go LINK now.");
3864    }
3865
3866    #[test]
3867    fn extract_edge_targets_frontmatter_fence_does_not_swallow_body_links() {
3868        // Regression: `search_by_link` / `forwardlinks` / `dbmd graph backlinks` feed the
3869        // WHOLE file (frontmatter + body) here. A stray code-fence run inside a
3870        // frontmatter value must NOT open a markdown fence that swallows the
3871        // body's real wiki-links. Frontmatter links are still edges; a link
3872        // genuinely inside a BODY fence is still ignored.
3873        let file = "\
3874---
3875type: note
3876summary: \"a note\"
3877ref: \"[[records/contacts/sarah]]\"
3878snippet: \"```\"
3879---
3880
3881Body mentions [[records/companies/acme]].
3882
3883```
3884[[records/contacts/ghost-example]] inside a body fence.
3885```
3886
3887After fence [[records/contacts/dave]].
3888";
3889        let got = extract_edge_targets(file);
3890        assert_eq!(
3891            got,
3892            vec![
3893                "records/contacts/sarah".to_string(), // frontmatter edge
3894                "records/companies/acme".to_string(), // body edge AFTER the frontmatter ```
3895                "records/contacts/dave".to_string(),  // body edge after a real body fence
3896            ],
3897            "a code fence inside frontmatter must not suppress body wiki-links, \
3898             and a real body-fenced link must still be ignored"
3899        );
3900    }
3901
3902    #[test]
3903    fn extract_edge_targets_handles_nested_indented_and_long_run_fences() {
3904        // Regression for the naive `starts_with("```")/("~~~")` toggle: a fence
3905        // nested inside another, an over-indented (>3 space) marker, and a
3906        // long-run fence wrapping a shorter inner one must all leave the block's
3907        // links un-extracted (validate treats the whole block as opaque). The
3908        // (char, run-length) tracker keys on the OPENING fence and closes only on
3909        // a matching char with run ≥ the opener.
3910
3911        // (a) A ```` ```` ````-run block (run 4) wrapping a ``` example (run 3).
3912        // The inner ``` does NOT close the outer run-4 fence, so both `[[...]]`
3913        // inside stay fenced.
3914        let nested = "\
3915Doc:
3916
3917````
3918```
3919[[records/contacts/bob]]
3920```
3921still fenced [[records/contacts/bob]]
3922````
3923
3924Real [[records/companies/acme]].
3925";
3926        assert_eq!(
3927            extract_edge_targets(nested),
3928            vec!["records/companies/acme".to_string()],
3929            "a nested ``` inside a ````-run fence must not leak the fenced links"
3930        );
3931
3932        // (b) A `~~~` block containing a ``` line (the standard way to document a
3933        // backtick fence). The inner backtick line must not flip the state.
3934        let tilde_wraps_backtick = "\
3935~~~
3936```
3937[[records/contacts/ghost]]
3938```
3939~~~
3940
3941After [[records/companies/acme]].
3942";
3943        assert_eq!(
3944            extract_edge_targets(tilde_wraps_backtick),
3945            vec!["records/companies/acme".to_string()],
3946            "a ``` line inside a ~~~ block must not invert the fence state"
3947        );
3948
3949        // (c) An over-indented ```` ``` ```` (4 spaces) is NOT a fence; the link
3950        // on the next line is live.
3951        let over_indented = "    ```\nLive [[records/contacts/sarah]].\n";
3952        assert_eq!(
3953            extract_edge_targets(over_indented),
3954            vec!["records/contacts/sarah".to_string()],
3955            "a >3-space-indented ``` is not a fence opener"
3956        );
3957    }
3958
3959    #[test]
3960    fn canonical_link_target_strips_md_dotslash_and_trims() {
3961        assert_eq!(canonical_link_target("  records/x.md  "), "records/x");
3962        assert_eq!(canonical_link_target("./records/y"), "records/y");
3963        assert_eq!(canonical_link_target("/records/z"), "records/z");
3964    }
3965
3966    #[test]
3967    fn link_edge_key_folds_case_only_on_case_insensitive_fs() {
3968        let a = link_edge_key("records/contacts/Sarah-Chen");
3969        let b = link_edge_key("records/contacts/sarah-chen");
3970        if fs_is_case_insensitive() {
3971            assert_eq!(a, b, "case-insensitive FS must fold the key");
3972        } else {
3973            assert_ne!(a, b, "case-sensitive FS must keep the key case-exact");
3974        }
3975    }
3976
3977    #[test]
3978    fn link_edge_key_unifies_nfc_and_nfd_normalization_forms() {
3979        // REGRESSION (Unicode encoding / silent graph break): on macOS/APFS a
3980        // file written in one Unicode normalization form and a link written in
3981        // the other name the SAME file (the FS folds NFC/NFD), but their raw
3982        // bytes differ. The edge comparison key must fold them to one key on
3983        // every platform, or the graph (backlinks/forwardlinks/orphans) keys the
3984        // two as different targets and silently misses the edge.
3985        let nfc = "records/contacts/jos\u{00e9}"; // é = U+00E9 (NFC)
3986        let nfd = "records/contacts/jose\u{0301}"; // e + U+0301 (NFD)
3987                                                   // The two inputs are genuinely byte-different (the test would be vacuous
3988                                                   // otherwise).
3989        assert_ne!(nfc, nfd, "test inputs must be byte-distinct NFC vs NFD");
3990        assert_eq!(
3991            link_edge_key(nfc),
3992            link_edge_key(nfd),
3993            "NFC and NFD spellings of the same name must produce one edge key"
3994        );
3995    }
3996
3997    // ── walk follows symlinked content ───────────────────────────────────────
3998
3999    #[cfg(unix)]
4000    #[test]
4001    fn walk_includes_symlinked_content_file_and_symlinked_folder() {
4002        use std::os::unix::fs::symlink;
4003        let dir = empty_store();
4004        let root = dir.path();
4005        // A regular file (control).
4006        write(
4007            root,
4008            "records/contacts/sarah.md",
4009            &content_md("2026-05-01T00:00:00Z"),
4010        );
4011        // A symlinked .md content file inside a real folder.
4012        let external_file = root.join("external-elena.md");
4013        fs::write(&external_file, content_md("2026-05-02T00:00:00Z")).unwrap();
4014        symlink(&external_file, root.join("records/contacts/elena.md")).unwrap();
4015        // A symlinked type folder.
4016        let external_dir = dir.path().join("external-companies");
4017        fs::create_dir_all(&external_dir).unwrap();
4018        fs::write(
4019            external_dir.join("acme.md"),
4020            content_md("2026-05-03T00:00:00Z"),
4021        )
4022        .unwrap();
4023        symlink(&external_dir, root.join("records/companies")).unwrap();
4024
4025        let store = open(&dir);
4026        let got = rels(&store.walk().unwrap());
4027        assert!(
4028            got.contains(&"records/contacts/elena.md".to_string()),
4029            "a symlinked content file must be walked: {got:?}"
4030        );
4031        assert!(
4032            got.contains(&"records/companies/acme.md".to_string()),
4033            "a file inside a symlinked type folder must be walked: {got:?}"
4034        );
4035    }
4036
4037    // ── find_links_to: padded / fenced / case ────────────────────────────────
4038
4039    #[test]
4040    fn find_links_to_matches_whitespace_padded_link() {
4041        let dir = empty_store();
4042        let root = dir.path();
4043        write(
4044            root,
4045            "records/profiles/a.md",
4046            "---\ntype: profile\nmeta-type: conclusion\nsummary: s\n---\nSee [[ records/contacts/sarah ]] today.\n",
4047        );
4048        let store = open(&dir);
4049        let got = rels(
4050            &store
4051                .find_links_to(Path::new("records/contacts/sarah"))
4052                .unwrap(),
4053        );
4054        assert_eq!(
4055            got,
4056            vec!["records/profiles/a.md".to_string()],
4057            "a padded `[[ x ]]` link must be found as a backward edge, matching forwardlinks"
4058        );
4059    }
4060
4061    #[test]
4062    fn find_links_to_ignores_fenced_example_link() {
4063        let dir = empty_store();
4064        let root = dir.path();
4065        write(
4066            root,
4067            "records/concepts/howto.md",
4068            "---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\n```markdown\n[[records/contacts/sarah]]\n```\n",
4069        );
4070        let store = open(&dir);
4071        let got = store
4072            .find_links_to(Path::new("records/contacts/sarah"))
4073            .unwrap();
4074        assert!(
4075            got.is_empty(),
4076            "a `[[...]]` only inside a fenced code block is not a backward edge: {got:?}"
4077        );
4078    }
4079
4080    #[cfg(unix)]
4081    #[test]
4082    fn find_links_to_matches_case_variant_on_case_insensitive_fs() {
4083        // Only meaningful on a case-insensitive filesystem; on a case-sensitive
4084        // one the case-variant link is genuinely a different target.
4085        if !fs_is_case_insensitive() {
4086            return;
4087        }
4088        let dir = empty_store();
4089        let root = dir.path();
4090        write(
4091            root,
4092            "records/profiles/bio.md",
4093            "---\ntype: profile\nmeta-type: conclusion\nsummary: s\n---\nSee [[records/contacts/Sarah-Chen]].\n",
4094        );
4095        let store = open(&dir);
4096        let got = rels(
4097            &store
4098                .find_links_to(Path::new("records/contacts/sarah-chen"))
4099                .unwrap(),
4100        );
4101        assert_eq!(
4102            got,
4103            vec!["records/profiles/bio.md".to_string()],
4104            "a case-variant link must be found on a case-insensitive filesystem"
4105        );
4106    }
4107}