Skip to main content

dbmd_core/
index.rs

1//! `index` — the hierarchical content catalog.
2//!
3//! A uniform three-level tree: root + per-layer + per-type-folder. **Two
4//! artifacts per type-folder:** the human `index.md` (capped 500, recency
5//! browse) and the machine `index.jsonl` (complete, structured — one JSON
6//! object per file). Both read `summary` + key frontmatter fields + links
7//! directly from each file — there is no extraction logic here.
8//!
9//! **Maintained write-through** by the write commands ([`Index::on_write`] /
10//! [`Index::on_rename`] / [`Index::on_remove`] — the loop path, O(changed), no
11//! store walk); [`Index::rebuild_all`] is the from-scratch SWEEP repair.
12//!
13//! **Key invariant:** write-through must produce a byte-identical `index.md`
14//! and (post-compaction) `index.jsonl` to a full [`Index::rebuild_all`] over
15//! the same end state — the loop path can never drift from the repair path.
16//!
17//! # Implementation notes (deviations the reader should know)
18//!
19//! - **Self-contained, by design.** This module does its own shard-aware folder
20//!   walk, its own minimal frontmatter read, and its own atomic write, using
21//!   only `store.root` (a public field) and the `serde_norway` / `serde_json` /
22//!   `chrono` / `walkdir` crates rather than routing through the sibling
23//!   `store`/`parser` helpers ([`Store::walk_type_folder`],
24//!   [`Store::recent_in_type_folder`], [`parser::read_file`], …). The index has
25//!   to stamp a *deterministic* `updated:` and emit a *canonical, compacted*
26//!   `index.jsonl` (see the two notes below); keeping the read/walk/write local
27//!   is what makes the byte-identity invariant a true byte comparison, free of
28//!   any incidental formatting the shared readers might introduce. The public
29//!   signatures in `lib.rs` are untouched.
30//! - **Deterministic `updated:` on the index files themselves.** An index's own
31//!   `updated` frontmatter is derived as the max `updated` over the files it
32//!   catalogs (max over children for root/layer) — NOT wall-clock-now. This is
33//!   what makes the byte-identity invariant a *true* byte comparison: a
34//!   write-through write and a `rebuild_all` over the same end state stamp the
35//!   same value. (The SPEC's rendered examples show a wall-clock-looking value;
36//!   the conventions list only requires `updated: <RFC3339>`, and the
37//!   property-tested invariant dominates.)
38//! - **`index.jsonl` is always compacted.** Write-through rewrites the affected
39//!   type-folder's jsonl in canonical form (one current line per path, recency
40//!   order) rather than appending superseded/tombstone lines, so the jsonl is
41//!   byte-identical to `rebuild_all` *immediately* (a strictly stronger
42//!   guarantee than the SPEC's "post-compaction"). This keeps the loop cost at
43//!   one sidecar read + one rewrite per touched type-folder — O(folder), the
44//!   sanctioned loop primitive, never a whole-`Store::walk`.
45//! - **Root/layer entry styling** follows plan §index (`(N)` numeric counts;
46//!   layer headings in the root carry the layer's total count) which is more
47//!   specific than the SPEC's illustrative `(42 files)` prose example. Type
48//!   folders are listed alphabetically (a deterministic order a derived artifact
49//!   needs); `scope: type-folder` follows the conventions list, not the one
50//!   SPEC example that wrote `scope: folder`.
51
52use std::collections::BTreeMap;
53use std::fs;
54use std::io::Write as _;
55use std::path::{Path, PathBuf};
56
57use chrono::{DateTime, FixedOffset, SecondsFormat};
58use serde::{Deserialize, Serialize};
59use serde_json::Value;
60
61use crate::parser::FolderMeta;
62use crate::store::{Layer, Store};
63
64/// The browse-view cap for a type-folder `index.md`.
65const MD_CAP: usize = 500;
66
67/// Placeholder summary for a content file that has no `summary` frontmatter.
68/// The index never invents a real summary — that is `dbmd fm init`'s job; this
69/// marker is what `dbmd validate` keys off (`INDEX`-class issue).
70const MISSING_SUMMARY: &str = "(no summary)";
71
72/// The root `index.md` H1.
73const ROOT_TITLE: &str = "Knowledge base index";
74
75/// Which level of the catalog an [`Index`] represents.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum IndexLevel {
78    /// The store-wide root `index.md` (layers + per-type counts).
79    Root,
80    /// A layer `index.md` (every type-folder under one layer).
81    Layer(Layer),
82    /// A type-folder `index.md` + `index.jsonl` (every file in the folder).
83    TypeFolder(PathBuf),
84}
85
86/// One record in a type-folder's `index.jsonl` — the complete, structured twin
87/// of a single `index.md` browse entry.
88///
89/// `tags` are the document's flat labels; `links` are its concept/relationship
90/// wiki-link targets. Both are copied verbatim from the file — never inferred.
91/// `fields` holds the remaining type-specific frontmatter so the structured
92/// query path can filter on any key without opening the file.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct IndexRecord {
95    /// Store-relative path of the file (the upsert key; last-write-wins).
96    /// Serialized with forward slashes regardless of OS (see [`path_serde`]) so
97    /// the `index.jsonl` catalog is byte-portable across platforms.
98    #[serde(with = "path_serde")]
99    pub path: PathBuf,
100    /// The file's `type`.
101    #[serde(rename = "type")]
102    pub type_: String,
103    /// The file's `summary`.
104    pub summary: String,
105    /// The file's flat `tags`.
106    #[serde(default)]
107    pub tags: Vec<String>,
108    /// The file's concept/relationship wiki-link targets (store-relative).
109    #[serde(default)]
110    pub links: Vec<String>,
111    /// `created` timestamp.
112    pub created: Option<DateTime<FixedOffset>>,
113    /// `updated` timestamp (the recency key for the `index.md` cap order).
114    pub updated: Option<DateTime<FixedOffset>>,
115    /// Remaining type-specific frontmatter fields, verbatim — including the
116    /// record's `id` (SPEC v0.4), which rides here like any other frontmatter
117    /// field rather than as a dedicated column, so `--where id=…` resolves
118    /// through the generic field path and existing sidecars stay byte-stable.
119    #[serde(flatten)]
120    pub fields: BTreeMap<String, Value>,
121}
122
123/// A built (or being-built) catalog for one [`IndexLevel`], with both rendered
124/// artifacts available. Pure data until written via [`Index::write_level`].
125#[derive(Debug, Clone, PartialEq)]
126pub struct Index {
127    /// Which level this catalog is for.
128    pub level: IndexLevel,
129    /// The complete record set for this level (type-folder level; empty for
130    /// root/layer rollups, which carry only counts).
131    pub records: Vec<IndexRecord>,
132    /// Per-child counts for root/layer rollups (child path → file count).
133    pub child_counts: BTreeMap<PathBuf, usize>,
134}
135
136impl Index {
137    /// Build a type-folder catalog by aggregating across date-shards, producing
138    /// both artifacts. `index.md` selection is recency (updated desc, ties by
139    /// path asc; cap 500 with a `## More` footer over the cap); `index.jsonl`
140    /// holds every file. A file missing `summary` gets a placeholder + a
141    /// validate-detectable issue (the index never invents summaries).
142    pub fn build_type_folder(store: &Store, type_folder: &Path) -> crate::Result<Index> {
143        let rel = normalize_rel(type_folder);
144        let abs = store.root.join(&rel);
145        let mut records = Vec::new();
146        for file_abs in walk_type_folder_files(store, &abs) {
147            let rel_path =
148                rel_to_store(&store.root, &file_abs).expect("walked file is under the store root");
149            // Abort the build on a malformed file rather than skip it. A skipped
150            // file would still be a content member the validator requires to be
151            // catalogued (`validate::walk_content_files` enumerates by filename,
152            // not by parseability), so silently dropping it would leave the store
153            // in a permanently invalid state (`INDEX_MISSING_ENTRY` /
154            // `INDEX_JSONL_DESYNC` that no rebuild can clear) and would desync the
155            // rollups (`build_layer`/`build_root` count the raw `.md` files). The
156            // loud `?` is the right outcome: `cleanup` now preserves the prior
157            // canonical sidecars (`min_depth(2)`), so an aborted rebuild leaves
158            // the existing catalogs intact and the operator a clear error naming
159            // the file to fix — never a destroyed or silently-wrong index.
160            records.push(record_from_file(&file_abs, rel_path)?);
161        }
162        sort_records(&mut records);
163        Ok(Index {
164            level: IndexLevel::TypeFolder(rel),
165            records,
166            child_counts: BTreeMap::new(),
167        })
168    }
169
170    /// Build a layer catalog: every non-empty type-folder under the layer with
171    /// `(N)` counts and a newest-file `summary` preview (≤ 80 chars), plus the
172    /// **loose records** that live directly at the layer root (files with no
173    /// type-folder between them and the layer). The type-folder rollup is the
174    /// `index.md`; the loose records are the layer's own `index.jsonl` (so
175    /// structured reads — `query`, dedup, `graph` — see a loose file the same
176    /// way they see a canonical one). A layer with no loose files carries no
177    /// `index.jsonl`, so existing stores are byte-unchanged.
178    pub fn build_layer(store: &Store, layer: Layer) -> crate::Result<Index> {
179        let mut child_counts = BTreeMap::new();
180        for tf in type_folders_in_layer(store, layer) {
181            let abs = store.root.join(&tf);
182            let n = walk_type_folder_files(store, &abs).len();
183            if n > 0 {
184                child_counts.insert(tf, n);
185            }
186        }
187        let mut records = Vec::new();
188        for file_abs in loose_files_in_layer(store, layer) {
189            let rel_path =
190                rel_to_store(&store.root, &file_abs).expect("walked file is under the store root");
191            // Abort on a malformed loose file rather than skip it, mirroring
192            // `build_type_folder`: a skipped file is still a content member the
193            // validator requires to be catalogued, so dropping it would leave a
194            // permanently-invalid index. The loud `?` names the file to fix.
195            records.push(record_from_file(&file_abs, rel_path)?);
196        }
197        sort_records(&mut records);
198        Ok(Index {
199            level: IndexLevel::Layer(layer),
200            records,
201            child_counts,
202        })
203    }
204
205    /// Build the store-wide root catalog: one heading per non-empty layer with
206    /// total count + bulleted per-type sub-entries with `(N)` counts.
207    pub fn build_root(store: &Store) -> crate::Result<Index> {
208        let mut child_counts = BTreeMap::new();
209        for layer in Layer::all() {
210            for tf in type_folders_in_layer(store, layer) {
211                let abs = store.root.join(&tf);
212                let n = walk_type_folder_files(store, &abs).len();
213                if n > 0 {
214                    child_counts.insert(tf, n);
215                }
216            }
217        }
218        Ok(Index {
219            level: IndexLevel::Root,
220            records: Vec::new(),
221            child_counts,
222        })
223    }
224
225    /// Render this catalog as a canonical `index.md`.
226    pub fn to_markdown(&self) -> String {
227        match &self.level {
228            IndexLevel::TypeFolder(folder) => self.render_type_folder_md(folder),
229            IndexLevel::Layer(layer) => self.render_layer_md(*layer),
230            IndexLevel::Root => self.render_root_md(),
231        }
232    }
233
234    /// Render this catalog's `records` as the complete `index.jsonl` (one JSON
235    /// object per file, stable key order so diffs stay minimal). Used at the
236    /// type-folder level for its files, and at the layer level for the loose
237    /// files that live directly at the layer root. The root rollup carries no
238    /// records, so it never produces a jsonl.
239    pub fn to_jsonl(&self) -> String {
240        let mut out = String::new();
241        for rec in &self.records {
242            // The record type derives a deterministic, sorted key order
243            // (declared fields first, then the flattened `fields` BTreeMap).
244            let line = serde_json::to_string(rec).expect("IndexRecord serializes");
245            out.push_str(&line);
246            out.push('\n');
247        }
248        out
249    }
250
251    // ── rendering helpers ────────────────────────────────────────────────
252
253    fn render_type_folder_md(&self, folder: &Path) -> String {
254        let folder_disp = path_to_unix(folder);
255        let updated = max_updated(self.records.iter().map(|r| r.updated.as_ref()));
256        let mut s = String::new();
257        s.push_str("---\n");
258        s.push_str("type: index\n");
259        s.push_str("scope: type-folder\n");
260        s.push_str(&format!("folder: {folder_disp}\n"));
261        if let Some(ts) = updated {
262            s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
263        }
264        s.push_str("---\n\n");
265        s.push_str(&format!("# {folder_disp}\n\n"));
266
267        let shown = self.records.len().min(MD_CAP);
268        for rec in self.records.iter().take(shown) {
269            s.push_str(&format_md_entry(rec));
270            s.push('\n');
271        }
272
273        if self.records.len() > MD_CAP {
274            let type_ = self.records.first().map(|r| r.type_.as_str()).unwrap_or("");
275            let layer = folder
276                .components()
277                .next()
278                .and_then(|c| c.as_os_str().to_str())
279                .unwrap_or("");
280            s.push('\n');
281            s.push_str(&more_footer(self.records.len(), type_, layer));
282        }
283        s
284    }
285
286    /// Store-less layer rollup: counts only, no preview / no derived `updated`
287    /// (a layer index needs each child's on-disk jsonl for those — see
288    /// [`render_layer_md_with_store`], the canonical path every disk write
289    /// uses). This pure-data render is structurally identical sans preview.
290    fn render_layer_md(&self, layer: Layer) -> String {
291        let layer_dir = layer_dir_name(layer);
292        let mut s = String::new();
293        s.push_str("---\n");
294        s.push_str("type: index\n");
295        s.push_str("scope: layer\n");
296        s.push_str(&format!("folder: {layer_dir}\n"));
297        s.push_str("---\n\n");
298        s.push_str(&format!("# {layer_dir}\n\n"));
299        for (tf, n) in &self.child_counts {
300            let tf_unix = path_to_unix(tf);
301            let display = capitalize(folder_basename(tf));
302            s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
303        }
304        s
305    }
306
307    /// Store-less root rollup: counts only (the canonical disk render adds a
308    /// derived `updated` — see [`render_root_md_with_store`]).
309    fn render_root_md(&self) -> String {
310        let mut s = String::new();
311        s.push_str("---\n");
312        s.push_str("type: index\n");
313        s.push_str("scope: root\n");
314        s.push_str("---\n\n");
315        s.push_str(&format!("# {ROOT_TITLE}\n"));
316        for layer in Layer::all() {
317            let layer_dir = layer_dir_name(layer);
318            let prefix = format!("{layer_dir}/");
319            let children: Vec<(&PathBuf, &usize)> = self
320                .child_counts
321                .iter()
322                .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
323                .collect();
324            if children.is_empty() {
325                continue;
326            }
327            let total: usize = children.iter().map(|(_, n)| **n).sum();
328            s.push('\n');
329            s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
330            for (tf, n) in children {
331                let tf_unix = path_to_unix(tf);
332                let display = capitalize(folder_basename(tf));
333                s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
334            }
335        }
336        s
337    }
338}
339
340// ─────────────────────────────────────────────────────────────────────────
341// Write-through + sweep (free functions on the impl block).
342// ─────────────────────────────────────────────────────────────────────────
343
344impl Index {
345    /// **Write-through (loop, O(changed)).** Upsert a new/updated content file.
346    /// Reads the affected type-folder's `index.jsonl` (the sanctioned per-folder
347    /// sidecar read — never a whole-store walk), applies the change, and
348    /// atomically rewrites that folder's `index.md` + `index.jsonl` plus the
349    /// parent layer + root rollups so the artifacts equal a `rebuild_all` over
350    /// the same end state.
351    pub fn on_write(store: &Store, file: &Path) -> crate::Result<()> {
352        let file_rel = normalize_rel(file);
353        // The generated catalog files are not content — never upsert one into
354        // itself. `build_type_folder`'s walk already excludes `index.md`
355        // (`walk_type_folder_files`); the loop path must apply the same
356        // exclusion or editing `index.md` via `fm set` inserts a phantom
357        // self-row, inflating every `(N)` count and breaking the
358        // write-through == rebuild byte-identity invariant.
359        if is_index_artifact(&file_rel) {
360            return Ok(());
361        }
362        // A loose file (directly at a layer root, no type-folder) is catalogued
363        // in its layer's own `index.jsonl`; the layer `index.md` rollup is
364        // unaffected (loose files do not change type-folder counts).
365        if let Some(layer) = loose_layer_of(&file_rel) {
366            return apply_loose_change(store, layer, &file_rel, false);
367        }
368        let file_abs = store.root.join(&file_rel);
369        let folder = type_folder_of(&file_rel)
370            .ok_or_else(|| bad_index(&file_rel, "file is not inside a layer/type-folder"))?;
371        let record = record_from_file(&file_abs, file_rel.clone())?;
372
373        // Serialize the sidecar read-modify-write so concurrent sanctioned
374        // writes to this folder don't clobber each other's rows (lost update).
375        let _lock = FolderLock::acquire(&store.root.join(&folder));
376        let mut records = read_jsonl_records(&store.root.join(&folder).join("index.jsonl"))?;
377        records.retain(|r| r.path != record.path);
378        records.push(record);
379        sort_records(&mut records);
380
381        write_type_folder_artifacts(store, &folder, &records)?;
382        update_parents(store, &folder)?;
383        Ok(())
384    }
385
386    /// **Write-through (loop, O(changed)).** Move a file's entry between
387    /// type-folder indexes (or within, if the same folder) in both `index.md`
388    /// and `index.jsonl`, fixing counts on both sides.
389    pub fn on_rename(store: &Store, old: &Path, new: &Path) -> crate::Result<()> {
390        let old_rel = normalize_rel(old);
391        let new_rel = normalize_rel(new);
392        // Index artifacts are generated, not catalogued — a rename of/into one
393        // is not a content move (same reasoning as `on_write`). Skip rather than
394        // insert a phantom self-row.
395        if is_index_artifact(&old_rel) || is_index_artifact(&new_rel) {
396            return Ok(());
397        }
398        // If either side is a loose file (layer root, no type-folder), decompose
399        // into remove-old + add-new: each entry point routes to the correct
400        // catalog (the layer `index.jsonl` for a loose side, the type-folder for
401        // the other), giving the same end state as the cross-folder path below
402        // while reusing the tested single-file paths.
403        if loose_layer_of(&old_rel).is_some() || loose_layer_of(&new_rel).is_some() {
404            Self::on_remove(store, &old_rel)?;
405            Self::on_write(store, &new_rel)?;
406            return Ok(());
407        }
408        let old_folder = type_folder_of(&old_rel)
409            .ok_or_else(|| bad_index(&old_rel, "source is not inside a layer/type-folder"))?;
410        let new_folder = type_folder_of(&new_rel)
411            .ok_or_else(|| bad_index(&new_rel, "target is not inside a layer/type-folder"))?;
412
413        // Serialize the sidecar read-modify-write(s). For a cross-folder rename,
414        // lock BOTH folders, always in sorted order, so two renames touching the
415        // same pair can't deadlock. Held for the whole operation via RAII.
416        let _locks = lock_folders(store, &old_folder, &new_folder);
417
418        // Drop from the old folder.
419        let mut old_records =
420            read_jsonl_records(&store.root.join(&old_folder).join("index.jsonl"))?;
421        old_records.retain(|r| r.path != old_rel);
422
423        if old_folder == new_folder {
424            // Same folder: re-read the (now-renamed) file and upsert.
425            let record = record_from_file(&store.root.join(&new_rel), new_rel.clone())?;
426            old_records.retain(|r| r.path != record.path);
427            old_records.push(record);
428            sort_records(&mut old_records);
429            write_type_folder_artifacts(store, &old_folder, &old_records)?;
430            update_parents(store, &old_folder)?;
431            return Ok(());
432        }
433
434        // Cross-folder: write the trimmed old folder (or drop its indexes if
435        // now empty), then upsert into the new folder.
436        sort_records(&mut old_records);
437        write_type_folder_artifacts(store, &old_folder, &old_records)?;
438
439        let record = record_from_file(&store.root.join(&new_rel), new_rel.clone())?;
440        let mut new_records =
441            read_jsonl_records(&store.root.join(&new_folder).join("index.jsonl"))?;
442        new_records.retain(|r| r.path != record.path);
443        new_records.push(record);
444        sort_records(&mut new_records);
445        write_type_folder_artifacts(store, &new_folder, &new_records)?;
446
447        update_parents(store, &old_folder)?;
448        update_parents(store, &new_folder)?;
449        Ok(())
450    }
451
452    /// **Write-through (loop, O(changed)).** Drop a file's entry from both
453    /// `index.md` and `index.jsonl`; decrement counts; if the browse view drops
454    /// below the cap, the next-most-recent is already present in the complete
455    /// jsonl record set and re-renders into the md automatically.
456    pub fn on_remove(store: &Store, file: &Path) -> crate::Result<()> {
457        let file_rel = normalize_rel(file);
458        // Removing a generated catalog artifact is not a content removal; it has
459        // no row to drop (it was never catalogued). Skip, mirroring `on_write`.
460        if is_index_artifact(&file_rel) {
461            return Ok(());
462        }
463        // Loose file → drop its row from the layer `index.jsonl`.
464        if let Some(layer) = loose_layer_of(&file_rel) {
465            return apply_loose_change(store, layer, &file_rel, true);
466        }
467        let folder = type_folder_of(&file_rel)
468            .ok_or_else(|| bad_index(&file_rel, "file is not inside a layer/type-folder"))?;
469        // Serialize the sidecar read-modify-write (see `on_write`).
470        let _lock = FolderLock::acquire(&store.root.join(&folder));
471        let mut records = read_jsonl_records(&store.root.join(&folder).join("index.jsonl"))?;
472        let before = records.len();
473        records.retain(|r| r.path != file_rel);
474        if records.len() == before {
475            // Nothing to remove; still normalize the folder + parents so the
476            // artifacts stay canonical.
477        }
478        sort_records(&mut records);
479        write_type_folder_artifacts(store, &folder, &records)?;
480        update_parents(store, &folder)?;
481        Ok(())
482    }
483
484    /// **SWEEP repair.** Walk the store once and atomically (re)write root +
485    /// every non-empty layer + every non-empty type-folder `index.md` and
486    /// `index.jsonl` (compacting the jsonl). Also runs [`Index::cleanup`].
487    pub fn rebuild_all(store: &Store) -> crate::Result<()> {
488        Index::cleanup(store)?;
489        for layer in Layer::all() {
490            for tf in type_folders_in_layer(store, layer) {
491                let idx = Index::build_type_folder(store, &tf)?;
492                if idx.records.is_empty() {
493                    continue;
494                }
495                write_type_folder_artifacts(store, &tf, &idx.records)?;
496            }
497            let layer_idx = Index::build_layer(store, layer)?;
498            let layer_index_md = store.root.join(layer_dir_name(layer)).join("index.md");
499            if layer_idx.child_counts.is_empty() {
500                remove_if_exists(&layer_index_md)?;
501            } else {
502                write_atomic(
503                    &layer_index_md,
504                    render_layer_md_with_store(store, &layer_idx),
505                )?;
506            }
507            // The layer's own `index.jsonl` — present iff the layer has loose
508            // files directly at its root. Independent of the rollup above: a
509            // layer can have loose files but no type-folders, or vice versa.
510            write_layer_jsonl(store, layer, &layer_idx.records)?;
511        }
512        let root_idx = Index::build_root(store)?;
513        let root_index_md = store.root.join("index.md");
514        if root_idx.child_counts.is_empty() {
515            remove_if_exists(&root_index_md)?;
516        } else {
517            write_atomic(&root_index_md, render_root_md_with_store(store, &root_idx))?;
518        }
519        Ok(())
520    }
521
522    /// Rebuild ONE type-folder's `index.md`/`index.jsonl` from a fresh walk, then
523    /// cascade the new child count up to the layer and root rollups — so a
524    /// scoped `dbmd index rebuild --folder` leaves the hierarchy consistent,
525    /// exactly like `rebuild_all` and the loop-path `on_write` already do.
526    /// (Writing only the folder, as the CLI used to, left stale layer/root
527    /// counts that `validate` would then flag as an index desync.)
528    pub fn rebuild_folder(store: &Store, folder: &Path) -> crate::Result<()> {
529        Self::write_level(store, &IndexLevel::TypeFolder(folder.to_path_buf()))?;
530        update_parents(store, folder)
531    }
532
533    /// Atomically write a single level's artifact(s) to disk.
534    pub fn write_level(store: &Store, level: &IndexLevel) -> crate::Result<()> {
535        match level {
536            IndexLevel::TypeFolder(folder) => {
537                let idx = Index::build_type_folder(store, folder)?;
538                if idx.records.is_empty() {
539                    remove_if_exists(&store.root.join(folder).join("index.md"))?;
540                    remove_if_exists(&store.root.join(folder).join("index.jsonl"))?;
541                } else {
542                    write_type_folder_artifacts(store, folder, &idx.records)?;
543                }
544            }
545            IndexLevel::Layer(layer) => {
546                let idx = Index::build_layer(store, *layer)?;
547                let p = store.root.join(layer_dir_name(*layer)).join("index.md");
548                if idx.child_counts.is_empty() {
549                    remove_if_exists(&p)?;
550                } else {
551                    write_atomic(&p, render_layer_md_with_store(store, &idx))?;
552                }
553                write_layer_jsonl(store, *layer, &idx.records)?;
554            }
555            IndexLevel::Root => {
556                let idx = Index::build_root(store)?;
557                let p = store.root.join("index.md");
558                if idx.child_counts.is_empty() {
559                    remove_if_exists(&p)?;
560                } else {
561                    write_atomic(&p, render_root_md_with_store(store, &idx))?;
562                }
563            }
564        }
565        Ok(())
566    }
567
568    /// Render the generated indexes to a string with `--- <path> ---`
569    /// separators instead of writing them (`--dry-run`).
570    pub fn render_dry_run(store: &Store, level: &IndexLevel) -> crate::Result<String> {
571        let mut out = String::new();
572        match level {
573            IndexLevel::TypeFolder(folder) => {
574                let idx = Index::build_type_folder(store, folder)?;
575                let md_path = path_to_unix(&folder.join("index.md"));
576                let jsonl_path = path_to_unix(&folder.join("index.jsonl"));
577                out.push_str(&format!("--- {md_path} ---\n"));
578                out.push_str(&idx.to_markdown());
579                out.push_str(&format!("--- {jsonl_path} ---\n"));
580                out.push_str(&idx.to_jsonl());
581            }
582            IndexLevel::Layer(layer) => {
583                let idx = Index::build_layer(store, *layer)?;
584                let md_path = format!("{}/index.md", layer_dir_name(*layer));
585                out.push_str(&format!("--- {md_path} ---\n"));
586                out.push_str(&render_layer_md_with_store(store, &idx));
587            }
588            IndexLevel::Root => {
589                let idx = Index::build_root(store)?;
590                out.push_str("--- index.md ---\n");
591                out.push_str(&render_root_md_with_store(store, &idx));
592            }
593        }
594        Ok(out)
595    }
596
597    /// Cleanup pass (part of [`Index::rebuild_all`]): delete `index.md` /
598    /// `index.jsonl` in non-canonical folders (date-shards that should carry
599    /// none). Symmetric with index creation.
600    ///
601    /// **Only deletes generated catalog artifacts, never user content.** Two
602    /// guards keep this from eating data:
603    /// - `min_depth(2)` so the walk starts *below* the type-folder root — the
604    ///   canonical `<type-folder>/index.md` + `index.jsonl` are never targeted
605    ///   here (they are rewritten by the per-folder builders, or removed only
606    ///   when the folder is genuinely empty, in the dedicated branch below). The
607    ///   old `min_depth(1)` deleted them up front, so a rebuild aborted by one
608    ///   malformed file left every type-folder catalog destroyed.
609    /// - [`is_deletable_catalog_artifact`] confirms a shard-level `index.md` is
610    ///   an actual generated catalog (or stale/garbage leftover), NOT a content
611    ///   file a user wrote at that name (e.g. `dbmd write …/index.md --type
612    ///   email`, plausible when mirroring a website/doc export). Matching by
613    ///   filename alone silently deleted such records on the next rebuild.
614    pub fn cleanup(store: &Store) -> crate::Result<()> {
615        for layer in Layer::all() {
616            let layer_dir = store.root.join(layer_dir_name(layer));
617            if !layer_dir.is_dir() {
618                continue;
619            }
620            for tf in type_folders_in_layer(store, layer) {
621                let tf_abs = store.root.join(&tf);
622                // Any generated index inside a shard (below the type-folder
623                // root) is non-canonical: delete it. Never touch a user content
624                // file that merely happens to be named index.md.
625                for entry in walkdir::WalkDir::new(&tf_abs)
626                    .min_depth(2)
627                    .follow_links(true)
628                    .into_iter()
629                    .filter_entry(|entry| store.owns_path(entry.path()))
630                    .filter_map(|e| e.ok())
631                {
632                    let p = entry.path();
633                    if is_index_artifact(p) && is_deletable_catalog_artifact(p) {
634                        remove_if_exists(p)?;
635                    }
636                }
637                // Empty type-folder → no index at its root either. Same content
638                // guard: an `index.md` here that is actually a user record (the
639                // only file in the folder) is preserved, not deleted.
640                if walk_type_folder_files(store, &tf_abs).is_empty() {
641                    let md = tf_abs.join("index.md");
642                    if is_deletable_catalog_artifact(&md) {
643                        remove_if_exists(&md)?;
644                    }
645                    remove_if_exists(&tf_abs.join("index.jsonl"))?;
646                }
647            }
648        }
649        Ok(())
650    }
651}
652
653// ─────────────────────────────────────────────────────────────────────────
654// Private free helpers — all self-contained, none call back into Store/parser.
655// ─────────────────────────────────────────────────────────────────────────
656
657/// Write both artifacts for a type-folder, or delete them if the folder is now
658/// empty. The single funnel both write-through and rebuild go through, so their
659/// output is byte-identical by construction.
660fn write_type_folder_artifacts(
661    store: &Store,
662    folder: &Path,
663    records: &[IndexRecord],
664) -> crate::Result<()> {
665    let folder_abs = store.root.join(folder);
666    let md_path = folder_abs.join("index.md");
667    let jsonl_path = folder_abs.join("index.jsonl");
668    if records.is_empty() {
669        remove_if_exists(&md_path)?;
670        remove_if_exists(&jsonl_path)?;
671        return Ok(());
672    }
673    let idx = Index {
674        level: IndexLevel::TypeFolder(folder.to_path_buf()),
675        records: records.to_vec(),
676        child_counts: BTreeMap::new(),
677    };
678    write_atomic(&md_path, idx.to_markdown())?;
679    write_atomic(&jsonl_path, idx.to_jsonl())?;
680    Ok(())
681}
682
683/// Re-render the layer + root rollups that sit above `folder` — the
684/// **loop path**, O(changed). Counts + previews come from the type-folders'
685/// on-disk `index.jsonl` sidecars ([`collect_child_stats`]), NOT from a
686/// content-tree walk: a single write reads one sidecar per type-folder (shared
687/// across the layer and root rollups) — never the millions of files under the
688/// shards. `build_layer` / `build_root` (which *do* walk the content tree) are
689/// reserved for the from-scratch sweeps ([`Index::rebuild_all`],
690/// [`Index::write_level`], [`Index::render_dry_run`]). The result is
691/// byte-identical to those builders because in the loop — exactly as in
692/// `rebuild_all` — every touched folder's jsonl is rewritten before its parents
693/// are rolled up, so the per-folder stat (`count` / `newest`) equals what a
694/// from-scratch walk would compute.
695fn update_parents(store: &Store, folder: &Path) -> crate::Result<()> {
696    // Read every type-folder's sidecar EXACTLY ONCE into a stat cache (`count` +
697    // `newest` record), then render both rollups from the cache. This removed the
698    // old 2–3×-per-write reparse (`child_counts_from_jsonl` for a count, plus
699    // `render_layer_md_with_store` / `render_root_md_with_store` each doing a full
700    // `read_jsonl_records` parse + sort just to take `.first()`); the output stays
701    // byte-identical (`count` == `read_jsonl_records().len()`, `newest` == its
702    // `.first()`).
703    //
704    // COST, stated honestly: this is `O(total catalogued records)` per write, NOT
705    // `O(changed)`. `collect_child_stats` reads and line-parses EVERY type-folder
706    // sidecar in the store to recompute the rollups, so a single high-volume
707    // folder (months of ingested emails) makes an unrelated tiny write scan that
708    // whole sidecar (a ~50× slowdown at ~200k records was measured). The crate's
709    // literal `Store::walk` guard holds — this reads `index.jsonl` sidecars, not
710    // the content tree — but the broader `O(changed)` complexity the loop path
711    // advertises is NOT met here. Restoring true `O(changed)` needs a persisted
712    // per-folder stat cache (or an in-place rollup patch for `on_write`); that is
713    // a deliberate change to the catalog hot path, tracked as a follow-up, not
714    // done inline. Until then, do not describe this op as `O(changed)`.
715    //
716    // CONCURRENCY: the layer `index.md` and the root `index.md` are SHARED across
717    // every type-folder, but the calling write only holds a lock on its OWN
718    // type-folder (`on_write`/`on_remove`/`on_rename`). Two concurrent writes to
719    // *different* type-folders would otherwise both read the sidecar set and both
720    // rewrite the same two rollups, losing one update (a stale rollup that no
721    // longer matches `rebuild_all` — a write-through/rebuild parity violation).
722    // Serialize the whole read-stats + render + write under a store-root lock so
723    // the last writer to commit its sidecar (each write commits its own
724    // `index.jsonl` BEFORE calling here) observes every committed sidecar. Lock
725    // order is always type-folder(s) → root, and nothing acquires the root lock
726    // before a type-folder lock, so this cannot deadlock with the per-folder
727    // locks held by the caller.
728    let _root_lock = FolderLock::acquire(&store.root);
729    let stats = collect_child_stats(store, &Layer::all())?;
730
731    let layer = folder
732        .components()
733        .next()
734        .and_then(|c| c.as_os_str().to_str())
735        .and_then(layer_from_dir_name);
736    if let Some(layer) = layer {
737        let p = store.root.join(layer_dir_name(layer)).join("index.md");
738        if layer_has_children(&stats, layer) {
739            write_atomic(
740                &p,
741                render_layer_md_from_stats(layer, &stats, &store.config.folders),
742            )?;
743        } else {
744            remove_if_exists(&p)?;
745        }
746    }
747    let rp = store.root.join("index.md");
748    if stats.values().any(|s| s.count > 0) {
749        write_atomic(
750            &rp,
751            render_root_md_from_stats(&stats, &store.config.folders),
752        )?;
753    } else {
754        remove_if_exists(&rp)?;
755    }
756    Ok(())
757}
758
759/// True if `layer` has at least one non-empty child type-folder in `stats`.
760fn layer_has_children(stats: &BTreeMap<PathBuf, FolderStat>, layer: Layer) -> bool {
761    let prefix = format!("{}/", layer_dir_name(layer));
762    stats
763        .iter()
764        .any(|(tf, s)| s.count > 0 && path_to_unix(tf).starts_with(&prefix))
765}
766
767/// Render a layer `index.md` from the prebuilt per-folder stat cache — each
768/// child's count + newest summary/updated come from its single cached sidecar
769/// read, so the rollup matches the folder artifacts exactly (write-through and
770/// rebuild alike) without re-reading any sidecar.
771fn render_layer_md_from_stats(
772    layer: Layer,
773    stats: &BTreeMap<PathBuf, FolderStat>,
774    folders: &BTreeMap<String, FolderMeta>,
775) -> String {
776    let layer_dir = layer_dir_name(layer);
777    let prefix = format!("{layer_dir}/");
778    let mut max_upd: Option<DateTime<FixedOffset>> = None;
779    let mut entries = String::new();
780    for (tf, stat) in stats {
781        if stat.count == 0 || !path_to_unix(tf).starts_with(&prefix) {
782            continue;
783        }
784        if let Some(u) = stat.newest.as_ref().and_then(|r| r.updated) {
785            max_upd = Some(match max_upd {
786                Some(cur) if cur >= u => cur,
787                _ => u,
788            });
789        }
790        let tf_unix = path_to_unix(tf);
791        let (display, description) = folder_label(&tf_unix, folder_basename(tf), folders);
792        entries.push_str(&folder_entry(&tf_unix, &display, stat.count, description));
793    }
794    let mut s = String::new();
795    s.push_str("---\n");
796    s.push_str("type: index\n");
797    s.push_str("scope: layer\n");
798    s.push_str(&format!("folder: {layer_dir}\n"));
799    if let Some(ts) = max_upd {
800        s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
801    }
802    s.push_str("---\n\n");
803    s.push_str(&format!("# {layer_dir}\n\n"));
804    s.push_str(&entries);
805    s
806}
807
808/// Render the root `index.md` from the prebuilt per-folder stat cache.
809fn render_root_md_from_stats(
810    stats: &BTreeMap<PathBuf, FolderStat>,
811    folders: &BTreeMap<String, FolderMeta>,
812) -> String {
813    let mut max_upd: Option<DateTime<FixedOffset>> = None;
814    for stat in stats.values() {
815        if stat.count == 0 {
816            continue;
817        }
818        if let Some(u) = stat.newest.as_ref().and_then(|r| r.updated) {
819            max_upd = Some(match max_upd {
820                Some(cur) if cur >= u => cur,
821                _ => u,
822            });
823        }
824    }
825    let mut s = String::new();
826    s.push_str("---\n");
827    s.push_str("type: index\n");
828    s.push_str("scope: root\n");
829    if let Some(ts) = max_upd {
830        s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
831    }
832    s.push_str("---\n\n");
833    s.push_str(&format!("# {ROOT_TITLE}\n"));
834    for layer in Layer::all() {
835        let layer_dir = layer_dir_name(layer);
836        let prefix = format!("{layer_dir}/");
837        let children: Vec<(&PathBuf, usize)> = stats
838            .iter()
839            .filter(|(tf, s)| s.count > 0 && path_to_unix(tf).starts_with(&prefix))
840            .map(|(tf, s)| (tf, s.count))
841            .collect();
842        if children.is_empty() {
843            continue;
844        }
845        let total: usize = children.iter().map(|(_, n)| *n).sum();
846        s.push('\n');
847        s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
848        for (tf, n) in children {
849            let tf_unix = path_to_unix(tf);
850            let (display, description) = folder_label(&tf_unix, folder_basename(tf), folders);
851            s.push_str(&folder_entry(&tf_unix, &display, n, description));
852        }
853    }
854    s
855}
856
857/// Render a layer `index.md`, reading each child's newest summary + max-updated
858/// straight from its on-disk `index.jsonl` (so the rollup matches the folder
859/// artifacts exactly, write-through and rebuild alike). The **sweep-path**
860/// renderer used by [`Index::rebuild_all`] / [`Index::write_level`] /
861/// [`Index::render_dry_run`]; the loop path uses the cache-based
862/// [`render_layer_md_from_stats`] to avoid re-reading sidecars.
863fn render_layer_md_with_store(store: &Store, idx: &Index) -> String {
864    let layer = match idx.level {
865        IndexLevel::Layer(l) => l,
866        _ => unreachable!("render_layer_md_with_store called on non-layer"),
867    };
868    let layer_dir = layer_dir_name(layer);
869    let mut max_upd: Option<DateTime<FixedOffset>> = None;
870    let mut entries = String::new();
871    for (tf, n) in &idx.child_counts {
872        let recs = read_jsonl_records(&store.root.join(tf).join("index.jsonl")).unwrap_or_default();
873        let newest = recs.first();
874        if let Some(u) = newest.and_then(|r| r.updated) {
875            max_upd = Some(match max_upd {
876                Some(cur) if cur >= u => cur,
877                _ => u,
878            });
879        }
880        let tf_unix = path_to_unix(tf);
881        let (display, description) =
882            folder_label(&tf_unix, folder_basename(tf), &store.config.folders);
883        entries.push_str(&folder_entry(&tf_unix, &display, *n, description));
884    }
885    let mut s = String::new();
886    s.push_str("---\n");
887    s.push_str("type: index\n");
888    s.push_str("scope: layer\n");
889    s.push_str(&format!("folder: {layer_dir}\n"));
890    if let Some(ts) = max_upd {
891        s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
892    }
893    s.push_str("---\n\n");
894    s.push_str(&format!("# {layer_dir}\n\n"));
895    s.push_str(&entries);
896    s
897}
898
899/// Render the root `index.md`, taking each child's max-updated from its on-disk
900/// `index.jsonl`. The **sweep-path** renderer (the loop path uses
901/// [`render_root_md_from_stats`]).
902fn render_root_md_with_store(store: &Store, idx: &Index) -> String {
903    let mut max_upd: Option<DateTime<FixedOffset>> = None;
904    for tf in idx.child_counts.keys() {
905        let recs = read_jsonl_records(&store.root.join(tf).join("index.jsonl")).unwrap_or_default();
906        if let Some(u) = recs.first().and_then(|r| r.updated) {
907            max_upd = Some(match max_upd {
908                Some(cur) if cur >= u => cur,
909                _ => u,
910            });
911        }
912    }
913    let mut s = String::new();
914    s.push_str("---\n");
915    s.push_str("type: index\n");
916    s.push_str("scope: root\n");
917    if let Some(ts) = max_upd {
918        s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
919    }
920    s.push_str("---\n\n");
921    s.push_str(&format!("# {ROOT_TITLE}\n"));
922    for layer in Layer::all() {
923        let layer_dir = layer_dir_name(layer);
924        let prefix = format!("{layer_dir}/");
925        let children: Vec<(&PathBuf, &usize)> = idx
926            .child_counts
927            .iter()
928            .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
929            .collect();
930        if children.is_empty() {
931            continue;
932        }
933        let total: usize = children.iter().map(|(_, n)| **n).sum();
934        s.push('\n');
935        s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
936        for (tf, n) in children {
937            let tf_unix = path_to_unix(tf);
938            let (display, description) =
939                folder_label(&tf_unix, folder_basename(tf), &store.config.folders);
940            s.push_str(&folder_entry(&tf_unix, &display, *n, description));
941        }
942    }
943    s
944}
945
946/// One `index.md` browse line: `- [[path]] — summary  ·  #tag #tag` (the
947/// `  ·  #…` suffix omitted when the file has no tags). The wiki-link target is
948/// the canonical **bare** store-relative path (no `.md` extension — the
949/// doctrine the writers emit and `validate` enforces via
950/// `WIKI_LINK_HAS_EXTENSION`); the jsonl `path` keeps the real on-disk name.
951fn format_md_entry(rec: &IndexRecord) -> String {
952    let path = wiki_target(&rec.path);
953    // Collapse the summary to a single line before interpolating it into the
954    // one-line browse entry. A hand-written file may legally carry a YAML block
955    // scalar (`summary: |-`) whose value spans multiple lines; rendered verbatim
956    // those embedded newlines break the line-oriented `index.md` format and can
957    // forge a standalone catalog entry (`\n- [[…|Click me]] — injected`). The
958    // CLI writers already collapse whitespace; do the same here so the spec's
959    // primary write path (agents writing files directly) can't corrupt the
960    // catalog.
961    let summary = collapse_whitespace(&rec.summary);
962    let mut line = format!("- [[{path}]] — {summary}");
963    if !rec.tags.is_empty() {
964        let tags = rec
965            .tags
966            .iter()
967            .map(|t| format!("#{t}"))
968            .collect::<Vec<_>>()
969            .join(" ");
970        line.push_str(&format!("  ·  {tags}"));
971    }
972    line
973}
974
975/// The deterministic `## More` footer for an over-cap type-folder.
976fn more_footer(total: usize, type_: &str, layer: &str) -> String {
977    format!(
978        "## More\n\nThis folder has {total} files. The {MD_CAP} most recent are listed above.\nUse `dbmd query --type {type_} --in {layer}` for the complete catalog.\n"
979    )
980}
981
982/// Canonical total order: `updated` descending (None sorts last), ties broken
983/// by store-relative path ascending. A *total* order, so write-through and
984/// rebuild never disagree on #500 vs #501.
985fn sort_records(records: &mut [IndexRecord]) {
986    records.sort_by(record_recency_cmp);
987}
988
989impl IndexRecord {
990    /// Build the [`IndexRecord`] a freshly-rebuilt `index.jsonl` *should* hold
991    /// for the file at `abs` (catalogued under store-relative `rel`).
992    ///
993    /// This is the single canonical projection from frontmatter → sidecar
994    /// record: [`Index::build_type_folder`] uses the same path to write the
995    /// jsonl, so the validator can rebuild the expected record here and compare
996    /// it field-for-field against the committed line — covering **every**
997    /// queryable/dedup field the query path reads (`summary`, `type`, `tags`,
998    /// `links`, `created`, `updated`, and every type-specific `fields` entry
999    /// like `email` / `domain` / `company` / `amount` / `vendor`) without the
1000    /// validator hand-rolling (and drifting from) the projection per field.
1001    pub(crate) fn expected_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
1002        record_from_file(abs, rel)
1003    }
1004}
1005
1006/// Build an [`IndexRecord`] from a file on disk. Missing `summary` →
1007/// [`MISSING_SUMMARY`] placeholder (the index never invents a summary).
1008fn record_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
1009    let mut meta = read_frontmatter(abs)?;
1010    // Records carry an effective `meta-type` in the catalog: the declared value
1011    // (already spilled into `fields` by `read_frontmatter`), or the default
1012    // `fact` when absent — so `--where meta-type=fact` sees un-annotated records.
1013    // Sources are evidence and carry no meta-type.
1014    if rel.starts_with("records") {
1015        meta.fields
1016            .entry("meta-type".to_string())
1017            .or_insert_with(|| Value::String("fact".to_string()));
1018    }
1019    Ok(IndexRecord {
1020        path: rel,
1021        type_: meta.type_.unwrap_or_default(),
1022        summary: meta.summary.unwrap_or_else(|| MISSING_SUMMARY.to_string()),
1023        tags: meta.tags,
1024        links: meta.links,
1025        created: meta.created,
1026        updated: meta.updated,
1027        fields: meta.fields,
1028    })
1029}
1030
1031/// The slice of a frontmatter this module needs.
1032struct FileMeta {
1033    type_: Option<String>,
1034    summary: Option<String>,
1035    tags: Vec<String>,
1036    links: Vec<String>,
1037    created: Option<DateTime<FixedOffset>>,
1038    updated: Option<DateTime<FixedOffset>>,
1039    fields: BTreeMap<String, Value>,
1040}
1041
1042/// Minimal frontmatter read: split the leading `---`…`---` block and parse it
1043/// as YAML, extracting the typed fields and spilling the rest into `fields`.
1044/// Self-contained (does not route through the `parser` module).
1045///
1046/// **Body bytes are never required to be UTF-8.** `sources/` is "preserved
1047/// verbatim" per the SPEC and routinely carries non-UTF-8 imports (Latin-1
1048/// emails dropped in by `rsync`/`mbsync`/`cp`); the body can hold any byte. We
1049/// read the file as raw bytes and lossily decode *only* the leading frontmatter
1050/// region, so a stray non-UTF-8 byte in the body can never abort the projection
1051/// (the old `fs::read_to_string` failed on the first such byte anywhere in the
1052/// file, taking a whole `rebuild_all` / write-through down with it). The
1053/// frontmatter itself is expected to be UTF-8; if it isn't, `U+FFFD` markers
1054/// surface in the parsed values rather than a hard abort.
1055fn read_frontmatter(abs: &Path) -> crate::Result<FileMeta> {
1056    let bytes = fs::read(abs)?;
1057    let yaml = extract_frontmatter_block_lossy(&bytes).unwrap_or_default();
1058    let map: serde_norway::Mapping = if yaml.trim().is_empty() {
1059        serde_norway::Mapping::new()
1060    } else {
1061        serde_norway::from_str(&yaml).map_err(|e| {
1062            crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1063                path: abs.to_path_buf(),
1064                message: format!("frontmatter YAML: {e}"),
1065            })
1066        })?
1067    };
1068
1069    let mut type_ = None;
1070    let mut summary = None;
1071    let mut tags = Vec::new();
1072    let mut links = Vec::new();
1073    let mut created = None;
1074    let mut updated = None;
1075    let mut fields = BTreeMap::new();
1076
1077    for (k, v) in map {
1078        let key = match k.as_str() {
1079            Some(s) => s.to_string(),
1080            None => continue,
1081        };
1082        match key.as_str() {
1083            // `type` and `summary` are coerced with the SAME scalar rule the
1084            // validator applies (`validate::scalar_string`: String/Number/Bool →
1085            // string). A bare `v.as_str()` returns `None` for an unquoted numeric
1086            // or boolean scalar (`summary: 2026`, `type: true`), so the index
1087            // would write the `(no summary)` / empty-type placeholder while
1088            // `dbmd validate` reads the file as HAVING that summary/type —
1089            // yielding a permanently-unfixable `INDEX_SUMMARY_MISMATCH` (every
1090            // rebuild reproduces the same mismatched placeholder). Coercing here
1091            // keeps the writer and the validator byte-for-byte in agreement.
1092            "type" => type_ = scalar_string(&v),
1093            "summary" => summary = scalar_string(&v),
1094            "tags" => tags = yaml_string_list(&v),
1095            "links" => links = yaml_string_list(&v),
1096            "created" => created = v.as_str().and_then(parse_ts),
1097            "updated" => updated = v.as_str().and_then(parse_ts),
1098            // `path`, `type`, `summary`, `tags`, `links`, `created`, `updated`
1099            // are the reserved IndexRecord keys; everything else (including
1100            // `id`, `status`, type-specific fields) goes to `fields`.
1101            "path" => {}
1102            _ => {
1103                fields.insert(key, yaml_to_json_value(&v));
1104            }
1105        }
1106    }
1107
1108    Ok(FileMeta {
1109        type_,
1110        summary,
1111        tags,
1112        links,
1113        created,
1114        updated,
1115        fields,
1116    })
1117}
1118
1119/// A YAML scalar (`String`/`Number`/`Bool`) rendered as a string; `None` for
1120/// sequences/mappings/null. **Must stay identical to `validate::scalar_string`**
1121/// so the index writer and the validator coerce `type`/`summary` the same way
1122/// (see [`read_frontmatter`]); an unquoted `summary: 2026` becomes `"2026"` in
1123/// both, not a placeholder here and a real value there. `pub(crate)` because
1124/// the `emit` projection coerces the same fields through the same rule.
1125pub(crate) fn scalar_string(v: &serde_norway::Value) -> Option<String> {
1126    match v {
1127        serde_norway::Value::String(s) => Some(s.clone()),
1128        serde_norway::Value::Number(n) => Some(n.to_string()),
1129        serde_norway::Value::Bool(b) => Some(b.to_string()),
1130        _ => None,
1131    }
1132}
1133
1134/// Lossily decode the leading frontmatter region of a file given its raw bytes,
1135/// then pull the YAML between the opening `---` and the next `---`. Only the
1136/// frontmatter region needs to be valid UTF-8 in practice; the body may carry
1137/// arbitrary bytes (a verbatim `sources/` import). Returns `None` when the file
1138/// has no frontmatter fence at its very start.
1139fn extract_frontmatter_block_lossy(bytes: &[u8]) -> Option<String> {
1140    // Decode lossily so a non-UTF-8 body byte never aborts the read. The
1141    // frontmatter is at the very start of the file, so a lossy whole-file decode
1142    // is correct for extracting it (and cheap relative to the YAML parse). A
1143    // leading UTF-8 BOM is stripped by `extract_frontmatter_block`.
1144    let text = String::from_utf8_lossy(bytes);
1145    extract_frontmatter_block(&text)
1146}
1147
1148/// Pull the YAML between a leading `---` line and the next `---` line. Returns
1149/// `None` when the file has no frontmatter fence at its very start.
1150fn extract_frontmatter_block(text: &str) -> Option<String> {
1151    let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
1152    let mut lines = trimmed.lines();
1153    let first = lines.next()?;
1154    if first.trim_end() != "---" {
1155        return None;
1156    }
1157    let mut block = String::new();
1158    for line in lines {
1159        if line.trim_end() == "---" {
1160            return Some(block);
1161        }
1162        block.push_str(line);
1163        block.push('\n');
1164    }
1165    None // no closing fence
1166}
1167
1168/// Read a string scalar or a sequence-of-string-scalars into a `Vec<String>`.
1169/// Wiki-link items keep their `[[…]]` form verbatim.
1170fn yaml_string_list(v: &serde_norway::Value) -> Vec<String> {
1171    match v {
1172        serde_norway::Value::String(s) => vec![s.clone()],
1173        serde_norway::Value::Sequence(seq) => seq
1174            .iter()
1175            .filter_map(yaml_string_or_wiki_link_literal)
1176            .collect(),
1177        _ => Vec::new(),
1178    }
1179}
1180
1181fn yaml_string_or_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
1182    v.as_str()
1183        .map(str::to_string)
1184        .or_else(|| unquoted_wiki_link_literal(v))
1185}
1186
1187/// Project a frontmatter YAML value into its verbatim JSON form: scalars and
1188/// collections as written, with the one YAML ambiguity resolved — an unquoted
1189/// inline wiki-link (`field: [[x]]`, which YAML reads as `Seq[Seq[String]]`)
1190/// becomes its `[[x]]` string literal. The single value projection the sidecar
1191/// records carry; `pub(crate)` because the `emit` dump projects frontmatter
1192/// through the same rule so `emit` and `query --json` present identical shapes.
1193pub(crate) fn yaml_to_json_value(v: &serde_norway::Value) -> Value {
1194    if let Some(link) = unquoted_wiki_link_literal(v) {
1195        return Value::String(link);
1196    }
1197    match v {
1198        serde_norway::Value::String(s) => Value::String(s.clone()),
1199        serde_norway::Value::Bool(b) => Value::Bool(*b),
1200        serde_norway::Value::Number(n) => {
1201            serde_json::to_value(n).unwrap_or_else(|_| Value::String(n.to_string()))
1202        }
1203        serde_norway::Value::Sequence(seq) => {
1204            Value::Array(seq.iter().map(yaml_to_json_value).collect())
1205        }
1206        serde_norway::Value::Mapping(_) | serde_norway::Value::Tagged(_) => {
1207            serde_json::to_value(v).unwrap_or(Value::Null)
1208        }
1209        serde_norway::Value::Null => Value::Null,
1210    }
1211}
1212
1213fn unquoted_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
1214    let serde_norway::Value::Sequence(outer) = v else {
1215        return None;
1216    };
1217    if outer.len() != 1 {
1218        return None;
1219    }
1220    let serde_norway::Value::Sequence(inner) = &outer[0] else {
1221        return None;
1222    };
1223    let [serde_norway::Value::String(target)] = inner.as_slice() else {
1224        return None;
1225    };
1226    Some(format!("[[{target}]]"))
1227}
1228
1229/// Parse an RFC3339 timestamp scalar. `pub(crate)` because the `emit`
1230/// projection reads `created`/`updated` through the same lenient rule
1231/// (unparseable ⇒ `None`, never an abort).
1232pub(crate) fn parse_ts(s: &str) -> Option<DateTime<FixedOffset>> {
1233    DateTime::parse_from_rfc3339(s.trim()).ok()
1234}
1235
1236/// Render a timestamp the same way `serde_json` renders an `IndexRecord`
1237/// timestamp (RFC3339, `Z` for UTC, sub-seconds preserved) so the md
1238/// frontmatter and the jsonl agree byte-for-byte.
1239fn fmt_ts(ts: &DateTime<FixedOffset>) -> String {
1240    ts.to_rfc3339_opts(SecondsFormat::AutoSi, true)
1241}
1242
1243/// Max `updated` over an iterator of optional timestamps.
1244fn max_updated<'a>(
1245    it: impl Iterator<Item = Option<&'a DateTime<FixedOffset>>>,
1246) -> Option<DateTime<FixedOffset>> {
1247    let mut best: Option<DateTime<FixedOffset>> = None;
1248    for ts in it.flatten() {
1249        best = Some(match best {
1250            Some(cur) if cur >= *ts => cur,
1251            _ => *ts,
1252        });
1253    }
1254    best
1255}
1256
1257/// Read a type-folder's `index.jsonl` into records, applying last-write-wins by
1258/// `path` over any un-compacted lines (so a half-compacted jsonl still reads
1259/// cleanly). Missing file → empty set. Returns records in canonical order.
1260fn read_jsonl_records(jsonl: &Path) -> crate::Result<Vec<IndexRecord>> {
1261    let text = match fs::read_to_string(jsonl) {
1262        Ok(t) => t,
1263        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1264        Err(e) => return Err(e.into()),
1265    };
1266    // Last-write-wins by path; preserve only the final occurrence.
1267    let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
1268    for (i, line) in text.lines().enumerate() {
1269        if line.trim().is_empty() {
1270            continue;
1271        }
1272        let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
1273            crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1274                path: jsonl.to_path_buf(),
1275                message: format!("line {}: {e}", i + 1),
1276            })
1277        })?;
1278        by_path.insert(rec.path.clone(), rec);
1279    }
1280    let mut records: Vec<IndexRecord> = by_path.into_values().collect();
1281    sort_records(&mut records);
1282    Ok(records)
1283}
1284
1285/// The minimal rollup stat a parent index needs from one type-folder's
1286/// `index.jsonl`: how many distinct files it catalogs (`count`) and the single
1287/// newest record (`newest`, the recency-sorted `.first()` — its `updated` feeds
1288/// the parent's derived `updated`, its `summary` the layer preview). Holding the
1289/// newest record alone, rather than the whole sidecar, is what keeps a rollup
1290/// recompute cheap regardless of how large the sidecar grows.
1291#[derive(Debug, Clone, Default, PartialEq)]
1292struct FolderStat {
1293    count: usize,
1294    newest: Option<IndexRecord>,
1295}
1296
1297/// Read a type-folder's `index.jsonl` ONCE and reduce it to a [`FolderStat`]:
1298/// distinct-`path` count (last-write-wins) plus the recency-newest record. A
1299/// missing sidecar is the default (`count: 0`, `newest: None`). This is the
1300/// **loop-path** rollup primitive — one streaming pass per sidecar, never the
1301/// content tree and never the 2–3× full reparse the old
1302/// `jsonl_record_count` + `read_jsonl_records` pair did. `count` is
1303/// byte-identical to [`read_jsonl_records`]`.len()` and `newest` to its
1304/// `.first()`, so a rollup built from these stats matches the from-scratch
1305/// builders byte-for-byte.
1306fn read_folder_stat(jsonl: &Path) -> crate::Result<FolderStat> {
1307    let text = match fs::read_to_string(jsonl) {
1308        Ok(t) => t,
1309        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FolderStat::default()),
1310        Err(e) => return Err(e.into()),
1311    };
1312    // Last-write-wins by path, exactly like `read_jsonl_records`, so count and
1313    // newest are computed over the same compacted record set.
1314    let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
1315    for (i, line) in text.lines().enumerate() {
1316        if line.trim().is_empty() {
1317            continue;
1318        }
1319        let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
1320            crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1321                path: jsonl.to_path_buf(),
1322                message: format!("line {}: {e}", i + 1),
1323            })
1324        })?;
1325        by_path.insert(rec.path.clone(), rec);
1326    }
1327    let count = by_path.len();
1328    // The newest record is the minimum under `sort_records`' order (updated
1329    // desc, None last, ties by path asc) — i.e. what `.first()` returns. Find it
1330    // with a single min-scan instead of sorting the whole set.
1331    let newest = by_path.into_values().min_by(record_recency_cmp);
1332    Ok(FolderStat { count, newest })
1333}
1334
1335/// The total order [`sort_records`] imposes, as a comparator over two records:
1336/// `updated` descending (None last), ties broken by store-relative path
1337/// ascending. Kept in one place so `read_folder_stat`'s min-scan agrees with the
1338/// sort byte-for-byte on which record is "newest".
1339fn record_recency_cmp(a: &IndexRecord, b: &IndexRecord) -> std::cmp::Ordering {
1340    match (b.updated, a.updated) {
1341        (Some(bu), Some(au)) => bu.cmp(&au),
1342        (Some(_), None) => std::cmp::Ordering::Greater, // a is None → after b
1343        (None, Some(_)) => std::cmp::Ordering::Less,    // b is None → after a
1344        (None, None) => std::cmp::Ordering::Equal,
1345    }
1346    .then_with(|| a.path.cmp(&b.path))
1347}
1348
1349/// Per-child rollup stats for `layers`, read from each type-folder's on-disk
1350/// `index.jsonl` (one [`read_folder_stat`] pass each) rather than walked from the
1351/// content tree. The **loop-path** counterpart to the from-scratch counting in
1352/// [`Index::build_layer`] / [`Index::build_root`], reusing one read per sidecar
1353/// across BOTH the layer and root rollups. Empty folders (`count == 0`) are kept
1354/// out of the map.
1355///
1356/// NOTE on cost: this performs one read per type-folder, but each read line-parses
1357/// that folder's entire `index.jsonl`, so the total is `O(total catalogued
1358/// records)`, not `O(type-folders)` — it reads the whole catalog every call. It
1359/// avoids the content-tree walk ([`Store::walk`]), but it is NOT `O(changed)`. See
1360/// [`update_parents`] for the honest bound and the follow-up to fix it.
1361fn collect_child_stats(
1362    store: &Store,
1363    layers: &[Layer],
1364) -> crate::Result<BTreeMap<PathBuf, FolderStat>> {
1365    let mut stats = BTreeMap::new();
1366    for &layer in layers {
1367        for tf in type_folders_in_layer(store, layer) {
1368            let stat = read_folder_stat(&store.root.join(&tf).join("index.jsonl"))?;
1369            if stat.count > 0 {
1370                stats.insert(tf, stat);
1371            }
1372        }
1373    }
1374    Ok(stats)
1375}
1376
1377/// Walk a type-folder's `.md` content files, recursing through date-shards,
1378/// excluding the `index.md` artifact itself and any hidden entries.
1379fn walk_type_folder_files(store: &Store, folder_abs: &Path) -> Vec<PathBuf> {
1380    let mut out = Vec::new();
1381    if !folder_abs.is_dir() {
1382        return out;
1383    }
1384    for entry in walkdir::WalkDir::new(folder_abs)
1385        .follow_links(true)
1386        .into_iter()
1387        .filter_entry(|e| !is_hidden(e.file_name()) && store.owns_path(e.path()))
1388        .filter_map(|e| e.ok())
1389    {
1390        if !entry.file_type().is_file() {
1391            continue;
1392        }
1393        let p = entry.path();
1394        if p.extension().and_then(|e| e.to_str()) != Some("md") {
1395            continue;
1396        }
1397        if p.file_name().and_then(|n| n.to_str()) == Some("index.md") {
1398            continue;
1399        }
1400        out.push(p.to_path_buf());
1401    }
1402    out
1403}
1404
1405/// The immediate type-folders under a layer (one directory level below the
1406/// layer dir), as store-relative paths. Hidden dirs and `log/` are skipped.
1407fn type_folders_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1408    let layer_dir = store.root.join(layer_dir_name(layer));
1409    let mut out = Vec::new();
1410    let rd = match fs::read_dir(&layer_dir) {
1411        Ok(rd) => rd,
1412        Err(_) => return out,
1413    };
1414    for entry in rd.flatten() {
1415        if !entry.path().is_dir() || !store.owns_path(&entry.path()) {
1416            continue;
1417        }
1418        let name = entry.file_name();
1419        let name = match name.to_str() {
1420            Some(n) => n,
1421            None => continue,
1422        };
1423        if is_hidden(entry.file_name().as_os_str()) || name == "log" {
1424            continue;
1425        }
1426        out.push(PathBuf::from(layer_dir_name(layer)).join(name));
1427    }
1428    out.sort();
1429    out
1430}
1431
1432/// The layer a *loose* content file sits directly in: `<layer>/<file>.md` with
1433/// no type-folder between them — exactly two path components, the first a known
1434/// layer. `None` for a file inside a type-folder (`<layer>/<type>/…`, the common
1435/// case) or one outside any layer. A loose file is catalogued in the layer's own
1436/// `index.jsonl`, not a type-folder's.
1437fn loose_layer_of(file_rel: &Path) -> Option<Layer> {
1438    let mut comps = file_rel.components();
1439    let layer = layer_from_dir_name(comps.next()?.as_os_str().to_str()?)?;
1440    comps.next()?; // the file segment must exist…
1441    if comps.next().is_some() {
1442        return None; // …and be the last one (else it's inside a type-folder)
1443    }
1444    Some(layer)
1445}
1446
1447/// The `.md` content files that live directly at a layer root (loose files),
1448/// excluding `index.md` and any subdirectory (type-folders are walked
1449/// separately). Non-recursive: only the layer's immediate children.
1450fn loose_files_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1451    let layer_dir = store.root.join(layer_dir_name(layer));
1452    let mut out = Vec::new();
1453    let rd = match fs::read_dir(&layer_dir) {
1454        Ok(rd) => rd,
1455        Err(_) => return out,
1456    };
1457    for entry in rd.flatten() {
1458        let p = entry.path();
1459        if !p.is_file() || !store.owns_path(&p) {
1460            continue;
1461        }
1462        if p.extension().and_then(|e| e.to_str()) != Some("md") {
1463            continue;
1464        }
1465        if is_index_artifact(&p) || is_hidden(entry.file_name().as_os_str()) {
1466            continue;
1467        }
1468        out.push(p);
1469    }
1470    out
1471}
1472
1473/// Write (or remove, when empty) a layer's own `index.jsonl` — the complete twin
1474/// for the loose files that live directly at the layer root. The single funnel
1475/// both write-through (`on_write`/`on_remove`/`on_rename`) and the sweeps
1476/// (`rebuild_all`/`write_level`) go through, so their output is byte-identical.
1477fn write_layer_jsonl(store: &Store, layer: Layer, records: &[IndexRecord]) -> crate::Result<()> {
1478    let path = store.root.join(layer_dir_name(layer)).join("index.jsonl");
1479    if records.is_empty() {
1480        remove_if_exists(&path)?;
1481        return Ok(());
1482    }
1483    let idx = Index {
1484        level: IndexLevel::Layer(layer),
1485        records: records.to_vec(),
1486        child_counts: BTreeMap::new(),
1487    };
1488    write_atomic(&path, idx.to_jsonl())
1489}
1490
1491/// Upsert (`removing` = false) or remove (`removing` = true) a loose file's row
1492/// in its layer `index.jsonl`, serialising the read-modify-write under a folder
1493/// lock (same discipline as the type-folder write-through). The layer `index.md`
1494/// rollup is untouched — loose files do not change type-folder counts.
1495fn apply_loose_change(
1496    store: &Store,
1497    layer: Layer,
1498    file_rel: &Path,
1499    removing: bool,
1500) -> crate::Result<()> {
1501    let layer_dir = store.root.join(layer_dir_name(layer));
1502    let _lock = FolderLock::acquire(&layer_dir);
1503    let jsonl = layer_dir.join("index.jsonl");
1504    let mut records = read_jsonl_records(&jsonl)?;
1505    records.retain(|r| r.path != file_rel);
1506    if !removing {
1507        records.push(record_from_file(
1508            &store.root.join(file_rel),
1509            file_rel.to_path_buf(),
1510        )?);
1511    }
1512    sort_records(&mut records);
1513    write_layer_jsonl(store, layer, &records)
1514}
1515
1516/// The type-folder a content file belongs to: `<layer>/<type>` (the first two
1517/// path components), or `None` if the path is not under a known layer with at
1518/// least a type segment.
1519fn type_folder_of(file_rel: &Path) -> Option<PathBuf> {
1520    let mut comps = file_rel.components();
1521    let layer = comps.next()?.as_os_str().to_str()?;
1522    layer_from_dir_name(layer)?;
1523    let type_seg = comps.next()?.as_os_str().to_str()?;
1524    Some(PathBuf::from(layer).join(type_seg))
1525}
1526
1527/// Convert an absolute path under `root` to a store-relative path.
1528fn rel_to_store(root: &Path, abs: &Path) -> Option<PathBuf> {
1529    abs.strip_prefix(root).ok().map(|p| p.to_path_buf())
1530}
1531
1532/// Normalize a possibly-absolute or `./`-prefixed path to a clean
1533/// store-relative form (drops a leading `./`; leaves already-relative paths).
1534fn normalize_rel(p: &Path) -> PathBuf {
1535    let s = path_to_unix(p);
1536    let s = s.strip_prefix("./").unwrap_or(&s);
1537    PathBuf::from(s)
1538}
1539
1540fn is_index_artifact(p: &Path) -> bool {
1541    matches!(
1542        p.file_name().and_then(|n| n.to_str()),
1543        Some("index.md") | Some("index.jsonl")
1544    )
1545}
1546
1547/// True when a file named `index.md` / `index.jsonl` is safe for [`Index::cleanup`]
1548/// to delete — i.e. it is a generated catalog artifact (or a stale/garbage
1549/// leftover from a previous build), NOT a user content file that merely happens
1550/// to be named `index.md`.
1551///
1552/// - `index.jsonl` is always a machine artifact (content files are `.md`), so it
1553///   is always deletable.
1554/// - `index.md` is deletable UNLESS it parses as a content file — frontmatter
1555///   whose `type` is some real record type (anything other than `index`). A
1556///   generated catalog carries `type: index`; a user record carries its own type
1557///   (`email`, `note`, …) and must be preserved (deleting it is silent,
1558///   unrecoverable data loss). A leftover with no/garbage frontmatter (e.g. a
1559///   bare `stale\n`) is treated as a deletable stale artifact.
1560fn is_deletable_catalog_artifact(p: &Path) -> bool {
1561    match p.file_name().and_then(|n| n.to_str()) {
1562        Some("index.jsonl") => true,
1563        Some("index.md") => match read_frontmatter(p) {
1564            // Real content file (non-`index` type) → preserve, never delete.
1565            Ok(meta) => meta.type_.as_deref().is_none_or(|t| t == "index"),
1566            // Unreadable / no frontmatter → a stale or garbage artifact, deletable.
1567            Err(_) => true,
1568        },
1569        _ => false,
1570    }
1571}
1572
1573fn is_hidden(name: &std::ffi::OsStr) -> bool {
1574    name.to_str().map(|s| s.starts_with('.')).unwrap_or(false)
1575}
1576
1577fn layer_dir_name(layer: Layer) -> &'static str {
1578    match layer {
1579        Layer::Sources => "sources",
1580        Layer::Records => "records",
1581    }
1582}
1583
1584/// Local layer-name parse. Mirrors the contract of [`Layer::from_dir_name`];
1585/// kept local to keep this module's walk self-contained (see the module header).
1586fn layer_from_dir_name(name: &str) -> Option<Layer> {
1587    match name {
1588        "sources" => Some(Layer::Sources),
1589        "records" => Some(Layer::Records),
1590        _ => None,
1591    }
1592}
1593
1594/// The final path component as a `&str` (folder basename).
1595fn folder_basename(p: &Path) -> &str {
1596    p.file_name().and_then(|n| n.to_str()).unwrap_or("")
1597}
1598
1599/// The canonical wiki-link target for a content path: the store-relative path
1600/// with `/` separators and the trailing `.md` stripped (the bare form the
1601/// `index.md` browse view links to).
1602fn wiki_target(p: &Path) -> String {
1603    let unix = path_to_unix(p);
1604    unix.strip_suffix(".md").unwrap_or(&unix).to_string()
1605}
1606
1607/// Render a path with `/` separators regardless of host OS, so artifacts are
1608/// identical on every platform.
1609///
1610/// A non-UTF-8 path component (reachable on Linux/ext4, db.md's primary
1611/// deployment target, where `sources/` files arrive verbatim from Latin-1
1612/// exports) is decoded **lossily** with `U+FFFD` markers rather than silently
1613/// dropped. The old `filter_map(|c| c.as_os_str().to_str())` dropped any bad
1614/// component entirely, so `sources/emails/caf\xe9.md` serialized as
1615/// `sources/emails` — a path pointing at the *directory*, not the file, that
1616/// also collapsed distinct files onto one `index.jsonl` key. Lossy decoding
1617/// keeps the leaf present and visibly marked.
1618fn path_to_unix(p: &Path) -> String {
1619    p.components()
1620        .map(|c| c.as_os_str().to_string_lossy().into_owned())
1621        .collect::<Vec<_>>()
1622        .join("/")
1623}
1624
1625/// Serde for [`IndexRecord::path`]: always forward-slash on the wire, so the
1626/// `index.jsonl` catalog is identical whether the store was written on POSIX or
1627/// Windows (a git clone across OSes yields the same paths, and the last-write-
1628/// wins upsert key never splits on separator style). On POSIX this matches the
1629/// default `PathBuf` serialization; on Windows it rewrites `\` to `/`.
1630mod path_serde {
1631    use super::path_to_unix;
1632    use serde::{Deserialize, Deserializer, Serializer};
1633    use std::path::{Path, PathBuf};
1634
1635    pub fn serialize<S: Serializer>(p: &Path, s: S) -> Result<S::Ok, S::Error> {
1636        s.serialize_str(&path_to_unix(p))
1637    }
1638
1639    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<PathBuf, D::Error> {
1640        Ok(PathBuf::from(String::deserialize(d)?))
1641    }
1642}
1643
1644/// ASCII-capitalize the first character.
1645fn capitalize(s: &str) -> String {
1646    let mut chars = s.chars();
1647    match chars.next() {
1648        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1649        None => String::new(),
1650    }
1651}
1652
1653/// Collapse all runs of whitespace (including newlines) into single spaces and
1654/// trim the ends — the single-line normalization the `index.md` browse entry
1655/// ([`format_md_entry`]) applies so a multi-line block-scalar summary can never
1656/// inject a newline into a catalog line.
1657fn collapse_whitespace(s: &str) -> String {
1658    s.split_whitespace().collect::<Vec<_>>().join(" ")
1659}
1660
1661/// Derive a folder's display name from its basename: separators (`-`, `_`)
1662/// become spaces and the first character is upper-cased (`hubspot-exports` →
1663/// `Hubspot exports`). A deterministic floor — the curator overrides it via
1664/// `DB.md ## Folders` (`records/x|HubSpot exports`) for casing the tool cannot
1665/// guess. The tool tidies a folder's *name*; it never infers its *meaning*.
1666fn default_display(basename: &str) -> String {
1667    let spaced: String = basename
1668        .chars()
1669        .map(|c| if c == '-' || c == '_' { ' ' } else { c })
1670        .collect();
1671    capitalize(&spaced)
1672}
1673
1674/// The display name + optional description a root/layer rollup shows for a child
1675/// type-folder: the curator's `## Folders` metadata when present, else the
1676/// derived display name and **no description**. This is the whole anti-"tool
1677/// invents the curator's judgment" contract for the rollups — a description is
1678/// surfaced only when the agent authored one; it is never composed from the
1679/// folder's newest member or any other content.
1680fn folder_label<'a>(
1681    tf_unix: &str,
1682    basename: &str,
1683    folders: &'a BTreeMap<String, FolderMeta>,
1684) -> (String, Option<&'a str>) {
1685    let meta = folders.get(tf_unix);
1686    let display = meta
1687        .and_then(|m| m.display.as_deref())
1688        .map(str::to_string)
1689        .unwrap_or_else(|| default_display(basename));
1690    (display, meta.and_then(|m| m.description.as_deref()))
1691}
1692
1693/// One root/layer rollup entry: `- [[<tf>/index|<Display>]] (<count>)` with an
1694/// ` — <description>` suffix only when the curator authored one.
1695fn folder_entry(tf_unix: &str, display: &str, count: usize, description: Option<&str>) -> String {
1696    match description {
1697        Some(d) => format!("- [[{tf_unix}/index|{display}]] ({count}) — {d}\n"),
1698        None => format!("- [[{tf_unix}/index|{display}]] ({count})\n"),
1699    }
1700}
1701
1702/// Atomic (rename-based) write for the **derived** catalog (`index.md` /
1703/// `index.jsonl`). Deliberately NOT `fsync`-durable like [`crate::fsx`]: the
1704/// index is rebuildable (`dbmd index rebuild`) and this is the O(changed)
1705/// write-through path, so a per-write `fsync` would be cost without benefit — a
1706/// crash-lost catalog write is recovered by a rebuild, not data loss. (Primary
1707/// data — content records, `log.md` — uses the durable `crate::fsx` path.)
1708fn write_atomic(path: &Path, contents: String) -> crate::Result<()> {
1709    if let Some(parent) = path.parent() {
1710        fs::create_dir_all(parent)?;
1711    }
1712    let dir = path.parent().unwrap_or_else(|| Path::new("."));
1713    let mut tmp = tempfile_in(dir)?;
1714    tmp.write_all(contents.as_bytes())?;
1715    tmp.flush()?;
1716    tmp.persist(path)?;
1717    Ok(())
1718}
1719
1720fn remove_if_exists(path: &Path) -> crate::Result<()> {
1721    match fs::remove_file(path) {
1722        Ok(()) => Ok(()),
1723        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1724        Err(e) => Err(e.into()),
1725    }
1726}
1727
1728fn bad_index(path: &Path, msg: &str) -> crate::Error {
1729    crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1730        path: path.to_path_buf(),
1731        message: msg.to_string(),
1732    })
1733}
1734
1735/// Per-type-folder advisory lock for the write-through sidecar read-modify-write.
1736///
1737/// The write-through update of a folder's `index.jsonl`/`index.md` is a
1738/// read-snapshot → modify → atomic-rename-over-whole-file sequence. The SPEC
1739/// sanctions many-writer concurrency for `records/` (`dbmd write` is
1740/// `create_new`-race-safe for the *content* file), but two concurrent writers to
1741/// the SAME type-folder would each read the same sidecar snapshot, add only their
1742/// own row, and rename their whole file over the other's — a classic lost update,
1743/// dropping most rows until a manual `dbmd index rebuild`. This lock serializes
1744/// the per-folder RMW (the content file is already serialized by `create_new`),
1745/// so concurrent sanctioned writes each see the other's row.
1746///
1747/// Implementation: a hidden `<type-folder>/.index.lock` acquired via `create_new`
1748/// (the same O_EXCL primitive `cmd/write.rs` uses), bounded-spin with a small
1749/// sleep, and stale-lock breaking by mtime age so a crashed writer can't wedge
1750/// the folder forever. The dotfile name keeps it out of the content walk
1751/// (`walk_type_folder_files` skips hidden) and out of `cleanup`
1752/// (`is_index_artifact` only matches `index.md`/`index.jsonl`). RAII: the lock is
1753/// released (file removed) on drop, including on the error paths.
1754struct FolderLock {
1755    path: PathBuf,
1756    held: bool,
1757}
1758
1759impl FolderLock {
1760    /// Acquire the lock for `folder_abs`. Waits until it either takes the lock or
1761    /// breaks a genuinely-stale one (a crashed writer's leftover, older than the
1762    /// staleness window). It does **not** give up after a fixed budget and
1763    /// proceed unlocked under contention.
1764    ///
1765    /// Why no contention budget: a single legitimate write can hold this lock for
1766    /// several seconds — `on_write`/`on_remove`/`on_rename` hold it across the
1767    /// whole body, and `update_parents` recomputes the rollups in
1768    /// `O(total catalogued records)`. A short give-up budget (the old ~6s) would
1769    /// expire while a LIVE writer still held the lock, and the loser would then
1770    /// run the sidecar read-modify-write with no mutual exclusion — both writers
1771    /// read the same `index.jsonl` snapshot, each adds only its own row, and one
1772    /// overwrites the other, silently dropping a catalogued record (the lost
1773    /// update this lock exists to prevent; surfaced only by a full
1774    /// `validate --all` as `INDEX_JSONL_DESYNC`). So a live holder is always
1775    /// waited out, never raced. Forward progress is still bounded against a
1776    /// *dead* holder: a lockfile older than `STALE_AFTER` is broken.
1777    ///
1778    /// Residual limitation (documented, follow-up): a single legitimate hold
1779    /// longer than `STALE_AFTER` could be mistaken for a crash and broken. That
1780    /// needs a pathological store (an `update_parents` rollup exceeding the
1781    /// window — itself the flagged `O(total)` hot-path cost). The complete fix is
1782    /// a holder heartbeat that refreshes the lockfile mtime during long ops; not
1783    /// done inline to keep this change surgical. Only a genuine non-contention
1784    /// error (e.g. a permission failure creating the lockfile) degrades to
1785    /// proceeding unlocked — never contention.
1786    fn acquire(folder_abs: &Path) -> Self {
1787        use std::time::{Duration, SystemTime};
1788        const SPIN: Duration = Duration::from_millis(10);
1789        const STALE_AFTER: Duration = Duration::from_secs(30);
1790
1791        let path = folder_abs.join(".index.lock");
1792        // Ensure the folder exists so the lockfile create can succeed.
1793        let _ = fs::create_dir_all(folder_abs);
1794        loop {
1795            match fs::OpenOptions::new()
1796                .write(true)
1797                .create_new(true)
1798                .open(&path)
1799            {
1800                Ok(_) => return FolderLock { path, held: true },
1801                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1802                    // Break a stale lock left by a crashed writer; otherwise wait
1803                    // for the live holder to release. NEVER proceed unlocked here.
1804                    let stale = fs::metadata(&path)
1805                        .and_then(|m| m.modified())
1806                        .ok()
1807                        .and_then(|t| SystemTime::now().duration_since(t).ok())
1808                        .map(|age| age > STALE_AFTER)
1809                        .unwrap_or(false);
1810                    if stale {
1811                        let _ = fs::remove_file(&path);
1812                        continue;
1813                    }
1814                    std::thread::sleep(SPIN);
1815                }
1816                // A non-contention error (permissions, read-only fs): we cannot
1817                // lock here at all, so proceed unlocked rather than fail a
1818                // sanctioned write — the prior best-effort behavior, but ONLY for
1819                // hard errors, never for contention.
1820                Err(_) => return FolderLock { path, held: false },
1821            }
1822        }
1823    }
1824}
1825
1826impl Drop for FolderLock {
1827    fn drop(&mut self) {
1828        if self.held {
1829            let _ = fs::remove_file(&self.path);
1830        }
1831    }
1832}
1833
1834/// Acquire the write-through lock for one or two type-folders. When `a == b`
1835/// (same-folder rename) only one lock is taken. For two distinct folders the
1836/// locks are always acquired in sorted order so a pair of concurrent renames
1837/// touching the same two folders can't deadlock by grabbing them in opposite
1838/// orders. Returns the guard(s); drop releases them.
1839fn lock_folders(store: &Store, a: &Path, b: &Path) -> Vec<FolderLock> {
1840    if a == b {
1841        return vec![FolderLock::acquire(&store.root.join(a))];
1842    }
1843    let (first, second) = if a < b { (a, b) } else { (b, a) };
1844    vec![
1845        FolderLock::acquire(&store.root.join(first)),
1846        FolderLock::acquire(&store.root.join(second)),
1847    ]
1848}
1849
1850// A tiny atomic-write helper. `tempfile` is a dev-dependency for tests; for
1851// the library path we hand-roll a temp-file-then-rename so writes are atomic
1852// without pulling `tempfile` into the non-dev dependency set. The file handle
1853// is held in an `Option` so `persist` can take it out without fighting the
1854// `Drop` impl (which only cleans up an un-persisted temp file).
1855struct AtomicTemp {
1856    file: Option<fs::File>,
1857    path: PathBuf,
1858    persisted: bool,
1859}
1860
1861impl AtomicTemp {
1862    fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
1863        self.file.as_mut().expect("temp file open").write_all(bytes)
1864    }
1865    fn flush(&mut self) -> std::io::Result<()> {
1866        self.file.as_mut().expect("temp file open").flush()
1867    }
1868    fn persist(mut self, dest: &Path) -> std::io::Result<()> {
1869        if let Some(f) = self.file.take() {
1870            f.sync_all().ok();
1871            // `f` dropped here, closing the handle before the rename.
1872        }
1873        fs::rename(&self.path, dest)?;
1874        self.persisted = true;
1875        Ok(())
1876    }
1877}
1878
1879impl Drop for AtomicTemp {
1880    fn drop(&mut self) {
1881        // Best-effort cleanup if not persisted (an error path bailed out).
1882        if !self.persisted {
1883            let _ = fs::remove_file(&self.path);
1884        }
1885    }
1886}
1887
1888fn tempfile_in(dir: &Path) -> std::io::Result<AtomicTemp> {
1889    use std::time::{SystemTime, UNIX_EPOCH};
1890    let nanos = SystemTime::now()
1891        .duration_since(UNIX_EPOCH)
1892        .map(|d| d.as_nanos())
1893        .unwrap_or(0);
1894    let pid = std::process::id();
1895    // Monotonic-ish unique suffix; the dir is the destination dir so rename is
1896    // same-filesystem and therefore atomic.
1897    let counter = next_temp_counter();
1898    let name = format!(".dbmd-index-{pid}-{nanos}-{counter}.tmp");
1899    let path = dir.join(name);
1900    let file = fs::OpenOptions::new()
1901        .write(true)
1902        .create_new(true)
1903        .open(&path)?;
1904    Ok(AtomicTemp {
1905        file: Some(file),
1906        path,
1907        persisted: false,
1908    })
1909}
1910
1911fn next_temp_counter() -> u64 {
1912    use std::sync::atomic::{AtomicU64, Ordering};
1913    static C: AtomicU64 = AtomicU64::new(0);
1914    C.fetch_add(1, Ordering::Relaxed)
1915}
1916
1917#[cfg(test)]
1918mod tests {
1919    use super::*;
1920    use std::collections::BTreeSet;
1921    use std::fs;
1922    use tempfile::TempDir;
1923
1924    // ── fixtures ─────────────────────────────────────────────────────────
1925
1926    /// A temp store with a `DB.md` marker. `store.config` is the parser default
1927    /// (these tests never exercise the config parser).
1928    fn mk_store() -> (TempDir, Store) {
1929        let dir = TempDir::new().unwrap();
1930        fs::write(dir.path().join("DB.md"), "# test store\n").unwrap();
1931        let store = Store {
1932            root: dir.path().to_path_buf(),
1933            config: crate::parser::Config::default(),
1934        };
1935        (dir, store)
1936    }
1937
1938    /// Write a content file at `rel` with the given frontmatter lines + body.
1939    /// `fm` is the raw YAML body between the fences (no `---`).
1940    fn write_raw(store: &Store, rel: &str, fm: &str, body: &str) {
1941        let abs = store.root.join(rel);
1942        fs::create_dir_all(abs.parent().unwrap()).unwrap();
1943        fs::write(&abs, format!("---\n{fm}\n---\n{body}")).unwrap();
1944    }
1945
1946    /// Convenience: write a typed content file with summary/updated/extras.
1947    fn write_doc(
1948        store: &Store,
1949        rel: &str,
1950        type_: &str,
1951        summary: Option<&str>,
1952        updated: Option<&str>,
1953        extra_yaml: &str,
1954    ) {
1955        let mut fm = format!("type: {type_}\n");
1956        if let Some(s) = summary {
1957            fm.push_str(&format!("summary: {s}\n"));
1958        }
1959        if let Some(u) = updated {
1960            fm.push_str(&format!("updated: {u}\n"));
1961        }
1962        fm.push_str(extra_yaml);
1963        write_raw(store, rel, fm.trim_end(), "\nbody text\n");
1964    }
1965
1966    fn read(store: &Store, rel: &str) -> String {
1967        fs::read_to_string(store.root.join(rel)).unwrap()
1968    }
1969
1970    fn exists(store: &Store, rel: &str) -> bool {
1971        store.root.join(rel).exists()
1972    }
1973
1974    /// Collect every `index.md` + `index.jsonl` under the store, mapped to its
1975    /// bytes — the surface the byte-identity invariant compares.
1976    fn snapshot_artifacts(store: &Store) -> BTreeMap<String, String> {
1977        let mut out = BTreeMap::new();
1978        for entry in walkdir::WalkDir::new(&store.root)
1979            .into_iter()
1980            .filter_map(|e| e.ok())
1981        {
1982            let p = entry.path();
1983            if is_index_artifact(p) {
1984                let rel = path_to_unix(&rel_to_store(&store.root, p).unwrap());
1985                out.insert(rel, fs::read_to_string(p).unwrap());
1986            }
1987        }
1988        out
1989    }
1990
1991    // ── build_type_folder + to_markdown ──────────────────────────────────
1992
1993    #[test]
1994    fn type_folder_aggregates_across_shards_in_recency_order() {
1995        let (_d, store) = mk_store();
1996        // Three emails across two month-shards, deliberately written
1997        // out-of-recency-order on disk.
1998        write_doc(
1999            &store,
2000            "sources/emails/2026/05/b-old.md",
2001            "email",
2002            Some("Older mail"),
2003            Some("2026-05-01T09:00:00Z"),
2004            "",
2005        );
2006        write_doc(
2007            &store,
2008            "sources/emails/2026/06/c-new.md",
2009            "email",
2010            Some("Newest mail"),
2011            Some("2026-06-15T12:00:00Z"),
2012            "",
2013        );
2014        write_doc(
2015            &store,
2016            "sources/emails/2026/05/a-mid.md",
2017            "email",
2018            Some("Middle mail"),
2019            Some("2026-05-20T08:00:00Z"),
2020            "",
2021        );
2022
2023        let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
2024        let paths: Vec<String> = idx.records.iter().map(|r| path_to_unix(&r.path)).collect();
2025        assert_eq!(
2026            paths,
2027            vec![
2028                "sources/emails/2026/06/c-new.md",
2029                "sources/emails/2026/05/a-mid.md",
2030                "sources/emails/2026/05/b-old.md",
2031            ],
2032            "records must aggregate across shards, newest `updated` first"
2033        );
2034    }
2035
2036    #[test]
2037    fn type_folder_md_format_entries_tags_and_derived_updated() {
2038        let (_d, store) = mk_store();
2039        write_doc(
2040            &store,
2041            "records/contacts/sarah-chen.md",
2042            "contact",
2043            Some("Renewal champion at Acme"),
2044            Some("2026-05-27T10:00:00Z"),
2045            "tags:\n  - renewal\n  - acme\n",
2046        );
2047        write_doc(
2048            &store,
2049            "records/contacts/no-tags.md",
2050            "contact",
2051            Some("Plain contact"),
2052            Some("2026-05-26T10:00:00Z"),
2053            "",
2054        );
2055
2056        let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
2057        let md = idx.to_markdown();
2058
2059        // Frontmatter is exact and the index's own `updated` is the MAX member
2060        // updated (the determinism the byte-identity invariant rests on).
2061        assert!(md.starts_with(
2062            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\nupdated: 2026-05-27T10:00:00Z\n---\n\n# records/contacts\n"
2063        ), "frontmatter/heading wrong:\n{md}");
2064
2065        // Entry with tags: `— summary  ·  #tag #tag`.
2066        assert!(
2067            md.contains(
2068                "- [[records/contacts/sarah-chen]] — Renewal champion at Acme  ·  #renewal #acme\n"
2069            ),
2070            "tagged entry wrong:\n{md}"
2071        );
2072        // Entry without tags omits the `  ·  ` suffix entirely.
2073        assert!(
2074            md.contains("- [[records/contacts/no-tags]] — Plain contact\n"),
2075            "untagged entry wrong:\n{md}"
2076        );
2077        assert!(
2078            !md.contains("Plain contact  ·"),
2079            "untagged entry must not emit a tag separator"
2080        );
2081        // No `## More` below the cap.
2082        assert!(!md.contains("## More"), "no footer expected under the cap");
2083    }
2084
2085    #[test]
2086    fn missing_summary_becomes_placeholder_not_invented() {
2087        let (_d, store) = mk_store();
2088        write_doc(
2089            &store,
2090            "records/notes/x.md",
2091            "note",
2092            None,
2093            Some("2026-05-27T10:00:00Z"),
2094            "",
2095        );
2096        let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
2097        assert_eq!(idx.records[0].summary, MISSING_SUMMARY);
2098        let md = idx.to_markdown();
2099        assert!(
2100            md.contains("- [[records/notes/x]] — (no summary)\n"),
2101            "missing summary must render the placeholder, not invent text:\n{md}"
2102        );
2103    }
2104
2105    // ── to_jsonl ─────────────────────────────────────────────────────────
2106
2107    #[test]
2108    fn jsonl_is_complete_structured_and_round_trips() {
2109        let (_d, store) = mk_store();
2110        write_doc(
2111            &store,
2112            "records/expenses/2026/05/e1.md",
2113            "expense",
2114            Some("Lunch with vendor"),
2115            Some("2026-05-10T10:00:00Z"),
2116            "created: 2026-05-10T09:00:00Z\nstatus: paid\namount: 42\ncompany: [[records/companies/acme]]\nrelated:\n  - [[records/concepts/spend]]\ntags:\n  - food\nlinks:\n  - records/concepts/spend\n  - [[records/concepts/renewal]]\n",
2117        );
2118        write_doc(
2119            &store,
2120            "records/expenses/2026/06/e2.md",
2121            "expense",
2122            Some("Cloud bill"),
2123            Some("2026-06-01T10:00:00Z"),
2124            "amount: 100\n",
2125        );
2126
2127        let idx = Index::build_type_folder(&store, Path::new("records/expenses")).unwrap();
2128        let jsonl = idx.to_jsonl();
2129        let lines: Vec<&str> = jsonl.lines().collect();
2130        assert_eq!(lines.len(), 2, "one JSON object per file, uncapped");
2131
2132        // Newest first (e2), and each line parses back to an equal record.
2133        let r0: IndexRecord = serde_json::from_str(lines[0]).unwrap();
2134        assert_eq!(path_to_unix(&r0.path), "records/expenses/2026/06/e2.md");
2135        assert_eq!(
2136            r0, idx.records[0],
2137            "jsonl line must round-trip to the record"
2138        );
2139
2140        // The first (data) record carries every reserved field + the extras in
2141        // `fields` (status/amount), and links/tags verbatim.
2142        let r1: IndexRecord = serde_json::from_str(lines[1]).unwrap();
2143        assert_eq!(r1.type_, "expense");
2144        assert_eq!(r1.summary, "Lunch with vendor");
2145        assert_eq!(r1.tags, vec!["food".to_string()]);
2146        assert_eq!(
2147            r1.links,
2148            vec![
2149                "records/concepts/spend".to_string(),
2150                "[[records/concepts/renewal]]".to_string()
2151            ]
2152        );
2153        assert_eq!(
2154            r1.created,
2155            Some(DateTime::parse_from_rfc3339("2026-05-10T09:00:00Z").unwrap())
2156        );
2157        assert_eq!(r1.fields.get("status"), Some(&Value::from("paid")));
2158        assert_eq!(r1.fields.get("amount"), Some(&Value::from(42)));
2159        assert_eq!(
2160            r1.fields.get("company"),
2161            Some(&Value::from("[[records/companies/acme]]"))
2162        );
2163        assert_eq!(
2164            r1.fields.get("related"),
2165            Some(&serde_json::json!(["[[records/concepts/spend]]"]))
2166        );
2167        // Reserved keys never leak into `fields`.
2168        for reserved in [
2169            "path", "type", "summary", "tags", "links", "created", "updated",
2170        ] {
2171            assert!(
2172                !r1.fields.contains_key(reserved),
2173                "reserved key {reserved} must not appear in fields"
2174            );
2175        }
2176
2177        // Stable key order: declared fields first, then sorted extras.
2178        assert!(
2179            lines[1].starts_with(
2180                r#"{"path":"records/expenses/2026/05/e1.md","type":"expense","summary":"Lunch with vendor","tags":["food"],"links":["records/concepts/spend","[[records/concepts/renewal]]"],"created":"2026-05-10T09:00:00Z","updated":"2026-05-10T10:00:00Z","#
2181            ),
2182            "jsonl key order not stable:\n{}",
2183            lines[1]
2184        );
2185        // The flattened extras come in BTreeMap (sorted) order. The catalog
2186        // injects `meta-type: fact` into every records-layer file that does not
2187        // declare one, so it appears among the sorted extras (between `company`
2188        // and `related`).
2189        assert!(
2190            lines[1].ends_with(r#""amount":42,"company":"[[records/companies/acme]]","meta-type":"fact","related":["[[records/concepts/spend]]"],"status":"paid"}"#),
2191            "extras must be sorted:\n{}",
2192            lines[1]
2193        );
2194    }
2195
2196    // ── cap + footer ─────────────────────────────────────────────────────
2197
2198    #[test]
2199    fn over_cap_md_shows_500_plus_footer_jsonl_holds_all() {
2200        let (_d, store) = mk_store();
2201        let total = MD_CAP + 7;
2202        for i in 0..total {
2203            // Distinct, monotonically increasing `updated` so order is total.
2204            let day = 1 + (i % 27);
2205            let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2206            let updated = format!("2026-05-{day:02}T00:00:{:02}Z", i % 60);
2207            write_doc(
2208                &store,
2209                &rel,
2210                "email",
2211                Some(&format!("mail {i}")),
2212                Some(&updated),
2213                "",
2214            );
2215        }
2216        let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
2217        assert_eq!(idx.records.len(), total, "jsonl/records keep every file");
2218
2219        let md = idx.to_markdown();
2220        let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
2221        assert_eq!(entry_lines, MD_CAP, "md browse view is capped at 500");
2222
2223        assert!(
2224            md.contains("## More\n\n"),
2225            "over-cap md needs a More footer"
2226        );
2227        assert!(
2228            md.contains(&format!(
2229                "This folder has {total} files. The 500 most recent are listed above.\n"
2230            )),
2231            "footer count wrong:\n{md}"
2232        );
2233        assert!(
2234            md.contains("Use `dbmd query --type email --in sources` for the complete catalog.\n"),
2235            "footer must infer type=email layer=sources:\n{md}"
2236        );
2237
2238        let jsonl = idx.to_jsonl();
2239        assert_eq!(jsonl.lines().count(), total, "jsonl is uncapped");
2240    }
2241
2242    // ── sort total order ─────────────────────────────────────────────────
2243
2244    #[test]
2245    fn sort_breaks_ties_by_path_and_puts_undated_last() {
2246        let mut recs = vec![
2247            rec("z/a.md", Some("2026-05-01T00:00:00Z")),
2248            rec("a/b.md", Some("2026-05-01T00:00:00Z")), // same updated, path < z/a
2249            rec("m/c.md", None),                         // undated → last
2250            rec("b/d.md", Some("2026-06-01T00:00:00Z")), // newest
2251        ];
2252        sort_records(&mut recs);
2253        let order: Vec<String> = recs.iter().map(|r| path_to_unix(&r.path)).collect();
2254        assert_eq!(order, vec!["b/d.md", "a/b.md", "z/a.md", "m/c.md"]);
2255    }
2256
2257    fn rec(path: &str, updated: Option<&str>) -> IndexRecord {
2258        IndexRecord {
2259            path: PathBuf::from(path),
2260            type_: "t".into(),
2261            summary: "s".into(),
2262            tags: vec![],
2263            links: vec![],
2264            created: None,
2265            updated: updated.map(|u| DateTime::parse_from_rfc3339(u).unwrap()),
2266            fields: BTreeMap::new(),
2267        }
2268    }
2269
2270    // ── build_layer / build_root ─────────────────────────────────────────
2271
2272    #[test]
2273    fn layer_index_lists_type_folders_with_counts() {
2274        let (_d, store) = mk_store();
2275        write_doc(
2276            &store,
2277            "records/contacts/a.md",
2278            "contact",
2279            Some("Contact A older"),
2280            Some("2026-05-01T00:00:00Z"),
2281            "",
2282        );
2283        write_doc(
2284            &store,
2285            "records/contacts/b.md",
2286            "contact",
2287            Some("Contact B newest"),
2288            Some("2026-05-09T00:00:00Z"),
2289            "",
2290        );
2291        write_doc(
2292            &store,
2293            "records/companies/x.md",
2294            "company",
2295            Some("Acme Inc"),
2296            Some("2026-05-05T00:00:00Z"),
2297            "",
2298        );
2299        // build the type-folder artifacts first (layer preview reads their jsonl)
2300        Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
2301        Index::write_level(&store, &IndexLevel::TypeFolder("records/companies".into())).unwrap();
2302
2303        Index::write_level(&store, &IndexLevel::Layer(Layer::Records)).unwrap();
2304        let md = read(&store, "records/index.md");
2305
2306        assert!(
2307            md.starts_with("---\ntype: index\nscope: layer\nfolder: records\n"),
2308            "layer fm:\n{md}"
2309        );
2310        // Alphabetical type-folder order: companies before contacts.
2311        let companies_at = md.find("companies/index").unwrap();
2312        let contacts_at = md.find("contacts/index").unwrap();
2313        assert!(
2314            companies_at < contacts_at,
2315            "type folders must be alphabetical"
2316        );
2317        // Count + display only — with no `## Folders`, the rollup never invents
2318        // a per-folder description from a member summary.
2319        assert!(
2320            md.contains("- [[records/contacts/index|Contacts]] (2)\n"),
2321            "contacts entry:\n{md}"
2322        );
2323        assert!(
2324            md.contains("- [[records/companies/index|Companies]] (1)\n"),
2325            "companies entry:\n{md}"
2326        );
2327        // Crucially: no member summary leaked into the rollup as a description.
2328        assert!(
2329            !md.contains("Contact B newest") && !md.contains("Acme Inc"),
2330            "layer rollup must not quote a member summary:\n{md}"
2331        );
2332        // Layer `updated` is the max across children (contacts b = 05-09).
2333        assert!(
2334            md.contains("updated: 2026-05-09T00:00:00Z\n"),
2335            "layer updated must be max child:\n{md}"
2336        );
2337    }
2338
2339    #[test]
2340    fn folders_section_supplies_authored_display_and_description() {
2341        // The aligned contract: rollups surface the curator's `## Folders`
2342        // display + description; the tool never invents one. A folder with no
2343        // entry shows counts only — no member summary leaks in as a description.
2344        let (_d, mut store) = mk_store();
2345        store.config.folders.insert(
2346            "records/contacts".into(),
2347            crate::parser::FolderMeta {
2348                display: None,
2349                description: Some("people across customer + prospect accounts".into()),
2350            },
2351        );
2352        store.config.folders.insert(
2353            "sources/hubspot-exports".into(),
2354            crate::parser::FolderMeta {
2355                display: Some("HubSpot exports".into()),
2356                description: Some("deal + pipeline exports".into()),
2357            },
2358        );
2359        write_doc(
2360            &store,
2361            "records/contacts/a.md",
2362            "contact",
2363            Some("Contact A"),
2364            Some("2026-05-01T00:00:00Z"),
2365            "",
2366        );
2367        // companies has NO `## Folders` entry → counts only.
2368        write_doc(
2369            &store,
2370            "records/companies/x.md",
2371            "company",
2372            Some("Acme Inc"),
2373            Some("2026-05-05T00:00:00Z"),
2374            "",
2375        );
2376        write_doc(
2377            &store,
2378            "sources/hubspot-exports/d.md",
2379            "hubspot-export",
2380            Some("a single deal export"),
2381            Some("2026-05-03T00:00:00Z"),
2382            "",
2383        );
2384
2385        Index::rebuild_all(&store).unwrap();
2386
2387        // Authored description surfaced (contacts), with the derived display.
2388        let records_layer = read(&store, "records/index.md");
2389        assert!(
2390            records_layer.contains("- [[records/contacts/index|Contacts]] (1) — people across customer + prospect accounts\n"),
2391            "authored description must surface:\n{records_layer}"
2392        );
2393        // No `## Folders` entry ⇒ counts only; the member summary never leaks in.
2394        assert!(
2395            records_layer.contains("- [[records/companies/index|Companies]] (1)\n")
2396                && !records_layer.contains("Acme Inc"),
2397            "un-described folder is counts-only:\n{records_layer}"
2398        );
2399
2400        // Display override beats the derived "Hubspot exports".
2401        let sources_layer = read(&store, "sources/index.md");
2402        assert!(
2403            sources_layer.contains("- [[sources/hubspot-exports/index|HubSpot exports]] (1) — deal + pipeline exports\n"),
2404            "display override + description must surface:\n{sources_layer}"
2405        );
2406
2407        // Root rollup carries the same authored metadata (display + description).
2408        let root = read(&store, "index.md");
2409        assert!(
2410            root.contains("- [[records/contacts/index|Contacts]] (1) — people across customer + prospect accounts\n"),
2411            "root surfaces authored description:\n{root}"
2412        );
2413        assert!(
2414            root.contains("- [[sources/hubspot-exports/index|HubSpot exports]] (1) — deal + pipeline exports\n"),
2415            "root surfaces display override:\n{root}"
2416        );
2417    }
2418
2419    #[test]
2420    fn default_display_turns_separators_to_spaces_and_caps() {
2421        assert_eq!(default_display("contacts"), "Contacts");
2422        assert_eq!(default_display("hubspot-exports"), "Hubspot exports");
2423        assert_eq!(default_display("usage_exports"), "Usage exports");
2424    }
2425
2426    #[test]
2427    fn root_index_groups_layers_with_totals_and_per_type_counts() {
2428        let (_d, store) = mk_store();
2429        write_doc(
2430            &store,
2431            "sources/emails/2026/05/a.md",
2432            "email",
2433            Some("Mail"),
2434            Some("2026-05-01T00:00:00Z"),
2435            "",
2436        );
2437        write_doc(
2438            &store,
2439            "sources/docs/d.md",
2440            "doc",
2441            Some("Doc"),
2442            Some("2026-05-02T00:00:00Z"),
2443            "",
2444        );
2445        write_doc(
2446            &store,
2447            "records/contacts/c.md",
2448            "contact",
2449            Some("C"),
2450            Some("2026-05-03T00:00:00Z"),
2451            "",
2452        );
2453        // wiki empty → no Wiki section
2454
2455        Index::rebuild_all(&store).unwrap();
2456        let md = read(&store, "index.md");
2457
2458        assert!(
2459            md.starts_with("---\ntype: index\nscope: root\n"),
2460            "root fm:\n{md}"
2461        );
2462        assert!(md.contains("# Knowledge base index\n"), "root title:\n{md}");
2463        // Layer heading with total count; Sources before Records (canonical).
2464        let sources_h = md
2465            .find("## Sources (2)")
2466            .expect("sources heading w/ total 2");
2467        let records_h = md
2468            .find("## Records (1)")
2469            .expect("records heading w/ total 1");
2470        assert!(sources_h < records_h, "Sources must precede Records");
2471        assert!(!md.contains("## Wiki"), "empty layer gets no section");
2472        // Per-type sub-entries with (N), no preview at root.
2473        assert!(
2474            md.contains("- [[sources/docs/index|Docs]] (1)\n"),
2475            "root docs entry:\n{md}"
2476        );
2477        assert!(
2478            md.contains("- [[sources/emails/index|Emails]] (1)\n"),
2479            "root emails entry:\n{md}"
2480        );
2481        assert!(
2482            md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
2483            "root contacts entry:\n{md}"
2484        );
2485        assert!(!md.contains("— "), "root entries carry no preview text");
2486    }
2487
2488    // ── write-through == rebuild (THE invariant) ─────────────────────────
2489
2490    #[test]
2491    fn on_write_matches_rebuild_byte_for_byte() {
2492        // Build a store incrementally via on_write, and a second identical store
2493        // via a single rebuild_all, then assert every index artifact is equal.
2494        let (_d1, wt) = mk_store();
2495        let (_d2, rb) = mk_store();
2496
2497        let docs: &[(&str, &str, &str, &str, &str)] = &[
2498            (
2499                "sources/emails/2026/05/e1.md",
2500                "email",
2501                "First mail",
2502                "2026-05-01T10:00:00Z",
2503                "tags:\n  - inbox\n",
2504            ),
2505            (
2506                "sources/emails/2026/06/e2.md",
2507                "email",
2508                "Second mail",
2509                "2026-06-01T10:00:00Z",
2510                "",
2511            ),
2512            (
2513                "records/contacts/sarah.md",
2514                "contact",
2515                "Sarah",
2516                "2026-05-15T10:00:00Z",
2517                "links:\n  - records/profiles/sarah\n",
2518            ),
2519            (
2520                "records/contacts/elena.md",
2521                "contact",
2522                "Elena",
2523                "2026-05-20T10:00:00Z",
2524                "status: active\n",
2525            ),
2526            (
2527                "records/profiles/sarah.md",
2528                "profile",
2529                "Sarah bio",
2530                "2026-05-21T10:00:00Z",
2531                "",
2532            ),
2533        ];
2534
2535        for (rel, t, sum, upd, extra) in docs {
2536            write_doc(&wt, rel, t, Some(sum), Some(upd), extra);
2537            write_doc(&rb, rel, t, Some(sum), Some(upd), extra);
2538            Index::on_write(&wt, Path::new(rel)).unwrap();
2539        }
2540        Index::rebuild_all(&rb).unwrap();
2541
2542        let a = snapshot_artifacts(&wt);
2543        let b = snapshot_artifacts(&rb);
2544        assert_eq!(
2545            a.keys().collect::<Vec<_>>(),
2546            b.keys().collect::<Vec<_>>(),
2547            "same set of index artifacts must exist"
2548        );
2549        for (k, v) in &a {
2550            assert_eq!(v, &b[k], "artifact {k} differs between write-through and rebuild:\n--- write-through ---\n{v}\n--- rebuild ---\n{}", b[k]);
2551        }
2552        // Sanity: artifacts actually exist (not a vacuous comparison of empties).
2553        assert!(a.contains_key("index.md"));
2554        assert!(a.contains_key("sources/emails/index.jsonl"));
2555        assert!(a.contains_key("records/contacts/index.md"));
2556    }
2557
2558    /// Regression (O(changed) bound, not just correctness): a loop op must
2559    /// recompute its parent rollups from the type-folder `index.jsonl` sidecars
2560    /// — never by walking the content tree of *sibling* folders it wasn't asked
2561    /// about. The byte-identity property test (which always indexes every folder
2562    /// before comparing) can't catch a violation, because a full-store walk
2563    /// produces the *correct* counts too; it just does so in `O(store files)`.
2564    ///
2565    /// The behavioral fingerprint of the old `update_parents → build_layer /
2566    /// build_root` (which called `walk_type_folder_files` on every type-folder in
2567    /// the store): a single `on_write` to `records/contacts/sarah.md` would
2568    /// surface, in the layer + root rollups, the file count of
2569    /// `records/companies` — a sibling that has content on disk but was NEVER
2570    /// passed to a write/index op, so it has no `index.jsonl`. An O(changed) loop
2571    /// op cannot "see" that un-indexed folder; a whole-store walk can. So this
2572    /// asserts the rollups reflect ONLY the sidecar-indexed folder, proving no
2573    /// content-tree walk happened.
2574    #[test]
2575    fn loop_op_does_not_walk_sibling_content_tree() {
2576        let (_d, store) = mk_store();
2577
2578        // A sibling type-folder with real content on disk, but deliberately
2579        // never indexed (no on_write / write_level / rebuild over it) ⇒ no
2580        // `records/companies/index.jsonl` exists.
2581        write_doc(
2582            &store,
2583            "records/companies/acme.md",
2584            "company",
2585            Some("Acme Inc"),
2586            Some("2026-05-05T00:00:00Z"),
2587            "",
2588        );
2589        write_doc(
2590            &store,
2591            "records/companies/globex.md",
2592            "company",
2593            Some("Globex"),
2594            Some("2026-05-06T00:00:00Z"),
2595            "",
2596        );
2597        assert!(
2598            !exists(&store, "records/companies/index.jsonl"),
2599            "precondition: companies must be un-indexed"
2600        );
2601
2602        // The ONLY loop op: a single write to a different type-folder.
2603        write_doc(
2604            &store,
2605            "records/contacts/sarah.md",
2606            "contact",
2607            Some("Sarah"),
2608            Some("2026-05-15T00:00:00Z"),
2609            "",
2610        );
2611        Index::on_write(&store, Path::new("records/contacts/sarah.md")).unwrap();
2612
2613        // The written folder is reflected in both rollups...
2614        let layer_md = read(&store, "records/index.md");
2615        let root_md = read(&store, "index.md");
2616        // (both rollups show counts only — no `## Folders` here, so no preview)
2617        assert!(
2618            layer_md.contains("- [[records/contacts/index|Contacts]] (1)\n")
2619                && !layer_md.contains("Sarah"),
2620            "layer must reflect the written folder, counts only:\n{layer_md}"
2621        );
2622        assert!(
2623            root_md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
2624            "root must reflect the written folder:\n{root_md}"
2625        );
2626
2627        // ...but the un-indexed sibling must be INVISIBLE to a loop op. If the
2628        // rollups mention `records/companies` at all, `on_write` walked the whole
2629        // content tree — the O(store) regression.
2630        assert!(
2631            !layer_md.contains("companies"),
2632            "loop op walked the sibling content tree: layer rollup counts un-indexed records/companies\n{layer_md}"
2633        );
2634        assert!(
2635            !root_md.contains("companies"),
2636            "loop op walked the sibling content tree: root rollup counts un-indexed records/companies\n{root_md}"
2637        );
2638        // The layer's only child is contacts ⇒ its total is exactly 1, not 3.
2639        assert!(
2640            root_md.contains("## Records (1)"),
2641            "root layer total must count only the sidecar-indexed folder (1), not walked siblings (would be 3):\n{root_md}"
2642        );
2643
2644        // And the sidecar-derived count IS what a full walk WOULD yield once the
2645        // sibling is indexed too — i.e. the fix changes cost, not the eventual
2646        // result. Index companies, then confirm the rollups now (and only now)
2647        // include it, byte-identical to a from-scratch rebuild.
2648        let (_d2, rb) = mk_store();
2649        for (rel, t, s, u) in [
2650            (
2651                "records/companies/acme.md",
2652                "company",
2653                "Acme Inc",
2654                "2026-05-05T00:00:00Z",
2655            ),
2656            (
2657                "records/companies/globex.md",
2658                "company",
2659                "Globex",
2660                "2026-05-06T00:00:00Z",
2661            ),
2662            (
2663                "records/contacts/sarah.md",
2664                "contact",
2665                "Sarah",
2666                "2026-05-15T00:00:00Z",
2667            ),
2668        ] {
2669            write_doc(&rb, rel, t, Some(s), Some(u), "");
2670        }
2671        Index::on_write(&store, Path::new("records/companies/acme.md")).unwrap();
2672        Index::on_write(&store, Path::new("records/companies/globex.md")).unwrap();
2673        Index::rebuild_all(&rb).unwrap();
2674        let a = snapshot_artifacts(&store);
2675        let b = snapshot_artifacts(&rb);
2676        assert_eq!(
2677            a.keys().collect::<BTreeSet<_>>(),
2678            b.keys().collect::<BTreeSet<_>>(),
2679            "same artifact set after indexing both folders"
2680        );
2681        for (k, v) in &a {
2682            assert_eq!(
2683                v, &b[k],
2684                "after indexing the sibling too, loop result must equal rebuild for {k}"
2685            );
2686        }
2687        assert!(
2688            read(&store, "index.md").contains("## Records (3)"),
2689            "now that both folders are indexed, the root total is 3"
2690        );
2691    }
2692
2693    /// Regression: a type filed at the path the toolkit ITSELF computes
2694    /// (`Store::shard_path_for`) must be indexable end-to-end. The class of bug
2695    /// is a 2-component `<layer>/<file>` path, which `type_folder_of` treats as
2696    /// having no type-folder — making the producer (path computation) disagree
2697    /// with the consumer (index): the loop path crashes (`on_write` → `Err`, it
2698    /// tries to write `index.md` *inside* a file) while the sweep path silently
2699    /// drops the page from every catalog. A conclusion `profile` is a custom
2700    /// (non-built-in) type, so `shard_path_for` files it under the records-layer
2701    /// fallback `records/profile/<file>` — a conforming 3-component path. This test
2702    /// drives both paths through the real `shard_path_for` output and asserts
2703    /// (1) `on_write` succeeds, (2) the page appears in the rebuilt catalog, and
2704    /// (3) write-through == rebuild.
2705    #[test]
2706    fn custom_type_at_shard_path_for_is_indexable_end_to_end() {
2707        let (_d1, wt) = mk_store();
2708        let (_d2, rb) = mk_store();
2709
2710        // The toolkit's own canonical write path for a custom-type record.
2711        let rel = wt
2712            .shard_path_for(
2713                "profile",
2714                &crate::parser::Frontmatter::default(),
2715                "renewal-theme",
2716            )
2717            .unwrap();
2718        let rel_str = path_to_unix(&rel);
2719        // Guard the precondition the consumer requires: 3+ components so
2720        // `type_folder_of` resolves a real `<layer>/<type-folder>`.
2721        assert!(
2722            type_folder_of(&rel).is_some(),
2723            "shard_path_for produced a path the index cannot file: {rel_str}"
2724        );
2725
2726        write_doc(
2727            &wt,
2728            &rel_str,
2729            "profile",
2730            Some("Renewal theme"),
2731            Some("2026-05-21T10:00:00Z"),
2732            "",
2733        );
2734        write_doc(
2735            &rb,
2736            &rel_str,
2737            "profile",
2738            Some("Renewal theme"),
2739            Some("2026-05-21T10:00:00Z"),
2740            "",
2741        );
2742
2743        // (1) Loop path must NOT error (a 2-component `<layer>/<file>` shape
2744        // returned Err(Io(NotADirectory))).
2745        Index::on_write(&wt, &rel)
2746            .expect("on_write must succeed for a toolkit-computed custom-type path");
2747        Index::rebuild_all(&rb).unwrap();
2748
2749        // (2) The page is present in the rebuilt catalog (the old flat-path bug
2750        // silently omitted it from every artifact). The individual page link
2751        // lives in the *type-folder* index; the *layer* index rolls the
2752        // type-folder up — assert both, since the bug erased both. A custom
2753        // type's canonical folder is the records-layer fallback `records/profile`.
2754        let page_link = wiki_target(&rel); // records/profile/renewal-theme
2755        let tf_md = read(&rb, "records/profile/index.md");
2756        assert!(
2757            tf_md.contains(&format!("[[{page_link}]]")),
2758            "type-folder index must list the page link, got:\n{tf_md}"
2759        );
2760        assert!(
2761            exists(&rb, "records/profile/index.jsonl"),
2762            "type-folder jsonl must exist"
2763        );
2764        assert!(
2765            read(&rb, "records/profile/index.jsonl").contains(&rel_str),
2766            "type-folder jsonl must contain the page row"
2767        );
2768        // The layer index rolls the type-folder up (proves the page's folder is
2769        // visible to the layer catalog, not dropped).
2770        let layer_md = read(&rb, "records/index.md");
2771        assert!(
2772            layer_md.contains("records/profile/index"),
2773            "layer index must roll up the records/profile type-folder, got:\n{layer_md}"
2774        );
2775
2776        // (3) Write-through equals rebuild byte-for-byte — loop and sweep agree.
2777        let a = snapshot_artifacts(&wt);
2778        let b = snapshot_artifacts(&rb);
2779        assert_eq!(
2780            a.keys().collect::<Vec<_>>(),
2781            b.keys().collect::<Vec<_>>(),
2782            "loop and sweep must produce the same artifact set"
2783        );
2784        for (k, v) in &a {
2785            assert_eq!(
2786                v, &b[k],
2787                "custom-type artifact {k} differs between on_write and rebuild"
2788            );
2789        }
2790    }
2791
2792    #[test]
2793    fn on_remove_then_rebuild_match_and_pull_in_next_over_cap() {
2794        let (_d1, wt) = mk_store();
2795        let (_d2, rb) = mk_store();
2796        let total = MD_CAP + 3; // 503 files; removing one keeps md full at 500
2797        let mut all_rels = Vec::new();
2798        for i in 0..total {
2799            let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2800            // `updated` strictly increasing across i by varying both minute and second
2801            let updated = format!("2026-05-10T00:{:02}:{:02}Z", i / 60, i % 60);
2802            write_doc(
2803                &wt,
2804                &rel,
2805                "email",
2806                Some(&format!("mail {i}")),
2807                Some(&updated),
2808                "",
2809            );
2810            write_doc(
2811                &rb,
2812                &rel,
2813                "email",
2814                Some(&format!("mail {i}")),
2815                Some(&updated),
2816                "",
2817            );
2818            all_rels.push(rel);
2819        }
2820        // Build write-through index, then remove the single newest file.
2821        Index::rebuild_all(&wt).unwrap();
2822        let newest = &all_rels[total - 1]; // highest i = newest updated
2823        fs::remove_file(wt.root.join(newest)).unwrap();
2824        Index::on_remove(&wt, Path::new(newest)).unwrap();
2825
2826        // Rebuild side: same end state (file physically absent).
2827        fs::remove_file(rb.root.join(newest)).unwrap();
2828        Index::rebuild_all(&rb).unwrap();
2829
2830        let a = snapshot_artifacts(&wt);
2831        let b = snapshot_artifacts(&rb);
2832        for (k, v) in &a {
2833            assert_eq!(v, &b[k], "after remove, artifact {k} drifted from rebuild");
2834        }
2835
2836        // The md must still hold exactly 500 entries (the 501st got pulled in)
2837        // and the removed file must be gone from both artifacts.
2838        let md = read(&wt, "sources/emails/index.md");
2839        assert_eq!(md.lines().filter(|l| l.starts_with("- [[")).count(), MD_CAP);
2840        // Removed (newest) file is gone from the bare-path md and the .md jsonl.
2841        assert!(
2842            !md.contains(&format!("[[{}]]", wiki_target(Path::new(newest)))),
2843            "removed file must not be listed in md"
2844        );
2845        // The file previously at rank 501 (excluded under the cap) is `all_rels[2]`
2846        // — `updated` increases with index, so newest-first rank 500 = index 2.
2847        // After dropping the newest it shifts into the visible 500.
2848        let pulled_in = &all_rels[2];
2849        assert!(
2850            md.contains(&format!("[[{}]]", wiki_target(Path::new(pulled_in)))),
2851            "the 501st-most-recent must be pulled into the browse view after a removal"
2852        );
2853        assert!(
2854            md.contains(&format!("This folder has {} files.", total - 1)),
2855            "footer count must decrement:\n{}",
2856            md.lines().rev().take(4).collect::<Vec<_>>().join("\n")
2857        );
2858        let jsonl = read(&wt, "sources/emails/index.jsonl");
2859        assert_eq!(
2860            jsonl.lines().count(),
2861            total - 1,
2862            "jsonl loses exactly the removed file"
2863        );
2864        assert!(
2865            !jsonl.contains(&path_to_unix(Path::new(newest))),
2866            "removed file must be gone from the jsonl too"
2867        );
2868    }
2869
2870    #[test]
2871    fn on_rename_cross_folder_matches_rebuild() {
2872        let (_d1, wt) = mk_store();
2873        let (_d2, rb) = mk_store();
2874        // Seed both stores identically.
2875        let seed: &[(&str, &str, &str, &str)] = &[
2876            (
2877                "records/contacts/a.md",
2878                "contact",
2879                "A",
2880                "2026-05-01T00:00:00Z",
2881            ),
2882            (
2883                "records/contacts/b.md",
2884                "contact",
2885                "B",
2886                "2026-05-02T00:00:00Z",
2887            ),
2888            (
2889                "records/companies/x.md",
2890                "company",
2891                "X",
2892                "2026-05-03T00:00:00Z",
2893            ),
2894        ];
2895        for (rel, t, s, u) in seed {
2896            write_doc(&wt, rel, t, Some(s), Some(u), "");
2897            write_doc(&rb, rel, t, Some(s), Some(u), "");
2898        }
2899        Index::rebuild_all(&wt).unwrap();
2900
2901        // Rename contacts/b.md -> companies/b.md (cross type-folder). The file's
2902        // `type` changes to match its new folder, as a real `dbmd rename` would.
2903        let old = "records/contacts/b.md";
2904        let new = "records/companies/b.md";
2905        fs::create_dir_all(wt.root.join("records/companies")).unwrap();
2906        fs::rename(wt.root.join(old), wt.root.join(new)).unwrap();
2907        // (type stays "contact" here; index copies frontmatter verbatim — the
2908        // test only asserts placement + parity with rebuild.)
2909        Index::on_rename(&wt, Path::new(old), Path::new(new)).unwrap();
2910
2911        // Rebuild side: same end state.
2912        fs::create_dir_all(rb.root.join("records/companies")).unwrap();
2913        fs::rename(rb.root.join(old), rb.root.join(new)).unwrap();
2914        Index::rebuild_all(&rb).unwrap();
2915
2916        let a = snapshot_artifacts(&wt);
2917        let b = snapshot_artifacts(&rb);
2918        assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
2919        for (k, v) in &a {
2920            assert_eq!(v, &b[k], "rename: artifact {k} drifted from rebuild");
2921        }
2922        // Concretely: b is gone from contacts, present in companies.
2923        let contacts = read(&wt, "records/contacts/index.md");
2924        assert!(!contacts.contains("records/contacts/b]]"));
2925        let companies = read(&wt, "records/companies/index.md");
2926        assert!(companies.contains("[[records/companies/b]]"));
2927    }
2928
2929    #[test]
2930    fn on_write_updates_existing_entry_in_place() {
2931        let (_d, store) = mk_store();
2932        write_doc(
2933            &store,
2934            "records/contacts/a.md",
2935            "contact",
2936            Some("Original"),
2937            Some("2026-05-01T00:00:00Z"),
2938            "",
2939        );
2940        Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2941        // Edit the same file: new summary + newer updated.
2942        write_doc(
2943            &store,
2944            "records/contacts/a.md",
2945            "contact",
2946            Some("Revised"),
2947            Some("2026-05-09T00:00:00Z"),
2948            "",
2949        );
2950        Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2951
2952        let jsonl = read(&store, "records/contacts/index.jsonl");
2953        assert_eq!(
2954            jsonl.lines().count(),
2955            1,
2956            "upsert must not duplicate the line"
2957        );
2958        assert!(jsonl.contains("Revised"), "jsonl must reflect the update");
2959        assert!(
2960            !jsonl.contains("Original"),
2961            "stale line must be gone (compacted)"
2962        );
2963        let md = read(&store, "records/contacts/index.md");
2964        assert!(md.contains("- [[records/contacts/a]] — Revised\n"));
2965        assert!(
2966            md.contains("updated: 2026-05-09T00:00:00Z\n"),
2967            "index updated must track the newer member"
2968        );
2969    }
2970
2971    // ── dry-run + cleanup ────────────────────────────────────────────────
2972
2973    #[test]
2974    fn dry_run_emits_separators_and_writes_nothing() {
2975        let (_d, store) = mk_store();
2976        write_doc(
2977            &store,
2978            "sources/emails/2026/05/a.md",
2979            "email",
2980            Some("Mail"),
2981            Some("2026-05-01T00:00:00Z"),
2982            "",
2983        );
2984        let out = Index::render_dry_run(&store, &IndexLevel::TypeFolder("sources/emails".into()))
2985            .unwrap();
2986        assert!(
2987            out.contains("--- sources/emails/index.md ---\n"),
2988            "md separator:\n{out}"
2989        );
2990        assert!(
2991            out.contains("--- sources/emails/index.jsonl ---\n"),
2992            "jsonl separator:\n{out}"
2993        );
2994        assert!(
2995            out.contains("- [[sources/emails/2026/05/a]] — Mail"),
2996            "md body present"
2997        );
2998        // Nothing was written to disk.
2999        assert!(
3000            !exists(&store, "sources/emails/index.md"),
3001            "dry-run must not write"
3002        );
3003        assert!(
3004            !exists(&store, "sources/emails/index.jsonl"),
3005            "dry-run must not write"
3006        );
3007    }
3008
3009    #[test]
3010    fn cleanup_removes_noncanonical_and_empty_indexes() {
3011        let (_d, store) = mk_store();
3012        write_doc(
3013            &store,
3014            "sources/emails/2026/05/a.md",
3015            "email",
3016            Some("Mail"),
3017            Some("2026-05-01T00:00:00Z"),
3018            "",
3019        );
3020        // A stray index inside a date-shard (non-canonical) ...
3021        fs::write(
3022            store.root.join("sources/emails/2026/05/index.md"),
3023            "stale\n",
3024        )
3025        .unwrap();
3026        fs::write(
3027            store.root.join("sources/emails/2026/05/index.jsonl"),
3028            "stale\n",
3029        )
3030        .unwrap();
3031        // ... and an index in an empty type-folder.
3032        fs::create_dir_all(store.root.join("records/empty")).unwrap();
3033        fs::write(store.root.join("records/empty/index.md"), "stale\n").unwrap();
3034
3035        Index::cleanup(&store).unwrap();
3036
3037        assert!(
3038            !exists(&store, "sources/emails/2026/05/index.md"),
3039            "shard index must be deleted"
3040        );
3041        assert!(
3042            !exists(&store, "sources/emails/2026/05/index.jsonl"),
3043            "shard jsonl must be deleted"
3044        );
3045        assert!(
3046            !exists(&store, "records/empty/index.md"),
3047            "empty-folder index must be deleted"
3048        );
3049        // The canonical type-folder file itself is untouched by cleanup.
3050        assert!(exists(&store, "sources/emails/2026/05/a.md"));
3051    }
3052
3053    #[test]
3054    fn rebuild_deletes_stale_indexes_for_emptied_folders() {
3055        let (_d, store) = mk_store();
3056        write_doc(
3057            &store,
3058            "records/contacts/a.md",
3059            "contact",
3060            Some("A"),
3061            Some("2026-05-01T00:00:00Z"),
3062            "",
3063        );
3064        Index::rebuild_all(&store).unwrap();
3065        assert!(exists(&store, "records/contacts/index.md"));
3066        assert!(exists(&store, "records/index.md"));
3067        assert!(exists(&store, "index.md"));
3068
3069        // Empty the folder entirely, then rebuild: all three levels vanish.
3070        fs::remove_file(store.root.join("records/contacts/a.md")).unwrap();
3071        Index::rebuild_all(&store).unwrap();
3072        assert!(
3073            !exists(&store, "records/contacts/index.md"),
3074            "emptied type-folder index gone"
3075        );
3076        assert!(
3077            !exists(&store, "records/index.md"),
3078            "now-empty layer index gone"
3079        );
3080        assert!(!exists(&store, "index.md"), "now-empty root index gone");
3081    }
3082
3083    // ── randomized parity (property-style) ───────────────────────────────
3084
3085    #[test]
3086    fn property_writethrough_equals_rebuild_under_mixed_ops() {
3087        // Deterministic pseudo-random op sequence (no rand crate): a small LCG.
3088        let (_d1, wt) = mk_store();
3089        let (_d2, rb) = mk_store();
3090        let mut seed: u64 = 0x9E3779B97F4A7C15;
3091        let mut next = || {
3092            seed = seed
3093                .wrapping_mul(6364136223846793005)
3094                .wrapping_add(1442695040888963407);
3095            (seed >> 33) as u32
3096        };
3097
3098        let folders = ["sources/emails", "records/contacts", "records/profiles"];
3099        let types = ["email", "contact", "profile"];
3100        let mut live: Vec<String> = Vec::new(); // store-relative paths that exist
3101
3102        for step in 0..120u32 {
3103            let r = next();
3104            let op = r % 10;
3105            if op < 6 || live.is_empty() {
3106                // CREATE/UPDATE
3107                let fi = (next() as usize) % folders.len();
3108                let folder = folders[fi];
3109                let id = next() % 40;
3110                let rel = if folder == "sources/emails" {
3111                    let month = 5 + (id % 2); // shard across two months
3112                    format!("{folder}/2026/{month:02}/f-{id:02}.md")
3113                } else {
3114                    format!("{folder}/f-{id:02}.md")
3115                };
3116                // recency varies with step so order is meaningful + total
3117                let updated = format!(
3118                    "2026-05-{:02}T{:02}:{:02}:00Z",
3119                    1 + (step % 27),
3120                    step % 24,
3121                    id % 60
3122                );
3123                let extra = if id % 3 == 0 {
3124                    "tags:\n  - x\n  - y\n"
3125                } else {
3126                    ""
3127                };
3128                write_doc(
3129                    &wt,
3130                    &rel,
3131                    types[fi],
3132                    Some(&format!("sum {step}")),
3133                    Some(&updated),
3134                    extra,
3135                );
3136                write_doc(
3137                    &rb,
3138                    &rel,
3139                    types[fi],
3140                    Some(&format!("sum {step}")),
3141                    Some(&updated),
3142                    extra,
3143                );
3144                Index::on_write(&wt, Path::new(&rel)).unwrap();
3145                if !live.contains(&rel) {
3146                    live.push(rel);
3147                }
3148            } else if op < 8 {
3149                // REMOVE a live file
3150                let idx = (next() as usize) % live.len();
3151                let rel = live.remove(idx);
3152                fs::remove_file(wt.root.join(&rel)).unwrap();
3153                fs::remove_file(rb.root.join(&rel)).ok();
3154                Index::on_remove(&wt, Path::new(&rel)).unwrap();
3155            } else {
3156                // RENAME a live file within the same layer (new id, maybe new type-folder)
3157                let idx = (next() as usize) % live.len();
3158                let old = live[idx].clone();
3159                // pick a destination folder in the same layer-ish set
3160                let fi = (next() as usize) % folders.len();
3161                let folder = folders[fi];
3162                let id = 50 + (next() % 40);
3163                let new = if folder == "sources/emails" {
3164                    format!("{folder}/2026/05/f-{id:02}.md")
3165                } else {
3166                    format!("{folder}/f-{id:02}.md")
3167                };
3168                if new == old || live.contains(&new) {
3169                    continue;
3170                }
3171                fs::create_dir_all(wt.root.join(&new).parent().unwrap()).unwrap();
3172                fs::create_dir_all(rb.root.join(&new).parent().unwrap()).unwrap();
3173                fs::rename(wt.root.join(&old), wt.root.join(&new)).unwrap();
3174                fs::rename(rb.root.join(&old), rb.root.join(&new)).unwrap();
3175                Index::on_rename(&wt, Path::new(&old), Path::new(&new)).unwrap();
3176                live[idx] = new;
3177            }
3178        }
3179
3180        // Now rebuild the rb side from the shared end state and compare.
3181        Index::rebuild_all(&rb).unwrap();
3182        let a = snapshot_artifacts(&wt);
3183        let b = snapshot_artifacts(&rb);
3184        assert_eq!(
3185            a.keys().collect::<BTreeSet<_>>(),
3186            b.keys().collect::<BTreeSet<_>>(),
3187            "write-through and rebuild must produce the same set of artifacts"
3188        );
3189        for (k, v) in &a {
3190            assert_eq!(
3191                v, &b[k],
3192                "INVARIANT VIOLATED: artifact {k} differs after mixed ops\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
3193                b[k]
3194            );
3195        }
3196        assert!(
3197            !a.is_empty(),
3198            "the run must have produced at least one artifact"
3199        );
3200    }
3201
3202    // ── regressions: cleanup must not delete user content ─────────────────
3203
3204    /// CRITICAL regression: a user content file named `index.md` inside a date
3205    /// shard (e.g. from a website/doc-export mirror) must SURVIVE `cleanup` /
3206    /// `rebuild_all`. The old filename-only match silently deleted it.
3207    #[test]
3208    fn cleanup_preserves_user_content_named_index_md_in_shard() {
3209        let (_d, store) = mk_store();
3210        // A real content record that merely happens to be named index.md.
3211        write_doc(
3212            &store,
3213            "sources/emails/2026/06/index.md",
3214            "email",
3215            Some("Important imported mail"),
3216            Some("2026-06-11T04:23:25Z"),
3217            "",
3218        );
3219        Index::cleanup(&store).unwrap();
3220        assert!(
3221            exists(&store, "sources/emails/2026/06/index.md"),
3222            "cleanup must not delete a user content file named index.md"
3223        );
3224        // A full rebuild (which runs cleanup first) must also preserve it.
3225        Index::rebuild_all(&store).unwrap();
3226        assert!(
3227            exists(&store, "sources/emails/2026/06/index.md"),
3228            "rebuild_all must not delete a user content file named index.md"
3229        );
3230        let kept = read(&store, "sources/emails/2026/06/index.md");
3231        assert!(
3232            kept.contains("Important imported mail"),
3233            "the user's record content must be intact"
3234        );
3235    }
3236
3237    /// HIGH regression: `cleanup` uses `min_depth(2)`, so the canonical
3238    /// type-folder-root `index.md`/`index.jsonl` are NOT deleted up front. A
3239    /// genuine generated catalog at the type-folder root survives a cleanup pass
3240    /// (it is only ever rewritten, or removed when the folder is truly empty).
3241    #[test]
3242    fn cleanup_keeps_canonical_type_folder_root_sidecars() {
3243        let (_d, store) = mk_store();
3244        write_doc(
3245            &store,
3246            "records/contacts/alice.md",
3247            "contact",
3248            Some("Alice"),
3249            Some("2026-05-01T00:00:00Z"),
3250            "",
3251        );
3252        Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
3253        assert!(exists(&store, "records/contacts/index.md"));
3254        assert!(exists(&store, "records/contacts/index.jsonl"));
3255        Index::cleanup(&store).unwrap();
3256        assert!(
3257            exists(&store, "records/contacts/index.md"),
3258            "cleanup must keep the canonical type-folder index.md (non-empty folder)"
3259        );
3260        assert!(
3261            exists(&store, "records/contacts/index.jsonl"),
3262            "cleanup must keep the canonical type-folder index.jsonl (non-empty folder)"
3263        );
3264    }
3265
3266    // ── regression: write-through must not catalog index artifacts ────────
3267
3268    /// HIGH regression: routing a generated `index.md` through `on_write` (as
3269    /// `dbmd fm set records/contacts/index.md …` would) must NOT insert a phantom
3270    /// self-row — counts and bytes stay equal to a rebuild.
3271    #[test]
3272    fn on_write_ignores_index_artifact_no_phantom_row() {
3273        let (_d, store) = mk_store();
3274        write_doc(
3275            &store,
3276            "records/contacts/alice.md",
3277            "contact",
3278            Some("Alice"),
3279            Some("2026-05-01T00:00:00Z"),
3280            "",
3281        );
3282        Index::on_write(&store, Path::new("records/contacts/alice.md")).unwrap();
3283        let jsonl_before = read(&store, "records/contacts/index.jsonl");
3284        assert_eq!(jsonl_before.lines().count(), 1);
3285
3286        // Tamper: route the catalog file itself through on_write.
3287        Index::on_write(&store, Path::new("records/contacts/index.md")).unwrap();
3288
3289        let jsonl_after = read(&store, "records/contacts/index.jsonl");
3290        assert_eq!(
3291            jsonl_after.lines().count(),
3292            1,
3293            "on_write on index.md must not add a phantom self-row"
3294        );
3295        assert!(
3296            !jsonl_after.contains("\"type\":\"index\""),
3297            "the catalog artifact must never appear as a catalogued row"
3298        );
3299        // Root rollup count stays 1 (not inflated to 2).
3300        let root = read(&store, "index.md");
3301        assert!(
3302            root.contains("[[records/contacts/index|Contacts]] (1)"),
3303            "count must not inflate:\n{root}"
3304        );
3305    }
3306
3307    // ── regression: multi-line summary cannot inject a catalog line ───────
3308
3309    /// HIGH regression: a block-scalar summary spanning multiple lines must be
3310    /// collapsed to one line in the browse entry, so it cannot forge a standalone
3311    /// `- [[…]]` catalog line.
3312    #[test]
3313    fn multiline_summary_is_single_lined_in_index_md() {
3314        let (_d, store) = mk_store();
3315        // A YAML block scalar whose value embeds a forged-looking entry line.
3316        write_raw(
3317            &store,
3318            "records/notes/evil.md",
3319            "type: note\nupdated: 2026-06-10T00:00:00Z\nsummary: |-\n  legit first line\n  - [[records/secrets/fake|Click me]] — injected entry",
3320            "\nbody\n",
3321        );
3322        let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
3323        let md = idx.to_markdown();
3324        // Exactly one browse entry line, and no embedded newline forging a second.
3325        let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
3326        assert_eq!(
3327            entry_lines, 1,
3328            "a multi-line summary must not produce extra entry lines:\n{md}"
3329        );
3330        assert!(
3331            md.contains(
3332                "- [[records/notes/evil]] — legit first line - [[records/secrets/fake|Click me]] — injected entry\n"
3333            ),
3334            "summary newlines must collapse to spaces inline:\n{md}"
3335        );
3336    }
3337
3338    // ── regression: writer/validator scalar coercion agreement ────────────
3339
3340    /// HIGH regression: an unquoted non-string scalar `summary`/`type`
3341    /// (`summary: 2026`, `type: true`) must be coerced to a string by the index
3342    /// writer exactly as `validate::scalar_string` does — so the index entry holds
3343    /// the real value (`2026`), not the `(no summary)` placeholder that produced a
3344    /// permanently-unfixable INDEX_SUMMARY_MISMATCH.
3345    #[test]
3346    fn non_string_scalar_summary_and_type_are_coerced_like_validator() {
3347        let (_d, store) = mk_store();
3348        write_raw(
3349            &store,
3350            "records/contacts/a.md",
3351            "type: contact\nupdated: 2026-05-01T00:00:00Z\nsummary: 2026",
3352            "\nbody\n",
3353        );
3354        let rec = record_from_file(
3355            &store.root.join("records/contacts/a.md"),
3356            PathBuf::from("records/contacts/a.md"),
3357        )
3358        .unwrap();
3359        // `summary: 2026` (YAML number) coerces to the string "2026", matching
3360        // the validator's `scalar_string` (Number -> n.to_string()).
3361        assert_eq!(rec.summary, "2026");
3362        assert_eq!(rec.type_, "contact");
3363
3364        // And the rendered index entry quotes the real value, not the placeholder.
3365        let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
3366        let md = idx.to_markdown();
3367        assert!(
3368            md.contains("- [[records/contacts/a]] — 2026\n"),
3369            "index entry must hold the coerced scalar, not the placeholder:\n{md}"
3370        );
3371
3372        // A boolean scalar type coerces to "true" (mirrors scalar_string(Bool)).
3373        write_raw(
3374            &store,
3375            "records/contacts/b.md",
3376            "type: true\nupdated: 2026-05-02T00:00:00Z\nsummary: hi",
3377            "\nbody\n",
3378        );
3379        let rec_b = record_from_file(
3380            &store.root.join("records/contacts/b.md"),
3381            PathBuf::from("records/contacts/b.md"),
3382        )
3383        .unwrap();
3384        assert_eq!(rec_b.type_, "true");
3385    }
3386
3387    // ── regression: non-UTF-8 body must not abort the projection ──────────
3388
3389    /// HIGH regression: a content file with valid-UTF-8 frontmatter but a
3390    /// non-UTF-8 byte in the BODY (a verbatim Latin-1 `sources/` import) must
3391    /// still project to an IndexRecord — `record_from_file` reads frontmatter
3392    /// without requiring the whole file to be UTF-8, so a stray byte can't abort
3393    /// `rebuild_all` / write-through for the entire store.
3394    #[test]
3395    fn non_utf8_body_does_not_abort_record_projection() {
3396        let (_d, store) = mk_store();
3397        let rel = "sources/emails/2026/06/x.md";
3398        let abs = store.root.join(rel);
3399        fs::create_dir_all(abs.parent().unwrap()).unwrap();
3400        // Valid-UTF-8 frontmatter; a raw 0xE9 (Latin-1 'é') in the body.
3401        let mut bytes: Vec<u8> =
3402            b"---\ntype: email\nupdated: 2026-06-11T00:00:00Z\nsummary: An imported email\n---\n\nCaf"
3403                .to_vec();
3404        bytes.push(0xE9);
3405        bytes.extend_from_slice(b" meeting notes\n");
3406        fs::write(&abs, bytes).unwrap();
3407
3408        let rec = record_from_file(&abs, PathBuf::from(rel))
3409            .expect("non-UTF-8 body must not abort the frontmatter read");
3410        assert_eq!(rec.summary, "An imported email");
3411        assert_eq!(rec.type_, "email");
3412
3413        // The full sweep indexes the folder rather than aborting the whole store.
3414        Index::rebuild_all(&store).unwrap();
3415        assert!(
3416            exists(&store, "sources/emails/index.jsonl"),
3417            "rebuild must produce the catalog despite a non-UTF-8 body byte"
3418        );
3419        assert!(
3420            read(&store, "sources/emails/index.jsonl").contains("An imported email"),
3421            "the record must be catalogued"
3422        );
3423    }
3424
3425    /// HIGH regression: a single malformed-YAML file must abort the rebuild
3426    /// loudly (not be silently skipped) — skipping it would leave the store in a
3427    /// permanently invalid state (`INDEX_MISSING_ENTRY` / `INDEX_JSONL_DESYNC`
3428    /// that no rebuild clears, since the validator enumerates members by
3429    /// filename, not by parseability) and would desync the rollups. The abort is
3430    /// safe because `cleanup` preserves the prior canonical catalogs
3431    /// (`min_depth(2)`), so an aborted rebuild leaves the existing sidecars
3432    /// intact and surfaces a clear error naming the file to fix.
3433    #[test]
3434    fn rebuild_aborts_on_malformed_file_and_keeps_prior_catalogs() {
3435        let (_d, store) = mk_store();
3436        write_doc(
3437            &store,
3438            "records/contacts/alice.md",
3439            "contact",
3440            Some("Alice"),
3441            Some("2026-05-01T00:00:00Z"),
3442            "",
3443        );
3444        write_doc(
3445            &store,
3446            "records/companies/acme.md",
3447            "company",
3448            Some("Acme"),
3449            Some("2026-05-02T00:00:00Z"),
3450            "",
3451        );
3452
3453        // A clean first rebuild establishes the canonical catalogs.
3454        Index::rebuild_all(&store).expect("clean rebuild succeeds");
3455        assert!(exists(&store, "records/contacts/index.jsonl"));
3456        assert!(exists(&store, "records/companies/index.jsonl"));
3457
3458        // Routine malformed file: unterminated quoted scalar.
3459        let bad = store.root.join("records/contacts/broken.md");
3460        fs::write(
3461            &bad,
3462            "---\ntype: contact\nsummary: \"unterminated\n---\nbody\n",
3463        )
3464        .unwrap();
3465
3466        // Must abort loudly — a silent skip leaves a file the validator requires
3467        // to be catalogued out of the index forever.
3468        Index::rebuild_all(&store)
3469            .expect_err("rebuild must abort, not silently skip, on a malformed file");
3470
3471        // The prior canonical catalogs survive the aborted rebuild: `cleanup`'s
3472        // `min_depth(2)` never deletes a type-folder's root-level sidecars, so a
3473        // mid-sweep abort leaves the existing indexes intact rather than wiped.
3474        assert!(
3475            exists(&store, "records/companies/index.jsonl"),
3476            "an aborted rebuild must not destroy a clean sibling folder's catalog"
3477        );
3478        assert!(
3479            exists(&store, "records/contacts/index.jsonl"),
3480            "an aborted rebuild must not destroy the affected folder's prior catalog"
3481        );
3482        let contacts_jsonl = read(&store, "records/contacts/index.jsonl");
3483        assert!(contacts_jsonl.contains("records/contacts/alice.md"));
3484    }
3485
3486    /// HIGH regression (problem B): `rebuild_all`'s rollup `(N)` counts must
3487    /// equal the catalogued `index.jsonl` record counts — never a raw `.md` walk
3488    /// that disagrees with the sidecar. The over-corrected skip-with-diagnostic
3489    /// build excluded a malformed file from `index.jsonl` while `build_layer` /
3490    /// `build_root` kept counting it via `walk_type_folder_files`, so a folder
3491    /// would show `Contacts (2)` in the root/layer rollups while its `index.jsonl`
3492    /// held only 1 record — and a single subsequent write-through (which derives
3493    /// `(N)` from the jsonl) rewrote it to `Contacts (1)`, making `rebuild_all`
3494    /// and write-through emit different bytes for the same state. With the loud
3495    /// abort, the only successful-rebuild states are fully consistent: every
3496    /// rollup `(N)` equals the catalogued record count AND equals what a
3497    /// write-through over the same files produces.
3498    #[test]
3499    fn rebuild_rollup_counts_equal_jsonl_records_and_write_through() {
3500        let (_d, store) = mk_store();
3501        // Two well-formed contacts: the rollups must read (2), matching the two
3502        // jsonl records — this is the count the skip-version inflated to a phantom
3503        // extra when a malformed sibling was present-but-uncatalogued.
3504        write_doc(
3505            &store,
3506            "records/contacts/alice.md",
3507            "contact",
3508            Some("Alice"),
3509            Some("2026-05-01T00:00:00Z"),
3510            "",
3511        );
3512        write_doc(
3513            &store,
3514            "records/contacts/bob.md",
3515            "contact",
3516            Some("Bob"),
3517            Some("2026-05-02T00:00:00Z"),
3518            "",
3519        );
3520        Index::rebuild_all(&store).expect("clean rebuild succeeds");
3521
3522        // The catalogued record set (index.jsonl) and the rollup (N) must agree.
3523        let jsonl_lines = read(&store, "records/contacts/index.jsonl")
3524            .lines()
3525            .filter(|l| !l.trim().is_empty())
3526            .count();
3527        assert_eq!(jsonl_lines, 2, "two well-formed files ⇒ two jsonl records");
3528        let layer_md = read(&store, "records/index.md");
3529        let root_md = read(&store, "index.md");
3530        assert!(
3531            layer_md.contains("- [[records/contacts/index|Contacts]] (2)"),
3532            "layer rollup (N) must equal the jsonl record count (2), not a raw .md walk:\n{layer_md}"
3533        );
3534        assert!(
3535            root_md.contains("- [[records/contacts/index|Contacts]] (2)\n")
3536                && root_md.contains("## Records (2)"),
3537            "root rollup (N)/layer total must equal the jsonl record count (2):\n{root_md}"
3538        );
3539
3540        // The decisive write-through == rebuild_all byte-identity check on the
3541        // SAME end state: a single on_write must not rewrite the rollups to a
3542        // different (N). Under the skip-version, rebuild_all's rollup walked the
3543        // raw .md tree while on_write derived (N) from the jsonl, so the two
3544        // diverged; the loud abort keeps both deriving (N) from the catalogued
3545        // records, so the bytes match exactly.
3546        let (_d2, wt) = mk_store();
3547        write_doc(
3548            &wt,
3549            "records/contacts/alice.md",
3550            "contact",
3551            Some("Alice"),
3552            Some("2026-05-01T00:00:00Z"),
3553            "",
3554        );
3555        write_doc(
3556            &wt,
3557            "records/contacts/bob.md",
3558            "contact",
3559            Some("Bob"),
3560            Some("2026-05-02T00:00:00Z"),
3561            "",
3562        );
3563        Index::on_write(&wt, Path::new("records/contacts/alice.md")).unwrap();
3564        Index::on_write(&wt, Path::new("records/contacts/bob.md")).unwrap();
3565
3566        let a = snapshot_artifacts(&wt);
3567        let b = snapshot_artifacts(&store);
3568        assert_eq!(
3569            a.keys().collect::<BTreeSet<_>>(),
3570            b.keys().collect::<BTreeSet<_>>(),
3571            "write-through and rebuild_all must produce the same artifact set"
3572        );
3573        for (k, v) in &a {
3574            assert_eq!(
3575                v, &b[k],
3576                "rollup bytes diverged between write-through and rebuild_all for {k} \
3577                 (a skip-version inflates rebuild_all's (N) above the jsonl record \
3578                 count, which write-through then rewrites):\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
3579                b[k]
3580            );
3581        }
3582    }
3583
3584    /// MEDIUM regression: a non-UTF-8 path component must be lossily decoded
3585    /// (kept, with U+FFFD), not silently dropped — so the index key points at the
3586    /// file, not its parent directory. Unix-only (ext4 allows the filename; APFS
3587    /// rejects it at the VFS layer).
3588    #[cfg(unix)]
3589    #[test]
3590    fn non_utf8_path_component_is_kept_not_dropped() {
3591        use std::ffi::OsStr;
3592        use std::os::unix::ffi::OsStrExt;
3593        // sources/emails/caf\xE9.md — the leaf has a non-UTF-8 byte.
3594        let mut leaf = b"caf".to_vec();
3595        leaf.push(0xE9);
3596        leaf.extend_from_slice(b".md");
3597        let p = Path::new("sources/emails").join(OsStr::from_bytes(&leaf));
3598        let unix = path_to_unix(&p);
3599        // The leaf is preserved (lossy), so the path is NOT collapsed to the
3600        // parent directory "sources/emails".
3601        assert_ne!(
3602            unix, "sources/emails",
3603            "non-UTF-8 leaf must not be dropped, collapsing the path to its parent dir"
3604        );
3605        assert!(
3606            unix.starts_with("sources/emails/caf"),
3607            "the lossy leaf must remain under its folder: {unix}"
3608        );
3609    }
3610
3611    // ── loose files (directly at a layer root, no type-folder) ───────────────
3612
3613    #[test]
3614    fn loose_file_is_catalogued_in_layer_jsonl_not_type_folder() {
3615        let (_d, store) = mk_store();
3616        // One canonical file (in a type-folder) and one loose file at the root.
3617        write_doc(
3618            &store,
3619            "records/contacts/alice.md",
3620            "contact",
3621            Some("Alice"),
3622            Some("2026-06-01T08:00:00Z"),
3623            "id: alice\n",
3624        );
3625        write_doc(
3626            &store,
3627            "records/loose.md",
3628            "contact",
3629            Some("Loose"),
3630            Some("2026-06-01T08:00:00Z"),
3631            "id: loose\n",
3632        );
3633        Index::rebuild_all(&store).unwrap();
3634
3635        // The layer carries its own jsonl listing exactly the loose file —
3636        // disjoint from the type-folder jsonl, so no double-count.
3637        assert!(
3638            exists(&store, "records/index.jsonl"),
3639            "layer jsonl must exist when loose files are present"
3640        );
3641        let layer_jsonl = read(&store, "records/index.jsonl");
3642        assert!(
3643            layer_jsonl.contains("records/loose.md"),
3644            "layer jsonl must list the loose file, got:\n{layer_jsonl}"
3645        );
3646        assert!(
3647            !layer_jsonl.contains("records/contacts/alice.md"),
3648            "layer jsonl must NOT list type-folder files"
3649        );
3650        let tf_jsonl = read(&store, "records/contacts/index.jsonl");
3651        assert!(tf_jsonl.contains("records/contacts/alice.md"));
3652        assert!(!tf_jsonl.contains("records/loose.md"));
3653
3654        // The layer index.md stays a pure type-folder rollup — no loose entry.
3655        let layer_md = read(&store, "records/index.md");
3656        assert!(
3657            layer_md.contains("records/contacts/index"),
3658            "layer md must roll up the type-folder, got:\n{layer_md}"
3659        );
3660        assert!(
3661            !layer_md.contains("records/loose"),
3662            "layer md must stay a rollup, not list loose files, got:\n{layer_md}"
3663        );
3664    }
3665
3666    #[test]
3667    fn loose_file_write_through_equals_rebuild() {
3668        let (_d1, wt) = mk_store();
3669        let (_d2, rb) = mk_store();
3670        for s in [&wt, &rb] {
3671            write_doc(
3672                s,
3673                "records/contacts/alice.md",
3674                "contact",
3675                Some("Alice"),
3676                Some("2026-06-01T08:00:00Z"),
3677                "id: alice\n",
3678            );
3679            write_doc(
3680                s,
3681                "records/loose.md",
3682                "contact",
3683                Some("Loose"),
3684                Some("2026-06-02T08:00:00Z"),
3685                "id: loose\n",
3686            );
3687        }
3688        // wt: write-through (loop); rb: full rebuild (sweep). Must agree byte-wise.
3689        Index::on_write(&wt, Path::new("records/contacts/alice.md")).unwrap();
3690        Index::on_write(&wt, Path::new("records/loose.md")).unwrap();
3691        Index::rebuild_all(&rb).unwrap();
3692
3693        let a = snapshot_artifacts(&wt);
3694        let b = snapshot_artifacts(&rb);
3695        assert_eq!(
3696            a.keys().collect::<Vec<_>>(),
3697            b.keys().collect::<Vec<_>>(),
3698            "loose-file loop and sweep must produce the same artifact set"
3699        );
3700        for (k, v) in &a {
3701            assert_eq!(
3702                v, &b[k],
3703                "loose-file artifact {k} differs between loop and sweep"
3704            );
3705        }
3706    }
3707
3708    #[test]
3709    fn removing_last_loose_file_clears_layer_jsonl() {
3710        let (_d, store) = mk_store();
3711        write_doc(
3712            &store,
3713            "records/loose.md",
3714            "contact",
3715            Some("Loose"),
3716            Some("2026-06-01T08:00:00Z"),
3717            "id: loose\n",
3718        );
3719        Index::on_write(&store, Path::new("records/loose.md")).unwrap();
3720        assert!(
3721            exists(&store, "records/index.jsonl"),
3722            "layer jsonl present after a loose write"
3723        );
3724        fs::remove_file(store.root.join("records/loose.md")).unwrap();
3725        Index::on_remove(&store, Path::new("records/loose.md")).unwrap();
3726        assert!(
3727            !exists(&store, "records/index.jsonl"),
3728            "layer jsonl must be removed once the last loose file is gone"
3729        );
3730    }
3731
3732    // ── concurrency: shared layer/root rollup under parallel write-through ────
3733
3734    #[test]
3735    fn concurrent_writes_to_different_type_folders_match_rebuild() {
3736        use std::sync::Arc;
3737        use std::thread;
3738
3739        // Two threads, each owning a DISTINCT type-folder, drive `on_write`
3740        // concurrently. The layer `index.md` and root `index.md` are shared
3741        // across both folders, but each `on_write` only locks its own
3742        // type-folder — so before the `update_parents` store-root lock, the two
3743        // threads raced to rewrite those shared rollups and one update was lost
3744        // (the rollup no longer matched `rebuild_all`). With the lock the final
3745        // rollups must be byte-identical to a from-scratch rebuild, regardless
3746        // of interleaving.
3747        let (_d, store) = mk_store();
3748        let folders = ["records/contacts", "records/companies"];
3749        let n = 12usize;
3750
3751        // Pre-create all content files (disjoint paths) so the threads race only
3752        // on the index write-through, not on content creation.
3753        for (fi, folder) in folders.iter().enumerate() {
3754            for i in 0..n {
3755                write_doc(
3756                    &store,
3757                    &format!("{folder}/f{fi}_{i}.md"),
3758                    "contact",
3759                    Some(&format!("Summary {fi}-{i}")),
3760                    Some(&format!("2026-06-{:02}T08:00:00Z", i + 1)),
3761                    &format!("id: f{fi}_{i}\n"),
3762                );
3763            }
3764        }
3765
3766        let store = Arc::new(store);
3767        let handles: Vec<_> = folders
3768            .iter()
3769            .enumerate()
3770            .map(|(fi, folder)| {
3771                let store = Arc::clone(&store);
3772                let folder = folder.to_string();
3773                thread::spawn(move || {
3774                    for i in 0..n {
3775                        let rel = format!("{folder}/f{fi}_{i}.md");
3776                        Index::on_write(&store, Path::new(&rel)).unwrap();
3777                    }
3778                })
3779            })
3780            .collect();
3781        for h in handles {
3782            h.join().unwrap();
3783        }
3784
3785        // Snapshot the write-through artifacts, then rebuild from scratch over
3786        // the identical content and snapshot again — they must agree exactly.
3787        let got = snapshot_artifacts(&store);
3788        Index::rebuild_all(&store).unwrap();
3789        let want = snapshot_artifacts(&store);
3790
3791        assert_eq!(
3792            got.keys().collect::<Vec<_>>(),
3793            want.keys().collect::<Vec<_>>(),
3794            "artifact set after concurrent write-through must match rebuild"
3795        );
3796        for (k, v) in &want {
3797            assert_eq!(
3798                &got[k], v,
3799                "rollup artifact {k} diverged from rebuild after concurrent writes"
3800            );
3801        }
3802    }
3803}