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