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