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