1use std::collections::BTreeMap;
46use std::fs::File;
47use std::path::{Path, PathBuf};
48
49use chrono::{DateTime, FixedOffset, SecondsFormat};
50use serde::{Deserialize, Serialize};
51use serde_json::Value;
52
53use crate::parser::FolderMeta;
54use crate::store::{Layer, Store};
55
56const MD_CAP: usize = 500;
58
59const MISSING_SUMMARY: &str = "(no summary)";
63
64const ROOT_TITLE: &str = "Knowledge base index";
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum IndexLevel {
70 Root,
72 Layer(Layer),
74 TypeFolder(PathBuf),
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct IndexRecord {
87 #[serde(with = "path_serde")]
91 pub path: PathBuf,
92 #[serde(rename = "type")]
94 pub type_: String,
95 pub summary: String,
97 #[serde(default)]
99 pub tags: Vec<String>,
100 #[serde(default)]
102 pub links: Vec<String>,
103 pub created: Option<DateTime<FixedOffset>>,
105 pub updated: Option<DateTime<FixedOffset>>,
107 #[serde(flatten)]
112 pub fields: BTreeMap<String, Value>,
113}
114
115#[derive(Debug, Clone, PartialEq)]
118pub struct Index {
119 pub level: IndexLevel,
121 pub records: Vec<IndexRecord>,
124 pub child_counts: BTreeMap<PathBuf, usize>,
126}
127
128impl Index {
129 pub fn build_type_folder(store: &Store, type_folder: &Path) -> crate::Result<Index> {
135 let rel = normalize_rel(type_folder);
136 let mut records = Vec::new();
137 for rel_path in walk_type_folder_files(store, &rel) {
138 records.push(record_from_store(store, &rel_path, rel_path.clone())?);
150 }
151 sort_records(&mut records);
152 Ok(Index {
153 level: IndexLevel::TypeFolder(rel),
154 records,
155 child_counts: BTreeMap::new(),
156 })
157 }
158
159 pub fn build_layer(store: &Store, layer: Layer) -> crate::Result<Index> {
168 let mut child_counts = BTreeMap::new();
169 for tf in type_folders_in_layer(store, layer) {
170 let n = walk_type_folder_files(store, &tf).len();
171 if n > 0 {
172 child_counts.insert(tf, n);
173 }
174 }
175 let mut records = Vec::new();
176 for rel_path in loose_files_in_layer(store, layer) {
177 records.push(record_from_store(store, &rel_path, rel_path.clone())?);
182 }
183 sort_records(&mut records);
184 Ok(Index {
185 level: IndexLevel::Layer(layer),
186 records,
187 child_counts,
188 })
189 }
190
191 pub fn build_root(store: &Store) -> crate::Result<Index> {
194 let mut child_counts = BTreeMap::new();
195 for layer in Layer::all() {
196 for tf in type_folders_in_layer(store, layer) {
197 let n = walk_type_folder_files(store, &tf).len();
198 if n > 0 {
199 child_counts.insert(tf, n);
200 }
201 }
202 }
203 Ok(Index {
204 level: IndexLevel::Root,
205 records: Vec::new(),
206 child_counts,
207 })
208 }
209
210 pub fn to_markdown(&self) -> String {
212 match &self.level {
213 IndexLevel::TypeFolder(folder) => self.render_type_folder_md(folder),
214 IndexLevel::Layer(layer) => self.render_layer_md(*layer),
215 IndexLevel::Root => self.render_root_md(),
216 }
217 }
218
219 pub fn to_jsonl(&self) -> String {
225 let mut out = String::new();
226 for rec in &self.records {
227 let line = serde_json::to_string(rec).expect("IndexRecord serializes");
230 out.push_str(&line);
231 out.push('\n');
232 }
233 out
234 }
235
236 fn render_type_folder_md(&self, folder: &Path) -> String {
239 let folder_disp = path_to_unix(folder);
240 let updated = max_updated(self.records.iter().map(|r| r.updated.as_ref()));
241 let mut s = String::new();
242 s.push_str("---\n");
243 s.push_str("type: index\n");
244 s.push_str("scope: type-folder\n");
245 s.push_str(&format!("folder: {folder_disp}\n"));
246 if let Some(ts) = updated {
247 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
248 }
249 s.push_str("---\n\n");
250 s.push_str(&format!("# {folder_disp}\n\n"));
251
252 let shown = self.records.len().min(MD_CAP);
253 for rec in self.records.iter().take(shown) {
254 s.push_str(&format_md_entry(rec));
255 s.push('\n');
256 }
257
258 if self.records.len() > MD_CAP {
259 let type_ = self.records.first().map(|r| r.type_.as_str()).unwrap_or("");
260 let layer = folder
261 .components()
262 .next()
263 .and_then(|c| c.as_os_str().to_str())
264 .unwrap_or("");
265 s.push('\n');
266 s.push_str(&more_footer(self.records.len(), type_, layer));
267 }
268 s
269 }
270
271 fn render_layer_md(&self, layer: Layer) -> String {
276 let layer_dir = layer_dir_name(layer);
277 let mut s = String::new();
278 s.push_str("---\n");
279 s.push_str("type: index\n");
280 s.push_str("scope: layer\n");
281 s.push_str(&format!("folder: {layer_dir}\n"));
282 s.push_str("---\n\n");
283 s.push_str(&format!("# {layer_dir}\n\n"));
284 for (tf, n) in &self.child_counts {
285 let tf_unix = path_to_unix(tf);
286 let display = capitalize(folder_basename(tf));
287 s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
288 }
289 s
290 }
291
292 fn render_root_md(&self) -> String {
295 let mut s = String::new();
296 s.push_str("---\n");
297 s.push_str("type: index\n");
298 s.push_str("scope: root\n");
299 s.push_str("---\n\n");
300 s.push_str(&format!("# {ROOT_TITLE}\n"));
301 for layer in Layer::all() {
302 let layer_dir = layer_dir_name(layer);
303 let prefix = format!("{layer_dir}/");
304 let children: Vec<(&PathBuf, &usize)> = self
305 .child_counts
306 .iter()
307 .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
308 .collect();
309 if children.is_empty() {
310 continue;
311 }
312 let total: usize = children.iter().map(|(_, n)| **n).sum();
313 s.push('\n');
314 s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
315 for (tf, n) in children {
316 let tf_unix = path_to_unix(tf);
317 let display = capitalize(folder_basename(tf));
318 s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
319 }
320 }
321 s
322 }
323}
324
325impl Index {
330 pub fn on_write(store: &Store, file: &Path) -> crate::Result<()> {
337 let file_rel = normalize_rel(file);
338 if is_index_artifact(&file_rel) {
345 return Ok(());
346 }
347 if let Some(layer) = loose_layer_of(&file_rel) {
351 return apply_loose_change(store, layer, &file_rel, false);
352 }
353 let folder = type_folder_of(&file_rel)
354 .ok_or_else(|| bad_index(&file_rel, "file is not inside a layer/type-folder"))?;
355 let record = record_from_store(store, &file_rel, file_rel.clone())?;
356
357 let _lock = FolderLock::acquire(store, &folder)?;
360 let mut records = read_jsonl_records(store, &folder.join("index.jsonl"))?;
361 records.retain(|r| r.path != record.path);
362 records.push(record);
363 sort_records(&mut records);
364
365 write_type_folder_artifacts(store, &folder, &records)?;
366 update_parents(store, &folder)?;
367 Ok(())
368 }
369
370 pub fn on_rename(store: &Store, old: &Path, new: &Path) -> crate::Result<()> {
374 let old_rel = normalize_rel(old);
375 let new_rel = normalize_rel(new);
376 if is_index_artifact(&old_rel) || is_index_artifact(&new_rel) {
380 return Ok(());
381 }
382 if loose_layer_of(&old_rel).is_some() || loose_layer_of(&new_rel).is_some() {
388 Self::on_remove(store, &old_rel)?;
389 Self::on_write(store, &new_rel)?;
390 return Ok(());
391 }
392 let old_folder = type_folder_of(&old_rel)
393 .ok_or_else(|| bad_index(&old_rel, "source is not inside a layer/type-folder"))?;
394 let new_folder = type_folder_of(&new_rel)
395 .ok_or_else(|| bad_index(&new_rel, "target is not inside a layer/type-folder"))?;
396
397 let _locks = lock_folders(store, &old_folder, &new_folder)?;
401
402 let mut old_records = read_jsonl_records(store, &old_folder.join("index.jsonl"))?;
404 old_records.retain(|r| r.path != old_rel);
405
406 if old_folder == new_folder {
407 let record = record_from_store(store, &new_rel, new_rel.clone())?;
409 old_records.retain(|r| r.path != record.path);
410 old_records.push(record);
411 sort_records(&mut old_records);
412 write_type_folder_artifacts(store, &old_folder, &old_records)?;
413 update_parents(store, &old_folder)?;
414 return Ok(());
415 }
416
417 sort_records(&mut old_records);
420 write_type_folder_artifacts(store, &old_folder, &old_records)?;
421
422 let record = record_from_store(store, &new_rel, new_rel.clone())?;
423 let mut new_records = read_jsonl_records(store, &new_folder.join("index.jsonl"))?;
424 new_records.retain(|r| r.path != record.path);
425 new_records.push(record);
426 sort_records(&mut new_records);
427 write_type_folder_artifacts(store, &new_folder, &new_records)?;
428
429 update_parents(store, &old_folder)?;
430 update_parents(store, &new_folder)?;
431 Ok(())
432 }
433
434 pub fn on_remove(store: &Store, file: &Path) -> crate::Result<()> {
439 let file_rel = normalize_rel(file);
440 if is_index_artifact(&file_rel) {
443 return Ok(());
444 }
445 if let Some(layer) = loose_layer_of(&file_rel) {
447 return apply_loose_change(store, layer, &file_rel, true);
448 }
449 let folder = type_folder_of(&file_rel)
450 .ok_or_else(|| bad_index(&file_rel, "file is not inside a layer/type-folder"))?;
451 let _lock = FolderLock::acquire(store, &folder)?;
453 let mut records = read_jsonl_records(store, &folder.join("index.jsonl"))?;
454 let before = records.len();
455 records.retain(|r| r.path != file_rel);
456 if records.len() == before {
457 }
460 sort_records(&mut records);
461 write_type_folder_artifacts(store, &folder, &records)?;
462 update_parents(store, &folder)?;
463 Ok(())
464 }
465
466 pub fn rebuild_all(store: &Store) -> crate::Result<()> {
470 Index::cleanup(store)?;
471 for layer in Layer::all() {
472 for tf in type_folders_in_layer(store, layer) {
473 let idx = Index::build_type_folder(store, &tf)?;
474 if idx.records.is_empty() {
475 continue;
476 }
477 write_type_folder_artifacts(store, &tf, &idx.records)?;
478 }
479 let layer_idx = Index::build_layer(store, layer)?;
480 let layer_index_md = PathBuf::from(layer_dir_name(layer)).join("index.md");
481 if layer_idx.child_counts.is_empty() {
482 remove_if_exists(store, &layer_index_md)?;
483 } else {
484 write_atomic(
485 store,
486 &layer_index_md,
487 render_layer_md_with_store(store, &layer_idx),
488 )?;
489 }
490 write_layer_jsonl(store, layer, &layer_idx.records)?;
494 }
495 let root_idx = Index::build_root(store)?;
496 let root_index_md = PathBuf::from("index.md");
497 if root_idx.child_counts.is_empty() {
498 remove_if_exists(store, &root_index_md)?;
499 } else {
500 write_atomic(
501 store,
502 &root_index_md,
503 render_root_md_with_store(store, &root_idx),
504 )?;
505 }
506 Ok(())
507 }
508
509 pub fn rebuild_folder(store: &Store, folder: &Path) -> crate::Result<()> {
516 Self::write_level(store, &IndexLevel::TypeFolder(folder.to_path_buf()))?;
517 update_parents(store, folder)
518 }
519
520 pub fn write_level(store: &Store, level: &IndexLevel) -> crate::Result<()> {
522 match level {
523 IndexLevel::TypeFolder(folder) => {
524 let idx = Index::build_type_folder(store, folder)?;
525 if idx.records.is_empty() {
526 remove_if_exists(store, &folder.join("index.md"))?;
527 remove_if_exists(store, &folder.join("index.jsonl"))?;
528 } else {
529 write_type_folder_artifacts(store, folder, &idx.records)?;
530 }
531 }
532 IndexLevel::Layer(layer) => {
533 let idx = Index::build_layer(store, *layer)?;
534 let p = PathBuf::from(layer_dir_name(*layer)).join("index.md");
535 if idx.child_counts.is_empty() {
536 remove_if_exists(store, &p)?;
537 } else {
538 write_atomic(store, &p, render_layer_md_with_store(store, &idx))?;
539 }
540 write_layer_jsonl(store, *layer, &idx.records)?;
541 }
542 IndexLevel::Root => {
543 let idx = Index::build_root(store)?;
544 let p = PathBuf::from("index.md");
545 if idx.child_counts.is_empty() {
546 remove_if_exists(store, &p)?;
547 } else {
548 write_atomic(store, &p, render_root_md_with_store(store, &idx))?;
549 }
550 }
551 }
552 Ok(())
553 }
554
555 pub fn render_dry_run(store: &Store, level: &IndexLevel) -> crate::Result<String> {
558 let mut out = String::new();
559 match level {
560 IndexLevel::TypeFolder(folder) => {
561 let idx = Index::build_type_folder(store, folder)?;
562 let md_path = path_to_unix(&folder.join("index.md"));
563 let jsonl_path = path_to_unix(&folder.join("index.jsonl"));
564 out.push_str(&format!("--- {md_path} ---\n"));
565 out.push_str(&idx.to_markdown());
566 out.push_str(&format!("--- {jsonl_path} ---\n"));
567 out.push_str(&idx.to_jsonl());
568 }
569 IndexLevel::Layer(layer) => {
570 let idx = Index::build_layer(store, *layer)?;
571 let md_path = format!("{}/index.md", layer_dir_name(*layer));
572 out.push_str(&format!("--- {md_path} ---\n"));
573 out.push_str(&render_layer_md_with_store(store, &idx));
574 }
575 IndexLevel::Root => {
576 let idx = Index::build_root(store)?;
577 out.push_str("--- index.md ---\n");
578 out.push_str(&render_root_md_with_store(store, &idx));
579 }
580 }
581 Ok(out)
582 }
583
584 pub fn cleanup(store: &Store) -> crate::Result<()> {
602 for layer in Layer::all() {
603 let layer_dir = PathBuf::from(layer_dir_name(layer));
604 if !store.directory_exists(&layer_dir).unwrap_or(false) {
605 continue;
606 }
607 for tf in type_folders_in_layer(store, layer) {
608 for p in store.walk_regular_files(&tf)? {
612 if p.components().count() >= tf.components().count() + 2
613 && is_index_artifact(&p)
614 && is_deletable_catalog_artifact(store, &p)
615 {
616 remove_if_exists(store, &p)?;
617 }
618 }
619 if walk_type_folder_files(store, &tf).is_empty() {
623 let md = tf.join("index.md");
624 if is_deletable_catalog_artifact(store, &md) {
625 remove_if_exists(store, &md)?;
626 }
627 remove_if_exists(store, &tf.join("index.jsonl"))?;
628 }
629 }
630 }
631 Ok(())
632 }
633}
634
635fn write_type_folder_artifacts(
643 store: &Store,
644 folder: &Path,
645 records: &[IndexRecord],
646) -> crate::Result<()> {
647 let md_path = folder.join("index.md");
648 let jsonl_path = folder.join("index.jsonl");
649 if records.is_empty() {
650 remove_if_exists(store, &md_path)?;
651 remove_if_exists(store, &jsonl_path)?;
652 return Ok(());
653 }
654 let idx = Index {
655 level: IndexLevel::TypeFolder(folder.to_path_buf()),
656 records: records.to_vec(),
657 child_counts: BTreeMap::new(),
658 };
659 write_atomic(store, &md_path, idx.to_markdown())?;
660 write_atomic(store, &jsonl_path, idx.to_jsonl())?;
661 Ok(())
662}
663
664fn update_parents(store: &Store, folder: &Path) -> crate::Result<()> {
677 let _root_lock = FolderLock::acquire(store, Path::new(""))?;
710 let stats = collect_child_stats(store, &Layer::all())?;
711
712 let layer = folder
713 .components()
714 .next()
715 .and_then(|c| c.as_os_str().to_str())
716 .and_then(layer_from_dir_name);
717 if let Some(layer) = layer {
718 let p = PathBuf::from(layer_dir_name(layer)).join("index.md");
719 if layer_has_children(&stats, layer) {
720 write_atomic(
721 store,
722 &p,
723 render_layer_md_from_stats(layer, &stats, &store.config.folders),
724 )?;
725 } else {
726 remove_if_exists(store, &p)?;
727 }
728 }
729 let rp = PathBuf::from("index.md");
730 if stats.values().any(|s| s.count > 0) {
731 write_atomic(
732 store,
733 &rp,
734 render_root_md_from_stats(&stats, &store.config.folders),
735 )?;
736 } else {
737 remove_if_exists(store, &rp)?;
738 }
739 Ok(())
740}
741
742fn layer_has_children(stats: &BTreeMap<PathBuf, FolderStat>, layer: Layer) -> bool {
744 let prefix = format!("{}/", layer_dir_name(layer));
745 stats
746 .iter()
747 .any(|(tf, s)| s.count > 0 && path_to_unix(tf).starts_with(&prefix))
748}
749
750fn render_layer_md_from_stats(
755 layer: Layer,
756 stats: &BTreeMap<PathBuf, FolderStat>,
757 folders: &BTreeMap<String, FolderMeta>,
758) -> String {
759 let layer_dir = layer_dir_name(layer);
760 let prefix = format!("{layer_dir}/");
761 let mut max_upd: Option<DateTime<FixedOffset>> = None;
762 let mut entries = String::new();
763 for (tf, stat) in stats {
764 if stat.count == 0 || !path_to_unix(tf).starts_with(&prefix) {
765 continue;
766 }
767 if let Some(u) = stat.newest.as_ref().and_then(|r| r.updated) {
768 max_upd = Some(match max_upd {
769 Some(cur) if cur >= u => cur,
770 _ => u,
771 });
772 }
773 let tf_unix = path_to_unix(tf);
774 let (display, description) = folder_label(&tf_unix, folder_basename(tf), folders);
775 entries.push_str(&folder_entry(&tf_unix, &display, stat.count, description));
776 }
777 let mut s = String::new();
778 s.push_str("---\n");
779 s.push_str("type: index\n");
780 s.push_str("scope: layer\n");
781 s.push_str(&format!("folder: {layer_dir}\n"));
782 if let Some(ts) = max_upd {
783 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
784 }
785 s.push_str("---\n\n");
786 s.push_str(&format!("# {layer_dir}\n\n"));
787 s.push_str(&entries);
788 s
789}
790
791fn render_root_md_from_stats(
793 stats: &BTreeMap<PathBuf, FolderStat>,
794 folders: &BTreeMap<String, FolderMeta>,
795) -> String {
796 let mut max_upd: Option<DateTime<FixedOffset>> = None;
797 for stat in stats.values() {
798 if stat.count == 0 {
799 continue;
800 }
801 if let Some(u) = stat.newest.as_ref().and_then(|r| r.updated) {
802 max_upd = Some(match max_upd {
803 Some(cur) if cur >= u => cur,
804 _ => u,
805 });
806 }
807 }
808 let mut s = String::new();
809 s.push_str("---\n");
810 s.push_str("type: index\n");
811 s.push_str("scope: root\n");
812 if let Some(ts) = max_upd {
813 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
814 }
815 s.push_str("---\n\n");
816 s.push_str(&format!("# {ROOT_TITLE}\n"));
817 for layer in Layer::all() {
818 let layer_dir = layer_dir_name(layer);
819 let prefix = format!("{layer_dir}/");
820 let children: Vec<(&PathBuf, usize)> = stats
821 .iter()
822 .filter(|(tf, s)| s.count > 0 && path_to_unix(tf).starts_with(&prefix))
823 .map(|(tf, s)| (tf, s.count))
824 .collect();
825 if children.is_empty() {
826 continue;
827 }
828 let total: usize = children.iter().map(|(_, n)| *n).sum();
829 s.push('\n');
830 s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
831 for (tf, n) in children {
832 let tf_unix = path_to_unix(tf);
833 let (display, description) = folder_label(&tf_unix, folder_basename(tf), folders);
834 s.push_str(&folder_entry(&tf_unix, &display, n, description));
835 }
836 }
837 s
838}
839
840fn render_layer_md_with_store(store: &Store, idx: &Index) -> String {
847 let layer = match idx.level {
848 IndexLevel::Layer(l) => l,
849 _ => unreachable!("render_layer_md_with_store called on non-layer"),
850 };
851 let layer_dir = layer_dir_name(layer);
852 let mut max_upd: Option<DateTime<FixedOffset>> = None;
853 let mut entries = String::new();
854 for (tf, n) in &idx.child_counts {
855 let recs = read_jsonl_records(store, &tf.join("index.jsonl")).unwrap_or_default();
856 let newest = recs.first();
857 if let Some(u) = newest.and_then(|r| r.updated) {
858 max_upd = Some(match max_upd {
859 Some(cur) if cur >= u => cur,
860 _ => u,
861 });
862 }
863 let tf_unix = path_to_unix(tf);
864 let (display, description) =
865 folder_label(&tf_unix, folder_basename(tf), &store.config.folders);
866 entries.push_str(&folder_entry(&tf_unix, &display, *n, description));
867 }
868 let mut s = String::new();
869 s.push_str("---\n");
870 s.push_str("type: index\n");
871 s.push_str("scope: layer\n");
872 s.push_str(&format!("folder: {layer_dir}\n"));
873 if let Some(ts) = max_upd {
874 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
875 }
876 s.push_str("---\n\n");
877 s.push_str(&format!("# {layer_dir}\n\n"));
878 s.push_str(&entries);
879 s
880}
881
882fn render_root_md_with_store(store: &Store, idx: &Index) -> String {
886 let mut max_upd: Option<DateTime<FixedOffset>> = None;
887 for tf in idx.child_counts.keys() {
888 let recs = read_jsonl_records(store, &tf.join("index.jsonl")).unwrap_or_default();
889 if let Some(u) = recs.first().and_then(|r| r.updated) {
890 max_upd = Some(match max_upd {
891 Some(cur) if cur >= u => cur,
892 _ => u,
893 });
894 }
895 }
896 let mut s = String::new();
897 s.push_str("---\n");
898 s.push_str("type: index\n");
899 s.push_str("scope: root\n");
900 if let Some(ts) = max_upd {
901 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
902 }
903 s.push_str("---\n\n");
904 s.push_str(&format!("# {ROOT_TITLE}\n"));
905 for layer in Layer::all() {
906 let layer_dir = layer_dir_name(layer);
907 let prefix = format!("{layer_dir}/");
908 let children: Vec<(&PathBuf, &usize)> = idx
909 .child_counts
910 .iter()
911 .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
912 .collect();
913 if children.is_empty() {
914 continue;
915 }
916 let total: usize = children.iter().map(|(_, n)| **n).sum();
917 s.push('\n');
918 s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
919 for (tf, n) in children {
920 let tf_unix = path_to_unix(tf);
921 let (display, description) =
922 folder_label(&tf_unix, folder_basename(tf), &store.config.folders);
923 s.push_str(&folder_entry(&tf_unix, &display, *n, description));
924 }
925 }
926 s
927}
928
929fn format_md_entry(rec: &IndexRecord) -> String {
935 let path = wiki_target(&rec.path);
936 let summary = collapse_whitespace(&rec.summary);
945 let mut line = format!("- [[{path}]] — {summary}");
946 if !rec.tags.is_empty() {
947 let tags = rec
948 .tags
949 .iter()
950 .map(|t| format!("#{t}"))
951 .collect::<Vec<_>>()
952 .join(" ");
953 line.push_str(&format!(" · {tags}"));
954 }
955 line
956}
957
958fn more_footer(total: usize, type_: &str, layer: &str) -> String {
960 format!(
961 "## More\n\nThis folder has {total} files. The {MD_CAP} most recent are listed above.\nUse `dbmd query --type {type_} --in {layer}` for the complete catalog.\n"
962 )
963}
964
965fn sort_records(records: &mut [IndexRecord]) {
969 records.sort_by(record_recency_cmp);
970}
971
972impl IndexRecord {
973 pub(crate) fn expected_from_store(
976 store: &Store,
977 path: &Path,
978 rel: PathBuf,
979 ) -> crate::Result<IndexRecord> {
980 record_from_store(store, path, rel)
981 }
982}
983
984fn record_from_store(store: &Store, path: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
985 let bytes = store.read_bounded(path, crate::parser::MAX_DBMD_FILE_BYTES)?;
986 let meta = read_frontmatter_bytes(&bytes, path)?;
987 record_from_meta(meta, rel)
988}
989
990fn record_from_meta(mut meta: FileMeta, rel: PathBuf) -> crate::Result<IndexRecord> {
991 if rel.starts_with("records") {
996 meta.fields
997 .entry("meta-type".to_string())
998 .or_insert_with(|| Value::String("fact".to_string()));
999 }
1000 Ok(IndexRecord {
1001 path: rel,
1002 type_: meta.type_.unwrap_or_default(),
1003 summary: meta.summary.unwrap_or_else(|| MISSING_SUMMARY.to_string()),
1004 tags: meta.tags,
1005 links: meta.links,
1006 created: meta.created,
1007 updated: meta.updated,
1008 fields: meta.fields,
1009 })
1010}
1011
1012struct FileMeta {
1014 type_: Option<String>,
1015 summary: Option<String>,
1016 tags: Vec<String>,
1017 links: Vec<String>,
1018 created: Option<DateTime<FixedOffset>>,
1019 updated: Option<DateTime<FixedOffset>>,
1020 fields: BTreeMap<String, Value>,
1021}
1022
1023fn read_frontmatter_bytes(bytes: &[u8], display_path: &Path) -> crate::Result<FileMeta> {
1037 let yaml = extract_frontmatter_block_lossy(bytes).unwrap_or_default();
1038 let map: serde_norway::Mapping = if yaml.trim().is_empty() {
1039 serde_norway::Mapping::new()
1040 } else {
1041 serde_norway::from_str(&yaml).map_err(|e| {
1042 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1043 path: display_path.to_path_buf(),
1044 message: format!("frontmatter YAML: {e}"),
1045 })
1046 })?
1047 };
1048
1049 let mut type_ = None;
1050 let mut summary = None;
1051 let mut tags = Vec::new();
1052 let mut links = Vec::new();
1053 let mut created = None;
1054 let mut updated = None;
1055 let mut fields = BTreeMap::new();
1056
1057 for (k, v) in map {
1058 let key = match k.as_str() {
1059 Some(s) => s.to_string(),
1060 None => continue,
1061 };
1062 match key.as_str() {
1063 "type" => type_ = scalar_string(&v),
1073 "summary" => summary = scalar_string(&v),
1074 "tags" => tags = yaml_string_list(&v),
1075 "links" => links = yaml_string_list(&v),
1076 "created" => created = v.as_str().and_then(parse_ts),
1077 "updated" => updated = v.as_str().and_then(parse_ts),
1078 "path" => {}
1082 _ => {
1083 fields.insert(key, yaml_to_json_value(&v));
1084 }
1085 }
1086 }
1087
1088 Ok(FileMeta {
1089 type_,
1090 summary,
1091 tags,
1092 links,
1093 created,
1094 updated,
1095 fields,
1096 })
1097}
1098
1099pub(crate) fn scalar_string(v: &serde_norway::Value) -> Option<String> {
1106 match v {
1107 serde_norway::Value::String(s) => Some(s.clone()),
1108 serde_norway::Value::Number(n) => Some(n.to_string()),
1109 serde_norway::Value::Bool(b) => Some(b.to_string()),
1110 _ => None,
1111 }
1112}
1113
1114fn extract_frontmatter_block_lossy(bytes: &[u8]) -> Option<String> {
1120 let text = String::from_utf8_lossy(bytes);
1125 extract_frontmatter_block(&text)
1126}
1127
1128fn extract_frontmatter_block(text: &str) -> Option<String> {
1131 let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
1132 let mut lines = trimmed.lines();
1133 let first = lines.next()?;
1134 if first.trim_end() != "---" {
1135 return None;
1136 }
1137 let mut block = String::new();
1138 for line in lines {
1139 if line.trim_end() == "---" {
1140 return Some(block);
1141 }
1142 block.push_str(line);
1143 block.push('\n');
1144 }
1145 None }
1147
1148fn yaml_string_list(v: &serde_norway::Value) -> Vec<String> {
1151 match v {
1152 serde_norway::Value::String(s) => vec![s.clone()],
1153 serde_norway::Value::Sequence(seq) => seq
1154 .iter()
1155 .filter_map(yaml_string_or_wiki_link_literal)
1156 .collect(),
1157 _ => Vec::new(),
1158 }
1159}
1160
1161fn yaml_string_or_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
1162 v.as_str()
1163 .map(str::to_string)
1164 .or_else(|| unquoted_wiki_link_literal(v))
1165}
1166
1167pub(crate) fn yaml_to_json_value(v: &serde_norway::Value) -> Value {
1174 if let Some(link) = unquoted_wiki_link_literal(v) {
1175 return Value::String(link);
1176 }
1177 match v {
1178 serde_norway::Value::String(s) => Value::String(s.clone()),
1179 serde_norway::Value::Bool(b) => Value::Bool(*b),
1180 serde_norway::Value::Number(n) => {
1181 serde_json::to_value(n).unwrap_or_else(|_| Value::String(n.to_string()))
1182 }
1183 serde_norway::Value::Sequence(seq) => {
1184 Value::Array(seq.iter().map(yaml_to_json_value).collect())
1185 }
1186 serde_norway::Value::Mapping(_) | serde_norway::Value::Tagged(_) => {
1187 serde_json::to_value(v).unwrap_or(Value::Null)
1188 }
1189 serde_norway::Value::Null => Value::Null,
1190 }
1191}
1192
1193fn unquoted_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
1194 let serde_norway::Value::Sequence(outer) = v else {
1195 return None;
1196 };
1197 if outer.len() != 1 {
1198 return None;
1199 }
1200 let serde_norway::Value::Sequence(inner) = &outer[0] else {
1201 return None;
1202 };
1203 let [serde_norway::Value::String(target)] = inner.as_slice() else {
1204 return None;
1205 };
1206 Some(format!("[[{target}]]"))
1207}
1208
1209pub(crate) fn parse_ts(s: &str) -> Option<DateTime<FixedOffset>> {
1213 DateTime::parse_from_rfc3339(s.trim()).ok()
1214}
1215
1216fn fmt_ts(ts: &DateTime<FixedOffset>) -> String {
1220 ts.to_rfc3339_opts(SecondsFormat::AutoSi, true)
1221}
1222
1223fn max_updated<'a>(
1225 it: impl Iterator<Item = Option<&'a DateTime<FixedOffset>>>,
1226) -> Option<DateTime<FixedOffset>> {
1227 let mut best: Option<DateTime<FixedOffset>> = None;
1228 for ts in it.flatten() {
1229 best = Some(match best {
1230 Some(cur) if cur >= *ts => cur,
1231 _ => *ts,
1232 });
1233 }
1234 best
1235}
1236
1237fn read_jsonl_records(store: &Store, jsonl: &Path) -> crate::Result<Vec<IndexRecord>> {
1241 let text = match store.read_text_bounded(jsonl, crate::parser::MAX_DBMD_FILE_BYTES) {
1242 Ok(t) => t,
1243 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1244 Err(e) => return Err(e.into()),
1245 };
1246 let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
1248 for (i, line) in text.lines().enumerate() {
1249 if line.trim().is_empty() {
1250 continue;
1251 }
1252 let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
1253 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1254 path: jsonl.to_path_buf(),
1255 message: format!("line {}: {e}", i + 1),
1256 })
1257 })?;
1258 by_path.insert(rec.path.clone(), rec);
1259 }
1260 let mut records: Vec<IndexRecord> = by_path.into_values().collect();
1261 sort_records(&mut records);
1262 Ok(records)
1263}
1264
1265#[derive(Debug, Clone, Default, PartialEq)]
1272struct FolderStat {
1273 count: usize,
1274 newest: Option<IndexRecord>,
1275}
1276
1277fn read_folder_stat(store: &Store, jsonl: &Path) -> crate::Result<FolderStat> {
1287 let text = match store.read_text_bounded(jsonl, crate::parser::MAX_DBMD_FILE_BYTES) {
1288 Ok(t) => t,
1289 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FolderStat::default()),
1290 Err(e) => return Err(e.into()),
1291 };
1292 let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
1295 for (i, line) in text.lines().enumerate() {
1296 if line.trim().is_empty() {
1297 continue;
1298 }
1299 let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
1300 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1301 path: jsonl.to_path_buf(),
1302 message: format!("line {}: {e}", i + 1),
1303 })
1304 })?;
1305 by_path.insert(rec.path.clone(), rec);
1306 }
1307 let count = by_path.len();
1308 let newest = by_path.into_values().min_by(record_recency_cmp);
1312 Ok(FolderStat { count, newest })
1313}
1314
1315fn record_recency_cmp(a: &IndexRecord, b: &IndexRecord) -> std::cmp::Ordering {
1320 match (b.updated, a.updated) {
1321 (Some(bu), Some(au)) => bu.cmp(&au),
1322 (Some(_), None) => std::cmp::Ordering::Greater, (None, Some(_)) => std::cmp::Ordering::Less, (None, None) => std::cmp::Ordering::Equal,
1325 }
1326 .then_with(|| a.path.cmp(&b.path))
1327}
1328
1329fn collect_child_stats(
1342 store: &Store,
1343 layers: &[Layer],
1344) -> crate::Result<BTreeMap<PathBuf, FolderStat>> {
1345 let mut stats = BTreeMap::new();
1346 for &layer in layers {
1347 for tf in type_folders_in_layer(store, layer) {
1348 let stat = read_folder_stat(store, &tf.join("index.jsonl"))?;
1349 if stat.count > 0 {
1350 stats.insert(tf, stat);
1351 }
1352 }
1353 }
1354 Ok(stats)
1355}
1356
1357fn walk_type_folder_files(store: &Store, folder: &Path) -> Vec<PathBuf> {
1360 store.walk_type_folder(folder).unwrap_or_default()
1361}
1362
1363fn type_folders_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1366 let mut out = Vec::new();
1367 let names = match store.directory_names(Path::new(layer_dir_name(layer))) {
1368 Ok(names) => names,
1369 Err(_) => return out,
1370 };
1371 for name in names {
1372 let name = match name.to_str() {
1373 Some(n) => n,
1374 None => continue,
1375 };
1376 if is_hidden(std::ffi::OsStr::new(name)) || name == "log" {
1377 continue;
1378 }
1379 out.push(PathBuf::from(layer_dir_name(layer)).join(name));
1380 }
1381 out.sort();
1382 out
1383}
1384
1385fn loose_layer_of(file_rel: &Path) -> Option<Layer> {
1391 let mut comps = file_rel.components();
1392 let layer = layer_from_dir_name(comps.next()?.as_os_str().to_str()?)?;
1393 comps.next()?; if comps.next().is_some() {
1395 return None; }
1397 Some(layer)
1398}
1399
1400fn loose_files_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1404 let mut out = Vec::new();
1405 let names = match store.regular_file_names(Path::new(layer_dir_name(layer))) {
1406 Ok(names) => names,
1407 Err(_) => return out,
1408 };
1409 for name in names {
1410 let p = PathBuf::from(layer_dir_name(layer)).join(&name);
1411 if p.extension().and_then(|e| e.to_str()) != Some("md") {
1412 continue;
1413 }
1414 if is_index_artifact(&p) || is_hidden(&name) {
1415 continue;
1416 }
1417 out.push(p);
1418 }
1419 out
1420}
1421
1422fn write_layer_jsonl(store: &Store, layer: Layer, records: &[IndexRecord]) -> crate::Result<()> {
1427 let path = PathBuf::from(layer_dir_name(layer)).join("index.jsonl");
1428 if records.is_empty() {
1429 remove_if_exists(store, &path)?;
1430 return Ok(());
1431 }
1432 let idx = Index {
1433 level: IndexLevel::Layer(layer),
1434 records: records.to_vec(),
1435 child_counts: BTreeMap::new(),
1436 };
1437 write_atomic(store, &path, idx.to_jsonl())
1438}
1439
1440fn apply_loose_change(
1445 store: &Store,
1446 layer: Layer,
1447 file_rel: &Path,
1448 removing: bool,
1449) -> crate::Result<()> {
1450 let layer_dir = PathBuf::from(layer_dir_name(layer));
1451 let _lock = FolderLock::acquire(store, &layer_dir)?;
1452 let jsonl = layer_dir.join("index.jsonl");
1453 let mut records = read_jsonl_records(store, &jsonl)?;
1454 records.retain(|r| r.path != file_rel);
1455 if !removing {
1456 records.push(record_from_store(store, file_rel, file_rel.to_path_buf())?);
1457 }
1458 sort_records(&mut records);
1459 write_layer_jsonl(store, layer, &records)
1460}
1461
1462fn type_folder_of(file_rel: &Path) -> Option<PathBuf> {
1466 let mut comps = file_rel.components();
1467 let layer = comps.next()?.as_os_str().to_str()?;
1468 layer_from_dir_name(layer)?;
1469 let type_seg = comps.next()?.as_os_str().to_str()?;
1470 Some(PathBuf::from(layer).join(type_seg))
1471}
1472
1473fn normalize_rel(p: &Path) -> PathBuf {
1476 let s = path_to_unix(p);
1477 let s = s.strip_prefix("./").unwrap_or(&s);
1478 PathBuf::from(s)
1479}
1480
1481fn is_index_artifact(p: &Path) -> bool {
1482 matches!(
1483 p.file_name().and_then(|n| n.to_str()),
1484 Some("index.md") | Some("index.jsonl")
1485 )
1486}
1487
1488fn is_deletable_catalog_artifact(store: &Store, p: &Path) -> bool {
1502 match p.file_name().and_then(|n| n.to_str()) {
1503 Some("index.jsonl") => true,
1504 Some("index.md") => match store
1505 .read_bounded(p, crate::parser::MAX_DBMD_FILE_BYTES)
1506 .map_err(crate::Error::from)
1507 .and_then(|bytes| read_frontmatter_bytes(&bytes, p))
1508 {
1509 Ok(meta) => meta.type_.as_deref().is_none_or(|t| t == "index"),
1511 Err(_) => true,
1513 },
1514 _ => false,
1515 }
1516}
1517
1518fn is_hidden(name: &std::ffi::OsStr) -> bool {
1519 name.to_str().map(|s| s.starts_with('.')).unwrap_or(false)
1520}
1521
1522fn layer_dir_name(layer: Layer) -> &'static str {
1523 match layer {
1524 Layer::Sources => "sources",
1525 Layer::Records => "records",
1526 }
1527}
1528
1529fn layer_from_dir_name(name: &str) -> Option<Layer> {
1532 match name {
1533 "sources" => Some(Layer::Sources),
1534 "records" => Some(Layer::Records),
1535 _ => None,
1536 }
1537}
1538
1539fn folder_basename(p: &Path) -> &str {
1541 p.file_name().and_then(|n| n.to_str()).unwrap_or("")
1542}
1543
1544fn wiki_target(p: &Path) -> String {
1548 let unix = path_to_unix(p);
1549 unix.strip_suffix(".md").unwrap_or(&unix).to_string()
1550}
1551
1552fn path_to_unix(p: &Path) -> String {
1564 p.components()
1565 .map(|c| c.as_os_str().to_string_lossy().into_owned())
1566 .collect::<Vec<_>>()
1567 .join("/")
1568}
1569
1570mod path_serde {
1576 use super::path_to_unix;
1577 use serde::{Deserialize, Deserializer, Serializer};
1578 use std::path::{Path, PathBuf};
1579
1580 pub fn serialize<S: Serializer>(p: &Path, s: S) -> Result<S::Ok, S::Error> {
1581 s.serialize_str(&path_to_unix(p))
1582 }
1583
1584 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<PathBuf, D::Error> {
1585 Ok(PathBuf::from(String::deserialize(d)?))
1586 }
1587}
1588
1589fn capitalize(s: &str) -> String {
1591 let mut chars = s.chars();
1592 match chars.next() {
1593 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1594 None => String::new(),
1595 }
1596}
1597
1598fn collapse_whitespace(s: &str) -> String {
1603 s.split_whitespace().collect::<Vec<_>>().join(" ")
1604}
1605
1606fn default_display(basename: &str) -> String {
1612 let spaced: String = basename
1613 .chars()
1614 .map(|c| if c == '-' || c == '_' { ' ' } else { c })
1615 .collect();
1616 capitalize(&spaced)
1617}
1618
1619fn folder_label<'a>(
1626 tf_unix: &str,
1627 basename: &str,
1628 folders: &'a BTreeMap<String, FolderMeta>,
1629) -> (String, Option<&'a str>) {
1630 let meta = folders.get(tf_unix);
1631 let display = meta
1632 .and_then(|m| m.display.as_deref())
1633 .map(str::to_string)
1634 .unwrap_or_else(|| default_display(basename));
1635 (display, meta.and_then(|m| m.description.as_deref()))
1636}
1637
1638fn folder_entry(tf_unix: &str, display: &str, count: usize, description: Option<&str>) -> String {
1641 match description {
1642 Some(d) => format!("- [[{tf_unix}/index|{display}]] ({count}) — {d}\n"),
1643 None => format!("- [[{tf_unix}/index|{display}]] ({count})\n"),
1644 }
1645}
1646
1647fn write_atomic(store: &Store, path: &Path, contents: String) -> crate::Result<()> {
1654 store.write_atomic_nondurable(path, contents.as_bytes())?;
1659 Ok(())
1660}
1661
1662fn remove_if_exists(store: &Store, path: &Path) -> crate::Result<()> {
1663 match store.remove_file(path) {
1664 Ok(()) => Ok(()),
1665 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1666 Err(e) => Err(e.into()),
1667 }
1668}
1669
1670fn bad_index(path: &Path, msg: &str) -> crate::Error {
1671 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1672 path: path.to_path_buf(),
1673 message: msg.to_string(),
1674 })
1675}
1676
1677struct FolderLock {
1697 _file: File,
1698}
1699
1700impl FolderLock {
1701 fn acquire(store: &Store, folder: &Path) -> crate::Result<Self> {
1726 Ok(Self {
1727 _file: store.lock_file(&folder.join(".index.lock"))?,
1728 })
1729 }
1730}
1731
1732fn lock_folders(store: &Store, a: &Path, b: &Path) -> crate::Result<Vec<FolderLock>> {
1738 if a == b {
1739 return Ok(vec![FolderLock::acquire(store, a)?]);
1740 }
1741 let (first, second) = if a < b { (a, b) } else { (b, a) };
1742 Ok(vec![
1743 FolderLock::acquire(store, first)?,
1744 FolderLock::acquire(store, second)?,
1745 ])
1746}
1747
1748#[cfg(test)]
1749mod tests {
1750 use super::*;
1751 use std::collections::BTreeSet;
1752 use std::fs;
1753 use tempfile::TempDir;
1754
1755 fn mk_store() -> (TempDir, Store) {
1760 let dir = TempDir::new().unwrap();
1761 fs::write(dir.path().join("DB.md"), "# test store\n").unwrap();
1762 let store =
1763 Store::from_root_and_config(dir.path(), crate::parser::Config::default()).unwrap();
1764 (dir, store)
1765 }
1766
1767 fn write_raw(store: &Store, rel: &str, fm: &str, body: &str) {
1770 let abs = store.root.join(rel);
1771 fs::create_dir_all(abs.parent().unwrap()).unwrap();
1772 fs::write(&abs, format!("---\n{fm}\n---\n{body}")).unwrap();
1773 }
1774
1775 fn write_doc(
1777 store: &Store,
1778 rel: &str,
1779 type_: &str,
1780 summary: Option<&str>,
1781 updated: Option<&str>,
1782 extra_yaml: &str,
1783 ) {
1784 let mut fm = format!("type: {type_}\n");
1785 if let Some(s) = summary {
1786 fm.push_str(&format!("summary: {s}\n"));
1787 }
1788 if let Some(u) = updated {
1789 fm.push_str(&format!("updated: {u}\n"));
1790 }
1791 fm.push_str(extra_yaml);
1792 write_raw(store, rel, fm.trim_end(), "\nbody text\n");
1793 }
1794
1795 fn read(store: &Store, rel: &str) -> String {
1796 fs::read_to_string(store.root.join(rel)).unwrap()
1797 }
1798
1799 fn exists(store: &Store, rel: &str) -> bool {
1800 store.root.join(rel).exists()
1801 }
1802
1803 fn snapshot_artifacts(store: &Store) -> BTreeMap<String, String> {
1806 let mut out = BTreeMap::new();
1807 for rel in store.walk_regular_files(Path::new("")).unwrap() {
1808 if is_index_artifact(&rel) {
1809 let key = path_to_unix(&rel);
1810 out.insert(key, store.read_text_bounded(&rel, u64::MAX).unwrap());
1811 }
1812 }
1813 out
1814 }
1815
1816 #[test]
1819 fn type_folder_aggregates_across_shards_in_recency_order() {
1820 let (_d, store) = mk_store();
1821 write_doc(
1824 &store,
1825 "sources/emails/2026/05/b-old.md",
1826 "email",
1827 Some("Older mail"),
1828 Some("2026-05-01T09:00:00Z"),
1829 "",
1830 );
1831 write_doc(
1832 &store,
1833 "sources/emails/2026/06/c-new.md",
1834 "email",
1835 Some("Newest mail"),
1836 Some("2026-06-15T12:00:00Z"),
1837 "",
1838 );
1839 write_doc(
1840 &store,
1841 "sources/emails/2026/05/a-mid.md",
1842 "email",
1843 Some("Middle mail"),
1844 Some("2026-05-20T08:00:00Z"),
1845 "",
1846 );
1847
1848 let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
1849 let paths: Vec<String> = idx.records.iter().map(|r| path_to_unix(&r.path)).collect();
1850 assert_eq!(
1851 paths,
1852 vec![
1853 "sources/emails/2026/06/c-new.md",
1854 "sources/emails/2026/05/a-mid.md",
1855 "sources/emails/2026/05/b-old.md",
1856 ],
1857 "records must aggregate across shards, newest `updated` first"
1858 );
1859 }
1860
1861 #[test]
1862 fn type_folder_md_format_entries_tags_and_derived_updated() {
1863 let (_d, store) = mk_store();
1864 write_doc(
1865 &store,
1866 "records/contacts/sarah-chen.md",
1867 "contact",
1868 Some("Renewal champion at Acme"),
1869 Some("2026-05-27T10:00:00Z"),
1870 "tags:\n - renewal\n - acme\n",
1871 );
1872 write_doc(
1873 &store,
1874 "records/contacts/no-tags.md",
1875 "contact",
1876 Some("Plain contact"),
1877 Some("2026-05-26T10:00:00Z"),
1878 "",
1879 );
1880
1881 let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
1882 let md = idx.to_markdown();
1883
1884 assert!(md.starts_with(
1887 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\nupdated: 2026-05-27T10:00:00Z\n---\n\n# records/contacts\n"
1888 ), "frontmatter/heading wrong:\n{md}");
1889
1890 assert!(
1892 md.contains(
1893 "- [[records/contacts/sarah-chen]] — Renewal champion at Acme · #renewal #acme\n"
1894 ),
1895 "tagged entry wrong:\n{md}"
1896 );
1897 assert!(
1899 md.contains("- [[records/contacts/no-tags]] — Plain contact\n"),
1900 "untagged entry wrong:\n{md}"
1901 );
1902 assert!(
1903 !md.contains("Plain contact ·"),
1904 "untagged entry must not emit a tag separator"
1905 );
1906 assert!(!md.contains("## More"), "no footer expected under the cap");
1908 }
1909
1910 #[test]
1911 fn missing_summary_becomes_placeholder_not_invented() {
1912 let (_d, store) = mk_store();
1913 write_doc(
1914 &store,
1915 "records/notes/x.md",
1916 "note",
1917 None,
1918 Some("2026-05-27T10:00:00Z"),
1919 "",
1920 );
1921 let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
1922 assert_eq!(idx.records[0].summary, MISSING_SUMMARY);
1923 let md = idx.to_markdown();
1924 assert!(
1925 md.contains("- [[records/notes/x]] — (no summary)\n"),
1926 "missing summary must render the placeholder, not invent text:\n{md}"
1927 );
1928 }
1929
1930 #[test]
1933 fn jsonl_is_complete_structured_and_round_trips() {
1934 let (_d, store) = mk_store();
1935 write_doc(
1936 &store,
1937 "records/expenses/2026/05/e1.md",
1938 "expense",
1939 Some("Lunch with vendor"),
1940 Some("2026-05-10T10:00:00Z"),
1941 "created: 2026-05-10T09:00:00Z\nstatus: paid\namount: 42\ncompany: [[records/companies/acme]]\nrelated:\n - [[records/concepts/spend]]\ntags:\n - food\nlinks:\n - records/concepts/spend\n - [[records/concepts/renewal]]\n",
1942 );
1943 write_doc(
1944 &store,
1945 "records/expenses/2026/06/e2.md",
1946 "expense",
1947 Some("Cloud bill"),
1948 Some("2026-06-01T10:00:00Z"),
1949 "amount: 100\n",
1950 );
1951
1952 let idx = Index::build_type_folder(&store, Path::new("records/expenses")).unwrap();
1953 let jsonl = idx.to_jsonl();
1954 let lines: Vec<&str> = jsonl.lines().collect();
1955 assert_eq!(lines.len(), 2, "one JSON object per file, uncapped");
1956
1957 let r0: IndexRecord = serde_json::from_str(lines[0]).unwrap();
1959 assert_eq!(path_to_unix(&r0.path), "records/expenses/2026/06/e2.md");
1960 assert_eq!(
1961 r0, idx.records[0],
1962 "jsonl line must round-trip to the record"
1963 );
1964
1965 let r1: IndexRecord = serde_json::from_str(lines[1]).unwrap();
1968 assert_eq!(r1.type_, "expense");
1969 assert_eq!(r1.summary, "Lunch with vendor");
1970 assert_eq!(r1.tags, vec!["food".to_string()]);
1971 assert_eq!(
1972 r1.links,
1973 vec![
1974 "records/concepts/spend".to_string(),
1975 "[[records/concepts/renewal]]".to_string()
1976 ]
1977 );
1978 assert_eq!(
1979 r1.created,
1980 Some(DateTime::parse_from_rfc3339("2026-05-10T09:00:00Z").unwrap())
1981 );
1982 assert_eq!(r1.fields.get("status"), Some(&Value::from("paid")));
1983 assert_eq!(r1.fields.get("amount"), Some(&Value::from(42)));
1984 assert_eq!(
1985 r1.fields.get("company"),
1986 Some(&Value::from("[[records/companies/acme]]"))
1987 );
1988 assert_eq!(
1989 r1.fields.get("related"),
1990 Some(&serde_json::json!(["[[records/concepts/spend]]"]))
1991 );
1992 for reserved in [
1994 "path", "type", "summary", "tags", "links", "created", "updated",
1995 ] {
1996 assert!(
1997 !r1.fields.contains_key(reserved),
1998 "reserved key {reserved} must not appear in fields"
1999 );
2000 }
2001
2002 assert!(
2004 lines[1].starts_with(
2005 r#"{"path":"records/expenses/2026/05/e1.md","type":"expense","summary":"Lunch with vendor","tags":["food"],"links":["records/concepts/spend","[[records/concepts/renewal]]"],"created":"2026-05-10T09:00:00Z","updated":"2026-05-10T10:00:00Z","#
2006 ),
2007 "jsonl key order not stable:\n{}",
2008 lines[1]
2009 );
2010 assert!(
2015 lines[1].ends_with(r#""amount":42,"company":"[[records/companies/acme]]","meta-type":"fact","related":["[[records/concepts/spend]]"],"status":"paid"}"#),
2016 "extras must be sorted:\n{}",
2017 lines[1]
2018 );
2019 }
2020
2021 #[test]
2024 fn over_cap_md_shows_500_plus_footer_jsonl_holds_all() {
2025 let (_d, store) = mk_store();
2026 let total = MD_CAP + 7;
2027 for i in 0..total {
2028 let day = 1 + (i % 27);
2030 let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2031 let updated = format!("2026-05-{day:02}T00:00:{:02}Z", i % 60);
2032 write_doc(
2033 &store,
2034 &rel,
2035 "email",
2036 Some(&format!("mail {i}")),
2037 Some(&updated),
2038 "",
2039 );
2040 }
2041 let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
2042 assert_eq!(idx.records.len(), total, "jsonl/records keep every file");
2043
2044 let md = idx.to_markdown();
2045 let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
2046 assert_eq!(entry_lines, MD_CAP, "md browse view is capped at 500");
2047
2048 assert!(
2049 md.contains("## More\n\n"),
2050 "over-cap md needs a More footer"
2051 );
2052 assert!(
2053 md.contains(&format!(
2054 "This folder has {total} files. The 500 most recent are listed above.\n"
2055 )),
2056 "footer count wrong:\n{md}"
2057 );
2058 assert!(
2059 md.contains("Use `dbmd query --type email --in sources` for the complete catalog.\n"),
2060 "footer must infer type=email layer=sources:\n{md}"
2061 );
2062
2063 let jsonl = idx.to_jsonl();
2064 assert_eq!(jsonl.lines().count(), total, "jsonl is uncapped");
2065 }
2066
2067 #[test]
2070 fn sort_breaks_ties_by_path_and_puts_undated_last() {
2071 let mut recs = vec![
2072 rec("z/a.md", Some("2026-05-01T00:00:00Z")),
2073 rec("a/b.md", Some("2026-05-01T00:00:00Z")), rec("m/c.md", None), rec("b/d.md", Some("2026-06-01T00:00:00Z")), ];
2077 sort_records(&mut recs);
2078 let order: Vec<String> = recs.iter().map(|r| path_to_unix(&r.path)).collect();
2079 assert_eq!(order, vec!["b/d.md", "a/b.md", "z/a.md", "m/c.md"]);
2080 }
2081
2082 fn rec(path: &str, updated: Option<&str>) -> IndexRecord {
2083 IndexRecord {
2084 path: PathBuf::from(path),
2085 type_: "t".into(),
2086 summary: "s".into(),
2087 tags: vec![],
2088 links: vec![],
2089 created: None,
2090 updated: updated.map(|u| DateTime::parse_from_rfc3339(u).unwrap()),
2091 fields: BTreeMap::new(),
2092 }
2093 }
2094
2095 #[test]
2098 fn layer_index_lists_type_folders_with_counts() {
2099 let (_d, store) = mk_store();
2100 write_doc(
2101 &store,
2102 "records/contacts/a.md",
2103 "contact",
2104 Some("Contact A older"),
2105 Some("2026-05-01T00:00:00Z"),
2106 "",
2107 );
2108 write_doc(
2109 &store,
2110 "records/contacts/b.md",
2111 "contact",
2112 Some("Contact B newest"),
2113 Some("2026-05-09T00:00:00Z"),
2114 "",
2115 );
2116 write_doc(
2117 &store,
2118 "records/companies/x.md",
2119 "company",
2120 Some("Acme Inc"),
2121 Some("2026-05-05T00:00:00Z"),
2122 "",
2123 );
2124 Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
2126 Index::write_level(&store, &IndexLevel::TypeFolder("records/companies".into())).unwrap();
2127
2128 Index::write_level(&store, &IndexLevel::Layer(Layer::Records)).unwrap();
2129 let md = read(&store, "records/index.md");
2130
2131 assert!(
2132 md.starts_with("---\ntype: index\nscope: layer\nfolder: records\n"),
2133 "layer fm:\n{md}"
2134 );
2135 let companies_at = md.find("companies/index").unwrap();
2137 let contacts_at = md.find("contacts/index").unwrap();
2138 assert!(
2139 companies_at < contacts_at,
2140 "type folders must be alphabetical"
2141 );
2142 assert!(
2145 md.contains("- [[records/contacts/index|Contacts]] (2)\n"),
2146 "contacts entry:\n{md}"
2147 );
2148 assert!(
2149 md.contains("- [[records/companies/index|Companies]] (1)\n"),
2150 "companies entry:\n{md}"
2151 );
2152 assert!(
2154 !md.contains("Contact B newest") && !md.contains("Acme Inc"),
2155 "layer rollup must not quote a member summary:\n{md}"
2156 );
2157 assert!(
2159 md.contains("updated: 2026-05-09T00:00:00Z\n"),
2160 "layer updated must be max child:\n{md}"
2161 );
2162 }
2163
2164 #[test]
2165 fn folders_section_supplies_authored_display_and_description() {
2166 let (_d, mut store) = mk_store();
2170 store.config.folders.insert(
2171 "records/contacts".into(),
2172 crate::parser::FolderMeta {
2173 display: None,
2174 description: Some("people across customer + prospect accounts".into()),
2175 },
2176 );
2177 store.config.folders.insert(
2178 "sources/hubspot-exports".into(),
2179 crate::parser::FolderMeta {
2180 display: Some("HubSpot exports".into()),
2181 description: Some("deal + pipeline exports".into()),
2182 },
2183 );
2184 write_doc(
2185 &store,
2186 "records/contacts/a.md",
2187 "contact",
2188 Some("Contact A"),
2189 Some("2026-05-01T00:00:00Z"),
2190 "",
2191 );
2192 write_doc(
2194 &store,
2195 "records/companies/x.md",
2196 "company",
2197 Some("Acme Inc"),
2198 Some("2026-05-05T00:00:00Z"),
2199 "",
2200 );
2201 write_doc(
2202 &store,
2203 "sources/hubspot-exports/d.md",
2204 "hubspot-export",
2205 Some("a single deal export"),
2206 Some("2026-05-03T00:00:00Z"),
2207 "",
2208 );
2209
2210 Index::rebuild_all(&store).unwrap();
2211
2212 let records_layer = read(&store, "records/index.md");
2214 assert!(
2215 records_layer.contains("- [[records/contacts/index|Contacts]] (1) — people across customer + prospect accounts\n"),
2216 "authored description must surface:\n{records_layer}"
2217 );
2218 assert!(
2220 records_layer.contains("- [[records/companies/index|Companies]] (1)\n")
2221 && !records_layer.contains("Acme Inc"),
2222 "un-described folder is counts-only:\n{records_layer}"
2223 );
2224
2225 let sources_layer = read(&store, "sources/index.md");
2227 assert!(
2228 sources_layer.contains("- [[sources/hubspot-exports/index|HubSpot exports]] (1) — deal + pipeline exports\n"),
2229 "display override + description must surface:\n{sources_layer}"
2230 );
2231
2232 let root = read(&store, "index.md");
2234 assert!(
2235 root.contains("- [[records/contacts/index|Contacts]] (1) — people across customer + prospect accounts\n"),
2236 "root surfaces authored description:\n{root}"
2237 );
2238 assert!(
2239 root.contains("- [[sources/hubspot-exports/index|HubSpot exports]] (1) — deal + pipeline exports\n"),
2240 "root surfaces display override:\n{root}"
2241 );
2242 }
2243
2244 #[test]
2245 fn default_display_turns_separators_to_spaces_and_caps() {
2246 assert_eq!(default_display("contacts"), "Contacts");
2247 assert_eq!(default_display("hubspot-exports"), "Hubspot exports");
2248 assert_eq!(default_display("usage_exports"), "Usage exports");
2249 }
2250
2251 #[test]
2252 fn root_index_groups_layers_with_totals_and_per_type_counts() {
2253 let (_d, store) = mk_store();
2254 write_doc(
2255 &store,
2256 "sources/emails/2026/05/a.md",
2257 "email",
2258 Some("Mail"),
2259 Some("2026-05-01T00:00:00Z"),
2260 "",
2261 );
2262 write_doc(
2263 &store,
2264 "sources/docs/d.md",
2265 "doc",
2266 Some("Doc"),
2267 Some("2026-05-02T00:00:00Z"),
2268 "",
2269 );
2270 write_doc(
2271 &store,
2272 "records/contacts/c.md",
2273 "contact",
2274 Some("C"),
2275 Some("2026-05-03T00:00:00Z"),
2276 "",
2277 );
2278 Index::rebuild_all(&store).unwrap();
2281 let md = read(&store, "index.md");
2282
2283 assert!(
2284 md.starts_with("---\ntype: index\nscope: root\n"),
2285 "root fm:\n{md}"
2286 );
2287 assert!(md.contains("# Knowledge base index\n"), "root title:\n{md}");
2288 let sources_h = md
2290 .find("## Sources (2)")
2291 .expect("sources heading w/ total 2");
2292 let records_h = md
2293 .find("## Records (1)")
2294 .expect("records heading w/ total 1");
2295 assert!(sources_h < records_h, "Sources must precede Records");
2296 assert!(!md.contains("## Wiki"), "empty layer gets no section");
2297 assert!(
2299 md.contains("- [[sources/docs/index|Docs]] (1)\n"),
2300 "root docs entry:\n{md}"
2301 );
2302 assert!(
2303 md.contains("- [[sources/emails/index|Emails]] (1)\n"),
2304 "root emails entry:\n{md}"
2305 );
2306 assert!(
2307 md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
2308 "root contacts entry:\n{md}"
2309 );
2310 assert!(!md.contains("— "), "root entries carry no preview text");
2311 }
2312
2313 #[test]
2316 fn on_write_matches_rebuild_byte_for_byte() {
2317 let (_d1, wt) = mk_store();
2320 let (_d2, rb) = mk_store();
2321
2322 let docs: &[(&str, &str, &str, &str, &str)] = &[
2323 (
2324 "sources/emails/2026/05/e1.md",
2325 "email",
2326 "First mail",
2327 "2026-05-01T10:00:00Z",
2328 "tags:\n - inbox\n",
2329 ),
2330 (
2331 "sources/emails/2026/06/e2.md",
2332 "email",
2333 "Second mail",
2334 "2026-06-01T10:00:00Z",
2335 "",
2336 ),
2337 (
2338 "records/contacts/sarah.md",
2339 "contact",
2340 "Sarah",
2341 "2026-05-15T10:00:00Z",
2342 "links:\n - records/profiles/sarah\n",
2343 ),
2344 (
2345 "records/contacts/elena.md",
2346 "contact",
2347 "Elena",
2348 "2026-05-20T10:00:00Z",
2349 "status: active\n",
2350 ),
2351 (
2352 "records/profiles/sarah.md",
2353 "profile",
2354 "Sarah bio",
2355 "2026-05-21T10:00:00Z",
2356 "",
2357 ),
2358 ];
2359
2360 for (rel, t, sum, upd, extra) in docs {
2361 write_doc(&wt, rel, t, Some(sum), Some(upd), extra);
2362 write_doc(&rb, rel, t, Some(sum), Some(upd), extra);
2363 Index::on_write(&wt, Path::new(rel)).unwrap();
2364 }
2365 Index::rebuild_all(&rb).unwrap();
2366
2367 let a = snapshot_artifacts(&wt);
2368 let b = snapshot_artifacts(&rb);
2369 assert_eq!(
2370 a.keys().collect::<Vec<_>>(),
2371 b.keys().collect::<Vec<_>>(),
2372 "same set of index artifacts must exist"
2373 );
2374 for (k, v) in &a {
2375 assert_eq!(v, &b[k], "artifact {k} differs between write-through and rebuild:\n--- write-through ---\n{v}\n--- rebuild ---\n{}", b[k]);
2376 }
2377 assert!(a.contains_key("index.md"));
2379 assert!(a.contains_key("sources/emails/index.jsonl"));
2380 assert!(a.contains_key("records/contacts/index.md"));
2381 }
2382
2383 #[test]
2400 fn loop_op_does_not_walk_sibling_content_tree() {
2401 let (_d, store) = mk_store();
2402
2403 write_doc(
2407 &store,
2408 "records/companies/acme.md",
2409 "company",
2410 Some("Acme Inc"),
2411 Some("2026-05-05T00:00:00Z"),
2412 "",
2413 );
2414 write_doc(
2415 &store,
2416 "records/companies/globex.md",
2417 "company",
2418 Some("Globex"),
2419 Some("2026-05-06T00:00:00Z"),
2420 "",
2421 );
2422 assert!(
2423 !exists(&store, "records/companies/index.jsonl"),
2424 "precondition: companies must be un-indexed"
2425 );
2426
2427 write_doc(
2429 &store,
2430 "records/contacts/sarah.md",
2431 "contact",
2432 Some("Sarah"),
2433 Some("2026-05-15T00:00:00Z"),
2434 "",
2435 );
2436 Index::on_write(&store, Path::new("records/contacts/sarah.md")).unwrap();
2437
2438 let layer_md = read(&store, "records/index.md");
2440 let root_md = read(&store, "index.md");
2441 assert!(
2443 layer_md.contains("- [[records/contacts/index|Contacts]] (1)\n")
2444 && !layer_md.contains("Sarah"),
2445 "layer must reflect the written folder, counts only:\n{layer_md}"
2446 );
2447 assert!(
2448 root_md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
2449 "root must reflect the written folder:\n{root_md}"
2450 );
2451
2452 assert!(
2456 !layer_md.contains("companies"),
2457 "loop op walked the sibling content tree: layer rollup counts un-indexed records/companies\n{layer_md}"
2458 );
2459 assert!(
2460 !root_md.contains("companies"),
2461 "loop op walked the sibling content tree: root rollup counts un-indexed records/companies\n{root_md}"
2462 );
2463 assert!(
2465 root_md.contains("## Records (1)"),
2466 "root layer total must count only the sidecar-indexed folder (1), not walked siblings (would be 3):\n{root_md}"
2467 );
2468
2469 let (_d2, rb) = mk_store();
2474 for (rel, t, s, u) in [
2475 (
2476 "records/companies/acme.md",
2477 "company",
2478 "Acme Inc",
2479 "2026-05-05T00:00:00Z",
2480 ),
2481 (
2482 "records/companies/globex.md",
2483 "company",
2484 "Globex",
2485 "2026-05-06T00:00:00Z",
2486 ),
2487 (
2488 "records/contacts/sarah.md",
2489 "contact",
2490 "Sarah",
2491 "2026-05-15T00:00:00Z",
2492 ),
2493 ] {
2494 write_doc(&rb, rel, t, Some(s), Some(u), "");
2495 }
2496 Index::on_write(&store, Path::new("records/companies/acme.md")).unwrap();
2497 Index::on_write(&store, Path::new("records/companies/globex.md")).unwrap();
2498 Index::rebuild_all(&rb).unwrap();
2499 let a = snapshot_artifacts(&store);
2500 let b = snapshot_artifacts(&rb);
2501 assert_eq!(
2502 a.keys().collect::<BTreeSet<_>>(),
2503 b.keys().collect::<BTreeSet<_>>(),
2504 "same artifact set after indexing both folders"
2505 );
2506 for (k, v) in &a {
2507 assert_eq!(
2508 v, &b[k],
2509 "after indexing the sibling too, loop result must equal rebuild for {k}"
2510 );
2511 }
2512 assert!(
2513 read(&store, "index.md").contains("## Records (3)"),
2514 "now that both folders are indexed, the root total is 3"
2515 );
2516 }
2517
2518 #[test]
2531 fn custom_type_at_shard_path_for_is_indexable_end_to_end() {
2532 let (_d1, wt) = mk_store();
2533 let (_d2, rb) = mk_store();
2534
2535 let rel = wt
2537 .shard_path_for(
2538 "profile",
2539 &crate::parser::Frontmatter::default(),
2540 "renewal-theme",
2541 )
2542 .unwrap();
2543 let rel_str = path_to_unix(&rel);
2544 assert!(
2547 type_folder_of(&rel).is_some(),
2548 "shard_path_for produced a path the index cannot file: {rel_str}"
2549 );
2550
2551 write_doc(
2552 &wt,
2553 &rel_str,
2554 "profile",
2555 Some("Renewal theme"),
2556 Some("2026-05-21T10:00:00Z"),
2557 "",
2558 );
2559 write_doc(
2560 &rb,
2561 &rel_str,
2562 "profile",
2563 Some("Renewal theme"),
2564 Some("2026-05-21T10:00:00Z"),
2565 "",
2566 );
2567
2568 Index::on_write(&wt, &rel)
2571 .expect("on_write must succeed for a toolkit-computed custom-type path");
2572 Index::rebuild_all(&rb).unwrap();
2573
2574 let page_link = wiki_target(&rel); let tf_md = read(&rb, "records/profile/index.md");
2581 assert!(
2582 tf_md.contains(&format!("[[{page_link}]]")),
2583 "type-folder index must list the page link, got:\n{tf_md}"
2584 );
2585 assert!(
2586 exists(&rb, "records/profile/index.jsonl"),
2587 "type-folder jsonl must exist"
2588 );
2589 assert!(
2590 read(&rb, "records/profile/index.jsonl").contains(&rel_str),
2591 "type-folder jsonl must contain the page row"
2592 );
2593 let layer_md = read(&rb, "records/index.md");
2596 assert!(
2597 layer_md.contains("records/profile/index"),
2598 "layer index must roll up the records/profile type-folder, got:\n{layer_md}"
2599 );
2600
2601 let a = snapshot_artifacts(&wt);
2603 let b = snapshot_artifacts(&rb);
2604 assert_eq!(
2605 a.keys().collect::<Vec<_>>(),
2606 b.keys().collect::<Vec<_>>(),
2607 "loop and sweep must produce the same artifact set"
2608 );
2609 for (k, v) in &a {
2610 assert_eq!(
2611 v, &b[k],
2612 "custom-type artifact {k} differs between on_write and rebuild"
2613 );
2614 }
2615 }
2616
2617 #[test]
2618 fn on_remove_then_rebuild_match_and_pull_in_next_over_cap() {
2619 let (_d1, wt) = mk_store();
2620 let (_d2, rb) = mk_store();
2621 let total = MD_CAP + 3; let mut all_rels = Vec::new();
2623 for i in 0..total {
2624 let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2625 let updated = format!("2026-05-10T00:{:02}:{:02}Z", i / 60, i % 60);
2627 write_doc(
2628 &wt,
2629 &rel,
2630 "email",
2631 Some(&format!("mail {i}")),
2632 Some(&updated),
2633 "",
2634 );
2635 write_doc(
2636 &rb,
2637 &rel,
2638 "email",
2639 Some(&format!("mail {i}")),
2640 Some(&updated),
2641 "",
2642 );
2643 all_rels.push(rel);
2644 }
2645 Index::rebuild_all(&wt).unwrap();
2647 let newest = &all_rels[total - 1]; fs::remove_file(wt.root.join(newest)).unwrap();
2649 Index::on_remove(&wt, Path::new(newest)).unwrap();
2650
2651 fs::remove_file(rb.root.join(newest)).unwrap();
2653 Index::rebuild_all(&rb).unwrap();
2654
2655 let a = snapshot_artifacts(&wt);
2656 let b = snapshot_artifacts(&rb);
2657 for (k, v) in &a {
2658 assert_eq!(v, &b[k], "after remove, artifact {k} drifted from rebuild");
2659 }
2660
2661 let md = read(&wt, "sources/emails/index.md");
2664 assert_eq!(md.lines().filter(|l| l.starts_with("- [[")).count(), MD_CAP);
2665 assert!(
2667 !md.contains(&format!("[[{}]]", wiki_target(Path::new(newest)))),
2668 "removed file must not be listed in md"
2669 );
2670 let pulled_in = &all_rels[2];
2674 assert!(
2675 md.contains(&format!("[[{}]]", wiki_target(Path::new(pulled_in)))),
2676 "the 501st-most-recent must be pulled into the browse view after a removal"
2677 );
2678 assert!(
2679 md.contains(&format!("This folder has {} files.", total - 1)),
2680 "footer count must decrement:\n{}",
2681 md.lines().rev().take(4).collect::<Vec<_>>().join("\n")
2682 );
2683 let jsonl = read(&wt, "sources/emails/index.jsonl");
2684 assert_eq!(
2685 jsonl.lines().count(),
2686 total - 1,
2687 "jsonl loses exactly the removed file"
2688 );
2689 assert!(
2690 !jsonl.contains(&path_to_unix(Path::new(newest))),
2691 "removed file must be gone from the jsonl too"
2692 );
2693 }
2694
2695 #[test]
2696 fn on_rename_cross_folder_matches_rebuild() {
2697 let (_d1, wt) = mk_store();
2698 let (_d2, rb) = mk_store();
2699 let seed: &[(&str, &str, &str, &str)] = &[
2701 (
2702 "records/contacts/a.md",
2703 "contact",
2704 "A",
2705 "2026-05-01T00:00:00Z",
2706 ),
2707 (
2708 "records/contacts/b.md",
2709 "contact",
2710 "B",
2711 "2026-05-02T00:00:00Z",
2712 ),
2713 (
2714 "records/companies/x.md",
2715 "company",
2716 "X",
2717 "2026-05-03T00:00:00Z",
2718 ),
2719 ];
2720 for (rel, t, s, u) in seed {
2721 write_doc(&wt, rel, t, Some(s), Some(u), "");
2722 write_doc(&rb, rel, t, Some(s), Some(u), "");
2723 }
2724 Index::rebuild_all(&wt).unwrap();
2725
2726 let old = "records/contacts/b.md";
2729 let new = "records/companies/b.md";
2730 fs::create_dir_all(wt.root.join("records/companies")).unwrap();
2731 fs::rename(wt.root.join(old), wt.root.join(new)).unwrap();
2732 Index::on_rename(&wt, Path::new(old), Path::new(new)).unwrap();
2735
2736 fs::create_dir_all(rb.root.join("records/companies")).unwrap();
2738 fs::rename(rb.root.join(old), rb.root.join(new)).unwrap();
2739 Index::rebuild_all(&rb).unwrap();
2740
2741 let a = snapshot_artifacts(&wt);
2742 let b = snapshot_artifacts(&rb);
2743 assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
2744 for (k, v) in &a {
2745 assert_eq!(v, &b[k], "rename: artifact {k} drifted from rebuild");
2746 }
2747 let contacts = read(&wt, "records/contacts/index.md");
2749 assert!(!contacts.contains("records/contacts/b]]"));
2750 let companies = read(&wt, "records/companies/index.md");
2751 assert!(companies.contains("[[records/companies/b]]"));
2752 }
2753
2754 #[test]
2755 fn on_write_updates_existing_entry_in_place() {
2756 let (_d, store) = mk_store();
2757 write_doc(
2758 &store,
2759 "records/contacts/a.md",
2760 "contact",
2761 Some("Original"),
2762 Some("2026-05-01T00:00:00Z"),
2763 "",
2764 );
2765 Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2766 write_doc(
2768 &store,
2769 "records/contacts/a.md",
2770 "contact",
2771 Some("Revised"),
2772 Some("2026-05-09T00:00:00Z"),
2773 "",
2774 );
2775 Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2776
2777 let jsonl = read(&store, "records/contacts/index.jsonl");
2778 assert_eq!(
2779 jsonl.lines().count(),
2780 1,
2781 "upsert must not duplicate the line"
2782 );
2783 assert!(jsonl.contains("Revised"), "jsonl must reflect the update");
2784 assert!(
2785 !jsonl.contains("Original"),
2786 "stale line must be gone (compacted)"
2787 );
2788 let md = read(&store, "records/contacts/index.md");
2789 assert!(md.contains("- [[records/contacts/a]] — Revised\n"));
2790 assert!(
2791 md.contains("updated: 2026-05-09T00:00:00Z\n"),
2792 "index updated must track the newer member"
2793 );
2794 }
2795
2796 #[test]
2799 fn dry_run_emits_separators_and_writes_nothing() {
2800 let (_d, store) = mk_store();
2801 write_doc(
2802 &store,
2803 "sources/emails/2026/05/a.md",
2804 "email",
2805 Some("Mail"),
2806 Some("2026-05-01T00:00:00Z"),
2807 "",
2808 );
2809 let out = Index::render_dry_run(&store, &IndexLevel::TypeFolder("sources/emails".into()))
2810 .unwrap();
2811 assert!(
2812 out.contains("--- sources/emails/index.md ---\n"),
2813 "md separator:\n{out}"
2814 );
2815 assert!(
2816 out.contains("--- sources/emails/index.jsonl ---\n"),
2817 "jsonl separator:\n{out}"
2818 );
2819 assert!(
2820 out.contains("- [[sources/emails/2026/05/a]] — Mail"),
2821 "md body present"
2822 );
2823 assert!(
2825 !exists(&store, "sources/emails/index.md"),
2826 "dry-run must not write"
2827 );
2828 assert!(
2829 !exists(&store, "sources/emails/index.jsonl"),
2830 "dry-run must not write"
2831 );
2832 }
2833
2834 #[test]
2835 fn cleanup_removes_noncanonical_and_empty_indexes() {
2836 let (_d, store) = mk_store();
2837 write_doc(
2838 &store,
2839 "sources/emails/2026/05/a.md",
2840 "email",
2841 Some("Mail"),
2842 Some("2026-05-01T00:00:00Z"),
2843 "",
2844 );
2845 fs::write(
2847 store.root.join("sources/emails/2026/05/index.md"),
2848 "stale\n",
2849 )
2850 .unwrap();
2851 fs::write(
2852 store.root.join("sources/emails/2026/05/index.jsonl"),
2853 "stale\n",
2854 )
2855 .unwrap();
2856 fs::create_dir_all(store.root.join("records/empty")).unwrap();
2858 fs::write(store.root.join("records/empty/index.md"), "stale\n").unwrap();
2859
2860 Index::cleanup(&store).unwrap();
2861
2862 assert!(
2863 !exists(&store, "sources/emails/2026/05/index.md"),
2864 "shard index must be deleted"
2865 );
2866 assert!(
2867 !exists(&store, "sources/emails/2026/05/index.jsonl"),
2868 "shard jsonl must be deleted"
2869 );
2870 assert!(
2871 !exists(&store, "records/empty/index.md"),
2872 "empty-folder index must be deleted"
2873 );
2874 assert!(exists(&store, "sources/emails/2026/05/a.md"));
2876 }
2877
2878 #[test]
2879 fn rebuild_deletes_stale_indexes_for_emptied_folders() {
2880 let (_d, store) = mk_store();
2881 write_doc(
2882 &store,
2883 "records/contacts/a.md",
2884 "contact",
2885 Some("A"),
2886 Some("2026-05-01T00:00:00Z"),
2887 "",
2888 );
2889 Index::rebuild_all(&store).unwrap();
2890 assert!(exists(&store, "records/contacts/index.md"));
2891 assert!(exists(&store, "records/index.md"));
2892 assert!(exists(&store, "index.md"));
2893
2894 fs::remove_file(store.root.join("records/contacts/a.md")).unwrap();
2896 Index::rebuild_all(&store).unwrap();
2897 assert!(
2898 !exists(&store, "records/contacts/index.md"),
2899 "emptied type-folder index gone"
2900 );
2901 assert!(
2902 !exists(&store, "records/index.md"),
2903 "now-empty layer index gone"
2904 );
2905 assert!(!exists(&store, "index.md"), "now-empty root index gone");
2906 }
2907
2908 #[test]
2911 fn property_writethrough_equals_rebuild_under_mixed_ops() {
2912 let (_d1, wt) = mk_store();
2914 let (_d2, rb) = mk_store();
2915 let mut seed: u64 = 0x9E3779B97F4A7C15;
2916 let mut next = || {
2917 seed = seed
2918 .wrapping_mul(6364136223846793005)
2919 .wrapping_add(1442695040888963407);
2920 (seed >> 33) as u32
2921 };
2922
2923 let folders = ["sources/emails", "records/contacts", "records/profiles"];
2924 let types = ["email", "contact", "profile"];
2925 let mut live: Vec<String> = Vec::new(); for step in 0..120u32 {
2928 let r = next();
2929 let op = r % 10;
2930 if op < 6 || live.is_empty() {
2931 let fi = (next() as usize) % folders.len();
2933 let folder = folders[fi];
2934 let id = next() % 40;
2935 let rel = if folder == "sources/emails" {
2936 let month = 5 + (id % 2); format!("{folder}/2026/{month:02}/f-{id:02}.md")
2938 } else {
2939 format!("{folder}/f-{id:02}.md")
2940 };
2941 let updated = format!(
2943 "2026-05-{:02}T{:02}:{:02}:00Z",
2944 1 + (step % 27),
2945 step % 24,
2946 id % 60
2947 );
2948 let extra = if id % 3 == 0 {
2949 "tags:\n - x\n - y\n"
2950 } else {
2951 ""
2952 };
2953 write_doc(
2954 &wt,
2955 &rel,
2956 types[fi],
2957 Some(&format!("sum {step}")),
2958 Some(&updated),
2959 extra,
2960 );
2961 write_doc(
2962 &rb,
2963 &rel,
2964 types[fi],
2965 Some(&format!("sum {step}")),
2966 Some(&updated),
2967 extra,
2968 );
2969 Index::on_write(&wt, Path::new(&rel)).unwrap();
2970 if !live.contains(&rel) {
2971 live.push(rel);
2972 }
2973 } else if op < 8 {
2974 let idx = (next() as usize) % live.len();
2976 let rel = live.remove(idx);
2977 fs::remove_file(wt.root.join(&rel)).unwrap();
2978 fs::remove_file(rb.root.join(&rel)).ok();
2979 Index::on_remove(&wt, Path::new(&rel)).unwrap();
2980 } else {
2981 let idx = (next() as usize) % live.len();
2983 let old = live[idx].clone();
2984 let fi = (next() as usize) % folders.len();
2986 let folder = folders[fi];
2987 let id = 50 + (next() % 40);
2988 let new = if folder == "sources/emails" {
2989 format!("{folder}/2026/05/f-{id:02}.md")
2990 } else {
2991 format!("{folder}/f-{id:02}.md")
2992 };
2993 if new == old || live.contains(&new) {
2994 continue;
2995 }
2996 fs::create_dir_all(wt.root.join(&new).parent().unwrap()).unwrap();
2997 fs::create_dir_all(rb.root.join(&new).parent().unwrap()).unwrap();
2998 fs::rename(wt.root.join(&old), wt.root.join(&new)).unwrap();
2999 fs::rename(rb.root.join(&old), rb.root.join(&new)).unwrap();
3000 Index::on_rename(&wt, Path::new(&old), Path::new(&new)).unwrap();
3001 live[idx] = new;
3002 }
3003 }
3004
3005 Index::rebuild_all(&rb).unwrap();
3007 let a = snapshot_artifacts(&wt);
3008 let b = snapshot_artifacts(&rb);
3009 assert_eq!(
3010 a.keys().collect::<BTreeSet<_>>(),
3011 b.keys().collect::<BTreeSet<_>>(),
3012 "write-through and rebuild must produce the same set of artifacts"
3013 );
3014 for (k, v) in &a {
3015 assert_eq!(
3016 v, &b[k],
3017 "INVARIANT VIOLATED: artifact {k} differs after mixed ops\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
3018 b[k]
3019 );
3020 }
3021 assert!(
3022 !a.is_empty(),
3023 "the run must have produced at least one artifact"
3024 );
3025 }
3026
3027 #[test]
3033 fn cleanup_preserves_user_content_named_index_md_in_shard() {
3034 let (_d, store) = mk_store();
3035 write_doc(
3037 &store,
3038 "sources/emails/2026/06/index.md",
3039 "email",
3040 Some("Important imported mail"),
3041 Some("2026-06-11T04:23:25Z"),
3042 "",
3043 );
3044 Index::cleanup(&store).unwrap();
3045 assert!(
3046 exists(&store, "sources/emails/2026/06/index.md"),
3047 "cleanup must not delete a user content file named index.md"
3048 );
3049 Index::rebuild_all(&store).unwrap();
3051 assert!(
3052 exists(&store, "sources/emails/2026/06/index.md"),
3053 "rebuild_all must not delete a user content file named index.md"
3054 );
3055 let kept = read(&store, "sources/emails/2026/06/index.md");
3056 assert!(
3057 kept.contains("Important imported mail"),
3058 "the user's record content must be intact"
3059 );
3060 }
3061
3062 #[test]
3067 fn cleanup_keeps_canonical_type_folder_root_sidecars() {
3068 let (_d, store) = mk_store();
3069 write_doc(
3070 &store,
3071 "records/contacts/alice.md",
3072 "contact",
3073 Some("Alice"),
3074 Some("2026-05-01T00:00:00Z"),
3075 "",
3076 );
3077 Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
3078 assert!(exists(&store, "records/contacts/index.md"));
3079 assert!(exists(&store, "records/contacts/index.jsonl"));
3080 Index::cleanup(&store).unwrap();
3081 assert!(
3082 exists(&store, "records/contacts/index.md"),
3083 "cleanup must keep the canonical type-folder index.md (non-empty folder)"
3084 );
3085 assert!(
3086 exists(&store, "records/contacts/index.jsonl"),
3087 "cleanup must keep the canonical type-folder index.jsonl (non-empty folder)"
3088 );
3089 }
3090
3091 #[test]
3097 fn on_write_ignores_index_artifact_no_phantom_row() {
3098 let (_d, store) = mk_store();
3099 write_doc(
3100 &store,
3101 "records/contacts/alice.md",
3102 "contact",
3103 Some("Alice"),
3104 Some("2026-05-01T00:00:00Z"),
3105 "",
3106 );
3107 Index::on_write(&store, Path::new("records/contacts/alice.md")).unwrap();
3108 let jsonl_before = read(&store, "records/contacts/index.jsonl");
3109 assert_eq!(jsonl_before.lines().count(), 1);
3110
3111 Index::on_write(&store, Path::new("records/contacts/index.md")).unwrap();
3113
3114 let jsonl_after = read(&store, "records/contacts/index.jsonl");
3115 assert_eq!(
3116 jsonl_after.lines().count(),
3117 1,
3118 "on_write on index.md must not add a phantom self-row"
3119 );
3120 assert!(
3121 !jsonl_after.contains("\"type\":\"index\""),
3122 "the catalog artifact must never appear as a catalogued row"
3123 );
3124 let root = read(&store, "index.md");
3126 assert!(
3127 root.contains("[[records/contacts/index|Contacts]] (1)"),
3128 "count must not inflate:\n{root}"
3129 );
3130 }
3131
3132 #[test]
3138 fn multiline_summary_is_single_lined_in_index_md() {
3139 let (_d, store) = mk_store();
3140 write_raw(
3142 &store,
3143 "records/notes/evil.md",
3144 "type: note\nupdated: 2026-06-10T00:00:00Z\nsummary: |-\n legit first line\n - [[records/secrets/fake|Click me]] — injected entry",
3145 "\nbody\n",
3146 );
3147 let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
3148 let md = idx.to_markdown();
3149 let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
3151 assert_eq!(
3152 entry_lines, 1,
3153 "a multi-line summary must not produce extra entry lines:\n{md}"
3154 );
3155 assert!(
3156 md.contains(
3157 "- [[records/notes/evil]] — legit first line - [[records/secrets/fake|Click me]] — injected entry\n"
3158 ),
3159 "summary newlines must collapse to spaces inline:\n{md}"
3160 );
3161 }
3162
3163 #[test]
3171 fn non_string_scalar_summary_and_type_are_coerced_like_validator() {
3172 let (_d, store) = mk_store();
3173 write_raw(
3174 &store,
3175 "records/contacts/a.md",
3176 "type: contact\nupdated: 2026-05-01T00:00:00Z\nsummary: 2026",
3177 "\nbody\n",
3178 );
3179 let rec = record_from_store(
3180 &store,
3181 Path::new("records/contacts/a.md"),
3182 PathBuf::from("records/contacts/a.md"),
3183 )
3184 .unwrap();
3185 assert_eq!(rec.summary, "2026");
3188 assert_eq!(rec.type_, "contact");
3189
3190 let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
3192 let md = idx.to_markdown();
3193 assert!(
3194 md.contains("- [[records/contacts/a]] — 2026\n"),
3195 "index entry must hold the coerced scalar, not the placeholder:\n{md}"
3196 );
3197
3198 write_raw(
3200 &store,
3201 "records/contacts/b.md",
3202 "type: true\nupdated: 2026-05-02T00:00:00Z\nsummary: hi",
3203 "\nbody\n",
3204 );
3205 let rec_b = record_from_store(
3206 &store,
3207 Path::new("records/contacts/b.md"),
3208 PathBuf::from("records/contacts/b.md"),
3209 )
3210 .unwrap();
3211 assert_eq!(rec_b.type_, "true");
3212 }
3213
3214 #[test]
3222 fn non_utf8_body_does_not_abort_record_projection() {
3223 let (_d, store) = mk_store();
3224 let rel = "sources/emails/2026/06/x.md";
3225 let abs = store.root.join(rel);
3226 fs::create_dir_all(abs.parent().unwrap()).unwrap();
3227 let mut bytes: Vec<u8> =
3229 b"---\ntype: email\nupdated: 2026-06-11T00:00:00Z\nsummary: An imported email\n---\n\nCaf"
3230 .to_vec();
3231 bytes.push(0xE9);
3232 bytes.extend_from_slice(b" meeting notes\n");
3233 fs::write(&abs, bytes).unwrap();
3234
3235 let rec = record_from_store(&store, Path::new(rel), PathBuf::from(rel))
3236 .expect("non-UTF-8 body must not abort the frontmatter read");
3237 assert_eq!(rec.summary, "An imported email");
3238 assert_eq!(rec.type_, "email");
3239
3240 Index::rebuild_all(&store).unwrap();
3242 assert!(
3243 exists(&store, "sources/emails/index.jsonl"),
3244 "rebuild must produce the catalog despite a non-UTF-8 body byte"
3245 );
3246 assert!(
3247 read(&store, "sources/emails/index.jsonl").contains("An imported email"),
3248 "the record must be catalogued"
3249 );
3250 }
3251
3252 #[test]
3261 fn rebuild_aborts_on_malformed_file_and_keeps_prior_catalogs() {
3262 let (_d, store) = mk_store();
3263 write_doc(
3264 &store,
3265 "records/contacts/alice.md",
3266 "contact",
3267 Some("Alice"),
3268 Some("2026-05-01T00:00:00Z"),
3269 "",
3270 );
3271 write_doc(
3272 &store,
3273 "records/companies/acme.md",
3274 "company",
3275 Some("Acme"),
3276 Some("2026-05-02T00:00:00Z"),
3277 "",
3278 );
3279
3280 Index::rebuild_all(&store).expect("clean rebuild succeeds");
3282 assert!(exists(&store, "records/contacts/index.jsonl"));
3283 assert!(exists(&store, "records/companies/index.jsonl"));
3284
3285 let bad = store.root.join("records/contacts/broken.md");
3287 fs::write(
3288 &bad,
3289 "---\ntype: contact\nsummary: \"unterminated\n---\nbody\n",
3290 )
3291 .unwrap();
3292
3293 Index::rebuild_all(&store)
3296 .expect_err("rebuild must abort, not silently skip, on a malformed file");
3297
3298 assert!(
3302 exists(&store, "records/companies/index.jsonl"),
3303 "an aborted rebuild must not destroy a clean sibling folder's catalog"
3304 );
3305 assert!(
3306 exists(&store, "records/contacts/index.jsonl"),
3307 "an aborted rebuild must not destroy the affected folder's prior catalog"
3308 );
3309 let contacts_jsonl = read(&store, "records/contacts/index.jsonl");
3310 assert!(contacts_jsonl.contains("records/contacts/alice.md"));
3311 }
3312
3313 #[test]
3326 fn rebuild_rollup_counts_equal_jsonl_records_and_write_through() {
3327 let (_d, store) = mk_store();
3328 write_doc(
3332 &store,
3333 "records/contacts/alice.md",
3334 "contact",
3335 Some("Alice"),
3336 Some("2026-05-01T00:00:00Z"),
3337 "",
3338 );
3339 write_doc(
3340 &store,
3341 "records/contacts/bob.md",
3342 "contact",
3343 Some("Bob"),
3344 Some("2026-05-02T00:00:00Z"),
3345 "",
3346 );
3347 Index::rebuild_all(&store).expect("clean rebuild succeeds");
3348
3349 let jsonl_lines = read(&store, "records/contacts/index.jsonl")
3351 .lines()
3352 .filter(|l| !l.trim().is_empty())
3353 .count();
3354 assert_eq!(jsonl_lines, 2, "two well-formed files ⇒ two jsonl records");
3355 let layer_md = read(&store, "records/index.md");
3356 let root_md = read(&store, "index.md");
3357 assert!(
3358 layer_md.contains("- [[records/contacts/index|Contacts]] (2)"),
3359 "layer rollup (N) must equal the jsonl record count (2), not a raw .md walk:\n{layer_md}"
3360 );
3361 assert!(
3362 root_md.contains("- [[records/contacts/index|Contacts]] (2)\n")
3363 && root_md.contains("## Records (2)"),
3364 "root rollup (N)/layer total must equal the jsonl record count (2):\n{root_md}"
3365 );
3366
3367 let (_d2, wt) = mk_store();
3374 write_doc(
3375 &wt,
3376 "records/contacts/alice.md",
3377 "contact",
3378 Some("Alice"),
3379 Some("2026-05-01T00:00:00Z"),
3380 "",
3381 );
3382 write_doc(
3383 &wt,
3384 "records/contacts/bob.md",
3385 "contact",
3386 Some("Bob"),
3387 Some("2026-05-02T00:00:00Z"),
3388 "",
3389 );
3390 Index::on_write(&wt, Path::new("records/contacts/alice.md")).unwrap();
3391 Index::on_write(&wt, Path::new("records/contacts/bob.md")).unwrap();
3392
3393 let a = snapshot_artifacts(&wt);
3394 let b = snapshot_artifacts(&store);
3395 assert_eq!(
3396 a.keys().collect::<BTreeSet<_>>(),
3397 b.keys().collect::<BTreeSet<_>>(),
3398 "write-through and rebuild_all must produce the same artifact set"
3399 );
3400 for (k, v) in &a {
3401 assert_eq!(
3402 v, &b[k],
3403 "rollup bytes diverged between write-through and rebuild_all for {k} \
3404 (a skip-version inflates rebuild_all's (N) above the jsonl record \
3405 count, which write-through then rewrites):\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
3406 b[k]
3407 );
3408 }
3409 }
3410
3411 #[cfg(unix)]
3416 #[test]
3417 fn non_utf8_path_component_is_kept_not_dropped() {
3418 use std::ffi::OsStr;
3419 use std::os::unix::ffi::OsStrExt;
3420 let mut leaf = b"caf".to_vec();
3422 leaf.push(0xE9);
3423 leaf.extend_from_slice(b".md");
3424 let p = Path::new("sources/emails").join(OsStr::from_bytes(&leaf));
3425 let unix = path_to_unix(&p);
3426 assert_ne!(
3429 unix, "sources/emails",
3430 "non-UTF-8 leaf must not be dropped, collapsing the path to its parent dir"
3431 );
3432 assert!(
3433 unix.starts_with("sources/emails/caf"),
3434 "the lossy leaf must remain under its folder: {unix}"
3435 );
3436 }
3437
3438 #[test]
3441 fn loose_file_is_catalogued_in_layer_jsonl_not_type_folder() {
3442 let (_d, store) = mk_store();
3443 write_doc(
3445 &store,
3446 "records/contacts/alice.md",
3447 "contact",
3448 Some("Alice"),
3449 Some("2026-06-01T08:00:00Z"),
3450 "id: alice\n",
3451 );
3452 write_doc(
3453 &store,
3454 "records/loose.md",
3455 "contact",
3456 Some("Loose"),
3457 Some("2026-06-01T08:00:00Z"),
3458 "id: loose\n",
3459 );
3460 Index::rebuild_all(&store).unwrap();
3461
3462 assert!(
3465 exists(&store, "records/index.jsonl"),
3466 "layer jsonl must exist when loose files are present"
3467 );
3468 let layer_jsonl = read(&store, "records/index.jsonl");
3469 assert!(
3470 layer_jsonl.contains("records/loose.md"),
3471 "layer jsonl must list the loose file, got:\n{layer_jsonl}"
3472 );
3473 assert!(
3474 !layer_jsonl.contains("records/contacts/alice.md"),
3475 "layer jsonl must NOT list type-folder files"
3476 );
3477 let tf_jsonl = read(&store, "records/contacts/index.jsonl");
3478 assert!(tf_jsonl.contains("records/contacts/alice.md"));
3479 assert!(!tf_jsonl.contains("records/loose.md"));
3480
3481 let layer_md = read(&store, "records/index.md");
3483 assert!(
3484 layer_md.contains("records/contacts/index"),
3485 "layer md must roll up the type-folder, got:\n{layer_md}"
3486 );
3487 assert!(
3488 !layer_md.contains("records/loose"),
3489 "layer md must stay a rollup, not list loose files, got:\n{layer_md}"
3490 );
3491 }
3492
3493 #[test]
3494 fn loose_file_write_through_equals_rebuild() {
3495 let (_d1, wt) = mk_store();
3496 let (_d2, rb) = mk_store();
3497 for s in [&wt, &rb] {
3498 write_doc(
3499 s,
3500 "records/contacts/alice.md",
3501 "contact",
3502 Some("Alice"),
3503 Some("2026-06-01T08:00:00Z"),
3504 "id: alice\n",
3505 );
3506 write_doc(
3507 s,
3508 "records/loose.md",
3509 "contact",
3510 Some("Loose"),
3511 Some("2026-06-02T08:00:00Z"),
3512 "id: loose\n",
3513 );
3514 }
3515 Index::on_write(&wt, Path::new("records/contacts/alice.md")).unwrap();
3517 Index::on_write(&wt, Path::new("records/loose.md")).unwrap();
3518 Index::rebuild_all(&rb).unwrap();
3519
3520 let a = snapshot_artifacts(&wt);
3521 let b = snapshot_artifacts(&rb);
3522 assert_eq!(
3523 a.keys().collect::<Vec<_>>(),
3524 b.keys().collect::<Vec<_>>(),
3525 "loose-file loop and sweep must produce the same artifact set"
3526 );
3527 for (k, v) in &a {
3528 assert_eq!(
3529 v, &b[k],
3530 "loose-file artifact {k} differs between loop and sweep"
3531 );
3532 }
3533 }
3534
3535 #[test]
3536 fn removing_last_loose_file_clears_layer_jsonl() {
3537 let (_d, store) = mk_store();
3538 write_doc(
3539 &store,
3540 "records/loose.md",
3541 "contact",
3542 Some("Loose"),
3543 Some("2026-06-01T08:00:00Z"),
3544 "id: loose\n",
3545 );
3546 Index::on_write(&store, Path::new("records/loose.md")).unwrap();
3547 assert!(
3548 exists(&store, "records/index.jsonl"),
3549 "layer jsonl present after a loose write"
3550 );
3551 fs::remove_file(store.root.join("records/loose.md")).unwrap();
3552 Index::on_remove(&store, Path::new("records/loose.md")).unwrap();
3553 assert!(
3554 !exists(&store, "records/index.jsonl"),
3555 "layer jsonl must be removed once the last loose file is gone"
3556 );
3557 }
3558
3559 #[test]
3562 fn concurrent_writes_to_different_type_folders_match_rebuild() {
3563 use std::sync::Arc;
3564 use std::thread;
3565
3566 let (_d, store) = mk_store();
3575 let folders = ["records/contacts", "records/companies"];
3576 let n = 12usize;
3577
3578 for (fi, folder) in folders.iter().enumerate() {
3581 for i in 0..n {
3582 write_doc(
3583 &store,
3584 &format!("{folder}/f{fi}_{i}.md"),
3585 "contact",
3586 Some(&format!("Summary {fi}-{i}")),
3587 Some(&format!("2026-06-{:02}T08:00:00Z", i + 1)),
3588 &format!("id: f{fi}_{i}\n"),
3589 );
3590 }
3591 }
3592
3593 let store = Arc::new(store);
3594 let handles: Vec<_> = folders
3595 .iter()
3596 .enumerate()
3597 .map(|(fi, folder)| {
3598 let store = Arc::clone(&store);
3599 let folder = folder.to_string();
3600 thread::spawn(move || {
3601 for i in 0..n {
3602 let rel = format!("{folder}/f{fi}_{i}.md");
3603 Index::on_write(&store, Path::new(&rel)).unwrap();
3604 }
3605 })
3606 })
3607 .collect();
3608 for h in handles {
3609 h.join().unwrap();
3610 }
3611
3612 let got = snapshot_artifacts(&store);
3615 Index::rebuild_all(&store).unwrap();
3616 let want = snapshot_artifacts(&store);
3617
3618 assert_eq!(
3619 got.keys().collect::<Vec<_>>(),
3620 want.keys().collect::<Vec<_>>(),
3621 "artifact set after concurrent write-through must match rebuild"
3622 );
3623 for (k, v) in &want {
3624 assert_eq!(
3625 &got[k], v,
3626 "rollup artifact {k} diverged from rebuild after concurrent writes"
3627 );
3628 }
3629 }
3630
3631 #[cfg(unix)]
3632 #[test]
3633 fn rebuild_stays_on_opened_root_after_path_replacement() {
3634 use std::os::unix::fs::symlink;
3635
3636 let sandbox = tempfile::tempdir().unwrap();
3637 let root = sandbox.path().join("store");
3638 fs::create_dir_all(root.join("records/notes")).unwrap();
3639 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
3640 fs::write(
3641 root.join("records/notes/owned.md"),
3642 "---\ntype: note\nsummary: owned summary\n---\n",
3643 )
3644 .unwrap();
3645 let store = Store::open_strict(&root).unwrap();
3646 let detached = sandbox.path().join("detached");
3647 fs::rename(&root, &detached).unwrap();
3648
3649 let replacement = sandbox.path().join("replacement");
3650 fs::create_dir_all(replacement.join("records/notes")).unwrap();
3651 fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
3652 fs::write(
3653 replacement.join("records/notes/replacement.md"),
3654 "---\ntype: note\nsummary: replacement secret\n---\n",
3655 )
3656 .unwrap();
3657 fs::write(
3658 replacement.join("records/notes/index.jsonl"),
3659 "replacement index sentinel\n",
3660 )
3661 .unwrap();
3662 symlink(&replacement, &root).unwrap();
3663
3664 Index::rebuild_all(&store).unwrap();
3665 let index = fs::read_to_string(detached.join("records/notes/index.jsonl")).unwrap();
3666 assert!(index.contains("owned summary"));
3667 assert!(!index.contains("replacement secret"));
3668 assert_eq!(
3669 fs::read_to_string(replacement.join("records/notes/index.jsonl")).unwrap(),
3670 "replacement index sentinel\n"
3671 );
3672 }
3673}