1use 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
63const MD_CAP: usize = 500;
65
66const MISSING_SUMMARY: &str = "(no summary)";
70
71const ROOT_TITLE: &str = "Knowledge base index";
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum IndexLevel {
77 Root,
79 Layer(Layer),
81 TypeFolder(PathBuf),
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct IndexRecord {
94 pub path: PathBuf,
96 #[serde(rename = "type")]
98 pub type_: String,
99 pub summary: String,
101 #[serde(default)]
103 pub tags: Vec<String>,
104 #[serde(default)]
106 pub links: Vec<String>,
107 pub created: Option<DateTime<FixedOffset>>,
109 pub updated: Option<DateTime<FixedOffset>>,
111 #[serde(flatten)]
113 pub fields: BTreeMap<String, Value>,
114}
115
116#[derive(Debug, Clone, PartialEq)]
119pub struct Index {
120 pub level: IndexLevel,
122 pub records: Vec<IndexRecord>,
125 pub child_counts: BTreeMap<PathBuf, usize>,
127}
128
129impl Index {
130 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 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 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 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 pub fn to_jsonl(&self) -> String {
203 let mut out = String::new();
204 for rec in &self.records {
205 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 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 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 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
303impl Index {
308 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 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 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 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 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 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 }
391 sort_records(&mut records);
392 write_type_folder_artifacts(store, &folder, &records)?;
393 update_parents(store, &folder)?;
394 Ok(())
395 }
396
397 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 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 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 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 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 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
528fn 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
558fn 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
603fn 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
647fn 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
692fn 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
712fn 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
719fn 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, (None, Some(_)) => std::cmp::Ordering::Less, (None, None) => std::cmp::Ordering::Equal,
729 }
730 .then_with(|| a.path.cmp(&b.path))
731 });
732}
733
734impl IndexRecord {
735 pub(crate) fn expected_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
747 record_from_file(abs, rel)
748 }
749}
750
751fn 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
767struct 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
778fn 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" => {}
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
836fn 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 }
855
856fn 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
911fn parse_ts(s: &str) -> Option<DateTime<FixedOffset>> {
913 DateTime::parse_from_rfc3339(s.trim()).ok()
914}
915
916fn fmt_ts(ts: &DateTime<FixedOffset>) -> String {
920 ts.to_rfc3339_opts(SecondsFormat::AutoSi, true)
921}
922
923fn 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
937fn 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 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
965fn 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
997fn 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
1018fn 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
1045fn 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
1072fn 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
1083fn rel_to_store(root: &Path, abs: &Path) -> Option<PathBuf> {
1085 abs.strip_prefix(root).ok().map(|p| p.to_path_buf())
1086}
1087
1088fn 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
1115fn 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
1126fn folder_basename(p: &Path) -> &str {
1128 p.file_name().and_then(|n| n.to_str()).unwrap_or("")
1129}
1130
1131fn wiki_target(p: &Path) -> String {
1135 let unix = path_to_unix(p);
1136 unix.strip_suffix(".md").unwrap_or(&unix).to_string()
1137}
1138
1139fn 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
1148fn 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
1157fn 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
1194struct 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 }
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 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 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 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 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 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 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 #[test]
1338 fn type_folder_aggregates_across_shards_in_recency_order() {
1339 let (_d, store) = mk_store();
1340 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 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 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 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 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 #[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 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 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 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 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 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 #[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 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 #[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")), rec("m/c.md", None), rec("b/d.md", Some("2026-06-01T00:00:00Z")), ];
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 #[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 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 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 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 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 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 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 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 #[test]
1741 fn on_write_matches_rebuild_byte_for_byte() {
1742 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 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 #[test]
1825 fn loop_op_does_not_walk_sibling_content_tree() {
1826 let (_d, store) = mk_store();
1827
1828 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 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 let layer_md = read(&store, "records/index.md");
1865 let root_md = read(&store, "index.md");
1866 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 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 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 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 #[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 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 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 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 let page_link = wiki_target(&rel); 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 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 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; 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 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 Index::rebuild_all(&wt).unwrap();
2068 let newest = &all_rels[total - 1]; fs::remove_file(wt.root.join(newest)).unwrap();
2070 Index::on_remove(&wt, Path::new(newest)).unwrap();
2071
2072 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 let md = read(&wt, "sources/emails/index.md");
2085 assert_eq!(md.lines().filter(|l| l.starts_with("- [[")).count(), MD_CAP);
2086 assert!(
2088 !md.contains(&format!("[[{}]]", wiki_target(Path::new(newest)))),
2089 "removed file must not be listed in md"
2090 );
2091 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 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 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 Index::on_rename(&wt, Path::new(old), Path::new(new)).unwrap();
2156
2157 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 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 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 #[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 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 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 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 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 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 #[test]
2332 fn property_writethrough_equals_rebuild_under_mixed_ops() {
2333 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(); for step in 0..120u32 {
2349 let r = next();
2350 let op = r % 10;
2351 if op < 6 || live.is_empty() {
2352 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); format!("{folder}/2026/{month:02}/f-{id:02}.md")
2359 } else {
2360 format!("{folder}/f-{id:02}.md")
2361 };
2362 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 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 let idx = (next() as usize) % live.len();
2404 let old = live[idx].clone();
2405 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 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}