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