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