Skip to main content

dbmd_core/
index.rs

1//! `index` — the hierarchical content catalog.
2//!
3//! A uniform three-level tree: root + per-layer + per-type-folder. **Two
4//! artifacts per type-folder:** the human `index.md` (capped 500, recency
5//! browse) and the machine `index.jsonl` (complete, structured — one JSON
6//! object per file). Both read `summary` + key frontmatter fields + links
7//! directly from each file — there is no extraction logic here.
8//!
9//! **Maintained write-through** by the write commands ([`Index::on_write`] /
10//! [`Index::on_rename`] / [`Index::on_remove`] — the loop path, O(changed), no
11//! store walk); [`Index::rebuild_all`] is the from-scratch SWEEP repair.
12//!
13//! **Key invariant:** write-through must produce a byte-identical `index.md`
14//! and (post-compaction) `index.jsonl` to a full [`Index::rebuild_all`] over
15//! the same end state — the loop path can never drift from the repair path.
16//!
17//! # Implementation notes (deviations the reader should know)
18//!
19//! - **Self-contained, by design.** This module does its own shard-aware folder
20//!   walk, its own minimal frontmatter read, and its own atomic write, using
21//!   only `store.root` (a public field) and the `serde_norway` / `serde_json` /
22//!   `chrono` / `walkdir` crates rather than routing through the sibling
23//!   `store`/`parser` helpers ([`Store::walk_type_folder`],
24//!   [`Store::recent_in_type_folder`], [`parser::read_file`], …). The index has
25//!   to stamp a *deterministic* `updated:` and emit a *canonical, compacted*
26//!   `index.jsonl` (see the two notes below); keeping the read/walk/write local
27//!   is what makes the byte-identity invariant a true byte comparison, free of
28//!   any incidental formatting the shared readers might introduce. The public
29//!   signatures in `lib.rs` are untouched.
30//! - **Deterministic `updated:` on the index files themselves.** An index's own
31//!   `updated` frontmatter is derived as the max `updated` over the files it
32//!   catalogs (max over children for root/layer) — NOT wall-clock-now. This is
33//!   what makes the byte-identity invariant a *true* byte comparison: a
34//!   write-through write and a `rebuild_all` over the same end state stamp the
35//!   same value. (The SPEC's rendered examples show a wall-clock-looking value;
36//!   the conventions list only requires `updated: <RFC3339>`, and the
37//!   property-tested invariant dominates.)
38//! - **`index.jsonl` is always compacted.** Write-through rewrites the affected
39//!   type-folder's jsonl in canonical form (one current line per path, recency
40//!   order) rather than appending superseded/tombstone lines, so the jsonl is
41//!   byte-identical to `rebuild_all` *immediately* (a strictly stronger
42//!   guarantee than the SPEC's "post-compaction"). This keeps the loop cost at
43//!   one sidecar read + one rewrite per touched type-folder — O(folder), the
44//!   sanctioned loop primitive, never a whole-`Store::walk`.
45//! - **Root/layer entry styling** follows plan §index (`(N)` numeric counts;
46//!   layer headings in the root carry the layer's total count) which is more
47//!   specific than the SPEC's illustrative `(42 files)` prose example. Type
48//!   folders are listed alphabetically (a deterministic order a derived artifact
49//!   needs); `scope: type-folder` follows the conventions list, not the one
50//!   SPEC example that wrote `scope: folder`.
51
52use std::collections::{BTreeMap, BTreeSet};
53use std::fs;
54use std::io::Write as _;
55use std::path::{Path, PathBuf};
56
57use chrono::{DateTime, FixedOffset, SecondsFormat};
58use serde::{Deserialize, Serialize};
59use serde_json::Value;
60
61use crate::store::{Layer, Store};
62
63/// The browse-view cap for a type-folder `index.md`.
64const MD_CAP: usize = 500;
65
66/// Placeholder summary for a content file that has no `summary` frontmatter.
67/// The index never invents a real summary — that is `dbmd fm init`'s job; this
68/// marker is what `dbmd validate` keys off (`INDEX`-class issue).
69const MISSING_SUMMARY: &str = "(no summary)";
70
71/// The root `index.md` H1.
72const ROOT_TITLE: &str = "Knowledge base index";
73
74/// Which level of the catalog an [`Index`] represents.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum IndexLevel {
77    /// The store-wide root `index.md` (layers + per-type counts).
78    Root,
79    /// A layer `index.md` (every type-folder under one layer).
80    Layer(Layer),
81    /// A type-folder `index.md` + `index.jsonl` (every file in the folder).
82    TypeFolder(PathBuf),
83}
84
85/// One record in a type-folder's `index.jsonl` — the complete, structured twin
86/// of a single `index.md` browse entry.
87///
88/// `tags` are the document's flat labels; `links` are its concept/relationship
89/// wiki-link targets. Both are copied verbatim from the file — never inferred.
90/// `fields` holds the remaining type-specific frontmatter so the structured
91/// query path can filter on any key without opening the file.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct IndexRecord {
94    /// Store-relative path of the file (the upsert key; last-write-wins).
95    pub path: PathBuf,
96    /// The file's `type`.
97    #[serde(rename = "type")]
98    pub type_: String,
99    /// The file's `summary`.
100    pub summary: String,
101    /// The file's flat `tags`.
102    #[serde(default)]
103    pub tags: Vec<String>,
104    /// The file's concept/relationship wiki-link targets (store-relative).
105    #[serde(default)]
106    pub links: Vec<String>,
107    /// `created` timestamp.
108    pub created: Option<DateTime<FixedOffset>>,
109    /// `updated` timestamp (the recency key for the `index.md` cap order).
110    pub updated: Option<DateTime<FixedOffset>>,
111    /// Remaining type-specific frontmatter fields, verbatim.
112    #[serde(flatten)]
113    pub fields: BTreeMap<String, Value>,
114}
115
116/// A built (or being-built) catalog for one [`IndexLevel`], with both rendered
117/// artifacts available. Pure data until written via [`Index::write_level`].
118#[derive(Debug, Clone, PartialEq)]
119pub struct Index {
120    /// Which level this catalog is for.
121    pub level: IndexLevel,
122    /// The complete record set for this level (type-folder level; empty for
123    /// root/layer rollups, which carry only counts).
124    pub records: Vec<IndexRecord>,
125    /// Per-child counts for root/layer rollups (child path → file count).
126    pub child_counts: BTreeMap<PathBuf, usize>,
127}
128
129impl Index {
130    /// Build a type-folder catalog by aggregating across date-shards, producing
131    /// both artifacts. `index.md` selection is recency (updated desc, ties by
132    /// path asc; cap 500 with a `## More` footer over the cap); `index.jsonl`
133    /// holds every file. A file missing `summary` gets a placeholder + a
134    /// validate-detectable issue (the index never invents summaries).
135    pub fn build_type_folder(store: &Store, type_folder: &Path) -> crate::Result<Index> {
136        let rel = normalize_rel(type_folder);
137        let abs = store.root.join(&rel);
138        let mut records = Vec::new();
139        for file_abs in walk_type_folder_files(&abs) {
140            let rel_path =
141                rel_to_store(&store.root, &file_abs).expect("walked file is under the store root");
142            records.push(record_from_file(&file_abs, rel_path)?);
143        }
144        sort_records(&mut records);
145        Ok(Index {
146            level: IndexLevel::TypeFolder(rel),
147            records,
148            child_counts: BTreeMap::new(),
149        })
150    }
151
152    /// Build a layer catalog: every non-empty type-folder under the layer with
153    /// `(N)` counts and a newest-file `summary` preview (≤ 80 chars).
154    pub fn build_layer(store: &Store, layer: Layer) -> crate::Result<Index> {
155        let mut child_counts = BTreeMap::new();
156        for tf in type_folders_in_layer(store, layer) {
157            let abs = store.root.join(&tf);
158            let n = walk_type_folder_files(&abs).len();
159            if n > 0 {
160                child_counts.insert(tf, n);
161            }
162        }
163        Ok(Index {
164            level: IndexLevel::Layer(layer),
165            records: Vec::new(),
166            child_counts,
167        })
168    }
169
170    /// Build the store-wide root catalog: one heading per non-empty layer with
171    /// total count + bulleted per-type sub-entries with `(N)` counts.
172    pub fn build_root(store: &Store) -> crate::Result<Index> {
173        let mut child_counts = BTreeMap::new();
174        for layer in Layer::all() {
175            for tf in type_folders_in_layer(store, layer) {
176                let abs = store.root.join(&tf);
177                let n = walk_type_folder_files(&abs).len();
178                if n > 0 {
179                    child_counts.insert(tf, n);
180                }
181            }
182        }
183        Ok(Index {
184            level: IndexLevel::Root,
185            records: Vec::new(),
186            child_counts,
187        })
188    }
189
190    /// Render this catalog as a canonical `index.md`.
191    pub fn to_markdown(&self) -> String {
192        match &self.level {
193            IndexLevel::TypeFolder(folder) => self.render_type_folder_md(folder),
194            IndexLevel::Layer(layer) => self.render_layer_md(*layer),
195            IndexLevel::Root => self.render_root_md(),
196        }
197    }
198
199    /// Render this type-folder catalog as the complete `index.jsonl` (one JSON
200    /// object per file, stable key order so diffs stay minimal). Type-folder
201    /// level only — root and layer stay markdown rollups.
202    pub fn to_jsonl(&self) -> String {
203        let mut out = String::new();
204        for rec in &self.records {
205            // The record type derives a deterministic, sorted key order
206            // (declared fields first, then the flattened `fields` BTreeMap).
207            let line = serde_json::to_string(rec).expect("IndexRecord serializes");
208            out.push_str(&line);
209            out.push('\n');
210        }
211        out
212    }
213
214    // ── rendering helpers ────────────────────────────────────────────────
215
216    fn render_type_folder_md(&self, folder: &Path) -> String {
217        let folder_disp = path_to_unix(folder);
218        let updated = max_updated(self.records.iter().map(|r| r.updated.as_ref()));
219        let mut s = String::new();
220        s.push_str("---\n");
221        s.push_str("type: index\n");
222        s.push_str("scope: type-folder\n");
223        s.push_str(&format!("folder: {folder_disp}\n"));
224        if let Some(ts) = updated {
225            s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
226        }
227        s.push_str("---\n\n");
228        s.push_str(&format!("# {folder_disp}\n\n"));
229
230        let shown = self.records.len().min(MD_CAP);
231        for rec in self.records.iter().take(shown) {
232            s.push_str(&format_md_entry(rec));
233            s.push('\n');
234        }
235
236        if self.records.len() > MD_CAP {
237            let type_ = self.records.first().map(|r| r.type_.as_str()).unwrap_or("");
238            let layer = folder
239                .components()
240                .next()
241                .and_then(|c| c.as_os_str().to_str())
242                .unwrap_or("");
243            s.push('\n');
244            s.push_str(&more_footer(self.records.len(), type_, layer));
245        }
246        s
247    }
248
249    /// Store-less layer rollup: counts only, no preview / no derived `updated`
250    /// (a layer index needs each child's on-disk jsonl for those — see
251    /// [`render_layer_md_with_store`], the canonical path every disk write
252    /// uses). This pure-data render is structurally identical sans preview.
253    fn render_layer_md(&self, layer: Layer) -> String {
254        let layer_dir = layer_dir_name(layer);
255        let mut s = String::new();
256        s.push_str("---\n");
257        s.push_str("type: index\n");
258        s.push_str("scope: layer\n");
259        s.push_str(&format!("folder: {layer_dir}\n"));
260        s.push_str("---\n\n");
261        s.push_str(&format!("# {layer_dir}\n\n"));
262        for (tf, n) in &self.child_counts {
263            let tf_unix = path_to_unix(tf);
264            let display = capitalize(folder_basename(tf));
265            s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
266        }
267        s
268    }
269
270    /// Store-less root rollup: counts only (the canonical disk render adds a
271    /// derived `updated` — see [`render_root_md_with_store`]).
272    fn render_root_md(&self) -> String {
273        let mut s = String::new();
274        s.push_str("---\n");
275        s.push_str("type: index\n");
276        s.push_str("scope: root\n");
277        s.push_str("---\n\n");
278        s.push_str(&format!("# {ROOT_TITLE}\n"));
279        for layer in Layer::all() {
280            let layer_dir = layer_dir_name(layer);
281            let prefix = format!("{layer_dir}/");
282            let children: Vec<(&PathBuf, &usize)> = self
283                .child_counts
284                .iter()
285                .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
286                .collect();
287            if children.is_empty() {
288                continue;
289            }
290            let total: usize = children.iter().map(|(_, n)| **n).sum();
291            s.push('\n');
292            s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
293            for (tf, n) in children {
294                let tf_unix = path_to_unix(tf);
295                let display = capitalize(folder_basename(tf));
296                s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
297            }
298        }
299        s
300    }
301}
302
303// ─────────────────────────────────────────────────────────────────────────
304// Write-through + sweep (free functions on the impl block).
305// ─────────────────────────────────────────────────────────────────────────
306
307impl Index {
308    /// **Write-through (loop, O(changed)).** Upsert a new/updated content file.
309    /// Reads the affected type-folder's `index.jsonl` (the sanctioned per-folder
310    /// sidecar read — never a whole-store walk), applies the change, and
311    /// atomically rewrites that folder's `index.md` + `index.jsonl` plus the
312    /// parent layer + root rollups so the artifacts equal a `rebuild_all` over
313    /// the same end state.
314    pub fn on_write(store: &Store, file: &Path) -> crate::Result<()> {
315        let file_rel = normalize_rel(file);
316        let file_abs = store.root.join(&file_rel);
317        let folder = type_folder_of(&file_rel)
318            .ok_or_else(|| bad_index(&file_rel, "file is not inside a layer/type-folder"))?;
319        let record = record_from_file(&file_abs, file_rel.clone())?;
320
321        let mut records = read_jsonl_records(&store.root.join(&folder).join("index.jsonl"))?;
322        records.retain(|r| r.path != record.path);
323        records.push(record);
324        sort_records(&mut records);
325
326        write_type_folder_artifacts(store, &folder, &records)?;
327        update_parents(store, &folder)?;
328        Ok(())
329    }
330
331    /// **Write-through (loop, O(changed)).** Move a file's entry between
332    /// type-folder indexes (or within, if the same folder) in both `index.md`
333    /// and `index.jsonl`, fixing counts on both sides.
334    pub fn on_rename(store: &Store, old: &Path, new: &Path) -> crate::Result<()> {
335        let old_rel = normalize_rel(old);
336        let new_rel = normalize_rel(new);
337        let old_folder = type_folder_of(&old_rel)
338            .ok_or_else(|| bad_index(&old_rel, "source is not inside a layer/type-folder"))?;
339        let new_folder = type_folder_of(&new_rel)
340            .ok_or_else(|| bad_index(&new_rel, "target is not inside a layer/type-folder"))?;
341
342        // Drop from the old folder.
343        let mut old_records =
344            read_jsonl_records(&store.root.join(&old_folder).join("index.jsonl"))?;
345        old_records.retain(|r| r.path != old_rel);
346
347        if old_folder == new_folder {
348            // Same folder: re-read the (now-renamed) file and upsert.
349            let record = record_from_file(&store.root.join(&new_rel), new_rel.clone())?;
350            old_records.retain(|r| r.path != record.path);
351            old_records.push(record);
352            sort_records(&mut old_records);
353            write_type_folder_artifacts(store, &old_folder, &old_records)?;
354            update_parents(store, &old_folder)?;
355            return Ok(());
356        }
357
358        // Cross-folder: write the trimmed old folder (or drop its indexes if
359        // now empty), then upsert into the new folder.
360        sort_records(&mut old_records);
361        write_type_folder_artifacts(store, &old_folder, &old_records)?;
362
363        let record = record_from_file(&store.root.join(&new_rel), new_rel.clone())?;
364        let mut new_records =
365            read_jsonl_records(&store.root.join(&new_folder).join("index.jsonl"))?;
366        new_records.retain(|r| r.path != record.path);
367        new_records.push(record);
368        sort_records(&mut new_records);
369        write_type_folder_artifacts(store, &new_folder, &new_records)?;
370
371        update_parents(store, &old_folder)?;
372        update_parents(store, &new_folder)?;
373        Ok(())
374    }
375
376    /// **Write-through (loop, O(changed)).** Drop a file's entry from both
377    /// `index.md` and `index.jsonl`; decrement counts; if the browse view drops
378    /// below the cap, the next-most-recent is already present in the complete
379    /// jsonl record set and re-renders into the md automatically.
380    pub fn on_remove(store: &Store, file: &Path) -> crate::Result<()> {
381        let file_rel = normalize_rel(file);
382        let folder = type_folder_of(&file_rel)
383            .ok_or_else(|| bad_index(&file_rel, "file is not inside a layer/type-folder"))?;
384        let mut records = read_jsonl_records(&store.root.join(&folder).join("index.jsonl"))?;
385        let before = records.len();
386        records.retain(|r| r.path != file_rel);
387        if records.len() == before {
388            // Nothing to remove; still normalize the folder + parents so the
389            // artifacts stay canonical.
390        }
391        sort_records(&mut records);
392        write_type_folder_artifacts(store, &folder, &records)?;
393        update_parents(store, &folder)?;
394        Ok(())
395    }
396
397    /// **SWEEP repair.** Walk the store once and atomically (re)write root +
398    /// every non-empty layer + every non-empty type-folder `index.md` and
399    /// `index.jsonl` (compacting the jsonl). Also runs [`Index::cleanup`].
400    pub fn rebuild_all(store: &Store) -> crate::Result<()> {
401        Index::cleanup(store)?;
402        for layer in Layer::all() {
403            for tf in type_folders_in_layer(store, layer) {
404                let idx = Index::build_type_folder(store, &tf)?;
405                if idx.records.is_empty() {
406                    continue;
407                }
408                write_type_folder_artifacts(store, &tf, &idx.records)?;
409            }
410            let layer_idx = Index::build_layer(store, layer)?;
411            let layer_index_md = store.root.join(layer_dir_name(layer)).join("index.md");
412            if layer_idx.child_counts.is_empty() {
413                remove_if_exists(&layer_index_md)?;
414            } else {
415                write_atomic(
416                    &layer_index_md,
417                    render_layer_md_with_store(store, &layer_idx),
418                )?;
419            }
420        }
421        let root_idx = Index::build_root(store)?;
422        let root_index_md = store.root.join("index.md");
423        if root_idx.child_counts.is_empty() {
424            remove_if_exists(&root_index_md)?;
425        } else {
426            write_atomic(&root_index_md, render_root_md_with_store(store, &root_idx))?;
427        }
428        Ok(())
429    }
430
431    /// Atomically write a single level's artifact(s) to disk.
432    pub fn write_level(store: &Store, level: &IndexLevel) -> crate::Result<()> {
433        match level {
434            IndexLevel::TypeFolder(folder) => {
435                let idx = Index::build_type_folder(store, folder)?;
436                if idx.records.is_empty() {
437                    remove_if_exists(&store.root.join(folder).join("index.md"))?;
438                    remove_if_exists(&store.root.join(folder).join("index.jsonl"))?;
439                } else {
440                    write_type_folder_artifacts(store, folder, &idx.records)?;
441                }
442            }
443            IndexLevel::Layer(layer) => {
444                let idx = Index::build_layer(store, *layer)?;
445                let p = store.root.join(layer_dir_name(*layer)).join("index.md");
446                if idx.child_counts.is_empty() {
447                    remove_if_exists(&p)?;
448                } else {
449                    write_atomic(&p, render_layer_md_with_store(store, &idx))?;
450                }
451            }
452            IndexLevel::Root => {
453                let idx = Index::build_root(store)?;
454                let p = store.root.join("index.md");
455                if idx.child_counts.is_empty() {
456                    remove_if_exists(&p)?;
457                } else {
458                    write_atomic(&p, render_root_md_with_store(store, &idx))?;
459                }
460            }
461        }
462        Ok(())
463    }
464
465    /// Render the generated indexes to a string with `--- <path> ---`
466    /// separators instead of writing them (`--dry-run`).
467    pub fn render_dry_run(store: &Store, level: &IndexLevel) -> crate::Result<String> {
468        let mut out = String::new();
469        match level {
470            IndexLevel::TypeFolder(folder) => {
471                let idx = Index::build_type_folder(store, folder)?;
472                let md_path = path_to_unix(&folder.join("index.md"));
473                let jsonl_path = path_to_unix(&folder.join("index.jsonl"));
474                out.push_str(&format!("--- {md_path} ---\n"));
475                out.push_str(&idx.to_markdown());
476                out.push_str(&format!("--- {jsonl_path} ---\n"));
477                out.push_str(&idx.to_jsonl());
478            }
479            IndexLevel::Layer(layer) => {
480                let idx = Index::build_layer(store, *layer)?;
481                let md_path = format!("{}/index.md", layer_dir_name(*layer));
482                out.push_str(&format!("--- {md_path} ---\n"));
483                out.push_str(&render_layer_md_with_store(store, &idx));
484            }
485            IndexLevel::Root => {
486                let idx = Index::build_root(store)?;
487                out.push_str("--- index.md ---\n");
488                out.push_str(&render_root_md_with_store(store, &idx));
489            }
490        }
491        Ok(out)
492    }
493
494    /// Cleanup pass (part of [`Index::rebuild_all`]): delete `index.md` /
495    /// `index.jsonl` in non-canonical folders (empty folders, or date-shards
496    /// that should carry none). Symmetric with index creation.
497    pub fn cleanup(store: &Store) -> crate::Result<()> {
498        for layer in Layer::all() {
499            let layer_dir = store.root.join(layer_dir_name(layer));
500            if !layer_dir.is_dir() {
501                continue;
502            }
503            for tf in type_folders_in_layer(store, layer) {
504                let tf_abs = store.root.join(&tf);
505                // Any index inside a shard (below the type-folder root) is
506                // non-canonical: delete it.
507                for entry in walkdir::WalkDir::new(&tf_abs)
508                    .min_depth(1)
509                    .into_iter()
510                    .filter_map(|e| e.ok())
511                {
512                    let p = entry.path();
513                    if is_index_artifact(p) {
514                        remove_if_exists(p)?;
515                    }
516                }
517                // Empty type-folder → no index at its root either.
518                if walk_type_folder_files(&tf_abs).is_empty() {
519                    remove_if_exists(&tf_abs.join("index.md"))?;
520                    remove_if_exists(&tf_abs.join("index.jsonl"))?;
521                }
522            }
523        }
524        Ok(())
525    }
526}
527
528// ─────────────────────────────────────────────────────────────────────────
529// Private free helpers — all self-contained, none call back into Store/parser.
530// ─────────────────────────────────────────────────────────────────────────
531
532/// Write both artifacts for a type-folder, or delete them if the folder is now
533/// empty. The single funnel both write-through and rebuild go through, so their
534/// output is byte-identical by construction.
535fn write_type_folder_artifacts(
536    store: &Store,
537    folder: &Path,
538    records: &[IndexRecord],
539) -> crate::Result<()> {
540    let folder_abs = store.root.join(folder);
541    let md_path = folder_abs.join("index.md");
542    let jsonl_path = folder_abs.join("index.jsonl");
543    if records.is_empty() {
544        remove_if_exists(&md_path)?;
545        remove_if_exists(&jsonl_path)?;
546        return Ok(());
547    }
548    let idx = Index {
549        level: IndexLevel::TypeFolder(folder.to_path_buf()),
550        records: records.to_vec(),
551        child_counts: BTreeMap::new(),
552    };
553    write_atomic(&md_path, idx.to_markdown())?;
554    write_atomic(&jsonl_path, idx.to_jsonl())?;
555    Ok(())
556}
557
558/// Re-render the layer + root rollups that sit above `folder` — the
559/// **loop path**, O(changed). Counts come from the type-folders' on-disk
560/// `index.jsonl` sidecars ([`child_counts_from_jsonl`]), NOT from a content-tree
561/// walk: a single write touches only the affected layer's sidecars (for the
562/// layer rollup) and one sidecar per type-folder (for the root rollup) — never
563/// the millions of files under the shards. `build_layer` / `build_root` (which
564/// *do* walk the content tree) are reserved for the from-scratch sweeps
565/// ([`Index::rebuild_all`], [`Index::write_level`], [`Index::render_dry_run`]).
566/// The result is byte-identical to those builders because in the loop — exactly
567/// as in `rebuild_all` — every touched folder's jsonl is rewritten before its
568/// parents are rolled up, so `jsonl_record_count == walk_type_folder_files.len()`
569/// for every folder read here.
570fn update_parents(store: &Store, folder: &Path) -> crate::Result<()> {
571    let layer = folder
572        .components()
573        .next()
574        .and_then(|c| c.as_os_str().to_str())
575        .and_then(layer_from_dir_name);
576    if let Some(layer) = layer {
577        let idx = Index {
578            level: IndexLevel::Layer(layer),
579            records: Vec::new(),
580            child_counts: child_counts_from_jsonl(store, &[layer])?,
581        };
582        let p = store.root.join(layer_dir_name(layer)).join("index.md");
583        if idx.child_counts.is_empty() {
584            remove_if_exists(&p)?;
585        } else {
586            write_atomic(&p, render_layer_md_with_store(store, &idx))?;
587        }
588    }
589    let root = Index {
590        level: IndexLevel::Root,
591        records: Vec::new(),
592        child_counts: child_counts_from_jsonl(store, &Layer::all())?,
593    };
594    let rp = store.root.join("index.md");
595    if root.child_counts.is_empty() {
596        remove_if_exists(&rp)?;
597    } else {
598        write_atomic(&rp, render_root_md_with_store(store, &root))?;
599    }
600    Ok(())
601}
602
603/// Render a layer `index.md`, reading each child's newest summary + max-updated
604/// straight from its on-disk `index.jsonl` (so the rollup matches the folder
605/// artifacts exactly, write-through and rebuild alike).
606fn render_layer_md_with_store(store: &Store, idx: &Index) -> String {
607    let layer = match idx.level {
608        IndexLevel::Layer(l) => l,
609        _ => unreachable!("render_layer_md_with_store called on non-layer"),
610    };
611    let layer_dir = layer_dir_name(layer);
612    let mut max_upd: Option<DateTime<FixedOffset>> = None;
613    let mut entries = String::new();
614    for (tf, n) in &idx.child_counts {
615        let recs = read_jsonl_records(&store.root.join(tf).join("index.jsonl")).unwrap_or_default();
616        let newest = recs.first();
617        if let Some(u) = newest.and_then(|r| r.updated) {
618            max_upd = Some(match max_upd {
619                Some(cur) if cur >= u => cur,
620                _ => u,
621            });
622        }
623        let tf_unix = path_to_unix(tf);
624        let display = capitalize(folder_basename(tf));
625        let preview = newest
626            .map(|r| truncate(&r.summary, 80))
627            .filter(|p| !p.is_empty() && p != MISSING_SUMMARY);
628        match preview {
629            Some(p) => entries.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n}) — {p}\n")),
630            None => entries.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n")),
631        }
632    }
633    let mut s = String::new();
634    s.push_str("---\n");
635    s.push_str("type: index\n");
636    s.push_str("scope: layer\n");
637    s.push_str(&format!("folder: {layer_dir}\n"));
638    if let Some(ts) = max_upd {
639        s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
640    }
641    s.push_str("---\n\n");
642    s.push_str(&format!("# {layer_dir}\n\n"));
643    s.push_str(&entries);
644    s
645}
646
647/// Render the root `index.md`, taking each child's max-updated from its on-disk
648/// `index.jsonl`.
649fn render_root_md_with_store(store: &Store, idx: &Index) -> String {
650    let mut max_upd: Option<DateTime<FixedOffset>> = None;
651    for tf in idx.child_counts.keys() {
652        let recs = read_jsonl_records(&store.root.join(tf).join("index.jsonl")).unwrap_or_default();
653        if let Some(u) = recs.first().and_then(|r| r.updated) {
654            max_upd = Some(match max_upd {
655                Some(cur) if cur >= u => cur,
656                _ => u,
657            });
658        }
659    }
660    let mut s = String::new();
661    s.push_str("---\n");
662    s.push_str("type: index\n");
663    s.push_str("scope: root\n");
664    if let Some(ts) = max_upd {
665        s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
666    }
667    s.push_str("---\n\n");
668    s.push_str(&format!("# {ROOT_TITLE}\n"));
669    for layer in Layer::all() {
670        let layer_dir = layer_dir_name(layer);
671        let prefix = format!("{layer_dir}/");
672        let children: Vec<(&PathBuf, &usize)> = idx
673            .child_counts
674            .iter()
675            .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
676            .collect();
677        if children.is_empty() {
678            continue;
679        }
680        let total: usize = children.iter().map(|(_, n)| **n).sum();
681        s.push('\n');
682        s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
683        for (tf, n) in children {
684            let tf_unix = path_to_unix(tf);
685            let display = capitalize(folder_basename(tf));
686            s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
687        }
688    }
689    s
690}
691
692/// One `index.md` browse line: `- [[path]] — summary  ·  #tag #tag` (the
693/// `  ·  #…` suffix omitted when the file has no tags). The wiki-link target is
694/// the canonical **bare** store-relative path (no `.md` extension — the
695/// doctrine the writers emit and `validate` enforces via
696/// `WIKI_LINK_HAS_EXTENSION`); the jsonl `path` keeps the real on-disk name.
697fn format_md_entry(rec: &IndexRecord) -> String {
698    let path = wiki_target(&rec.path);
699    let mut line = format!("- [[{path}]] — {}", rec.summary);
700    if !rec.tags.is_empty() {
701        let tags = rec
702            .tags
703            .iter()
704            .map(|t| format!("#{t}"))
705            .collect::<Vec<_>>()
706            .join(" ");
707        line.push_str(&format!("  ·  {tags}"));
708    }
709    line
710}
711
712/// The deterministic `## More` footer for an over-cap type-folder.
713fn more_footer(total: usize, type_: &str, layer: &str) -> String {
714    format!(
715        "## More\n\nThis folder has {total} files. The {MD_CAP} most recent are listed above.\nUse `dbmd index query --type {type_} --in {layer}` for the complete catalog.\n"
716    )
717}
718
719/// Canonical total order: `updated` descending (None sorts last), ties broken
720/// by store-relative path ascending. A *total* order, so write-through and
721/// rebuild never disagree on #500 vs #501.
722fn sort_records(records: &mut [IndexRecord]) {
723    records.sort_by(|a, b| {
724        match (b.updated, a.updated) {
725            (Some(bu), Some(au)) => bu.cmp(&au),
726            (Some(_), None) => std::cmp::Ordering::Greater, // a is None → after b
727            (None, Some(_)) => std::cmp::Ordering::Less,    // b is None → after a
728            (None, None) => std::cmp::Ordering::Equal,
729        }
730        .then_with(|| a.path.cmp(&b.path))
731    });
732}
733
734impl IndexRecord {
735    /// Build the [`IndexRecord`] a freshly-rebuilt `index.jsonl` *should* hold
736    /// for the file at `abs` (catalogued under store-relative `rel`).
737    ///
738    /// This is the single canonical projection from frontmatter → sidecar
739    /// record: [`Index::build_type_folder`] uses the same path to write the
740    /// jsonl, so the validator can rebuild the expected record here and compare
741    /// it field-for-field against the committed line — covering **every**
742    /// queryable/dedup field the query path reads (`summary`, `type`, `tags`,
743    /// `links`, `created`, `updated`, and every type-specific `fields` entry
744    /// like `email` / `domain` / `company` / `amount` / `vendor`) without the
745    /// validator hand-rolling (and drifting from) the projection per field.
746    pub(crate) fn expected_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
747        record_from_file(abs, rel)
748    }
749}
750
751/// Build an [`IndexRecord`] from a file on disk. Missing `summary` →
752/// [`MISSING_SUMMARY`] placeholder (the index never invents a summary).
753fn record_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
754    let meta = read_frontmatter(abs)?;
755    Ok(IndexRecord {
756        path: rel,
757        type_: meta.type_.unwrap_or_default(),
758        summary: meta.summary.unwrap_or_else(|| MISSING_SUMMARY.to_string()),
759        tags: meta.tags,
760        links: meta.links,
761        created: meta.created,
762        updated: meta.updated,
763        fields: meta.fields,
764    })
765}
766
767/// The slice of a frontmatter this module needs.
768struct FileMeta {
769    type_: Option<String>,
770    summary: Option<String>,
771    tags: Vec<String>,
772    links: Vec<String>,
773    created: Option<DateTime<FixedOffset>>,
774    updated: Option<DateTime<FixedOffset>>,
775    fields: BTreeMap<String, Value>,
776}
777
778/// Minimal frontmatter read: split the leading `---`…`---` block and parse it
779/// as YAML, extracting the typed fields and spilling the rest into `fields`.
780/// Self-contained (does not route through the `parser` module).
781fn read_frontmatter(abs: &Path) -> crate::Result<FileMeta> {
782    let text = fs::read_to_string(abs)?;
783    let yaml = extract_frontmatter_block(&text).unwrap_or_default();
784    let map: serde_norway::Mapping = if yaml.trim().is_empty() {
785        serde_norway::Mapping::new()
786    } else {
787        serde_norway::from_str(&yaml).map_err(|e| {
788            crate::Error::Store(crate::store::StoreError::BadTypeIndex {
789                path: abs.to_path_buf(),
790                message: format!("frontmatter YAML: {e}"),
791            })
792        })?
793    };
794
795    let mut type_ = None;
796    let mut summary = None;
797    let mut tags = Vec::new();
798    let mut links = Vec::new();
799    let mut created = None;
800    let mut updated = None;
801    let mut fields = BTreeMap::new();
802
803    for (k, v) in map {
804        let key = match k.as_str() {
805            Some(s) => s.to_string(),
806            None => continue,
807        };
808        match key.as_str() {
809            "type" => type_ = v.as_str().map(str::to_string),
810            "summary" => summary = v.as_str().map(str::to_string),
811            "tags" => tags = yaml_string_list(&v),
812            "links" => links = yaml_string_list(&v),
813            "created" => created = v.as_str().and_then(parse_ts),
814            "updated" => updated = v.as_str().and_then(parse_ts),
815            // `path`, `type`, `summary`, `tags`, `links`, `created`, `updated`
816            // are the reserved IndexRecord keys; everything else (including
817            // `id`, `status`, type-specific fields) goes to `fields`.
818            "path" => {}
819            _ => {
820                fields.insert(key, yaml_to_json_value(&v));
821            }
822        }
823    }
824
825    Ok(FileMeta {
826        type_,
827        summary,
828        tags,
829        links,
830        created,
831        updated,
832        fields,
833    })
834}
835
836/// Pull the YAML between a leading `---` line and the next `---` line. Returns
837/// `None` when the file has no frontmatter fence at its very start.
838fn extract_frontmatter_block(text: &str) -> Option<String> {
839    let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
840    let mut lines = trimmed.lines();
841    let first = lines.next()?;
842    if first.trim_end() != "---" {
843        return None;
844    }
845    let mut block = String::new();
846    for line in lines {
847        if line.trim_end() == "---" {
848            return Some(block);
849        }
850        block.push_str(line);
851        block.push('\n');
852    }
853    None // no closing fence
854}
855
856/// Read a string scalar or a sequence-of-string-scalars into a `Vec<String>`.
857/// Wiki-link items keep their `[[…]]` form verbatim.
858fn yaml_string_list(v: &serde_norway::Value) -> Vec<String> {
859    match v {
860        serde_norway::Value::String(s) => vec![s.clone()],
861        serde_norway::Value::Sequence(seq) => seq
862            .iter()
863            .filter_map(yaml_string_or_wiki_link_literal)
864            .collect(),
865        _ => Vec::new(),
866    }
867}
868
869fn yaml_string_or_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
870    v.as_str()
871        .map(str::to_string)
872        .or_else(|| unquoted_wiki_link_literal(v))
873}
874
875fn yaml_to_json_value(v: &serde_norway::Value) -> Value {
876    if let Some(link) = unquoted_wiki_link_literal(v) {
877        return Value::String(link);
878    }
879    match v {
880        serde_norway::Value::String(s) => Value::String(s.clone()),
881        serde_norway::Value::Bool(b) => Value::Bool(*b),
882        serde_norway::Value::Number(n) => {
883            serde_json::to_value(n).unwrap_or_else(|_| Value::String(n.to_string()))
884        }
885        serde_norway::Value::Sequence(seq) => {
886            Value::Array(seq.iter().map(yaml_to_json_value).collect())
887        }
888        serde_norway::Value::Mapping(_) | serde_norway::Value::Tagged(_) => {
889            serde_json::to_value(v).unwrap_or(Value::Null)
890        }
891        serde_norway::Value::Null => Value::Null,
892    }
893}
894
895fn unquoted_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
896    let serde_norway::Value::Sequence(outer) = v else {
897        return None;
898    };
899    if outer.len() != 1 {
900        return None;
901    }
902    let serde_norway::Value::Sequence(inner) = &outer[0] else {
903        return None;
904    };
905    let [serde_norway::Value::String(target)] = inner.as_slice() else {
906        return None;
907    };
908    Some(format!("[[{target}]]"))
909}
910
911/// Parse an RFC3339 timestamp scalar.
912fn parse_ts(s: &str) -> Option<DateTime<FixedOffset>> {
913    DateTime::parse_from_rfc3339(s.trim()).ok()
914}
915
916/// Render a timestamp the same way `serde_json` renders an `IndexRecord`
917/// timestamp (RFC3339, `Z` for UTC, sub-seconds preserved) so the md
918/// frontmatter and the jsonl agree byte-for-byte.
919fn fmt_ts(ts: &DateTime<FixedOffset>) -> String {
920    ts.to_rfc3339_opts(SecondsFormat::AutoSi, true)
921}
922
923/// Max `updated` over an iterator of optional timestamps.
924fn max_updated<'a>(
925    it: impl Iterator<Item = Option<&'a DateTime<FixedOffset>>>,
926) -> Option<DateTime<FixedOffset>> {
927    let mut best: Option<DateTime<FixedOffset>> = None;
928    for ts in it.flatten() {
929        best = Some(match best {
930            Some(cur) if cur >= *ts => cur,
931            _ => *ts,
932        });
933    }
934    best
935}
936
937/// Read a type-folder's `index.jsonl` into records, applying last-write-wins by
938/// `path` over any un-compacted lines (so a half-compacted jsonl still reads
939/// cleanly). Missing file → empty set. Returns records in canonical order.
940fn read_jsonl_records(jsonl: &Path) -> crate::Result<Vec<IndexRecord>> {
941    let text = match fs::read_to_string(jsonl) {
942        Ok(t) => t,
943        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
944        Err(e) => return Err(e.into()),
945    };
946    // Last-write-wins by path; preserve only the final occurrence.
947    let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
948    for (i, line) in text.lines().enumerate() {
949        if line.trim().is_empty() {
950            continue;
951        }
952        let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
953            crate::Error::Store(crate::store::StoreError::BadTypeIndex {
954                path: jsonl.to_path_buf(),
955                message: format!("line {}: {e}", i + 1),
956            })
957        })?;
958        by_path.insert(rec.path.clone(), rec);
959    }
960    let mut records: Vec<IndexRecord> = by_path.into_values().collect();
961    sort_records(&mut records);
962    Ok(records)
963}
964
965/// Count the distinct content files a type-folder's `index.jsonl` catalogs —
966/// the **loop-path** count primitive, the rollup analogue of reading the
967/// per-folder sidecar. It reads only the one small sidecar (one line per file),
968/// never the content tree, so a rollup recompute over `K` type-folders is
969/// `O(K · folder)` sidecar reads — never `O(store files)` like
970/// [`walk_type_folder_files`]. Distinct-`path` (last-write-wins) so the count is
971/// byte-identical to [`read_jsonl_records`]`.len()` even on a half-compacted
972/// jsonl; a missing sidecar is `0`. Within the loop and within
973/// [`Index::rebuild_all`] the folder's jsonl is always rewritten before its
974/// parents are rolled up, so this equals `walk_type_folder_files(folder).len()`.
975fn jsonl_record_count(jsonl: &Path) -> crate::Result<usize> {
976    let text = match fs::read_to_string(jsonl) {
977        Ok(t) => t,
978        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
979        Err(e) => return Err(e.into()),
980    };
981    let mut paths: BTreeSet<PathBuf> = BTreeSet::new();
982    for (i, line) in text.lines().enumerate() {
983        if line.trim().is_empty() {
984            continue;
985        }
986        let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
987            crate::Error::Store(crate::store::StoreError::BadTypeIndex {
988                path: jsonl.to_path_buf(),
989                message: format!("line {}: {e}", i + 1),
990            })
991        })?;
992        paths.insert(rec.path);
993    }
994    Ok(paths.len())
995}
996
997/// Per-child rollup counts for `layers`, read from each type-folder's on-disk
998/// `index.jsonl` (via [`jsonl_record_count`]) rather than walked from the
999/// content tree. The **loop-path** counterpart to the from-scratch counting in
1000/// [`Index::build_layer`] / [`Index::build_root`]: it keeps [`update_parents`]
1001/// `O(type-folders)` so a single write never re-enumerates the whole store.
1002fn child_counts_from_jsonl(
1003    store: &Store,
1004    layers: &[Layer],
1005) -> crate::Result<BTreeMap<PathBuf, usize>> {
1006    let mut child_counts = BTreeMap::new();
1007    for &layer in layers {
1008        for tf in type_folders_in_layer(store, layer) {
1009            let n = jsonl_record_count(&store.root.join(&tf).join("index.jsonl"))?;
1010            if n > 0 {
1011                child_counts.insert(tf, n);
1012            }
1013        }
1014    }
1015    Ok(child_counts)
1016}
1017
1018/// Walk a type-folder's `.md` content files, recursing through date-shards,
1019/// excluding the `index.md` artifact itself and any hidden entries.
1020fn walk_type_folder_files(folder_abs: &Path) -> Vec<PathBuf> {
1021    let mut out = Vec::new();
1022    if !folder_abs.is_dir() {
1023        return out;
1024    }
1025    for entry in walkdir::WalkDir::new(folder_abs)
1026        .into_iter()
1027        .filter_entry(|e| !is_hidden(e.file_name()))
1028        .filter_map(|e| e.ok())
1029    {
1030        if !entry.file_type().is_file() {
1031            continue;
1032        }
1033        let p = entry.path();
1034        if p.extension().and_then(|e| e.to_str()) != Some("md") {
1035            continue;
1036        }
1037        if p.file_name().and_then(|n| n.to_str()) == Some("index.md") {
1038            continue;
1039        }
1040        out.push(p.to_path_buf());
1041    }
1042    out
1043}
1044
1045/// The immediate type-folders under a layer (one directory level below the
1046/// layer dir), as store-relative paths. Hidden dirs and `log/` are skipped.
1047fn type_folders_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1048    let layer_dir = store.root.join(layer_dir_name(layer));
1049    let mut out = Vec::new();
1050    let rd = match fs::read_dir(&layer_dir) {
1051        Ok(rd) => rd,
1052        Err(_) => return out,
1053    };
1054    for entry in rd.flatten() {
1055        if !entry.path().is_dir() {
1056            continue;
1057        }
1058        let name = entry.file_name();
1059        let name = match name.to_str() {
1060            Some(n) => n,
1061            None => continue,
1062        };
1063        if is_hidden(entry.file_name().as_os_str()) || name == "log" {
1064            continue;
1065        }
1066        out.push(PathBuf::from(layer_dir_name(layer)).join(name));
1067    }
1068    out.sort();
1069    out
1070}
1071
1072/// The type-folder a content file belongs to: `<layer>/<type>` (the first two
1073/// path components), or `None` if the path is not under a known layer with at
1074/// least a type segment.
1075fn type_folder_of(file_rel: &Path) -> Option<PathBuf> {
1076    let mut comps = file_rel.components();
1077    let layer = comps.next()?.as_os_str().to_str()?;
1078    layer_from_dir_name(layer)?;
1079    let type_seg = comps.next()?.as_os_str().to_str()?;
1080    Some(PathBuf::from(layer).join(type_seg))
1081}
1082
1083/// Convert an absolute path under `root` to a store-relative path.
1084fn rel_to_store(root: &Path, abs: &Path) -> Option<PathBuf> {
1085    abs.strip_prefix(root).ok().map(|p| p.to_path_buf())
1086}
1087
1088/// Normalize a possibly-absolute or `./`-prefixed path to a clean
1089/// store-relative form (drops a leading `./`; leaves already-relative paths).
1090fn normalize_rel(p: &Path) -> PathBuf {
1091    let s = path_to_unix(p);
1092    let s = s.strip_prefix("./").unwrap_or(&s);
1093    PathBuf::from(s)
1094}
1095
1096fn is_index_artifact(p: &Path) -> bool {
1097    matches!(
1098        p.file_name().and_then(|n| n.to_str()),
1099        Some("index.md") | Some("index.jsonl")
1100    )
1101}
1102
1103fn is_hidden(name: &std::ffi::OsStr) -> bool {
1104    name.to_str().map(|s| s.starts_with('.')).unwrap_or(false)
1105}
1106
1107fn layer_dir_name(layer: Layer) -> &'static str {
1108    match layer {
1109        Layer::Sources => "sources",
1110        Layer::Records => "records",
1111        Layer::Wiki => "wiki",
1112    }
1113}
1114
1115/// Local layer-name parse. Mirrors the contract of [`Layer::from_dir_name`];
1116/// kept local to keep this module's walk self-contained (see the module header).
1117fn layer_from_dir_name(name: &str) -> Option<Layer> {
1118    match name {
1119        "sources" => Some(Layer::Sources),
1120        "records" => Some(Layer::Records),
1121        "wiki" => Some(Layer::Wiki),
1122        _ => None,
1123    }
1124}
1125
1126/// The final path component as a `&str` (folder basename).
1127fn folder_basename(p: &Path) -> &str {
1128    p.file_name().and_then(|n| n.to_str()).unwrap_or("")
1129}
1130
1131/// The canonical wiki-link target for a content path: the store-relative path
1132/// with `/` separators and the trailing `.md` stripped (the bare form the
1133/// `index.md` browse view links to).
1134fn wiki_target(p: &Path) -> String {
1135    let unix = path_to_unix(p);
1136    unix.strip_suffix(".md").unwrap_or(&unix).to_string()
1137}
1138
1139/// Render a path with `/` separators regardless of host OS, so artifacts are
1140/// identical on every platform.
1141fn path_to_unix(p: &Path) -> String {
1142    p.components()
1143        .filter_map(|c| c.as_os_str().to_str())
1144        .collect::<Vec<_>>()
1145        .join("/")
1146}
1147
1148/// ASCII-capitalize the first character.
1149fn capitalize(s: &str) -> String {
1150    let mut chars = s.chars();
1151    match chars.next() {
1152        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1153        None => String::new(),
1154    }
1155}
1156
1157/// Truncate to at most `max` chars (char-boundary safe), single-line.
1158fn truncate(s: &str, max: usize) -> String {
1159    let one_line: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
1160    if one_line.chars().count() <= max {
1161        one_line
1162    } else {
1163        one_line.chars().take(max).collect()
1164    }
1165}
1166
1167fn write_atomic(path: &Path, contents: String) -> crate::Result<()> {
1168    if let Some(parent) = path.parent() {
1169        fs::create_dir_all(parent)?;
1170    }
1171    let dir = path.parent().unwrap_or_else(|| Path::new("."));
1172    let mut tmp = tempfile_in(dir)?;
1173    tmp.write_all(contents.as_bytes())?;
1174    tmp.flush()?;
1175    tmp.persist(path)?;
1176    Ok(())
1177}
1178
1179fn remove_if_exists(path: &Path) -> crate::Result<()> {
1180    match fs::remove_file(path) {
1181        Ok(()) => Ok(()),
1182        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1183        Err(e) => Err(e.into()),
1184    }
1185}
1186
1187fn bad_index(path: &Path, msg: &str) -> crate::Error {
1188    crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1189        path: path.to_path_buf(),
1190        message: msg.to_string(),
1191    })
1192}
1193
1194// A tiny atomic-write helper. `tempfile` is a dev-dependency for tests; for
1195// the library path we hand-roll a temp-file-then-rename so writes are atomic
1196// without pulling `tempfile` into the non-dev dependency set. The file handle
1197// is held in an `Option` so `persist` can take it out without fighting the
1198// `Drop` impl (which only cleans up an un-persisted temp file).
1199struct AtomicTemp {
1200    file: Option<fs::File>,
1201    path: PathBuf,
1202    persisted: bool,
1203}
1204
1205impl AtomicTemp {
1206    fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
1207        self.file.as_mut().expect("temp file open").write_all(bytes)
1208    }
1209    fn flush(&mut self) -> std::io::Result<()> {
1210        self.file.as_mut().expect("temp file open").flush()
1211    }
1212    fn persist(mut self, dest: &Path) -> std::io::Result<()> {
1213        if let Some(f) = self.file.take() {
1214            f.sync_all().ok();
1215            // `f` dropped here, closing the handle before the rename.
1216        }
1217        fs::rename(&self.path, dest)?;
1218        self.persisted = true;
1219        Ok(())
1220    }
1221}
1222
1223impl Drop for AtomicTemp {
1224    fn drop(&mut self) {
1225        // Best-effort cleanup if not persisted (an error path bailed out).
1226        if !self.persisted {
1227            let _ = fs::remove_file(&self.path);
1228        }
1229    }
1230}
1231
1232fn tempfile_in(dir: &Path) -> std::io::Result<AtomicTemp> {
1233    use std::time::{SystemTime, UNIX_EPOCH};
1234    let nanos = SystemTime::now()
1235        .duration_since(UNIX_EPOCH)
1236        .map(|d| d.as_nanos())
1237        .unwrap_or(0);
1238    let pid = std::process::id();
1239    // Monotonic-ish unique suffix; the dir is the destination dir so rename is
1240    // same-filesystem and therefore atomic.
1241    let counter = next_temp_counter();
1242    let name = format!(".dbmd-index-{pid}-{nanos}-{counter}.tmp");
1243    let path = dir.join(name);
1244    let file = fs::OpenOptions::new()
1245        .write(true)
1246        .create_new(true)
1247        .open(&path)?;
1248    Ok(AtomicTemp {
1249        file: Some(file),
1250        path,
1251        persisted: false,
1252    })
1253}
1254
1255fn next_temp_counter() -> u64 {
1256    use std::sync::atomic::{AtomicU64, Ordering};
1257    static C: AtomicU64 = AtomicU64::new(0);
1258    C.fetch_add(1, Ordering::Relaxed)
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263    use super::*;
1264    use std::collections::BTreeSet;
1265    use std::fs;
1266    use tempfile::TempDir;
1267
1268    // ── fixtures ─────────────────────────────────────────────────────────
1269
1270    /// A temp store with a `DB.md` marker. `store.config` is the parser default
1271    /// (these tests never exercise the config parser).
1272    fn mk_store() -> (TempDir, Store) {
1273        let dir = TempDir::new().unwrap();
1274        fs::write(dir.path().join("DB.md"), "# test store\n").unwrap();
1275        let store = Store {
1276            root: dir.path().to_path_buf(),
1277            config: crate::parser::Config::default(),
1278        };
1279        (dir, store)
1280    }
1281
1282    /// Write a content file at `rel` with the given frontmatter lines + body.
1283    /// `fm` is the raw YAML body between the fences (no `---`).
1284    fn write_raw(store: &Store, rel: &str, fm: &str, body: &str) {
1285        let abs = store.root.join(rel);
1286        fs::create_dir_all(abs.parent().unwrap()).unwrap();
1287        fs::write(&abs, format!("---\n{fm}\n---\n{body}")).unwrap();
1288    }
1289
1290    /// Convenience: write a typed content file with summary/updated/extras.
1291    fn write_doc(
1292        store: &Store,
1293        rel: &str,
1294        type_: &str,
1295        summary: Option<&str>,
1296        updated: Option<&str>,
1297        extra_yaml: &str,
1298    ) {
1299        let mut fm = format!("type: {type_}\n");
1300        if let Some(s) = summary {
1301            fm.push_str(&format!("summary: {s}\n"));
1302        }
1303        if let Some(u) = updated {
1304            fm.push_str(&format!("updated: {u}\n"));
1305        }
1306        fm.push_str(extra_yaml);
1307        write_raw(store, rel, fm.trim_end(), "\nbody text\n");
1308    }
1309
1310    fn read(store: &Store, rel: &str) -> String {
1311        fs::read_to_string(store.root.join(rel)).unwrap()
1312    }
1313
1314    fn exists(store: &Store, rel: &str) -> bool {
1315        store.root.join(rel).exists()
1316    }
1317
1318    /// Collect every `index.md` + `index.jsonl` under the store, mapped to its
1319    /// bytes — the surface the byte-identity invariant compares.
1320    fn snapshot_artifacts(store: &Store) -> BTreeMap<String, String> {
1321        let mut out = BTreeMap::new();
1322        for entry in walkdir::WalkDir::new(&store.root)
1323            .into_iter()
1324            .filter_map(|e| e.ok())
1325        {
1326            let p = entry.path();
1327            if is_index_artifact(p) {
1328                let rel = path_to_unix(&rel_to_store(&store.root, p).unwrap());
1329                out.insert(rel, fs::read_to_string(p).unwrap());
1330            }
1331        }
1332        out
1333    }
1334
1335    // ── build_type_folder + to_markdown ──────────────────────────────────
1336
1337    #[test]
1338    fn type_folder_aggregates_across_shards_in_recency_order() {
1339        let (_d, store) = mk_store();
1340        // Three emails across two month-shards, deliberately written
1341        // out-of-recency-order on disk.
1342        write_doc(
1343            &store,
1344            "sources/emails/2026/05/b-old.md",
1345            "email",
1346            Some("Older mail"),
1347            Some("2026-05-01T09:00:00Z"),
1348            "",
1349        );
1350        write_doc(
1351            &store,
1352            "sources/emails/2026/06/c-new.md",
1353            "email",
1354            Some("Newest mail"),
1355            Some("2026-06-15T12:00:00Z"),
1356            "",
1357        );
1358        write_doc(
1359            &store,
1360            "sources/emails/2026/05/a-mid.md",
1361            "email",
1362            Some("Middle mail"),
1363            Some("2026-05-20T08:00:00Z"),
1364            "",
1365        );
1366
1367        let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
1368        let paths: Vec<String> = idx.records.iter().map(|r| path_to_unix(&r.path)).collect();
1369        assert_eq!(
1370            paths,
1371            vec![
1372                "sources/emails/2026/06/c-new.md",
1373                "sources/emails/2026/05/a-mid.md",
1374                "sources/emails/2026/05/b-old.md",
1375            ],
1376            "records must aggregate across shards, newest `updated` first"
1377        );
1378    }
1379
1380    #[test]
1381    fn type_folder_md_format_entries_tags_and_derived_updated() {
1382        let (_d, store) = mk_store();
1383        write_doc(
1384            &store,
1385            "records/contacts/sarah-chen.md",
1386            "contact",
1387            Some("Renewal champion at Acme"),
1388            Some("2026-05-27T10:00:00Z"),
1389            "tags:\n  - renewal\n  - acme\n",
1390        );
1391        write_doc(
1392            &store,
1393            "records/contacts/no-tags.md",
1394            "contact",
1395            Some("Plain contact"),
1396            Some("2026-05-26T10:00:00Z"),
1397            "",
1398        );
1399
1400        let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
1401        let md = idx.to_markdown();
1402
1403        // Frontmatter is exact and the index's own `updated` is the MAX member
1404        // updated (the determinism the byte-identity invariant rests on).
1405        assert!(md.starts_with(
1406            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\nupdated: 2026-05-27T10:00:00Z\n---\n\n# records/contacts\n"
1407        ), "frontmatter/heading wrong:\n{md}");
1408
1409        // Entry with tags: `— summary  ·  #tag #tag`.
1410        assert!(
1411            md.contains(
1412                "- [[records/contacts/sarah-chen]] — Renewal champion at Acme  ·  #renewal #acme\n"
1413            ),
1414            "tagged entry wrong:\n{md}"
1415        );
1416        // Entry without tags omits the `  ·  ` suffix entirely.
1417        assert!(
1418            md.contains("- [[records/contacts/no-tags]] — Plain contact\n"),
1419            "untagged entry wrong:\n{md}"
1420        );
1421        assert!(
1422            !md.contains("Plain contact  ·"),
1423            "untagged entry must not emit a tag separator"
1424        );
1425        // No `## More` below the cap.
1426        assert!(!md.contains("## More"), "no footer expected under the cap");
1427    }
1428
1429    #[test]
1430    fn missing_summary_becomes_placeholder_not_invented() {
1431        let (_d, store) = mk_store();
1432        write_doc(
1433            &store,
1434            "records/notes/x.md",
1435            "note",
1436            None,
1437            Some("2026-05-27T10:00:00Z"),
1438            "",
1439        );
1440        let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
1441        assert_eq!(idx.records[0].summary, MISSING_SUMMARY);
1442        let md = idx.to_markdown();
1443        assert!(
1444            md.contains("- [[records/notes/x]] — (no summary)\n"),
1445            "missing summary must render the placeholder, not invent text:\n{md}"
1446        );
1447    }
1448
1449    // ── to_jsonl ─────────────────────────────────────────────────────────
1450
1451    #[test]
1452    fn jsonl_is_complete_structured_and_round_trips() {
1453        let (_d, store) = mk_store();
1454        write_doc(
1455            &store,
1456            "records/expenses/2026/05/e1.md",
1457            "expense",
1458            Some("Lunch with vendor"),
1459            Some("2026-05-10T10:00:00Z"),
1460            "created: 2026-05-10T09:00:00Z\nstatus: paid\namount: 42\ncompany: [[records/companies/acme]]\nrelated:\n  - [[wiki/themes/spend]]\ntags:\n  - food\nlinks:\n  - wiki/themes/spend\n  - [[wiki/themes/renewal]]\n",
1461        );
1462        write_doc(
1463            &store,
1464            "records/expenses/2026/06/e2.md",
1465            "expense",
1466            Some("Cloud bill"),
1467            Some("2026-06-01T10:00:00Z"),
1468            "amount: 100\n",
1469        );
1470
1471        let idx = Index::build_type_folder(&store, Path::new("records/expenses")).unwrap();
1472        let jsonl = idx.to_jsonl();
1473        let lines: Vec<&str> = jsonl.lines().collect();
1474        assert_eq!(lines.len(), 2, "one JSON object per file, uncapped");
1475
1476        // Newest first (e2), and each line parses back to an equal record.
1477        let r0: IndexRecord = serde_json::from_str(lines[0]).unwrap();
1478        assert_eq!(path_to_unix(&r0.path), "records/expenses/2026/06/e2.md");
1479        assert_eq!(
1480            r0, idx.records[0],
1481            "jsonl line must round-trip to the record"
1482        );
1483
1484        // The first (data) record carries every reserved field + the extras in
1485        // `fields` (status/amount), and links/tags verbatim.
1486        let r1: IndexRecord = serde_json::from_str(lines[1]).unwrap();
1487        assert_eq!(r1.type_, "expense");
1488        assert_eq!(r1.summary, "Lunch with vendor");
1489        assert_eq!(r1.tags, vec!["food".to_string()]);
1490        assert_eq!(
1491            r1.links,
1492            vec![
1493                "wiki/themes/spend".to_string(),
1494                "[[wiki/themes/renewal]]".to_string()
1495            ]
1496        );
1497        assert_eq!(
1498            r1.created,
1499            Some(DateTime::parse_from_rfc3339("2026-05-10T09:00:00Z").unwrap())
1500        );
1501        assert_eq!(r1.fields.get("status"), Some(&Value::from("paid")));
1502        assert_eq!(r1.fields.get("amount"), Some(&Value::from(42)));
1503        assert_eq!(
1504            r1.fields.get("company"),
1505            Some(&Value::from("[[records/companies/acme]]"))
1506        );
1507        assert_eq!(
1508            r1.fields.get("related"),
1509            Some(&serde_json::json!(["[[wiki/themes/spend]]"]))
1510        );
1511        // Reserved keys never leak into `fields`.
1512        for reserved in [
1513            "path", "type", "summary", "tags", "links", "created", "updated",
1514        ] {
1515            assert!(
1516                !r1.fields.contains_key(reserved),
1517                "reserved key {reserved} must not appear in fields"
1518            );
1519        }
1520
1521        // Stable key order: declared fields first, then sorted extras.
1522        assert!(
1523            lines[1].starts_with(
1524                r#"{"path":"records/expenses/2026/05/e1.md","type":"expense","summary":"Lunch with vendor","tags":["food"],"links":["wiki/themes/spend","[[wiki/themes/renewal]]"],"created":"2026-05-10T09:00:00Z","updated":"2026-05-10T10:00:00Z","#
1525            ),
1526            "jsonl key order not stable:\n{}",
1527            lines[1]
1528        );
1529        // The flattened extras come in BTreeMap (sorted) order.
1530        assert!(
1531            lines[1].ends_with(r#""amount":42,"company":"[[records/companies/acme]]","related":["[[wiki/themes/spend]]"],"status":"paid"}"#),
1532            "extras must be sorted:\n{}",
1533            lines[1]
1534        );
1535    }
1536
1537    // ── cap + footer ─────────────────────────────────────────────────────
1538
1539    #[test]
1540    fn over_cap_md_shows_500_plus_footer_jsonl_holds_all() {
1541        let (_d, store) = mk_store();
1542        let total = MD_CAP + 7;
1543        for i in 0..total {
1544            // Distinct, monotonically increasing `updated` so order is total.
1545            let day = 1 + (i % 27);
1546            let rel = format!("sources/emails/2026/05/m-{i:04}.md");
1547            let updated = format!("2026-05-{day:02}T00:00:{:02}Z", i % 60);
1548            write_doc(
1549                &store,
1550                &rel,
1551                "email",
1552                Some(&format!("mail {i}")),
1553                Some(&updated),
1554                "",
1555            );
1556        }
1557        let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
1558        assert_eq!(idx.records.len(), total, "jsonl/records keep every file");
1559
1560        let md = idx.to_markdown();
1561        let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
1562        assert_eq!(entry_lines, MD_CAP, "md browse view is capped at 500");
1563
1564        assert!(
1565            md.contains("## More\n\n"),
1566            "over-cap md needs a More footer"
1567        );
1568        assert!(
1569            md.contains(&format!(
1570                "This folder has {total} files. The 500 most recent are listed above.\n"
1571            )),
1572            "footer count wrong:\n{md}"
1573        );
1574        assert!(
1575            md.contains(
1576                "Use `dbmd index query --type email --in sources` for the complete catalog.\n"
1577            ),
1578            "footer must infer type=email layer=sources:\n{md}"
1579        );
1580
1581        let jsonl = idx.to_jsonl();
1582        assert_eq!(jsonl.lines().count(), total, "jsonl is uncapped");
1583    }
1584
1585    // ── sort total order ─────────────────────────────────────────────────
1586
1587    #[test]
1588    fn sort_breaks_ties_by_path_and_puts_undated_last() {
1589        let mut recs = vec![
1590            rec("z/a.md", Some("2026-05-01T00:00:00Z")),
1591            rec("a/b.md", Some("2026-05-01T00:00:00Z")), // same updated, path < z/a
1592            rec("m/c.md", None),                         // undated → last
1593            rec("b/d.md", Some("2026-06-01T00:00:00Z")), // newest
1594        ];
1595        sort_records(&mut recs);
1596        let order: Vec<String> = recs.iter().map(|r| path_to_unix(&r.path)).collect();
1597        assert_eq!(order, vec!["b/d.md", "a/b.md", "z/a.md", "m/c.md"]);
1598    }
1599
1600    fn rec(path: &str, updated: Option<&str>) -> IndexRecord {
1601        IndexRecord {
1602            path: PathBuf::from(path),
1603            type_: "t".into(),
1604            summary: "s".into(),
1605            tags: vec![],
1606            links: vec![],
1607            created: None,
1608            updated: updated.map(|u| DateTime::parse_from_rfc3339(u).unwrap()),
1609            fields: BTreeMap::new(),
1610        }
1611    }
1612
1613    // ── build_layer / build_root ─────────────────────────────────────────
1614
1615    #[test]
1616    fn layer_index_lists_type_folders_with_counts_and_preview() {
1617        let (_d, store) = mk_store();
1618        write_doc(
1619            &store,
1620            "records/contacts/a.md",
1621            "contact",
1622            Some("Contact A older"),
1623            Some("2026-05-01T00:00:00Z"),
1624            "",
1625        );
1626        write_doc(
1627            &store,
1628            "records/contacts/b.md",
1629            "contact",
1630            Some("Contact B newest"),
1631            Some("2026-05-09T00:00:00Z"),
1632            "",
1633        );
1634        write_doc(
1635            &store,
1636            "records/companies/x.md",
1637            "company",
1638            Some("Acme Inc"),
1639            Some("2026-05-05T00:00:00Z"),
1640            "",
1641        );
1642        // build the type-folder artifacts first (layer preview reads their jsonl)
1643        Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
1644        Index::write_level(&store, &IndexLevel::TypeFolder("records/companies".into())).unwrap();
1645
1646        Index::write_level(&store, &IndexLevel::Layer(Layer::Records)).unwrap();
1647        let md = read(&store, "records/index.md");
1648
1649        assert!(
1650            md.starts_with("---\ntype: index\nscope: layer\nfolder: records\n"),
1651            "layer fm:\n{md}"
1652        );
1653        // Alphabetical type-folder order: companies before contacts.
1654        let companies_at = md.find("companies/index").unwrap();
1655        let contacts_at = md.find("contacts/index").unwrap();
1656        assert!(
1657            companies_at < contacts_at,
1658            "type folders must be alphabetical"
1659        );
1660        // Count + display + newest-summary preview.
1661        assert!(
1662            md.contains("- [[records/contacts/index|Contacts]] (2) — Contact B newest\n"),
1663            "contacts entry:\n{md}"
1664        );
1665        assert!(
1666            md.contains("- [[records/companies/index|Companies]] (1) — Acme Inc\n"),
1667            "companies entry:\n{md}"
1668        );
1669        // Layer `updated` is the max across children (contacts b = 05-09).
1670        assert!(
1671            md.contains("updated: 2026-05-09T00:00:00Z\n"),
1672            "layer updated must be max child:\n{md}"
1673        );
1674    }
1675
1676    #[test]
1677    fn root_index_groups_layers_with_totals_and_per_type_counts() {
1678        let (_d, store) = mk_store();
1679        write_doc(
1680            &store,
1681            "sources/emails/2026/05/a.md",
1682            "email",
1683            Some("Mail"),
1684            Some("2026-05-01T00:00:00Z"),
1685            "",
1686        );
1687        write_doc(
1688            &store,
1689            "sources/docs/d.md",
1690            "doc",
1691            Some("Doc"),
1692            Some("2026-05-02T00:00:00Z"),
1693            "",
1694        );
1695        write_doc(
1696            &store,
1697            "records/contacts/c.md",
1698            "contact",
1699            Some("C"),
1700            Some("2026-05-03T00:00:00Z"),
1701            "",
1702        );
1703        // wiki empty → no Wiki section
1704
1705        Index::rebuild_all(&store).unwrap();
1706        let md = read(&store, "index.md");
1707
1708        assert!(
1709            md.starts_with("---\ntype: index\nscope: root\n"),
1710            "root fm:\n{md}"
1711        );
1712        assert!(md.contains("# Knowledge base index\n"), "root title:\n{md}");
1713        // Layer heading with total count; Sources before Records (canonical).
1714        let sources_h = md
1715            .find("## Sources (2)")
1716            .expect("sources heading w/ total 2");
1717        let records_h = md
1718            .find("## Records (1)")
1719            .expect("records heading w/ total 1");
1720        assert!(sources_h < records_h, "Sources must precede Records");
1721        assert!(!md.contains("## Wiki"), "empty layer gets no section");
1722        // Per-type sub-entries with (N), no preview at root.
1723        assert!(
1724            md.contains("- [[sources/docs/index|Docs]] (1)\n"),
1725            "root docs entry:\n{md}"
1726        );
1727        assert!(
1728            md.contains("- [[sources/emails/index|Emails]] (1)\n"),
1729            "root emails entry:\n{md}"
1730        );
1731        assert!(
1732            md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
1733            "root contacts entry:\n{md}"
1734        );
1735        assert!(!md.contains("— "), "root entries carry no preview text");
1736    }
1737
1738    // ── write-through == rebuild (THE invariant) ─────────────────────────
1739
1740    #[test]
1741    fn on_write_matches_rebuild_byte_for_byte() {
1742        // Build a store incrementally via on_write, and a second identical store
1743        // via a single rebuild_all, then assert every index artifact is equal.
1744        let (_d1, wt) = mk_store();
1745        let (_d2, rb) = mk_store();
1746
1747        let docs: &[(&str, &str, &str, &str, &str)] = &[
1748            (
1749                "sources/emails/2026/05/e1.md",
1750                "email",
1751                "First mail",
1752                "2026-05-01T10:00:00Z",
1753                "tags:\n  - inbox\n",
1754            ),
1755            (
1756                "sources/emails/2026/06/e2.md",
1757                "email",
1758                "Second mail",
1759                "2026-06-01T10:00:00Z",
1760                "",
1761            ),
1762            (
1763                "records/contacts/sarah.md",
1764                "contact",
1765                "Sarah",
1766                "2026-05-15T10:00:00Z",
1767                "links:\n  - wiki/people/sarah\n",
1768            ),
1769            (
1770                "records/contacts/elena.md",
1771                "contact",
1772                "Elena",
1773                "2026-05-20T10:00:00Z",
1774                "status: active\n",
1775            ),
1776            (
1777                "wiki/people/sarah.md",
1778                "wiki-page",
1779                "Sarah bio",
1780                "2026-05-21T10:00:00Z",
1781                "",
1782            ),
1783        ];
1784
1785        for (rel, t, sum, upd, extra) in docs {
1786            write_doc(&wt, rel, t, Some(sum), Some(upd), extra);
1787            write_doc(&rb, rel, t, Some(sum), Some(upd), extra);
1788            Index::on_write(&wt, Path::new(rel)).unwrap();
1789        }
1790        Index::rebuild_all(&rb).unwrap();
1791
1792        let a = snapshot_artifacts(&wt);
1793        let b = snapshot_artifacts(&rb);
1794        assert_eq!(
1795            a.keys().collect::<Vec<_>>(),
1796            b.keys().collect::<Vec<_>>(),
1797            "same set of index artifacts must exist"
1798        );
1799        for (k, v) in &a {
1800            assert_eq!(v, &b[k], "artifact {k} differs between write-through and rebuild:\n--- write-through ---\n{v}\n--- rebuild ---\n{}", b[k]);
1801        }
1802        // Sanity: artifacts actually exist (not a vacuous comparison of empties).
1803        assert!(a.contains_key("index.md"));
1804        assert!(a.contains_key("sources/emails/index.jsonl"));
1805        assert!(a.contains_key("records/contacts/index.md"));
1806    }
1807
1808    /// Regression (O(changed) bound, not just correctness): a loop op must
1809    /// recompute its parent rollups from the type-folder `index.jsonl` sidecars
1810    /// — never by walking the content tree of *sibling* folders it wasn't asked
1811    /// about. The byte-identity property test (which always indexes every folder
1812    /// before comparing) can't catch a violation, because a full-store walk
1813    /// produces the *correct* counts too; it just does so in `O(store files)`.
1814    ///
1815    /// The behavioral fingerprint of the old `update_parents → build_layer /
1816    /// build_root` (which called `walk_type_folder_files` on every type-folder in
1817    /// the store): a single `on_write` to `records/contacts/sarah.md` would
1818    /// surface, in the layer + root rollups, the file count of
1819    /// `records/companies` — a sibling that has content on disk but was NEVER
1820    /// passed to a write/index op, so it has no `index.jsonl`. An O(changed) loop
1821    /// op cannot "see" that un-indexed folder; a whole-store walk can. So this
1822    /// asserts the rollups reflect ONLY the sidecar-indexed folder, proving no
1823    /// content-tree walk happened.
1824    #[test]
1825    fn loop_op_does_not_walk_sibling_content_tree() {
1826        let (_d, store) = mk_store();
1827
1828        // A sibling type-folder with real content on disk, but deliberately
1829        // never indexed (no on_write / write_level / rebuild over it) ⇒ no
1830        // `records/companies/index.jsonl` exists.
1831        write_doc(
1832            &store,
1833            "records/companies/acme.md",
1834            "company",
1835            Some("Acme Inc"),
1836            Some("2026-05-05T00:00:00Z"),
1837            "",
1838        );
1839        write_doc(
1840            &store,
1841            "records/companies/globex.md",
1842            "company",
1843            Some("Globex"),
1844            Some("2026-05-06T00:00:00Z"),
1845            "",
1846        );
1847        assert!(
1848            !exists(&store, "records/companies/index.jsonl"),
1849            "precondition: companies must be un-indexed"
1850        );
1851
1852        // The ONLY loop op: a single write to a different type-folder.
1853        write_doc(
1854            &store,
1855            "records/contacts/sarah.md",
1856            "contact",
1857            Some("Sarah"),
1858            Some("2026-05-15T00:00:00Z"),
1859            "",
1860        );
1861        Index::on_write(&store, Path::new("records/contacts/sarah.md")).unwrap();
1862
1863        // The written folder is reflected in both rollups...
1864        let layer_md = read(&store, "records/index.md");
1865        let root_md = read(&store, "index.md");
1866        // (layer rollup appends a summary preview, root does not)
1867        assert!(
1868            layer_md.contains("- [[records/contacts/index|Contacts]] (1) — Sarah\n"),
1869            "layer must reflect the written folder:\n{layer_md}"
1870        );
1871        assert!(
1872            root_md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
1873            "root must reflect the written folder:\n{root_md}"
1874        );
1875
1876        // ...but the un-indexed sibling must be INVISIBLE to a loop op. If the
1877        // rollups mention `records/companies` at all, `on_write` walked the whole
1878        // content tree — the O(store) regression.
1879        assert!(
1880            !layer_md.contains("companies"),
1881            "loop op walked the sibling content tree: layer rollup counts un-indexed records/companies\n{layer_md}"
1882        );
1883        assert!(
1884            !root_md.contains("companies"),
1885            "loop op walked the sibling content tree: root rollup counts un-indexed records/companies\n{root_md}"
1886        );
1887        // The layer's only child is contacts ⇒ its total is exactly 1, not 3.
1888        assert!(
1889            root_md.contains("## Records (1)"),
1890            "root layer total must count only the sidecar-indexed folder (1), not walked siblings (would be 3):\n{root_md}"
1891        );
1892
1893        // And the sidecar-derived count IS what a full walk WOULD yield once the
1894        // sibling is indexed too — i.e. the fix changes cost, not the eventual
1895        // result. Index companies, then confirm the rollups now (and only now)
1896        // include it, byte-identical to a from-scratch rebuild.
1897        let (_d2, rb) = mk_store();
1898        for (rel, t, s, u) in [
1899            (
1900                "records/companies/acme.md",
1901                "company",
1902                "Acme Inc",
1903                "2026-05-05T00:00:00Z",
1904            ),
1905            (
1906                "records/companies/globex.md",
1907                "company",
1908                "Globex",
1909                "2026-05-06T00:00:00Z",
1910            ),
1911            (
1912                "records/contacts/sarah.md",
1913                "contact",
1914                "Sarah",
1915                "2026-05-15T00:00:00Z",
1916            ),
1917        ] {
1918            write_doc(&rb, rel, t, Some(s), Some(u), "");
1919        }
1920        Index::on_write(&store, Path::new("records/companies/acme.md")).unwrap();
1921        Index::on_write(&store, Path::new("records/companies/globex.md")).unwrap();
1922        Index::rebuild_all(&rb).unwrap();
1923        let a = snapshot_artifacts(&store);
1924        let b = snapshot_artifacts(&rb);
1925        assert_eq!(
1926            a.keys().collect::<BTreeSet<_>>(),
1927            b.keys().collect::<BTreeSet<_>>(),
1928            "same artifact set after indexing both folders"
1929        );
1930        for (k, v) in &a {
1931            assert_eq!(
1932                v, &b[k],
1933                "after indexing the sibling too, loop result must equal rebuild for {k}"
1934            );
1935        }
1936        assert!(
1937            read(&store, "index.md").contains("## Records (3)"),
1938            "now that both folders are indexed, the root total is 3"
1939        );
1940    }
1941
1942    /// Regression: a wiki-page filed at the path the toolkit ITSELF computes
1943    /// (`Store::shard_path_for`) must be indexable end-to-end. The bug was that
1944    /// `shard_path_for("wiki-page", …)` returned a 2-component `wiki/<file>`
1945    /// path, which `type_folder_of` treats as having no type-folder. That made
1946    /// the producer (path computation) disagree with the consumer (index): the
1947    /// loop path crashed (`on_write` → `Err`, it tried to write `index.md`
1948    /// *inside* a file) while the sweep path silently dropped the page from
1949    /// every catalog. This test drives both paths through the real
1950    /// `shard_path_for` output and asserts (1) `on_write` succeeds, (2) the page
1951    /// appears in the rebuilt catalog, and (3) write-through == rebuild.
1952    #[test]
1953    fn wiki_page_at_shard_path_for_is_indexable_end_to_end() {
1954        let (_d1, wt) = mk_store();
1955        let (_d2, rb) = mk_store();
1956
1957        // The toolkit's own canonical write path for a wiki-page.
1958        let rel = wt
1959            .shard_path_for(
1960                "wiki-page",
1961                &crate::parser::Frontmatter::default(),
1962                "renewal-theme",
1963            )
1964            .unwrap();
1965        let rel_str = path_to_unix(&rel);
1966        // Guard the precondition the consumer requires: 3+ components so
1967        // `type_folder_of` resolves a real `<layer>/<type-folder>`.
1968        assert!(
1969            type_folder_of(&rel).is_some(),
1970            "shard_path_for produced a path the index cannot file: {rel_str}"
1971        );
1972
1973        write_doc(
1974            &wt,
1975            &rel_str,
1976            "wiki-page",
1977            Some("Renewal theme"),
1978            Some("2026-05-21T10:00:00Z"),
1979            "",
1980        );
1981        write_doc(
1982            &rb,
1983            &rel_str,
1984            "wiki-page",
1985            Some("Renewal theme"),
1986            Some("2026-05-21T10:00:00Z"),
1987            "",
1988        );
1989
1990        // (1) Loop path must NOT error (the old `wiki/<file>` shape returned
1991        // Err(Io(NotADirectory))).
1992        Index::on_write(&wt, &rel)
1993            .expect("on_write must succeed for a toolkit-computed wiki-page path");
1994        Index::rebuild_all(&rb).unwrap();
1995
1996        // (2) The page is present in the rebuilt catalog (the old flat-path bug
1997        // silently omitted it from every artifact). The individual page link
1998        // lives in the *type-folder* index; the *layer* index rolls the
1999        // type-folder up — assert both, since the bug erased both.
2000        let page_link = wiki_target(&rel); // wiki/topics/renewal-theme
2001        let tf_md = read(&rb, "wiki/topics/index.md");
2002        assert!(
2003            tf_md.contains(&format!("[[{page_link}]]")),
2004            "type-folder index must list the page link, got:\n{tf_md}"
2005        );
2006        assert!(
2007            exists(&rb, "wiki/topics/index.jsonl"),
2008            "type-folder jsonl must exist"
2009        );
2010        assert!(
2011            read(&rb, "wiki/topics/index.jsonl").contains(&rel_str),
2012            "type-folder jsonl must contain the page row"
2013        );
2014        // The layer index rolls the type-folder up (proves the page's folder is
2015        // visible to the layer catalog, not dropped).
2016        let layer_md = read(&rb, "wiki/index.md");
2017        assert!(
2018            layer_md.contains("wiki/topics/index"),
2019            "layer index must roll up the wiki/topics type-folder, got:\n{layer_md}"
2020        );
2021
2022        // (3) Write-through equals rebuild byte-for-byte — loop and sweep agree.
2023        let a = snapshot_artifacts(&wt);
2024        let b = snapshot_artifacts(&rb);
2025        assert_eq!(
2026            a.keys().collect::<Vec<_>>(),
2027            b.keys().collect::<Vec<_>>(),
2028            "loop and sweep must produce the same artifact set"
2029        );
2030        for (k, v) in &a {
2031            assert_eq!(
2032                v, &b[k],
2033                "wiki-page artifact {k} differs between on_write and rebuild"
2034            );
2035        }
2036    }
2037
2038    #[test]
2039    fn on_remove_then_rebuild_match_and_pull_in_next_over_cap() {
2040        let (_d1, wt) = mk_store();
2041        let (_d2, rb) = mk_store();
2042        let total = MD_CAP + 3; // 503 files; removing one keeps md full at 500
2043        let mut all_rels = Vec::new();
2044        for i in 0..total {
2045            let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2046            // `updated` strictly increasing across i by varying both minute and second
2047            let updated = format!("2026-05-10T00:{:02}:{:02}Z", i / 60, i % 60);
2048            write_doc(
2049                &wt,
2050                &rel,
2051                "email",
2052                Some(&format!("mail {i}")),
2053                Some(&updated),
2054                "",
2055            );
2056            write_doc(
2057                &rb,
2058                &rel,
2059                "email",
2060                Some(&format!("mail {i}")),
2061                Some(&updated),
2062                "",
2063            );
2064            all_rels.push(rel);
2065        }
2066        // Build write-through index, then remove the single newest file.
2067        Index::rebuild_all(&wt).unwrap();
2068        let newest = &all_rels[total - 1]; // highest i = newest updated
2069        fs::remove_file(wt.root.join(newest)).unwrap();
2070        Index::on_remove(&wt, Path::new(newest)).unwrap();
2071
2072        // Rebuild side: same end state (file physically absent).
2073        fs::remove_file(rb.root.join(newest)).unwrap();
2074        Index::rebuild_all(&rb).unwrap();
2075
2076        let a = snapshot_artifacts(&wt);
2077        let b = snapshot_artifacts(&rb);
2078        for (k, v) in &a {
2079            assert_eq!(v, &b[k], "after remove, artifact {k} drifted from rebuild");
2080        }
2081
2082        // The md must still hold exactly 500 entries (the 501st got pulled in)
2083        // and the removed file must be gone from both artifacts.
2084        let md = read(&wt, "sources/emails/index.md");
2085        assert_eq!(md.lines().filter(|l| l.starts_with("- [[")).count(), MD_CAP);
2086        // Removed (newest) file is gone from the bare-path md and the .md jsonl.
2087        assert!(
2088            !md.contains(&format!("[[{}]]", wiki_target(Path::new(newest)))),
2089            "removed file must not be listed in md"
2090        );
2091        // The file previously at rank 501 (excluded under the cap) is `all_rels[2]`
2092        // — `updated` increases with index, so newest-first rank 500 = index 2.
2093        // After dropping the newest it shifts into the visible 500.
2094        let pulled_in = &all_rels[2];
2095        assert!(
2096            md.contains(&format!("[[{}]]", wiki_target(Path::new(pulled_in)))),
2097            "the 501st-most-recent must be pulled into the browse view after a removal"
2098        );
2099        assert!(
2100            md.contains(&format!("This folder has {} files.", total - 1)),
2101            "footer count must decrement:\n{}",
2102            md.lines().rev().take(4).collect::<Vec<_>>().join("\n")
2103        );
2104        let jsonl = read(&wt, "sources/emails/index.jsonl");
2105        assert_eq!(
2106            jsonl.lines().count(),
2107            total - 1,
2108            "jsonl loses exactly the removed file"
2109        );
2110        assert!(
2111            !jsonl.contains(&path_to_unix(Path::new(newest))),
2112            "removed file must be gone from the jsonl too"
2113        );
2114    }
2115
2116    #[test]
2117    fn on_rename_cross_folder_matches_rebuild() {
2118        let (_d1, wt) = mk_store();
2119        let (_d2, rb) = mk_store();
2120        // Seed both stores identically.
2121        let seed: &[(&str, &str, &str, &str)] = &[
2122            (
2123                "records/contacts/a.md",
2124                "contact",
2125                "A",
2126                "2026-05-01T00:00:00Z",
2127            ),
2128            (
2129                "records/contacts/b.md",
2130                "contact",
2131                "B",
2132                "2026-05-02T00:00:00Z",
2133            ),
2134            (
2135                "records/companies/x.md",
2136                "company",
2137                "X",
2138                "2026-05-03T00:00:00Z",
2139            ),
2140        ];
2141        for (rel, t, s, u) in seed {
2142            write_doc(&wt, rel, t, Some(s), Some(u), "");
2143            write_doc(&rb, rel, t, Some(s), Some(u), "");
2144        }
2145        Index::rebuild_all(&wt).unwrap();
2146
2147        // Rename contacts/b.md -> companies/b.md (cross type-folder). The file's
2148        // `type` changes to match its new folder, as a real `dbmd rename` would.
2149        let old = "records/contacts/b.md";
2150        let new = "records/companies/b.md";
2151        fs::create_dir_all(wt.root.join("records/companies")).unwrap();
2152        fs::rename(wt.root.join(old), wt.root.join(new)).unwrap();
2153        // (type stays "contact" here; index copies frontmatter verbatim — the
2154        // test only asserts placement + parity with rebuild.)
2155        Index::on_rename(&wt, Path::new(old), Path::new(new)).unwrap();
2156
2157        // Rebuild side: same end state.
2158        fs::create_dir_all(rb.root.join("records/companies")).unwrap();
2159        fs::rename(rb.root.join(old), rb.root.join(new)).unwrap();
2160        Index::rebuild_all(&rb).unwrap();
2161
2162        let a = snapshot_artifacts(&wt);
2163        let b = snapshot_artifacts(&rb);
2164        assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
2165        for (k, v) in &a {
2166            assert_eq!(v, &b[k], "rename: artifact {k} drifted from rebuild");
2167        }
2168        // Concretely: b is gone from contacts, present in companies.
2169        let contacts = read(&wt, "records/contacts/index.md");
2170        assert!(!contacts.contains("records/contacts/b]]"));
2171        let companies = read(&wt, "records/companies/index.md");
2172        assert!(companies.contains("[[records/companies/b]]"));
2173    }
2174
2175    #[test]
2176    fn on_write_updates_existing_entry_in_place() {
2177        let (_d, store) = mk_store();
2178        write_doc(
2179            &store,
2180            "records/contacts/a.md",
2181            "contact",
2182            Some("Original"),
2183            Some("2026-05-01T00:00:00Z"),
2184            "",
2185        );
2186        Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2187        // Edit the same file: new summary + newer updated.
2188        write_doc(
2189            &store,
2190            "records/contacts/a.md",
2191            "contact",
2192            Some("Revised"),
2193            Some("2026-05-09T00:00:00Z"),
2194            "",
2195        );
2196        Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2197
2198        let jsonl = read(&store, "records/contacts/index.jsonl");
2199        assert_eq!(
2200            jsonl.lines().count(),
2201            1,
2202            "upsert must not duplicate the line"
2203        );
2204        assert!(jsonl.contains("Revised"), "jsonl must reflect the update");
2205        assert!(
2206            !jsonl.contains("Original"),
2207            "stale line must be gone (compacted)"
2208        );
2209        let md = read(&store, "records/contacts/index.md");
2210        assert!(md.contains("- [[records/contacts/a]] — Revised\n"));
2211        assert!(
2212            md.contains("updated: 2026-05-09T00:00:00Z\n"),
2213            "index updated must track the newer member"
2214        );
2215    }
2216
2217    // ── dry-run + cleanup ────────────────────────────────────────────────
2218
2219    #[test]
2220    fn dry_run_emits_separators_and_writes_nothing() {
2221        let (_d, store) = mk_store();
2222        write_doc(
2223            &store,
2224            "sources/emails/2026/05/a.md",
2225            "email",
2226            Some("Mail"),
2227            Some("2026-05-01T00:00:00Z"),
2228            "",
2229        );
2230        let out = Index::render_dry_run(&store, &IndexLevel::TypeFolder("sources/emails".into()))
2231            .unwrap();
2232        assert!(
2233            out.contains("--- sources/emails/index.md ---\n"),
2234            "md separator:\n{out}"
2235        );
2236        assert!(
2237            out.contains("--- sources/emails/index.jsonl ---\n"),
2238            "jsonl separator:\n{out}"
2239        );
2240        assert!(
2241            out.contains("- [[sources/emails/2026/05/a]] — Mail"),
2242            "md body present"
2243        );
2244        // Nothing was written to disk.
2245        assert!(
2246            !exists(&store, "sources/emails/index.md"),
2247            "dry-run must not write"
2248        );
2249        assert!(
2250            !exists(&store, "sources/emails/index.jsonl"),
2251            "dry-run must not write"
2252        );
2253    }
2254
2255    #[test]
2256    fn cleanup_removes_noncanonical_and_empty_indexes() {
2257        let (_d, store) = mk_store();
2258        write_doc(
2259            &store,
2260            "sources/emails/2026/05/a.md",
2261            "email",
2262            Some("Mail"),
2263            Some("2026-05-01T00:00:00Z"),
2264            "",
2265        );
2266        // A stray index inside a date-shard (non-canonical) ...
2267        fs::write(
2268            store.root.join("sources/emails/2026/05/index.md"),
2269            "stale\n",
2270        )
2271        .unwrap();
2272        fs::write(
2273            store.root.join("sources/emails/2026/05/index.jsonl"),
2274            "stale\n",
2275        )
2276        .unwrap();
2277        // ... and an index in an empty type-folder.
2278        fs::create_dir_all(store.root.join("records/empty")).unwrap();
2279        fs::write(store.root.join("records/empty/index.md"), "stale\n").unwrap();
2280
2281        Index::cleanup(&store).unwrap();
2282
2283        assert!(
2284            !exists(&store, "sources/emails/2026/05/index.md"),
2285            "shard index must be deleted"
2286        );
2287        assert!(
2288            !exists(&store, "sources/emails/2026/05/index.jsonl"),
2289            "shard jsonl must be deleted"
2290        );
2291        assert!(
2292            !exists(&store, "records/empty/index.md"),
2293            "empty-folder index must be deleted"
2294        );
2295        // The canonical type-folder file itself is untouched by cleanup.
2296        assert!(exists(&store, "sources/emails/2026/05/a.md"));
2297    }
2298
2299    #[test]
2300    fn rebuild_deletes_stale_indexes_for_emptied_folders() {
2301        let (_d, store) = mk_store();
2302        write_doc(
2303            &store,
2304            "records/contacts/a.md",
2305            "contact",
2306            Some("A"),
2307            Some("2026-05-01T00:00:00Z"),
2308            "",
2309        );
2310        Index::rebuild_all(&store).unwrap();
2311        assert!(exists(&store, "records/contacts/index.md"));
2312        assert!(exists(&store, "records/index.md"));
2313        assert!(exists(&store, "index.md"));
2314
2315        // Empty the folder entirely, then rebuild: all three levels vanish.
2316        fs::remove_file(store.root.join("records/contacts/a.md")).unwrap();
2317        Index::rebuild_all(&store).unwrap();
2318        assert!(
2319            !exists(&store, "records/contacts/index.md"),
2320            "emptied type-folder index gone"
2321        );
2322        assert!(
2323            !exists(&store, "records/index.md"),
2324            "now-empty layer index gone"
2325        );
2326        assert!(!exists(&store, "index.md"), "now-empty root index gone");
2327    }
2328
2329    // ── randomized parity (property-style) ───────────────────────────────
2330
2331    #[test]
2332    fn property_writethrough_equals_rebuild_under_mixed_ops() {
2333        // Deterministic pseudo-random op sequence (no rand crate): a small LCG.
2334        let (_d1, wt) = mk_store();
2335        let (_d2, rb) = mk_store();
2336        let mut seed: u64 = 0x9E3779B97F4A7C15;
2337        let mut next = || {
2338            seed = seed
2339                .wrapping_mul(6364136223846793005)
2340                .wrapping_add(1442695040888963407);
2341            (seed >> 33) as u32
2342        };
2343
2344        let folders = ["sources/emails", "records/contacts", "wiki/people"];
2345        let types = ["email", "contact", "wiki-page"];
2346        let mut live: Vec<String> = Vec::new(); // store-relative paths that exist
2347
2348        for step in 0..120u32 {
2349            let r = next();
2350            let op = r % 10;
2351            if op < 6 || live.is_empty() {
2352                // CREATE/UPDATE
2353                let fi = (next() as usize) % folders.len();
2354                let folder = folders[fi];
2355                let id = next() % 40;
2356                let rel = if folder == "sources/emails" {
2357                    let month = 5 + (id % 2); // shard across two months
2358                    format!("{folder}/2026/{month:02}/f-{id:02}.md")
2359                } else {
2360                    format!("{folder}/f-{id:02}.md")
2361                };
2362                // recency varies with step so order is meaningful + total
2363                let updated = format!(
2364                    "2026-05-{:02}T{:02}:{:02}:00Z",
2365                    1 + (step % 27),
2366                    step % 24,
2367                    id % 60
2368                );
2369                let extra = if id % 3 == 0 {
2370                    "tags:\n  - x\n  - y\n"
2371                } else {
2372                    ""
2373                };
2374                write_doc(
2375                    &wt,
2376                    &rel,
2377                    types[fi],
2378                    Some(&format!("sum {step}")),
2379                    Some(&updated),
2380                    extra,
2381                );
2382                write_doc(
2383                    &rb,
2384                    &rel,
2385                    types[fi],
2386                    Some(&format!("sum {step}")),
2387                    Some(&updated),
2388                    extra,
2389                );
2390                Index::on_write(&wt, Path::new(&rel)).unwrap();
2391                if !live.contains(&rel) {
2392                    live.push(rel);
2393                }
2394            } else if op < 8 {
2395                // REMOVE a live file
2396                let idx = (next() as usize) % live.len();
2397                let rel = live.remove(idx);
2398                fs::remove_file(wt.root.join(&rel)).unwrap();
2399                fs::remove_file(rb.root.join(&rel)).ok();
2400                Index::on_remove(&wt, Path::new(&rel)).unwrap();
2401            } else {
2402                // RENAME a live file within the same layer (new id, maybe new type-folder)
2403                let idx = (next() as usize) % live.len();
2404                let old = live[idx].clone();
2405                // pick a destination folder in the same layer-ish set
2406                let fi = (next() as usize) % folders.len();
2407                let folder = folders[fi];
2408                let id = 50 + (next() % 40);
2409                let new = if folder == "sources/emails" {
2410                    format!("{folder}/2026/05/f-{id:02}.md")
2411                } else {
2412                    format!("{folder}/f-{id:02}.md")
2413                };
2414                if new == old || live.contains(&new) {
2415                    continue;
2416                }
2417                fs::create_dir_all(wt.root.join(&new).parent().unwrap()).unwrap();
2418                fs::create_dir_all(rb.root.join(&new).parent().unwrap()).unwrap();
2419                fs::rename(wt.root.join(&old), wt.root.join(&new)).unwrap();
2420                fs::rename(rb.root.join(&old), rb.root.join(&new)).unwrap();
2421                Index::on_rename(&wt, Path::new(&old), Path::new(&new)).unwrap();
2422                live[idx] = new;
2423            }
2424        }
2425
2426        // Now rebuild the rb side from the shared end state and compare.
2427        Index::rebuild_all(&rb).unwrap();
2428        let a = snapshot_artifacts(&wt);
2429        let b = snapshot_artifacts(&rb);
2430        assert_eq!(
2431            a.keys().collect::<BTreeSet<_>>(),
2432            b.keys().collect::<BTreeSet<_>>(),
2433            "write-through and rebuild must produce the same set of artifacts"
2434        );
2435        for (k, v) in &a {
2436            assert_eq!(
2437                v, &b[k],
2438                "INVARIANT VIOLATED: artifact {k} differs after mixed ops\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
2439                b[k]
2440            );
2441        }
2442        assert!(
2443            !a.is_empty(),
2444            "the run must have produced at least one artifact"
2445        );
2446    }
2447}