1use std::collections::BTreeMap;
53use std::fs;
54use std::io::Write as _;
55use std::path::{Path, PathBuf};
56
57use chrono::{DateTime, FixedOffset, SecondsFormat};
58use serde::{Deserialize, Serialize};
59use serde_json::Value;
60
61use crate::parser::FolderMeta;
62use crate::store::{Layer, Store};
63
64const MD_CAP: usize = 500;
66
67const MISSING_SUMMARY: &str = "(no summary)";
71
72const ROOT_TITLE: &str = "Knowledge base index";
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum IndexLevel {
78 Root,
80 Layer(Layer),
82 TypeFolder(PathBuf),
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct IndexRecord {
95 #[serde(with = "path_serde")]
99 pub path: PathBuf,
100 #[serde(rename = "type")]
102 pub type_: String,
103 pub summary: String,
105 #[serde(default)]
107 pub tags: Vec<String>,
108 #[serde(default)]
110 pub links: Vec<String>,
111 pub created: Option<DateTime<FixedOffset>>,
113 pub updated: Option<DateTime<FixedOffset>>,
115 #[serde(flatten)]
120 pub fields: BTreeMap<String, Value>,
121}
122
123#[derive(Debug, Clone, PartialEq)]
126pub struct Index {
127 pub level: IndexLevel,
129 pub records: Vec<IndexRecord>,
132 pub child_counts: BTreeMap<PathBuf, usize>,
134}
135
136impl Index {
137 pub fn build_type_folder(store: &Store, type_folder: &Path) -> crate::Result<Index> {
143 let rel = normalize_rel(type_folder);
144 let abs = store.root.join(&rel);
145 let mut records = Vec::new();
146 for file_abs in walk_type_folder_files(&abs) {
147 let rel_path =
148 rel_to_store(&store.root, &file_abs).expect("walked file is under the store root");
149 records.push(record_from_file(&file_abs, rel_path)?);
161 }
162 sort_records(&mut records);
163 Ok(Index {
164 level: IndexLevel::TypeFolder(rel),
165 records,
166 child_counts: BTreeMap::new(),
167 })
168 }
169
170 pub fn build_layer(store: &Store, layer: Layer) -> crate::Result<Index> {
179 let mut child_counts = BTreeMap::new();
180 for tf in type_folders_in_layer(store, layer) {
181 let abs = store.root.join(&tf);
182 let n = walk_type_folder_files(&abs).len();
183 if n > 0 {
184 child_counts.insert(tf, n);
185 }
186 }
187 let mut records = Vec::new();
188 for file_abs in loose_files_in_layer(store, layer) {
189 let rel_path =
190 rel_to_store(&store.root, &file_abs).expect("walked file is under the store root");
191 records.push(record_from_file(&file_abs, rel_path)?);
196 }
197 sort_records(&mut records);
198 Ok(Index {
199 level: IndexLevel::Layer(layer),
200 records,
201 child_counts,
202 })
203 }
204
205 pub fn build_root(store: &Store) -> crate::Result<Index> {
208 let mut child_counts = BTreeMap::new();
209 for layer in Layer::all() {
210 for tf in type_folders_in_layer(store, layer) {
211 let abs = store.root.join(&tf);
212 let n = walk_type_folder_files(&abs).len();
213 if n > 0 {
214 child_counts.insert(tf, n);
215 }
216 }
217 }
218 Ok(Index {
219 level: IndexLevel::Root,
220 records: Vec::new(),
221 child_counts,
222 })
223 }
224
225 pub fn to_markdown(&self) -> String {
227 match &self.level {
228 IndexLevel::TypeFolder(folder) => self.render_type_folder_md(folder),
229 IndexLevel::Layer(layer) => self.render_layer_md(*layer),
230 IndexLevel::Root => self.render_root_md(),
231 }
232 }
233
234 pub fn to_jsonl(&self) -> String {
240 let mut out = String::new();
241 for rec in &self.records {
242 let line = serde_json::to_string(rec).expect("IndexRecord serializes");
245 out.push_str(&line);
246 out.push('\n');
247 }
248 out
249 }
250
251 fn render_type_folder_md(&self, folder: &Path) -> String {
254 let folder_disp = path_to_unix(folder);
255 let updated = max_updated(self.records.iter().map(|r| r.updated.as_ref()));
256 let mut s = String::new();
257 s.push_str("---\n");
258 s.push_str("type: index\n");
259 s.push_str("scope: type-folder\n");
260 s.push_str(&format!("folder: {folder_disp}\n"));
261 if let Some(ts) = updated {
262 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
263 }
264 s.push_str("---\n\n");
265 s.push_str(&format!("# {folder_disp}\n\n"));
266
267 let shown = self.records.len().min(MD_CAP);
268 for rec in self.records.iter().take(shown) {
269 s.push_str(&format_md_entry(rec));
270 s.push('\n');
271 }
272
273 if self.records.len() > MD_CAP {
274 let type_ = self.records.first().map(|r| r.type_.as_str()).unwrap_or("");
275 let layer = folder
276 .components()
277 .next()
278 .and_then(|c| c.as_os_str().to_str())
279 .unwrap_or("");
280 s.push('\n');
281 s.push_str(&more_footer(self.records.len(), type_, layer));
282 }
283 s
284 }
285
286 fn render_layer_md(&self, layer: Layer) -> String {
291 let layer_dir = layer_dir_name(layer);
292 let mut s = String::new();
293 s.push_str("---\n");
294 s.push_str("type: index\n");
295 s.push_str("scope: layer\n");
296 s.push_str(&format!("folder: {layer_dir}\n"));
297 s.push_str("---\n\n");
298 s.push_str(&format!("# {layer_dir}\n\n"));
299 for (tf, n) in &self.child_counts {
300 let tf_unix = path_to_unix(tf);
301 let display = capitalize(folder_basename(tf));
302 s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
303 }
304 s
305 }
306
307 fn render_root_md(&self) -> String {
310 let mut s = String::new();
311 s.push_str("---\n");
312 s.push_str("type: index\n");
313 s.push_str("scope: root\n");
314 s.push_str("---\n\n");
315 s.push_str(&format!("# {ROOT_TITLE}\n"));
316 for layer in Layer::all() {
317 let layer_dir = layer_dir_name(layer);
318 let prefix = format!("{layer_dir}/");
319 let children: Vec<(&PathBuf, &usize)> = self
320 .child_counts
321 .iter()
322 .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
323 .collect();
324 if children.is_empty() {
325 continue;
326 }
327 let total: usize = children.iter().map(|(_, n)| **n).sum();
328 s.push('\n');
329 s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
330 for (tf, n) in children {
331 let tf_unix = path_to_unix(tf);
332 let display = capitalize(folder_basename(tf));
333 s.push_str(&format!("- [[{tf_unix}/index|{display}]] ({n})\n"));
334 }
335 }
336 s
337 }
338}
339
340impl Index {
345 pub fn on_write(store: &Store, file: &Path) -> crate::Result<()> {
352 let file_rel = normalize_rel(file);
353 if is_index_artifact(&file_rel) {
360 return Ok(());
361 }
362 if let Some(layer) = loose_layer_of(&file_rel) {
366 return apply_loose_change(store, layer, &file_rel, false);
367 }
368 let file_abs = store.root.join(&file_rel);
369 let folder = type_folder_of(&file_rel)
370 .ok_or_else(|| bad_index(&file_rel, "file is not inside a layer/type-folder"))?;
371 let record = record_from_file(&file_abs, file_rel.clone())?;
372
373 let _lock = FolderLock::acquire(&store.root.join(&folder));
376 let mut records = read_jsonl_records(&store.root.join(&folder).join("index.jsonl"))?;
377 records.retain(|r| r.path != record.path);
378 records.push(record);
379 sort_records(&mut records);
380
381 write_type_folder_artifacts(store, &folder, &records)?;
382 update_parents(store, &folder)?;
383 Ok(())
384 }
385
386 pub fn on_rename(store: &Store, old: &Path, new: &Path) -> crate::Result<()> {
390 let old_rel = normalize_rel(old);
391 let new_rel = normalize_rel(new);
392 if is_index_artifact(&old_rel) || is_index_artifact(&new_rel) {
396 return Ok(());
397 }
398 if loose_layer_of(&old_rel).is_some() || loose_layer_of(&new_rel).is_some() {
404 Self::on_remove(store, &old_rel)?;
405 Self::on_write(store, &new_rel)?;
406 return Ok(());
407 }
408 let old_folder = type_folder_of(&old_rel)
409 .ok_or_else(|| bad_index(&old_rel, "source is not inside a layer/type-folder"))?;
410 let new_folder = type_folder_of(&new_rel)
411 .ok_or_else(|| bad_index(&new_rel, "target is not inside a layer/type-folder"))?;
412
413 let _locks = lock_folders(store, &old_folder, &new_folder);
417
418 let mut old_records =
420 read_jsonl_records(&store.root.join(&old_folder).join("index.jsonl"))?;
421 old_records.retain(|r| r.path != old_rel);
422
423 if old_folder == new_folder {
424 let record = record_from_file(&store.root.join(&new_rel), new_rel.clone())?;
426 old_records.retain(|r| r.path != record.path);
427 old_records.push(record);
428 sort_records(&mut old_records);
429 write_type_folder_artifacts(store, &old_folder, &old_records)?;
430 update_parents(store, &old_folder)?;
431 return Ok(());
432 }
433
434 sort_records(&mut old_records);
437 write_type_folder_artifacts(store, &old_folder, &old_records)?;
438
439 let record = record_from_file(&store.root.join(&new_rel), new_rel.clone())?;
440 let mut new_records =
441 read_jsonl_records(&store.root.join(&new_folder).join("index.jsonl"))?;
442 new_records.retain(|r| r.path != record.path);
443 new_records.push(record);
444 sort_records(&mut new_records);
445 write_type_folder_artifacts(store, &new_folder, &new_records)?;
446
447 update_parents(store, &old_folder)?;
448 update_parents(store, &new_folder)?;
449 Ok(())
450 }
451
452 pub fn on_remove(store: &Store, file: &Path) -> crate::Result<()> {
457 let file_rel = normalize_rel(file);
458 if is_index_artifact(&file_rel) {
461 return Ok(());
462 }
463 if let Some(layer) = loose_layer_of(&file_rel) {
465 return apply_loose_change(store, layer, &file_rel, true);
466 }
467 let folder = type_folder_of(&file_rel)
468 .ok_or_else(|| bad_index(&file_rel, "file is not inside a layer/type-folder"))?;
469 let _lock = FolderLock::acquire(&store.root.join(&folder));
471 let mut records = read_jsonl_records(&store.root.join(&folder).join("index.jsonl"))?;
472 let before = records.len();
473 records.retain(|r| r.path != file_rel);
474 if records.len() == before {
475 }
478 sort_records(&mut records);
479 write_type_folder_artifacts(store, &folder, &records)?;
480 update_parents(store, &folder)?;
481 Ok(())
482 }
483
484 pub fn rebuild_all(store: &Store) -> crate::Result<()> {
488 Index::cleanup(store)?;
489 for layer in Layer::all() {
490 for tf in type_folders_in_layer(store, layer) {
491 let idx = Index::build_type_folder(store, &tf)?;
492 if idx.records.is_empty() {
493 continue;
494 }
495 write_type_folder_artifacts(store, &tf, &idx.records)?;
496 }
497 let layer_idx = Index::build_layer(store, layer)?;
498 let layer_index_md = store.root.join(layer_dir_name(layer)).join("index.md");
499 if layer_idx.child_counts.is_empty() {
500 remove_if_exists(&layer_index_md)?;
501 } else {
502 write_atomic(
503 &layer_index_md,
504 render_layer_md_with_store(store, &layer_idx),
505 )?;
506 }
507 write_layer_jsonl(store, layer, &layer_idx.records)?;
511 }
512 let root_idx = Index::build_root(store)?;
513 let root_index_md = store.root.join("index.md");
514 if root_idx.child_counts.is_empty() {
515 remove_if_exists(&root_index_md)?;
516 } else {
517 write_atomic(&root_index_md, render_root_md_with_store(store, &root_idx))?;
518 }
519 Ok(())
520 }
521
522 pub fn rebuild_folder(store: &Store, folder: &Path) -> crate::Result<()> {
529 Self::write_level(store, &IndexLevel::TypeFolder(folder.to_path_buf()))?;
530 update_parents(store, folder)
531 }
532
533 pub fn write_level(store: &Store, level: &IndexLevel) -> crate::Result<()> {
535 match level {
536 IndexLevel::TypeFolder(folder) => {
537 let idx = Index::build_type_folder(store, folder)?;
538 if idx.records.is_empty() {
539 remove_if_exists(&store.root.join(folder).join("index.md"))?;
540 remove_if_exists(&store.root.join(folder).join("index.jsonl"))?;
541 } else {
542 write_type_folder_artifacts(store, folder, &idx.records)?;
543 }
544 }
545 IndexLevel::Layer(layer) => {
546 let idx = Index::build_layer(store, *layer)?;
547 let p = store.root.join(layer_dir_name(*layer)).join("index.md");
548 if idx.child_counts.is_empty() {
549 remove_if_exists(&p)?;
550 } else {
551 write_atomic(&p, render_layer_md_with_store(store, &idx))?;
552 }
553 write_layer_jsonl(store, *layer, &idx.records)?;
554 }
555 IndexLevel::Root => {
556 let idx = Index::build_root(store)?;
557 let p = store.root.join("index.md");
558 if idx.child_counts.is_empty() {
559 remove_if_exists(&p)?;
560 } else {
561 write_atomic(&p, render_root_md_with_store(store, &idx))?;
562 }
563 }
564 }
565 Ok(())
566 }
567
568 pub fn render_dry_run(store: &Store, level: &IndexLevel) -> crate::Result<String> {
571 let mut out = String::new();
572 match level {
573 IndexLevel::TypeFolder(folder) => {
574 let idx = Index::build_type_folder(store, folder)?;
575 let md_path = path_to_unix(&folder.join("index.md"));
576 let jsonl_path = path_to_unix(&folder.join("index.jsonl"));
577 out.push_str(&format!("--- {md_path} ---\n"));
578 out.push_str(&idx.to_markdown());
579 out.push_str(&format!("--- {jsonl_path} ---\n"));
580 out.push_str(&idx.to_jsonl());
581 }
582 IndexLevel::Layer(layer) => {
583 let idx = Index::build_layer(store, *layer)?;
584 let md_path = format!("{}/index.md", layer_dir_name(*layer));
585 out.push_str(&format!("--- {md_path} ---\n"));
586 out.push_str(&render_layer_md_with_store(store, &idx));
587 }
588 IndexLevel::Root => {
589 let idx = Index::build_root(store)?;
590 out.push_str("--- index.md ---\n");
591 out.push_str(&render_root_md_with_store(store, &idx));
592 }
593 }
594 Ok(out)
595 }
596
597 pub fn cleanup(store: &Store) -> crate::Result<()> {
615 for layer in Layer::all() {
616 let layer_dir = store.root.join(layer_dir_name(layer));
617 if !layer_dir.is_dir() {
618 continue;
619 }
620 for tf in type_folders_in_layer(store, layer) {
621 let tf_abs = store.root.join(&tf);
622 for entry in walkdir::WalkDir::new(&tf_abs)
626 .min_depth(2)
627 .into_iter()
628 .filter_map(|e| e.ok())
629 {
630 let p = entry.path();
631 if is_index_artifact(p) && is_deletable_catalog_artifact(p) {
632 remove_if_exists(p)?;
633 }
634 }
635 if walk_type_folder_files(&tf_abs).is_empty() {
639 let md = tf_abs.join("index.md");
640 if is_deletable_catalog_artifact(&md) {
641 remove_if_exists(&md)?;
642 }
643 remove_if_exists(&tf_abs.join("index.jsonl"))?;
644 }
645 }
646 }
647 Ok(())
648 }
649}
650
651fn write_type_folder_artifacts(
659 store: &Store,
660 folder: &Path,
661 records: &[IndexRecord],
662) -> crate::Result<()> {
663 let folder_abs = store.root.join(folder);
664 let md_path = folder_abs.join("index.md");
665 let jsonl_path = folder_abs.join("index.jsonl");
666 if records.is_empty() {
667 remove_if_exists(&md_path)?;
668 remove_if_exists(&jsonl_path)?;
669 return Ok(());
670 }
671 let idx = Index {
672 level: IndexLevel::TypeFolder(folder.to_path_buf()),
673 records: records.to_vec(),
674 child_counts: BTreeMap::new(),
675 };
676 write_atomic(&md_path, idx.to_markdown())?;
677 write_atomic(&jsonl_path, idx.to_jsonl())?;
678 Ok(())
679}
680
681fn update_parents(store: &Store, folder: &Path) -> crate::Result<()> {
694 let _root_lock = FolderLock::acquire(&store.root);
727 let stats = collect_child_stats(store, &Layer::all())?;
728
729 let layer = folder
730 .components()
731 .next()
732 .and_then(|c| c.as_os_str().to_str())
733 .and_then(layer_from_dir_name);
734 if let Some(layer) = layer {
735 let p = store.root.join(layer_dir_name(layer)).join("index.md");
736 if layer_has_children(&stats, layer) {
737 write_atomic(
738 &p,
739 render_layer_md_from_stats(layer, &stats, &store.config.folders),
740 )?;
741 } else {
742 remove_if_exists(&p)?;
743 }
744 }
745 let rp = store.root.join("index.md");
746 if stats.values().any(|s| s.count > 0) {
747 write_atomic(
748 &rp,
749 render_root_md_from_stats(&stats, &store.config.folders),
750 )?;
751 } else {
752 remove_if_exists(&rp)?;
753 }
754 Ok(())
755}
756
757fn layer_has_children(stats: &BTreeMap<PathBuf, FolderStat>, layer: Layer) -> bool {
759 let prefix = format!("{}/", layer_dir_name(layer));
760 stats
761 .iter()
762 .any(|(tf, s)| s.count > 0 && path_to_unix(tf).starts_with(&prefix))
763}
764
765fn render_layer_md_from_stats(
770 layer: Layer,
771 stats: &BTreeMap<PathBuf, FolderStat>,
772 folders: &BTreeMap<String, FolderMeta>,
773) -> String {
774 let layer_dir = layer_dir_name(layer);
775 let prefix = format!("{layer_dir}/");
776 let mut max_upd: Option<DateTime<FixedOffset>> = None;
777 let mut entries = String::new();
778 for (tf, stat) in stats {
779 if stat.count == 0 || !path_to_unix(tf).starts_with(&prefix) {
780 continue;
781 }
782 if let Some(u) = stat.newest.as_ref().and_then(|r| r.updated) {
783 max_upd = Some(match max_upd {
784 Some(cur) if cur >= u => cur,
785 _ => u,
786 });
787 }
788 let tf_unix = path_to_unix(tf);
789 let (display, description) = folder_label(&tf_unix, folder_basename(tf), folders);
790 entries.push_str(&folder_entry(&tf_unix, &display, stat.count, description));
791 }
792 let mut s = String::new();
793 s.push_str("---\n");
794 s.push_str("type: index\n");
795 s.push_str("scope: layer\n");
796 s.push_str(&format!("folder: {layer_dir}\n"));
797 if let Some(ts) = max_upd {
798 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
799 }
800 s.push_str("---\n\n");
801 s.push_str(&format!("# {layer_dir}\n\n"));
802 s.push_str(&entries);
803 s
804}
805
806fn render_root_md_from_stats(
808 stats: &BTreeMap<PathBuf, FolderStat>,
809 folders: &BTreeMap<String, FolderMeta>,
810) -> String {
811 let mut max_upd: Option<DateTime<FixedOffset>> = None;
812 for stat in stats.values() {
813 if stat.count == 0 {
814 continue;
815 }
816 if let Some(u) = stat.newest.as_ref().and_then(|r| r.updated) {
817 max_upd = Some(match max_upd {
818 Some(cur) if cur >= u => cur,
819 _ => u,
820 });
821 }
822 }
823 let mut s = String::new();
824 s.push_str("---\n");
825 s.push_str("type: index\n");
826 s.push_str("scope: root\n");
827 if let Some(ts) = max_upd {
828 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
829 }
830 s.push_str("---\n\n");
831 s.push_str(&format!("# {ROOT_TITLE}\n"));
832 for layer in Layer::all() {
833 let layer_dir = layer_dir_name(layer);
834 let prefix = format!("{layer_dir}/");
835 let children: Vec<(&PathBuf, usize)> = stats
836 .iter()
837 .filter(|(tf, s)| s.count > 0 && path_to_unix(tf).starts_with(&prefix))
838 .map(|(tf, s)| (tf, s.count))
839 .collect();
840 if children.is_empty() {
841 continue;
842 }
843 let total: usize = children.iter().map(|(_, n)| *n).sum();
844 s.push('\n');
845 s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
846 for (tf, n) in children {
847 let tf_unix = path_to_unix(tf);
848 let (display, description) = folder_label(&tf_unix, folder_basename(tf), folders);
849 s.push_str(&folder_entry(&tf_unix, &display, n, description));
850 }
851 }
852 s
853}
854
855fn render_layer_md_with_store(store: &Store, idx: &Index) -> String {
862 let layer = match idx.level {
863 IndexLevel::Layer(l) => l,
864 _ => unreachable!("render_layer_md_with_store called on non-layer"),
865 };
866 let layer_dir = layer_dir_name(layer);
867 let mut max_upd: Option<DateTime<FixedOffset>> = None;
868 let mut entries = String::new();
869 for (tf, n) in &idx.child_counts {
870 let recs = read_jsonl_records(&store.root.join(tf).join("index.jsonl")).unwrap_or_default();
871 let newest = recs.first();
872 if let Some(u) = newest.and_then(|r| r.updated) {
873 max_upd = Some(match max_upd {
874 Some(cur) if cur >= u => cur,
875 _ => u,
876 });
877 }
878 let tf_unix = path_to_unix(tf);
879 let (display, description) =
880 folder_label(&tf_unix, folder_basename(tf), &store.config.folders);
881 entries.push_str(&folder_entry(&tf_unix, &display, *n, description));
882 }
883 let mut s = String::new();
884 s.push_str("---\n");
885 s.push_str("type: index\n");
886 s.push_str("scope: layer\n");
887 s.push_str(&format!("folder: {layer_dir}\n"));
888 if let Some(ts) = max_upd {
889 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
890 }
891 s.push_str("---\n\n");
892 s.push_str(&format!("# {layer_dir}\n\n"));
893 s.push_str(&entries);
894 s
895}
896
897fn render_root_md_with_store(store: &Store, idx: &Index) -> String {
901 let mut max_upd: Option<DateTime<FixedOffset>> = None;
902 for tf in idx.child_counts.keys() {
903 let recs = read_jsonl_records(&store.root.join(tf).join("index.jsonl")).unwrap_or_default();
904 if let Some(u) = recs.first().and_then(|r| r.updated) {
905 max_upd = Some(match max_upd {
906 Some(cur) if cur >= u => cur,
907 _ => u,
908 });
909 }
910 }
911 let mut s = String::new();
912 s.push_str("---\n");
913 s.push_str("type: index\n");
914 s.push_str("scope: root\n");
915 if let Some(ts) = max_upd {
916 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
917 }
918 s.push_str("---\n\n");
919 s.push_str(&format!("# {ROOT_TITLE}\n"));
920 for layer in Layer::all() {
921 let layer_dir = layer_dir_name(layer);
922 let prefix = format!("{layer_dir}/");
923 let children: Vec<(&PathBuf, &usize)> = idx
924 .child_counts
925 .iter()
926 .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
927 .collect();
928 if children.is_empty() {
929 continue;
930 }
931 let total: usize = children.iter().map(|(_, n)| **n).sum();
932 s.push('\n');
933 s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
934 for (tf, n) in children {
935 let tf_unix = path_to_unix(tf);
936 let (display, description) =
937 folder_label(&tf_unix, folder_basename(tf), &store.config.folders);
938 s.push_str(&folder_entry(&tf_unix, &display, *n, description));
939 }
940 }
941 s
942}
943
944fn format_md_entry(rec: &IndexRecord) -> String {
950 let path = wiki_target(&rec.path);
951 let summary = collapse_whitespace(&rec.summary);
960 let mut line = format!("- [[{path}]] — {summary}");
961 if !rec.tags.is_empty() {
962 let tags = rec
963 .tags
964 .iter()
965 .map(|t| format!("#{t}"))
966 .collect::<Vec<_>>()
967 .join(" ");
968 line.push_str(&format!(" · {tags}"));
969 }
970 line
971}
972
973fn more_footer(total: usize, type_: &str, layer: &str) -> String {
975 format!(
976 "## 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"
977 )
978}
979
980fn sort_records(records: &mut [IndexRecord]) {
984 records.sort_by(record_recency_cmp);
985}
986
987impl IndexRecord {
988 pub(crate) fn expected_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
1000 record_from_file(abs, rel)
1001 }
1002}
1003
1004fn record_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
1007 let mut meta = read_frontmatter(abs)?;
1008 if rel.starts_with("records") {
1013 meta.fields
1014 .entry("meta-type".to_string())
1015 .or_insert_with(|| Value::String("fact".to_string()));
1016 }
1017 Ok(IndexRecord {
1018 path: rel,
1019 type_: meta.type_.unwrap_or_default(),
1020 summary: meta.summary.unwrap_or_else(|| MISSING_SUMMARY.to_string()),
1021 tags: meta.tags,
1022 links: meta.links,
1023 created: meta.created,
1024 updated: meta.updated,
1025 fields: meta.fields,
1026 })
1027}
1028
1029struct FileMeta {
1031 type_: Option<String>,
1032 summary: Option<String>,
1033 tags: Vec<String>,
1034 links: Vec<String>,
1035 created: Option<DateTime<FixedOffset>>,
1036 updated: Option<DateTime<FixedOffset>>,
1037 fields: BTreeMap<String, Value>,
1038}
1039
1040fn read_frontmatter(abs: &Path) -> crate::Result<FileMeta> {
1054 let bytes = fs::read(abs)?;
1055 let yaml = extract_frontmatter_block_lossy(&bytes).unwrap_or_default();
1056 let map: serde_norway::Mapping = if yaml.trim().is_empty() {
1057 serde_norway::Mapping::new()
1058 } else {
1059 serde_norway::from_str(&yaml).map_err(|e| {
1060 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1061 path: abs.to_path_buf(),
1062 message: format!("frontmatter YAML: {e}"),
1063 })
1064 })?
1065 };
1066
1067 let mut type_ = None;
1068 let mut summary = None;
1069 let mut tags = Vec::new();
1070 let mut links = Vec::new();
1071 let mut created = None;
1072 let mut updated = None;
1073 let mut fields = BTreeMap::new();
1074
1075 for (k, v) in map {
1076 let key = match k.as_str() {
1077 Some(s) => s.to_string(),
1078 None => continue,
1079 };
1080 match key.as_str() {
1081 "type" => type_ = scalar_string(&v),
1091 "summary" => summary = scalar_string(&v),
1092 "tags" => tags = yaml_string_list(&v),
1093 "links" => links = yaml_string_list(&v),
1094 "created" => created = v.as_str().and_then(parse_ts),
1095 "updated" => updated = v.as_str().and_then(parse_ts),
1096 "path" => {}
1100 _ => {
1101 fields.insert(key, yaml_to_json_value(&v));
1102 }
1103 }
1104 }
1105
1106 Ok(FileMeta {
1107 type_,
1108 summary,
1109 tags,
1110 links,
1111 created,
1112 updated,
1113 fields,
1114 })
1115}
1116
1117pub(crate) fn scalar_string(v: &serde_norway::Value) -> Option<String> {
1124 match v {
1125 serde_norway::Value::String(s) => Some(s.clone()),
1126 serde_norway::Value::Number(n) => Some(n.to_string()),
1127 serde_norway::Value::Bool(b) => Some(b.to_string()),
1128 _ => None,
1129 }
1130}
1131
1132fn extract_frontmatter_block_lossy(bytes: &[u8]) -> Option<String> {
1138 let text = String::from_utf8_lossy(bytes);
1143 extract_frontmatter_block(&text)
1144}
1145
1146fn extract_frontmatter_block(text: &str) -> Option<String> {
1149 let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
1150 let mut lines = trimmed.lines();
1151 let first = lines.next()?;
1152 if first.trim_end() != "---" {
1153 return None;
1154 }
1155 let mut block = String::new();
1156 for line in lines {
1157 if line.trim_end() == "---" {
1158 return Some(block);
1159 }
1160 block.push_str(line);
1161 block.push('\n');
1162 }
1163 None }
1165
1166fn yaml_string_list(v: &serde_norway::Value) -> Vec<String> {
1169 match v {
1170 serde_norway::Value::String(s) => vec![s.clone()],
1171 serde_norway::Value::Sequence(seq) => seq
1172 .iter()
1173 .filter_map(yaml_string_or_wiki_link_literal)
1174 .collect(),
1175 _ => Vec::new(),
1176 }
1177}
1178
1179fn yaml_string_or_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
1180 v.as_str()
1181 .map(str::to_string)
1182 .or_else(|| unquoted_wiki_link_literal(v))
1183}
1184
1185pub(crate) fn yaml_to_json_value(v: &serde_norway::Value) -> Value {
1192 if let Some(link) = unquoted_wiki_link_literal(v) {
1193 return Value::String(link);
1194 }
1195 match v {
1196 serde_norway::Value::String(s) => Value::String(s.clone()),
1197 serde_norway::Value::Bool(b) => Value::Bool(*b),
1198 serde_norway::Value::Number(n) => {
1199 serde_json::to_value(n).unwrap_or_else(|_| Value::String(n.to_string()))
1200 }
1201 serde_norway::Value::Sequence(seq) => {
1202 Value::Array(seq.iter().map(yaml_to_json_value).collect())
1203 }
1204 serde_norway::Value::Mapping(_) | serde_norway::Value::Tagged(_) => {
1205 serde_json::to_value(v).unwrap_or(Value::Null)
1206 }
1207 serde_norway::Value::Null => Value::Null,
1208 }
1209}
1210
1211fn unquoted_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
1212 let serde_norway::Value::Sequence(outer) = v else {
1213 return None;
1214 };
1215 if outer.len() != 1 {
1216 return None;
1217 }
1218 let serde_norway::Value::Sequence(inner) = &outer[0] else {
1219 return None;
1220 };
1221 let [serde_norway::Value::String(target)] = inner.as_slice() else {
1222 return None;
1223 };
1224 Some(format!("[[{target}]]"))
1225}
1226
1227pub(crate) fn parse_ts(s: &str) -> Option<DateTime<FixedOffset>> {
1231 DateTime::parse_from_rfc3339(s.trim()).ok()
1232}
1233
1234fn fmt_ts(ts: &DateTime<FixedOffset>) -> String {
1238 ts.to_rfc3339_opts(SecondsFormat::AutoSi, true)
1239}
1240
1241fn max_updated<'a>(
1243 it: impl Iterator<Item = Option<&'a DateTime<FixedOffset>>>,
1244) -> Option<DateTime<FixedOffset>> {
1245 let mut best: Option<DateTime<FixedOffset>> = None;
1246 for ts in it.flatten() {
1247 best = Some(match best {
1248 Some(cur) if cur >= *ts => cur,
1249 _ => *ts,
1250 });
1251 }
1252 best
1253}
1254
1255fn read_jsonl_records(jsonl: &Path) -> crate::Result<Vec<IndexRecord>> {
1259 let text = match fs::read_to_string(jsonl) {
1260 Ok(t) => t,
1261 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1262 Err(e) => return Err(e.into()),
1263 };
1264 let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
1266 for (i, line) in text.lines().enumerate() {
1267 if line.trim().is_empty() {
1268 continue;
1269 }
1270 let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
1271 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1272 path: jsonl.to_path_buf(),
1273 message: format!("line {}: {e}", i + 1),
1274 })
1275 })?;
1276 by_path.insert(rec.path.clone(), rec);
1277 }
1278 let mut records: Vec<IndexRecord> = by_path.into_values().collect();
1279 sort_records(&mut records);
1280 Ok(records)
1281}
1282
1283#[derive(Debug, Clone, Default, PartialEq)]
1290struct FolderStat {
1291 count: usize,
1292 newest: Option<IndexRecord>,
1293}
1294
1295fn read_folder_stat(jsonl: &Path) -> crate::Result<FolderStat> {
1305 let text = match fs::read_to_string(jsonl) {
1306 Ok(t) => t,
1307 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FolderStat::default()),
1308 Err(e) => return Err(e.into()),
1309 };
1310 let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
1313 for (i, line) in text.lines().enumerate() {
1314 if line.trim().is_empty() {
1315 continue;
1316 }
1317 let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
1318 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1319 path: jsonl.to_path_buf(),
1320 message: format!("line {}: {e}", i + 1),
1321 })
1322 })?;
1323 by_path.insert(rec.path.clone(), rec);
1324 }
1325 let count = by_path.len();
1326 let newest = by_path.into_values().min_by(record_recency_cmp);
1330 Ok(FolderStat { count, newest })
1331}
1332
1333fn record_recency_cmp(a: &IndexRecord, b: &IndexRecord) -> std::cmp::Ordering {
1338 match (b.updated, a.updated) {
1339 (Some(bu), Some(au)) => bu.cmp(&au),
1340 (Some(_), None) => std::cmp::Ordering::Greater, (None, Some(_)) => std::cmp::Ordering::Less, (None, None) => std::cmp::Ordering::Equal,
1343 }
1344 .then_with(|| a.path.cmp(&b.path))
1345}
1346
1347fn collect_child_stats(
1360 store: &Store,
1361 layers: &[Layer],
1362) -> crate::Result<BTreeMap<PathBuf, FolderStat>> {
1363 let mut stats = BTreeMap::new();
1364 for &layer in layers {
1365 for tf in type_folders_in_layer(store, layer) {
1366 let stat = read_folder_stat(&store.root.join(&tf).join("index.jsonl"))?;
1367 if stat.count > 0 {
1368 stats.insert(tf, stat);
1369 }
1370 }
1371 }
1372 Ok(stats)
1373}
1374
1375fn walk_type_folder_files(folder_abs: &Path) -> Vec<PathBuf> {
1378 let mut out = Vec::new();
1379 if !folder_abs.is_dir() {
1380 return out;
1381 }
1382 for entry in walkdir::WalkDir::new(folder_abs)
1383 .into_iter()
1384 .filter_entry(|e| !is_hidden(e.file_name()))
1385 .filter_map(|e| e.ok())
1386 {
1387 if !entry.file_type().is_file() {
1388 continue;
1389 }
1390 let p = entry.path();
1391 if p.extension().and_then(|e| e.to_str()) != Some("md") {
1392 continue;
1393 }
1394 if p.file_name().and_then(|n| n.to_str()) == Some("index.md") {
1395 continue;
1396 }
1397 out.push(p.to_path_buf());
1398 }
1399 out
1400}
1401
1402fn type_folders_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1405 let layer_dir = store.root.join(layer_dir_name(layer));
1406 let mut out = Vec::new();
1407 let rd = match fs::read_dir(&layer_dir) {
1408 Ok(rd) => rd,
1409 Err(_) => return out,
1410 };
1411 for entry in rd.flatten() {
1412 if !entry.path().is_dir() {
1413 continue;
1414 }
1415 let name = entry.file_name();
1416 let name = match name.to_str() {
1417 Some(n) => n,
1418 None => continue,
1419 };
1420 if is_hidden(entry.file_name().as_os_str()) || name == "log" {
1421 continue;
1422 }
1423 out.push(PathBuf::from(layer_dir_name(layer)).join(name));
1424 }
1425 out.sort();
1426 out
1427}
1428
1429fn loose_layer_of(file_rel: &Path) -> Option<Layer> {
1435 let mut comps = file_rel.components();
1436 let layer = layer_from_dir_name(comps.next()?.as_os_str().to_str()?)?;
1437 comps.next()?; if comps.next().is_some() {
1439 return None; }
1441 Some(layer)
1442}
1443
1444fn loose_files_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1448 let layer_dir = store.root.join(layer_dir_name(layer));
1449 let mut out = Vec::new();
1450 let rd = match fs::read_dir(&layer_dir) {
1451 Ok(rd) => rd,
1452 Err(_) => return out,
1453 };
1454 for entry in rd.flatten() {
1455 let p = entry.path();
1456 if !p.is_file() {
1457 continue;
1458 }
1459 if p.extension().and_then(|e| e.to_str()) != Some("md") {
1460 continue;
1461 }
1462 if is_index_artifact(&p) || is_hidden(entry.file_name().as_os_str()) {
1463 continue;
1464 }
1465 out.push(p);
1466 }
1467 out
1468}
1469
1470fn write_layer_jsonl(store: &Store, layer: Layer, records: &[IndexRecord]) -> crate::Result<()> {
1475 let path = store.root.join(layer_dir_name(layer)).join("index.jsonl");
1476 if records.is_empty() {
1477 remove_if_exists(&path)?;
1478 return Ok(());
1479 }
1480 let idx = Index {
1481 level: IndexLevel::Layer(layer),
1482 records: records.to_vec(),
1483 child_counts: BTreeMap::new(),
1484 };
1485 write_atomic(&path, idx.to_jsonl())
1486}
1487
1488fn apply_loose_change(
1493 store: &Store,
1494 layer: Layer,
1495 file_rel: &Path,
1496 removing: bool,
1497) -> crate::Result<()> {
1498 let layer_dir = store.root.join(layer_dir_name(layer));
1499 let _lock = FolderLock::acquire(&layer_dir);
1500 let jsonl = layer_dir.join("index.jsonl");
1501 let mut records = read_jsonl_records(&jsonl)?;
1502 records.retain(|r| r.path != file_rel);
1503 if !removing {
1504 records.push(record_from_file(
1505 &store.root.join(file_rel),
1506 file_rel.to_path_buf(),
1507 )?);
1508 }
1509 sort_records(&mut records);
1510 write_layer_jsonl(store, layer, &records)
1511}
1512
1513fn type_folder_of(file_rel: &Path) -> Option<PathBuf> {
1517 let mut comps = file_rel.components();
1518 let layer = comps.next()?.as_os_str().to_str()?;
1519 layer_from_dir_name(layer)?;
1520 let type_seg = comps.next()?.as_os_str().to_str()?;
1521 Some(PathBuf::from(layer).join(type_seg))
1522}
1523
1524fn rel_to_store(root: &Path, abs: &Path) -> Option<PathBuf> {
1526 abs.strip_prefix(root).ok().map(|p| p.to_path_buf())
1527}
1528
1529fn normalize_rel(p: &Path) -> PathBuf {
1532 let s = path_to_unix(p);
1533 let s = s.strip_prefix("./").unwrap_or(&s);
1534 PathBuf::from(s)
1535}
1536
1537fn is_index_artifact(p: &Path) -> bool {
1538 matches!(
1539 p.file_name().and_then(|n| n.to_str()),
1540 Some("index.md") | Some("index.jsonl")
1541 )
1542}
1543
1544fn is_deletable_catalog_artifact(p: &Path) -> bool {
1558 match p.file_name().and_then(|n| n.to_str()) {
1559 Some("index.jsonl") => true,
1560 Some("index.md") => match read_frontmatter(p) {
1561 Ok(meta) => meta.type_.as_deref().is_none_or(|t| t == "index"),
1563 Err(_) => true,
1565 },
1566 _ => false,
1567 }
1568}
1569
1570fn is_hidden(name: &std::ffi::OsStr) -> bool {
1571 name.to_str().map(|s| s.starts_with('.')).unwrap_or(false)
1572}
1573
1574fn layer_dir_name(layer: Layer) -> &'static str {
1575 match layer {
1576 Layer::Sources => "sources",
1577 Layer::Records => "records",
1578 }
1579}
1580
1581fn layer_from_dir_name(name: &str) -> Option<Layer> {
1584 match name {
1585 "sources" => Some(Layer::Sources),
1586 "records" => Some(Layer::Records),
1587 _ => None,
1588 }
1589}
1590
1591fn folder_basename(p: &Path) -> &str {
1593 p.file_name().and_then(|n| n.to_str()).unwrap_or("")
1594}
1595
1596fn wiki_target(p: &Path) -> String {
1600 let unix = path_to_unix(p);
1601 unix.strip_suffix(".md").unwrap_or(&unix).to_string()
1602}
1603
1604fn path_to_unix(p: &Path) -> String {
1616 p.components()
1617 .map(|c| c.as_os_str().to_string_lossy().into_owned())
1618 .collect::<Vec<_>>()
1619 .join("/")
1620}
1621
1622mod path_serde {
1628 use super::path_to_unix;
1629 use serde::{Deserialize, Deserializer, Serializer};
1630 use std::path::{Path, PathBuf};
1631
1632 pub fn serialize<S: Serializer>(p: &Path, s: S) -> Result<S::Ok, S::Error> {
1633 s.serialize_str(&path_to_unix(p))
1634 }
1635
1636 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<PathBuf, D::Error> {
1637 Ok(PathBuf::from(String::deserialize(d)?))
1638 }
1639}
1640
1641fn capitalize(s: &str) -> String {
1643 let mut chars = s.chars();
1644 match chars.next() {
1645 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1646 None => String::new(),
1647 }
1648}
1649
1650fn collapse_whitespace(s: &str) -> String {
1655 s.split_whitespace().collect::<Vec<_>>().join(" ")
1656}
1657
1658fn default_display(basename: &str) -> String {
1664 let spaced: String = basename
1665 .chars()
1666 .map(|c| if c == '-' || c == '_' { ' ' } else { c })
1667 .collect();
1668 capitalize(&spaced)
1669}
1670
1671fn folder_label<'a>(
1678 tf_unix: &str,
1679 basename: &str,
1680 folders: &'a BTreeMap<String, FolderMeta>,
1681) -> (String, Option<&'a str>) {
1682 let meta = folders.get(tf_unix);
1683 let display = meta
1684 .and_then(|m| m.display.as_deref())
1685 .map(str::to_string)
1686 .unwrap_or_else(|| default_display(basename));
1687 (display, meta.and_then(|m| m.description.as_deref()))
1688}
1689
1690fn folder_entry(tf_unix: &str, display: &str, count: usize, description: Option<&str>) -> String {
1693 match description {
1694 Some(d) => format!("- [[{tf_unix}/index|{display}]] ({count}) — {d}\n"),
1695 None => format!("- [[{tf_unix}/index|{display}]] ({count})\n"),
1696 }
1697}
1698
1699fn write_atomic(path: &Path, contents: String) -> crate::Result<()> {
1706 if let Some(parent) = path.parent() {
1707 fs::create_dir_all(parent)?;
1708 }
1709 let dir = path.parent().unwrap_or_else(|| Path::new("."));
1710 let mut tmp = tempfile_in(dir)?;
1711 tmp.write_all(contents.as_bytes())?;
1712 tmp.flush()?;
1713 tmp.persist(path)?;
1714 Ok(())
1715}
1716
1717fn remove_if_exists(path: &Path) -> crate::Result<()> {
1718 match fs::remove_file(path) {
1719 Ok(()) => Ok(()),
1720 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1721 Err(e) => Err(e.into()),
1722 }
1723}
1724
1725fn bad_index(path: &Path, msg: &str) -> crate::Error {
1726 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1727 path: path.to_path_buf(),
1728 message: msg.to_string(),
1729 })
1730}
1731
1732struct FolderLock {
1752 path: PathBuf,
1753 held: bool,
1754}
1755
1756impl FolderLock {
1757 fn acquire(folder_abs: &Path) -> Self {
1784 use std::time::{Duration, SystemTime};
1785 const SPIN: Duration = Duration::from_millis(10);
1786 const STALE_AFTER: Duration = Duration::from_secs(30);
1787
1788 let path = folder_abs.join(".index.lock");
1789 let _ = fs::create_dir_all(folder_abs);
1791 loop {
1792 match fs::OpenOptions::new()
1793 .write(true)
1794 .create_new(true)
1795 .open(&path)
1796 {
1797 Ok(_) => return FolderLock { path, held: true },
1798 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1799 let stale = fs::metadata(&path)
1802 .and_then(|m| m.modified())
1803 .ok()
1804 .and_then(|t| SystemTime::now().duration_since(t).ok())
1805 .map(|age| age > STALE_AFTER)
1806 .unwrap_or(false);
1807 if stale {
1808 let _ = fs::remove_file(&path);
1809 continue;
1810 }
1811 std::thread::sleep(SPIN);
1812 }
1813 Err(_) => return FolderLock { path, held: false },
1818 }
1819 }
1820 }
1821}
1822
1823impl Drop for FolderLock {
1824 fn drop(&mut self) {
1825 if self.held {
1826 let _ = fs::remove_file(&self.path);
1827 }
1828 }
1829}
1830
1831fn lock_folders(store: &Store, a: &Path, b: &Path) -> Vec<FolderLock> {
1837 if a == b {
1838 return vec![FolderLock::acquire(&store.root.join(a))];
1839 }
1840 let (first, second) = if a < b { (a, b) } else { (b, a) };
1841 vec![
1842 FolderLock::acquire(&store.root.join(first)),
1843 FolderLock::acquire(&store.root.join(second)),
1844 ]
1845}
1846
1847struct AtomicTemp {
1853 file: Option<fs::File>,
1854 path: PathBuf,
1855 persisted: bool,
1856}
1857
1858impl AtomicTemp {
1859 fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
1860 self.file.as_mut().expect("temp file open").write_all(bytes)
1861 }
1862 fn flush(&mut self) -> std::io::Result<()> {
1863 self.file.as_mut().expect("temp file open").flush()
1864 }
1865 fn persist(mut self, dest: &Path) -> std::io::Result<()> {
1866 if let Some(f) = self.file.take() {
1867 f.sync_all().ok();
1868 }
1870 fs::rename(&self.path, dest)?;
1871 self.persisted = true;
1872 Ok(())
1873 }
1874}
1875
1876impl Drop for AtomicTemp {
1877 fn drop(&mut self) {
1878 if !self.persisted {
1880 let _ = fs::remove_file(&self.path);
1881 }
1882 }
1883}
1884
1885fn tempfile_in(dir: &Path) -> std::io::Result<AtomicTemp> {
1886 use std::time::{SystemTime, UNIX_EPOCH};
1887 let nanos = SystemTime::now()
1888 .duration_since(UNIX_EPOCH)
1889 .map(|d| d.as_nanos())
1890 .unwrap_or(0);
1891 let pid = std::process::id();
1892 let counter = next_temp_counter();
1895 let name = format!(".dbmd-index-{pid}-{nanos}-{counter}.tmp");
1896 let path = dir.join(name);
1897 let file = fs::OpenOptions::new()
1898 .write(true)
1899 .create_new(true)
1900 .open(&path)?;
1901 Ok(AtomicTemp {
1902 file: Some(file),
1903 path,
1904 persisted: false,
1905 })
1906}
1907
1908fn next_temp_counter() -> u64 {
1909 use std::sync::atomic::{AtomicU64, Ordering};
1910 static C: AtomicU64 = AtomicU64::new(0);
1911 C.fetch_add(1, Ordering::Relaxed)
1912}
1913
1914#[cfg(test)]
1915mod tests {
1916 use super::*;
1917 use std::collections::BTreeSet;
1918 use std::fs;
1919 use tempfile::TempDir;
1920
1921 fn mk_store() -> (TempDir, Store) {
1926 let dir = TempDir::new().unwrap();
1927 fs::write(dir.path().join("DB.md"), "# test store\n").unwrap();
1928 let store = Store {
1929 root: dir.path().to_path_buf(),
1930 config: crate::parser::Config::default(),
1931 };
1932 (dir, store)
1933 }
1934
1935 fn write_raw(store: &Store, rel: &str, fm: &str, body: &str) {
1938 let abs = store.root.join(rel);
1939 fs::create_dir_all(abs.parent().unwrap()).unwrap();
1940 fs::write(&abs, format!("---\n{fm}\n---\n{body}")).unwrap();
1941 }
1942
1943 fn write_doc(
1945 store: &Store,
1946 rel: &str,
1947 type_: &str,
1948 summary: Option<&str>,
1949 updated: Option<&str>,
1950 extra_yaml: &str,
1951 ) {
1952 let mut fm = format!("type: {type_}\n");
1953 if let Some(s) = summary {
1954 fm.push_str(&format!("summary: {s}\n"));
1955 }
1956 if let Some(u) = updated {
1957 fm.push_str(&format!("updated: {u}\n"));
1958 }
1959 fm.push_str(extra_yaml);
1960 write_raw(store, rel, fm.trim_end(), "\nbody text\n");
1961 }
1962
1963 fn read(store: &Store, rel: &str) -> String {
1964 fs::read_to_string(store.root.join(rel)).unwrap()
1965 }
1966
1967 fn exists(store: &Store, rel: &str) -> bool {
1968 store.root.join(rel).exists()
1969 }
1970
1971 fn snapshot_artifacts(store: &Store) -> BTreeMap<String, String> {
1974 let mut out = BTreeMap::new();
1975 for entry in walkdir::WalkDir::new(&store.root)
1976 .into_iter()
1977 .filter_map(|e| e.ok())
1978 {
1979 let p = entry.path();
1980 if is_index_artifact(p) {
1981 let rel = path_to_unix(&rel_to_store(&store.root, p).unwrap());
1982 out.insert(rel, fs::read_to_string(p).unwrap());
1983 }
1984 }
1985 out
1986 }
1987
1988 #[test]
1991 fn type_folder_aggregates_across_shards_in_recency_order() {
1992 let (_d, store) = mk_store();
1993 write_doc(
1996 &store,
1997 "sources/emails/2026/05/b-old.md",
1998 "email",
1999 Some("Older mail"),
2000 Some("2026-05-01T09:00:00Z"),
2001 "",
2002 );
2003 write_doc(
2004 &store,
2005 "sources/emails/2026/06/c-new.md",
2006 "email",
2007 Some("Newest mail"),
2008 Some("2026-06-15T12:00:00Z"),
2009 "",
2010 );
2011 write_doc(
2012 &store,
2013 "sources/emails/2026/05/a-mid.md",
2014 "email",
2015 Some("Middle mail"),
2016 Some("2026-05-20T08:00:00Z"),
2017 "",
2018 );
2019
2020 let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
2021 let paths: Vec<String> = idx.records.iter().map(|r| path_to_unix(&r.path)).collect();
2022 assert_eq!(
2023 paths,
2024 vec![
2025 "sources/emails/2026/06/c-new.md",
2026 "sources/emails/2026/05/a-mid.md",
2027 "sources/emails/2026/05/b-old.md",
2028 ],
2029 "records must aggregate across shards, newest `updated` first"
2030 );
2031 }
2032
2033 #[test]
2034 fn type_folder_md_format_entries_tags_and_derived_updated() {
2035 let (_d, store) = mk_store();
2036 write_doc(
2037 &store,
2038 "records/contacts/sarah-chen.md",
2039 "contact",
2040 Some("Renewal champion at Acme"),
2041 Some("2026-05-27T10:00:00Z"),
2042 "tags:\n - renewal\n - acme\n",
2043 );
2044 write_doc(
2045 &store,
2046 "records/contacts/no-tags.md",
2047 "contact",
2048 Some("Plain contact"),
2049 Some("2026-05-26T10:00:00Z"),
2050 "",
2051 );
2052
2053 let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
2054 let md = idx.to_markdown();
2055
2056 assert!(md.starts_with(
2059 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\nupdated: 2026-05-27T10:00:00Z\n---\n\n# records/contacts\n"
2060 ), "frontmatter/heading wrong:\n{md}");
2061
2062 assert!(
2064 md.contains(
2065 "- [[records/contacts/sarah-chen]] — Renewal champion at Acme · #renewal #acme\n"
2066 ),
2067 "tagged entry wrong:\n{md}"
2068 );
2069 assert!(
2071 md.contains("- [[records/contacts/no-tags]] — Plain contact\n"),
2072 "untagged entry wrong:\n{md}"
2073 );
2074 assert!(
2075 !md.contains("Plain contact ·"),
2076 "untagged entry must not emit a tag separator"
2077 );
2078 assert!(!md.contains("## More"), "no footer expected under the cap");
2080 }
2081
2082 #[test]
2083 fn missing_summary_becomes_placeholder_not_invented() {
2084 let (_d, store) = mk_store();
2085 write_doc(
2086 &store,
2087 "records/notes/x.md",
2088 "note",
2089 None,
2090 Some("2026-05-27T10:00:00Z"),
2091 "",
2092 );
2093 let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
2094 assert_eq!(idx.records[0].summary, MISSING_SUMMARY);
2095 let md = idx.to_markdown();
2096 assert!(
2097 md.contains("- [[records/notes/x]] — (no summary)\n"),
2098 "missing summary must render the placeholder, not invent text:\n{md}"
2099 );
2100 }
2101
2102 #[test]
2105 fn jsonl_is_complete_structured_and_round_trips() {
2106 let (_d, store) = mk_store();
2107 write_doc(
2108 &store,
2109 "records/expenses/2026/05/e1.md",
2110 "expense",
2111 Some("Lunch with vendor"),
2112 Some("2026-05-10T10:00:00Z"),
2113 "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",
2114 );
2115 write_doc(
2116 &store,
2117 "records/expenses/2026/06/e2.md",
2118 "expense",
2119 Some("Cloud bill"),
2120 Some("2026-06-01T10:00:00Z"),
2121 "amount: 100\n",
2122 );
2123
2124 let idx = Index::build_type_folder(&store, Path::new("records/expenses")).unwrap();
2125 let jsonl = idx.to_jsonl();
2126 let lines: Vec<&str> = jsonl.lines().collect();
2127 assert_eq!(lines.len(), 2, "one JSON object per file, uncapped");
2128
2129 let r0: IndexRecord = serde_json::from_str(lines[0]).unwrap();
2131 assert_eq!(path_to_unix(&r0.path), "records/expenses/2026/06/e2.md");
2132 assert_eq!(
2133 r0, idx.records[0],
2134 "jsonl line must round-trip to the record"
2135 );
2136
2137 let r1: IndexRecord = serde_json::from_str(lines[1]).unwrap();
2140 assert_eq!(r1.type_, "expense");
2141 assert_eq!(r1.summary, "Lunch with vendor");
2142 assert_eq!(r1.tags, vec!["food".to_string()]);
2143 assert_eq!(
2144 r1.links,
2145 vec![
2146 "records/concepts/spend".to_string(),
2147 "[[records/concepts/renewal]]".to_string()
2148 ]
2149 );
2150 assert_eq!(
2151 r1.created,
2152 Some(DateTime::parse_from_rfc3339("2026-05-10T09:00:00Z").unwrap())
2153 );
2154 assert_eq!(r1.fields.get("status"), Some(&Value::from("paid")));
2155 assert_eq!(r1.fields.get("amount"), Some(&Value::from(42)));
2156 assert_eq!(
2157 r1.fields.get("company"),
2158 Some(&Value::from("[[records/companies/acme]]"))
2159 );
2160 assert_eq!(
2161 r1.fields.get("related"),
2162 Some(&serde_json::json!(["[[records/concepts/spend]]"]))
2163 );
2164 for reserved in [
2166 "path", "type", "summary", "tags", "links", "created", "updated",
2167 ] {
2168 assert!(
2169 !r1.fields.contains_key(reserved),
2170 "reserved key {reserved} must not appear in fields"
2171 );
2172 }
2173
2174 assert!(
2176 lines[1].starts_with(
2177 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","#
2178 ),
2179 "jsonl key order not stable:\n{}",
2180 lines[1]
2181 );
2182 assert!(
2187 lines[1].ends_with(r#""amount":42,"company":"[[records/companies/acme]]","meta-type":"fact","related":["[[records/concepts/spend]]"],"status":"paid"}"#),
2188 "extras must be sorted:\n{}",
2189 lines[1]
2190 );
2191 }
2192
2193 #[test]
2196 fn over_cap_md_shows_500_plus_footer_jsonl_holds_all() {
2197 let (_d, store) = mk_store();
2198 let total = MD_CAP + 7;
2199 for i in 0..total {
2200 let day = 1 + (i % 27);
2202 let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2203 let updated = format!("2026-05-{day:02}T00:00:{:02}Z", i % 60);
2204 write_doc(
2205 &store,
2206 &rel,
2207 "email",
2208 Some(&format!("mail {i}")),
2209 Some(&updated),
2210 "",
2211 );
2212 }
2213 let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
2214 assert_eq!(idx.records.len(), total, "jsonl/records keep every file");
2215
2216 let md = idx.to_markdown();
2217 let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
2218 assert_eq!(entry_lines, MD_CAP, "md browse view is capped at 500");
2219
2220 assert!(
2221 md.contains("## More\n\n"),
2222 "over-cap md needs a More footer"
2223 );
2224 assert!(
2225 md.contains(&format!(
2226 "This folder has {total} files. The 500 most recent are listed above.\n"
2227 )),
2228 "footer count wrong:\n{md}"
2229 );
2230 assert!(
2231 md.contains("Use `dbmd query --type email --in sources` for the complete catalog.\n"),
2232 "footer must infer type=email layer=sources:\n{md}"
2233 );
2234
2235 let jsonl = idx.to_jsonl();
2236 assert_eq!(jsonl.lines().count(), total, "jsonl is uncapped");
2237 }
2238
2239 #[test]
2242 fn sort_breaks_ties_by_path_and_puts_undated_last() {
2243 let mut recs = vec![
2244 rec("z/a.md", Some("2026-05-01T00:00:00Z")),
2245 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")), ];
2249 sort_records(&mut recs);
2250 let order: Vec<String> = recs.iter().map(|r| path_to_unix(&r.path)).collect();
2251 assert_eq!(order, vec!["b/d.md", "a/b.md", "z/a.md", "m/c.md"]);
2252 }
2253
2254 fn rec(path: &str, updated: Option<&str>) -> IndexRecord {
2255 IndexRecord {
2256 path: PathBuf::from(path),
2257 type_: "t".into(),
2258 summary: "s".into(),
2259 tags: vec![],
2260 links: vec![],
2261 created: None,
2262 updated: updated.map(|u| DateTime::parse_from_rfc3339(u).unwrap()),
2263 fields: BTreeMap::new(),
2264 }
2265 }
2266
2267 #[test]
2270 fn layer_index_lists_type_folders_with_counts() {
2271 let (_d, store) = mk_store();
2272 write_doc(
2273 &store,
2274 "records/contacts/a.md",
2275 "contact",
2276 Some("Contact A older"),
2277 Some("2026-05-01T00:00:00Z"),
2278 "",
2279 );
2280 write_doc(
2281 &store,
2282 "records/contacts/b.md",
2283 "contact",
2284 Some("Contact B newest"),
2285 Some("2026-05-09T00:00:00Z"),
2286 "",
2287 );
2288 write_doc(
2289 &store,
2290 "records/companies/x.md",
2291 "company",
2292 Some("Acme Inc"),
2293 Some("2026-05-05T00:00:00Z"),
2294 "",
2295 );
2296 Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
2298 Index::write_level(&store, &IndexLevel::TypeFolder("records/companies".into())).unwrap();
2299
2300 Index::write_level(&store, &IndexLevel::Layer(Layer::Records)).unwrap();
2301 let md = read(&store, "records/index.md");
2302
2303 assert!(
2304 md.starts_with("---\ntype: index\nscope: layer\nfolder: records\n"),
2305 "layer fm:\n{md}"
2306 );
2307 let companies_at = md.find("companies/index").unwrap();
2309 let contacts_at = md.find("contacts/index").unwrap();
2310 assert!(
2311 companies_at < contacts_at,
2312 "type folders must be alphabetical"
2313 );
2314 assert!(
2317 md.contains("- [[records/contacts/index|Contacts]] (2)\n"),
2318 "contacts entry:\n{md}"
2319 );
2320 assert!(
2321 md.contains("- [[records/companies/index|Companies]] (1)\n"),
2322 "companies entry:\n{md}"
2323 );
2324 assert!(
2326 !md.contains("Contact B newest") && !md.contains("Acme Inc"),
2327 "layer rollup must not quote a member summary:\n{md}"
2328 );
2329 assert!(
2331 md.contains("updated: 2026-05-09T00:00:00Z\n"),
2332 "layer updated must be max child:\n{md}"
2333 );
2334 }
2335
2336 #[test]
2337 fn folders_section_supplies_authored_display_and_description() {
2338 let (_d, mut store) = mk_store();
2342 store.config.folders.insert(
2343 "records/contacts".into(),
2344 crate::parser::FolderMeta {
2345 display: None,
2346 description: Some("people across customer + prospect accounts".into()),
2347 },
2348 );
2349 store.config.folders.insert(
2350 "sources/hubspot-exports".into(),
2351 crate::parser::FolderMeta {
2352 display: Some("HubSpot exports".into()),
2353 description: Some("deal + pipeline exports".into()),
2354 },
2355 );
2356 write_doc(
2357 &store,
2358 "records/contacts/a.md",
2359 "contact",
2360 Some("Contact A"),
2361 Some("2026-05-01T00:00:00Z"),
2362 "",
2363 );
2364 write_doc(
2366 &store,
2367 "records/companies/x.md",
2368 "company",
2369 Some("Acme Inc"),
2370 Some("2026-05-05T00:00:00Z"),
2371 "",
2372 );
2373 write_doc(
2374 &store,
2375 "sources/hubspot-exports/d.md",
2376 "hubspot-export",
2377 Some("a single deal export"),
2378 Some("2026-05-03T00:00:00Z"),
2379 "",
2380 );
2381
2382 Index::rebuild_all(&store).unwrap();
2383
2384 let records_layer = read(&store, "records/index.md");
2386 assert!(
2387 records_layer.contains("- [[records/contacts/index|Contacts]] (1) — people across customer + prospect accounts\n"),
2388 "authored description must surface:\n{records_layer}"
2389 );
2390 assert!(
2392 records_layer.contains("- [[records/companies/index|Companies]] (1)\n")
2393 && !records_layer.contains("Acme Inc"),
2394 "un-described folder is counts-only:\n{records_layer}"
2395 );
2396
2397 let sources_layer = read(&store, "sources/index.md");
2399 assert!(
2400 sources_layer.contains("- [[sources/hubspot-exports/index|HubSpot exports]] (1) — deal + pipeline exports\n"),
2401 "display override + description must surface:\n{sources_layer}"
2402 );
2403
2404 let root = read(&store, "index.md");
2406 assert!(
2407 root.contains("- [[records/contacts/index|Contacts]] (1) — people across customer + prospect accounts\n"),
2408 "root surfaces authored description:\n{root}"
2409 );
2410 assert!(
2411 root.contains("- [[sources/hubspot-exports/index|HubSpot exports]] (1) — deal + pipeline exports\n"),
2412 "root surfaces display override:\n{root}"
2413 );
2414 }
2415
2416 #[test]
2417 fn default_display_turns_separators_to_spaces_and_caps() {
2418 assert_eq!(default_display("contacts"), "Contacts");
2419 assert_eq!(default_display("hubspot-exports"), "Hubspot exports");
2420 assert_eq!(default_display("usage_exports"), "Usage exports");
2421 }
2422
2423 #[test]
2424 fn root_index_groups_layers_with_totals_and_per_type_counts() {
2425 let (_d, store) = mk_store();
2426 write_doc(
2427 &store,
2428 "sources/emails/2026/05/a.md",
2429 "email",
2430 Some("Mail"),
2431 Some("2026-05-01T00:00:00Z"),
2432 "",
2433 );
2434 write_doc(
2435 &store,
2436 "sources/docs/d.md",
2437 "doc",
2438 Some("Doc"),
2439 Some("2026-05-02T00:00:00Z"),
2440 "",
2441 );
2442 write_doc(
2443 &store,
2444 "records/contacts/c.md",
2445 "contact",
2446 Some("C"),
2447 Some("2026-05-03T00:00:00Z"),
2448 "",
2449 );
2450 Index::rebuild_all(&store).unwrap();
2453 let md = read(&store, "index.md");
2454
2455 assert!(
2456 md.starts_with("---\ntype: index\nscope: root\n"),
2457 "root fm:\n{md}"
2458 );
2459 assert!(md.contains("# Knowledge base index\n"), "root title:\n{md}");
2460 let sources_h = md
2462 .find("## Sources (2)")
2463 .expect("sources heading w/ total 2");
2464 let records_h = md
2465 .find("## Records (1)")
2466 .expect("records heading w/ total 1");
2467 assert!(sources_h < records_h, "Sources must precede Records");
2468 assert!(!md.contains("## Wiki"), "empty layer gets no section");
2469 assert!(
2471 md.contains("- [[sources/docs/index|Docs]] (1)\n"),
2472 "root docs entry:\n{md}"
2473 );
2474 assert!(
2475 md.contains("- [[sources/emails/index|Emails]] (1)\n"),
2476 "root emails entry:\n{md}"
2477 );
2478 assert!(
2479 md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
2480 "root contacts entry:\n{md}"
2481 );
2482 assert!(!md.contains("— "), "root entries carry no preview text");
2483 }
2484
2485 #[test]
2488 fn on_write_matches_rebuild_byte_for_byte() {
2489 let (_d1, wt) = mk_store();
2492 let (_d2, rb) = mk_store();
2493
2494 let docs: &[(&str, &str, &str, &str, &str)] = &[
2495 (
2496 "sources/emails/2026/05/e1.md",
2497 "email",
2498 "First mail",
2499 "2026-05-01T10:00:00Z",
2500 "tags:\n - inbox\n",
2501 ),
2502 (
2503 "sources/emails/2026/06/e2.md",
2504 "email",
2505 "Second mail",
2506 "2026-06-01T10:00:00Z",
2507 "",
2508 ),
2509 (
2510 "records/contacts/sarah.md",
2511 "contact",
2512 "Sarah",
2513 "2026-05-15T10:00:00Z",
2514 "links:\n - records/profiles/sarah\n",
2515 ),
2516 (
2517 "records/contacts/elena.md",
2518 "contact",
2519 "Elena",
2520 "2026-05-20T10:00:00Z",
2521 "status: active\n",
2522 ),
2523 (
2524 "records/profiles/sarah.md",
2525 "profile",
2526 "Sarah bio",
2527 "2026-05-21T10:00:00Z",
2528 "",
2529 ),
2530 ];
2531
2532 for (rel, t, sum, upd, extra) in docs {
2533 write_doc(&wt, rel, t, Some(sum), Some(upd), extra);
2534 write_doc(&rb, rel, t, Some(sum), Some(upd), extra);
2535 Index::on_write(&wt, Path::new(rel)).unwrap();
2536 }
2537 Index::rebuild_all(&rb).unwrap();
2538
2539 let a = snapshot_artifacts(&wt);
2540 let b = snapshot_artifacts(&rb);
2541 assert_eq!(
2542 a.keys().collect::<Vec<_>>(),
2543 b.keys().collect::<Vec<_>>(),
2544 "same set of index artifacts must exist"
2545 );
2546 for (k, v) in &a {
2547 assert_eq!(v, &b[k], "artifact {k} differs between write-through and rebuild:\n--- write-through ---\n{v}\n--- rebuild ---\n{}", b[k]);
2548 }
2549 assert!(a.contains_key("index.md"));
2551 assert!(a.contains_key("sources/emails/index.jsonl"));
2552 assert!(a.contains_key("records/contacts/index.md"));
2553 }
2554
2555 #[test]
2572 fn loop_op_does_not_walk_sibling_content_tree() {
2573 let (_d, store) = mk_store();
2574
2575 write_doc(
2579 &store,
2580 "records/companies/acme.md",
2581 "company",
2582 Some("Acme Inc"),
2583 Some("2026-05-05T00:00:00Z"),
2584 "",
2585 );
2586 write_doc(
2587 &store,
2588 "records/companies/globex.md",
2589 "company",
2590 Some("Globex"),
2591 Some("2026-05-06T00:00:00Z"),
2592 "",
2593 );
2594 assert!(
2595 !exists(&store, "records/companies/index.jsonl"),
2596 "precondition: companies must be un-indexed"
2597 );
2598
2599 write_doc(
2601 &store,
2602 "records/contacts/sarah.md",
2603 "contact",
2604 Some("Sarah"),
2605 Some("2026-05-15T00:00:00Z"),
2606 "",
2607 );
2608 Index::on_write(&store, Path::new("records/contacts/sarah.md")).unwrap();
2609
2610 let layer_md = read(&store, "records/index.md");
2612 let root_md = read(&store, "index.md");
2613 assert!(
2615 layer_md.contains("- [[records/contacts/index|Contacts]] (1)\n")
2616 && !layer_md.contains("Sarah"),
2617 "layer must reflect the written folder, counts only:\n{layer_md}"
2618 );
2619 assert!(
2620 root_md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
2621 "root must reflect the written folder:\n{root_md}"
2622 );
2623
2624 assert!(
2628 !layer_md.contains("companies"),
2629 "loop op walked the sibling content tree: layer rollup counts un-indexed records/companies\n{layer_md}"
2630 );
2631 assert!(
2632 !root_md.contains("companies"),
2633 "loop op walked the sibling content tree: root rollup counts un-indexed records/companies\n{root_md}"
2634 );
2635 assert!(
2637 root_md.contains("## Records (1)"),
2638 "root layer total must count only the sidecar-indexed folder (1), not walked siblings (would be 3):\n{root_md}"
2639 );
2640
2641 let (_d2, rb) = mk_store();
2646 for (rel, t, s, u) in [
2647 (
2648 "records/companies/acme.md",
2649 "company",
2650 "Acme Inc",
2651 "2026-05-05T00:00:00Z",
2652 ),
2653 (
2654 "records/companies/globex.md",
2655 "company",
2656 "Globex",
2657 "2026-05-06T00:00:00Z",
2658 ),
2659 (
2660 "records/contacts/sarah.md",
2661 "contact",
2662 "Sarah",
2663 "2026-05-15T00:00:00Z",
2664 ),
2665 ] {
2666 write_doc(&rb, rel, t, Some(s), Some(u), "");
2667 }
2668 Index::on_write(&store, Path::new("records/companies/acme.md")).unwrap();
2669 Index::on_write(&store, Path::new("records/companies/globex.md")).unwrap();
2670 Index::rebuild_all(&rb).unwrap();
2671 let a = snapshot_artifacts(&store);
2672 let b = snapshot_artifacts(&rb);
2673 assert_eq!(
2674 a.keys().collect::<BTreeSet<_>>(),
2675 b.keys().collect::<BTreeSet<_>>(),
2676 "same artifact set after indexing both folders"
2677 );
2678 for (k, v) in &a {
2679 assert_eq!(
2680 v, &b[k],
2681 "after indexing the sibling too, loop result must equal rebuild for {k}"
2682 );
2683 }
2684 assert!(
2685 read(&store, "index.md").contains("## Records (3)"),
2686 "now that both folders are indexed, the root total is 3"
2687 );
2688 }
2689
2690 #[test]
2703 fn custom_type_at_shard_path_for_is_indexable_end_to_end() {
2704 let (_d1, wt) = mk_store();
2705 let (_d2, rb) = mk_store();
2706
2707 let rel = wt
2709 .shard_path_for(
2710 "profile",
2711 &crate::parser::Frontmatter::default(),
2712 "renewal-theme",
2713 )
2714 .unwrap();
2715 let rel_str = path_to_unix(&rel);
2716 assert!(
2719 type_folder_of(&rel).is_some(),
2720 "shard_path_for produced a path the index cannot file: {rel_str}"
2721 );
2722
2723 write_doc(
2724 &wt,
2725 &rel_str,
2726 "profile",
2727 Some("Renewal theme"),
2728 Some("2026-05-21T10:00:00Z"),
2729 "",
2730 );
2731 write_doc(
2732 &rb,
2733 &rel_str,
2734 "profile",
2735 Some("Renewal theme"),
2736 Some("2026-05-21T10:00:00Z"),
2737 "",
2738 );
2739
2740 Index::on_write(&wt, &rel)
2743 .expect("on_write must succeed for a toolkit-computed custom-type path");
2744 Index::rebuild_all(&rb).unwrap();
2745
2746 let page_link = wiki_target(&rel); let tf_md = read(&rb, "records/profile/index.md");
2753 assert!(
2754 tf_md.contains(&format!("[[{page_link}]]")),
2755 "type-folder index must list the page link, got:\n{tf_md}"
2756 );
2757 assert!(
2758 exists(&rb, "records/profile/index.jsonl"),
2759 "type-folder jsonl must exist"
2760 );
2761 assert!(
2762 read(&rb, "records/profile/index.jsonl").contains(&rel_str),
2763 "type-folder jsonl must contain the page row"
2764 );
2765 let layer_md = read(&rb, "records/index.md");
2768 assert!(
2769 layer_md.contains("records/profile/index"),
2770 "layer index must roll up the records/profile type-folder, got:\n{layer_md}"
2771 );
2772
2773 let a = snapshot_artifacts(&wt);
2775 let b = snapshot_artifacts(&rb);
2776 assert_eq!(
2777 a.keys().collect::<Vec<_>>(),
2778 b.keys().collect::<Vec<_>>(),
2779 "loop and sweep must produce the same artifact set"
2780 );
2781 for (k, v) in &a {
2782 assert_eq!(
2783 v, &b[k],
2784 "custom-type artifact {k} differs between on_write and rebuild"
2785 );
2786 }
2787 }
2788
2789 #[test]
2790 fn on_remove_then_rebuild_match_and_pull_in_next_over_cap() {
2791 let (_d1, wt) = mk_store();
2792 let (_d2, rb) = mk_store();
2793 let total = MD_CAP + 3; let mut all_rels = Vec::new();
2795 for i in 0..total {
2796 let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2797 let updated = format!("2026-05-10T00:{:02}:{:02}Z", i / 60, i % 60);
2799 write_doc(
2800 &wt,
2801 &rel,
2802 "email",
2803 Some(&format!("mail {i}")),
2804 Some(&updated),
2805 "",
2806 );
2807 write_doc(
2808 &rb,
2809 &rel,
2810 "email",
2811 Some(&format!("mail {i}")),
2812 Some(&updated),
2813 "",
2814 );
2815 all_rels.push(rel);
2816 }
2817 Index::rebuild_all(&wt).unwrap();
2819 let newest = &all_rels[total - 1]; fs::remove_file(wt.root.join(newest)).unwrap();
2821 Index::on_remove(&wt, Path::new(newest)).unwrap();
2822
2823 fs::remove_file(rb.root.join(newest)).unwrap();
2825 Index::rebuild_all(&rb).unwrap();
2826
2827 let a = snapshot_artifacts(&wt);
2828 let b = snapshot_artifacts(&rb);
2829 for (k, v) in &a {
2830 assert_eq!(v, &b[k], "after remove, artifact {k} drifted from rebuild");
2831 }
2832
2833 let md = read(&wt, "sources/emails/index.md");
2836 assert_eq!(md.lines().filter(|l| l.starts_with("- [[")).count(), MD_CAP);
2837 assert!(
2839 !md.contains(&format!("[[{}]]", wiki_target(Path::new(newest)))),
2840 "removed file must not be listed in md"
2841 );
2842 let pulled_in = &all_rels[2];
2846 assert!(
2847 md.contains(&format!("[[{}]]", wiki_target(Path::new(pulled_in)))),
2848 "the 501st-most-recent must be pulled into the browse view after a removal"
2849 );
2850 assert!(
2851 md.contains(&format!("This folder has {} files.", total - 1)),
2852 "footer count must decrement:\n{}",
2853 md.lines().rev().take(4).collect::<Vec<_>>().join("\n")
2854 );
2855 let jsonl = read(&wt, "sources/emails/index.jsonl");
2856 assert_eq!(
2857 jsonl.lines().count(),
2858 total - 1,
2859 "jsonl loses exactly the removed file"
2860 );
2861 assert!(
2862 !jsonl.contains(&path_to_unix(Path::new(newest))),
2863 "removed file must be gone from the jsonl too"
2864 );
2865 }
2866
2867 #[test]
2868 fn on_rename_cross_folder_matches_rebuild() {
2869 let (_d1, wt) = mk_store();
2870 let (_d2, rb) = mk_store();
2871 let seed: &[(&str, &str, &str, &str)] = &[
2873 (
2874 "records/contacts/a.md",
2875 "contact",
2876 "A",
2877 "2026-05-01T00:00:00Z",
2878 ),
2879 (
2880 "records/contacts/b.md",
2881 "contact",
2882 "B",
2883 "2026-05-02T00:00:00Z",
2884 ),
2885 (
2886 "records/companies/x.md",
2887 "company",
2888 "X",
2889 "2026-05-03T00:00:00Z",
2890 ),
2891 ];
2892 for (rel, t, s, u) in seed {
2893 write_doc(&wt, rel, t, Some(s), Some(u), "");
2894 write_doc(&rb, rel, t, Some(s), Some(u), "");
2895 }
2896 Index::rebuild_all(&wt).unwrap();
2897
2898 let old = "records/contacts/b.md";
2901 let new = "records/companies/b.md";
2902 fs::create_dir_all(wt.root.join("records/companies")).unwrap();
2903 fs::rename(wt.root.join(old), wt.root.join(new)).unwrap();
2904 Index::on_rename(&wt, Path::new(old), Path::new(new)).unwrap();
2907
2908 fs::create_dir_all(rb.root.join("records/companies")).unwrap();
2910 fs::rename(rb.root.join(old), rb.root.join(new)).unwrap();
2911 Index::rebuild_all(&rb).unwrap();
2912
2913 let a = snapshot_artifacts(&wt);
2914 let b = snapshot_artifacts(&rb);
2915 assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
2916 for (k, v) in &a {
2917 assert_eq!(v, &b[k], "rename: artifact {k} drifted from rebuild");
2918 }
2919 let contacts = read(&wt, "records/contacts/index.md");
2921 assert!(!contacts.contains("records/contacts/b]]"));
2922 let companies = read(&wt, "records/companies/index.md");
2923 assert!(companies.contains("[[records/companies/b]]"));
2924 }
2925
2926 #[test]
2927 fn on_write_updates_existing_entry_in_place() {
2928 let (_d, store) = mk_store();
2929 write_doc(
2930 &store,
2931 "records/contacts/a.md",
2932 "contact",
2933 Some("Original"),
2934 Some("2026-05-01T00:00:00Z"),
2935 "",
2936 );
2937 Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2938 write_doc(
2940 &store,
2941 "records/contacts/a.md",
2942 "contact",
2943 Some("Revised"),
2944 Some("2026-05-09T00:00:00Z"),
2945 "",
2946 );
2947 Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2948
2949 let jsonl = read(&store, "records/contacts/index.jsonl");
2950 assert_eq!(
2951 jsonl.lines().count(),
2952 1,
2953 "upsert must not duplicate the line"
2954 );
2955 assert!(jsonl.contains("Revised"), "jsonl must reflect the update");
2956 assert!(
2957 !jsonl.contains("Original"),
2958 "stale line must be gone (compacted)"
2959 );
2960 let md = read(&store, "records/contacts/index.md");
2961 assert!(md.contains("- [[records/contacts/a]] — Revised\n"));
2962 assert!(
2963 md.contains("updated: 2026-05-09T00:00:00Z\n"),
2964 "index updated must track the newer member"
2965 );
2966 }
2967
2968 #[test]
2971 fn dry_run_emits_separators_and_writes_nothing() {
2972 let (_d, store) = mk_store();
2973 write_doc(
2974 &store,
2975 "sources/emails/2026/05/a.md",
2976 "email",
2977 Some("Mail"),
2978 Some("2026-05-01T00:00:00Z"),
2979 "",
2980 );
2981 let out = Index::render_dry_run(&store, &IndexLevel::TypeFolder("sources/emails".into()))
2982 .unwrap();
2983 assert!(
2984 out.contains("--- sources/emails/index.md ---\n"),
2985 "md separator:\n{out}"
2986 );
2987 assert!(
2988 out.contains("--- sources/emails/index.jsonl ---\n"),
2989 "jsonl separator:\n{out}"
2990 );
2991 assert!(
2992 out.contains("- [[sources/emails/2026/05/a]] — Mail"),
2993 "md body present"
2994 );
2995 assert!(
2997 !exists(&store, "sources/emails/index.md"),
2998 "dry-run must not write"
2999 );
3000 assert!(
3001 !exists(&store, "sources/emails/index.jsonl"),
3002 "dry-run must not write"
3003 );
3004 }
3005
3006 #[test]
3007 fn cleanup_removes_noncanonical_and_empty_indexes() {
3008 let (_d, store) = mk_store();
3009 write_doc(
3010 &store,
3011 "sources/emails/2026/05/a.md",
3012 "email",
3013 Some("Mail"),
3014 Some("2026-05-01T00:00:00Z"),
3015 "",
3016 );
3017 fs::write(
3019 store.root.join("sources/emails/2026/05/index.md"),
3020 "stale\n",
3021 )
3022 .unwrap();
3023 fs::write(
3024 store.root.join("sources/emails/2026/05/index.jsonl"),
3025 "stale\n",
3026 )
3027 .unwrap();
3028 fs::create_dir_all(store.root.join("records/empty")).unwrap();
3030 fs::write(store.root.join("records/empty/index.md"), "stale\n").unwrap();
3031
3032 Index::cleanup(&store).unwrap();
3033
3034 assert!(
3035 !exists(&store, "sources/emails/2026/05/index.md"),
3036 "shard index must be deleted"
3037 );
3038 assert!(
3039 !exists(&store, "sources/emails/2026/05/index.jsonl"),
3040 "shard jsonl must be deleted"
3041 );
3042 assert!(
3043 !exists(&store, "records/empty/index.md"),
3044 "empty-folder index must be deleted"
3045 );
3046 assert!(exists(&store, "sources/emails/2026/05/a.md"));
3048 }
3049
3050 #[test]
3051 fn rebuild_deletes_stale_indexes_for_emptied_folders() {
3052 let (_d, store) = mk_store();
3053 write_doc(
3054 &store,
3055 "records/contacts/a.md",
3056 "contact",
3057 Some("A"),
3058 Some("2026-05-01T00:00:00Z"),
3059 "",
3060 );
3061 Index::rebuild_all(&store).unwrap();
3062 assert!(exists(&store, "records/contacts/index.md"));
3063 assert!(exists(&store, "records/index.md"));
3064 assert!(exists(&store, "index.md"));
3065
3066 fs::remove_file(store.root.join("records/contacts/a.md")).unwrap();
3068 Index::rebuild_all(&store).unwrap();
3069 assert!(
3070 !exists(&store, "records/contacts/index.md"),
3071 "emptied type-folder index gone"
3072 );
3073 assert!(
3074 !exists(&store, "records/index.md"),
3075 "now-empty layer index gone"
3076 );
3077 assert!(!exists(&store, "index.md"), "now-empty root index gone");
3078 }
3079
3080 #[test]
3083 fn property_writethrough_equals_rebuild_under_mixed_ops() {
3084 let (_d1, wt) = mk_store();
3086 let (_d2, rb) = mk_store();
3087 let mut seed: u64 = 0x9E3779B97F4A7C15;
3088 let mut next = || {
3089 seed = seed
3090 .wrapping_mul(6364136223846793005)
3091 .wrapping_add(1442695040888963407);
3092 (seed >> 33) as u32
3093 };
3094
3095 let folders = ["sources/emails", "records/contacts", "records/profiles"];
3096 let types = ["email", "contact", "profile"];
3097 let mut live: Vec<String> = Vec::new(); for step in 0..120u32 {
3100 let r = next();
3101 let op = r % 10;
3102 if op < 6 || live.is_empty() {
3103 let fi = (next() as usize) % folders.len();
3105 let folder = folders[fi];
3106 let id = next() % 40;
3107 let rel = if folder == "sources/emails" {
3108 let month = 5 + (id % 2); format!("{folder}/2026/{month:02}/f-{id:02}.md")
3110 } else {
3111 format!("{folder}/f-{id:02}.md")
3112 };
3113 let updated = format!(
3115 "2026-05-{:02}T{:02}:{:02}:00Z",
3116 1 + (step % 27),
3117 step % 24,
3118 id % 60
3119 );
3120 let extra = if id % 3 == 0 {
3121 "tags:\n - x\n - y\n"
3122 } else {
3123 ""
3124 };
3125 write_doc(
3126 &wt,
3127 &rel,
3128 types[fi],
3129 Some(&format!("sum {step}")),
3130 Some(&updated),
3131 extra,
3132 );
3133 write_doc(
3134 &rb,
3135 &rel,
3136 types[fi],
3137 Some(&format!("sum {step}")),
3138 Some(&updated),
3139 extra,
3140 );
3141 Index::on_write(&wt, Path::new(&rel)).unwrap();
3142 if !live.contains(&rel) {
3143 live.push(rel);
3144 }
3145 } else if op < 8 {
3146 let idx = (next() as usize) % live.len();
3148 let rel = live.remove(idx);
3149 fs::remove_file(wt.root.join(&rel)).unwrap();
3150 fs::remove_file(rb.root.join(&rel)).ok();
3151 Index::on_remove(&wt, Path::new(&rel)).unwrap();
3152 } else {
3153 let idx = (next() as usize) % live.len();
3155 let old = live[idx].clone();
3156 let fi = (next() as usize) % folders.len();
3158 let folder = folders[fi];
3159 let id = 50 + (next() % 40);
3160 let new = if folder == "sources/emails" {
3161 format!("{folder}/2026/05/f-{id:02}.md")
3162 } else {
3163 format!("{folder}/f-{id:02}.md")
3164 };
3165 if new == old || live.contains(&new) {
3166 continue;
3167 }
3168 fs::create_dir_all(wt.root.join(&new).parent().unwrap()).unwrap();
3169 fs::create_dir_all(rb.root.join(&new).parent().unwrap()).unwrap();
3170 fs::rename(wt.root.join(&old), wt.root.join(&new)).unwrap();
3171 fs::rename(rb.root.join(&old), rb.root.join(&new)).unwrap();
3172 Index::on_rename(&wt, Path::new(&old), Path::new(&new)).unwrap();
3173 live[idx] = new;
3174 }
3175 }
3176
3177 Index::rebuild_all(&rb).unwrap();
3179 let a = snapshot_artifacts(&wt);
3180 let b = snapshot_artifacts(&rb);
3181 assert_eq!(
3182 a.keys().collect::<BTreeSet<_>>(),
3183 b.keys().collect::<BTreeSet<_>>(),
3184 "write-through and rebuild must produce the same set of artifacts"
3185 );
3186 for (k, v) in &a {
3187 assert_eq!(
3188 v, &b[k],
3189 "INVARIANT VIOLATED: artifact {k} differs after mixed ops\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
3190 b[k]
3191 );
3192 }
3193 assert!(
3194 !a.is_empty(),
3195 "the run must have produced at least one artifact"
3196 );
3197 }
3198
3199 #[test]
3205 fn cleanup_preserves_user_content_named_index_md_in_shard() {
3206 let (_d, store) = mk_store();
3207 write_doc(
3209 &store,
3210 "sources/emails/2026/06/index.md",
3211 "email",
3212 Some("Important imported mail"),
3213 Some("2026-06-11T04:23:25Z"),
3214 "",
3215 );
3216 Index::cleanup(&store).unwrap();
3217 assert!(
3218 exists(&store, "sources/emails/2026/06/index.md"),
3219 "cleanup must not delete a user content file named index.md"
3220 );
3221 Index::rebuild_all(&store).unwrap();
3223 assert!(
3224 exists(&store, "sources/emails/2026/06/index.md"),
3225 "rebuild_all must not delete a user content file named index.md"
3226 );
3227 let kept = read(&store, "sources/emails/2026/06/index.md");
3228 assert!(
3229 kept.contains("Important imported mail"),
3230 "the user's record content must be intact"
3231 );
3232 }
3233
3234 #[test]
3239 fn cleanup_keeps_canonical_type_folder_root_sidecars() {
3240 let (_d, store) = mk_store();
3241 write_doc(
3242 &store,
3243 "records/contacts/alice.md",
3244 "contact",
3245 Some("Alice"),
3246 Some("2026-05-01T00:00:00Z"),
3247 "",
3248 );
3249 Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
3250 assert!(exists(&store, "records/contacts/index.md"));
3251 assert!(exists(&store, "records/contacts/index.jsonl"));
3252 Index::cleanup(&store).unwrap();
3253 assert!(
3254 exists(&store, "records/contacts/index.md"),
3255 "cleanup must keep the canonical type-folder index.md (non-empty folder)"
3256 );
3257 assert!(
3258 exists(&store, "records/contacts/index.jsonl"),
3259 "cleanup must keep the canonical type-folder index.jsonl (non-empty folder)"
3260 );
3261 }
3262
3263 #[test]
3269 fn on_write_ignores_index_artifact_no_phantom_row() {
3270 let (_d, store) = mk_store();
3271 write_doc(
3272 &store,
3273 "records/contacts/alice.md",
3274 "contact",
3275 Some("Alice"),
3276 Some("2026-05-01T00:00:00Z"),
3277 "",
3278 );
3279 Index::on_write(&store, Path::new("records/contacts/alice.md")).unwrap();
3280 let jsonl_before = read(&store, "records/contacts/index.jsonl");
3281 assert_eq!(jsonl_before.lines().count(), 1);
3282
3283 Index::on_write(&store, Path::new("records/contacts/index.md")).unwrap();
3285
3286 let jsonl_after = read(&store, "records/contacts/index.jsonl");
3287 assert_eq!(
3288 jsonl_after.lines().count(),
3289 1,
3290 "on_write on index.md must not add a phantom self-row"
3291 );
3292 assert!(
3293 !jsonl_after.contains("\"type\":\"index\""),
3294 "the catalog artifact must never appear as a catalogued row"
3295 );
3296 let root = read(&store, "index.md");
3298 assert!(
3299 root.contains("[[records/contacts/index|Contacts]] (1)"),
3300 "count must not inflate:\n{root}"
3301 );
3302 }
3303
3304 #[test]
3310 fn multiline_summary_is_single_lined_in_index_md() {
3311 let (_d, store) = mk_store();
3312 write_raw(
3314 &store,
3315 "records/notes/evil.md",
3316 "type: note\nupdated: 2026-06-10T00:00:00Z\nsummary: |-\n legit first line\n - [[records/secrets/fake|Click me]] — injected entry",
3317 "\nbody\n",
3318 );
3319 let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
3320 let md = idx.to_markdown();
3321 let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
3323 assert_eq!(
3324 entry_lines, 1,
3325 "a multi-line summary must not produce extra entry lines:\n{md}"
3326 );
3327 assert!(
3328 md.contains(
3329 "- [[records/notes/evil]] — legit first line - [[records/secrets/fake|Click me]] — injected entry\n"
3330 ),
3331 "summary newlines must collapse to spaces inline:\n{md}"
3332 );
3333 }
3334
3335 #[test]
3343 fn non_string_scalar_summary_and_type_are_coerced_like_validator() {
3344 let (_d, store) = mk_store();
3345 write_raw(
3346 &store,
3347 "records/contacts/a.md",
3348 "type: contact\nupdated: 2026-05-01T00:00:00Z\nsummary: 2026",
3349 "\nbody\n",
3350 );
3351 let rec = record_from_file(
3352 &store.root.join("records/contacts/a.md"),
3353 PathBuf::from("records/contacts/a.md"),
3354 )
3355 .unwrap();
3356 assert_eq!(rec.summary, "2026");
3359 assert_eq!(rec.type_, "contact");
3360
3361 let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
3363 let md = idx.to_markdown();
3364 assert!(
3365 md.contains("- [[records/contacts/a]] — 2026\n"),
3366 "index entry must hold the coerced scalar, not the placeholder:\n{md}"
3367 );
3368
3369 write_raw(
3371 &store,
3372 "records/contacts/b.md",
3373 "type: true\nupdated: 2026-05-02T00:00:00Z\nsummary: hi",
3374 "\nbody\n",
3375 );
3376 let rec_b = record_from_file(
3377 &store.root.join("records/contacts/b.md"),
3378 PathBuf::from("records/contacts/b.md"),
3379 )
3380 .unwrap();
3381 assert_eq!(rec_b.type_, "true");
3382 }
3383
3384 #[test]
3392 fn non_utf8_body_does_not_abort_record_projection() {
3393 let (_d, store) = mk_store();
3394 let rel = "sources/emails/2026/06/x.md";
3395 let abs = store.root.join(rel);
3396 fs::create_dir_all(abs.parent().unwrap()).unwrap();
3397 let mut bytes: Vec<u8> =
3399 b"---\ntype: email\nupdated: 2026-06-11T00:00:00Z\nsummary: An imported email\n---\n\nCaf"
3400 .to_vec();
3401 bytes.push(0xE9);
3402 bytes.extend_from_slice(b" meeting notes\n");
3403 fs::write(&abs, bytes).unwrap();
3404
3405 let rec = record_from_file(&abs, PathBuf::from(rel))
3406 .expect("non-UTF-8 body must not abort the frontmatter read");
3407 assert_eq!(rec.summary, "An imported email");
3408 assert_eq!(rec.type_, "email");
3409
3410 Index::rebuild_all(&store).unwrap();
3412 assert!(
3413 exists(&store, "sources/emails/index.jsonl"),
3414 "rebuild must produce the catalog despite a non-UTF-8 body byte"
3415 );
3416 assert!(
3417 read(&store, "sources/emails/index.jsonl").contains("An imported email"),
3418 "the record must be catalogued"
3419 );
3420 }
3421
3422 #[test]
3431 fn rebuild_aborts_on_malformed_file_and_keeps_prior_catalogs() {
3432 let (_d, store) = mk_store();
3433 write_doc(
3434 &store,
3435 "records/contacts/alice.md",
3436 "contact",
3437 Some("Alice"),
3438 Some("2026-05-01T00:00:00Z"),
3439 "",
3440 );
3441 write_doc(
3442 &store,
3443 "records/companies/acme.md",
3444 "company",
3445 Some("Acme"),
3446 Some("2026-05-02T00:00:00Z"),
3447 "",
3448 );
3449
3450 Index::rebuild_all(&store).expect("clean rebuild succeeds");
3452 assert!(exists(&store, "records/contacts/index.jsonl"));
3453 assert!(exists(&store, "records/companies/index.jsonl"));
3454
3455 let bad = store.root.join("records/contacts/broken.md");
3457 fs::write(
3458 &bad,
3459 "---\ntype: contact\nsummary: \"unterminated\n---\nbody\n",
3460 )
3461 .unwrap();
3462
3463 Index::rebuild_all(&store)
3466 .expect_err("rebuild must abort, not silently skip, on a malformed file");
3467
3468 assert!(
3472 exists(&store, "records/companies/index.jsonl"),
3473 "an aborted rebuild must not destroy a clean sibling folder's catalog"
3474 );
3475 assert!(
3476 exists(&store, "records/contacts/index.jsonl"),
3477 "an aborted rebuild must not destroy the affected folder's prior catalog"
3478 );
3479 let contacts_jsonl = read(&store, "records/contacts/index.jsonl");
3480 assert!(contacts_jsonl.contains("records/contacts/alice.md"));
3481 }
3482
3483 #[test]
3496 fn rebuild_rollup_counts_equal_jsonl_records_and_write_through() {
3497 let (_d, store) = mk_store();
3498 write_doc(
3502 &store,
3503 "records/contacts/alice.md",
3504 "contact",
3505 Some("Alice"),
3506 Some("2026-05-01T00:00:00Z"),
3507 "",
3508 );
3509 write_doc(
3510 &store,
3511 "records/contacts/bob.md",
3512 "contact",
3513 Some("Bob"),
3514 Some("2026-05-02T00:00:00Z"),
3515 "",
3516 );
3517 Index::rebuild_all(&store).expect("clean rebuild succeeds");
3518
3519 let jsonl_lines = read(&store, "records/contacts/index.jsonl")
3521 .lines()
3522 .filter(|l| !l.trim().is_empty())
3523 .count();
3524 assert_eq!(jsonl_lines, 2, "two well-formed files ⇒ two jsonl records");
3525 let layer_md = read(&store, "records/index.md");
3526 let root_md = read(&store, "index.md");
3527 assert!(
3528 layer_md.contains("- [[records/contacts/index|Contacts]] (2)"),
3529 "layer rollup (N) must equal the jsonl record count (2), not a raw .md walk:\n{layer_md}"
3530 );
3531 assert!(
3532 root_md.contains("- [[records/contacts/index|Contacts]] (2)\n")
3533 && root_md.contains("## Records (2)"),
3534 "root rollup (N)/layer total must equal the jsonl record count (2):\n{root_md}"
3535 );
3536
3537 let (_d2, wt) = mk_store();
3544 write_doc(
3545 &wt,
3546 "records/contacts/alice.md",
3547 "contact",
3548 Some("Alice"),
3549 Some("2026-05-01T00:00:00Z"),
3550 "",
3551 );
3552 write_doc(
3553 &wt,
3554 "records/contacts/bob.md",
3555 "contact",
3556 Some("Bob"),
3557 Some("2026-05-02T00:00:00Z"),
3558 "",
3559 );
3560 Index::on_write(&wt, Path::new("records/contacts/alice.md")).unwrap();
3561 Index::on_write(&wt, Path::new("records/contacts/bob.md")).unwrap();
3562
3563 let a = snapshot_artifacts(&wt);
3564 let b = snapshot_artifacts(&store);
3565 assert_eq!(
3566 a.keys().collect::<BTreeSet<_>>(),
3567 b.keys().collect::<BTreeSet<_>>(),
3568 "write-through and rebuild_all must produce the same artifact set"
3569 );
3570 for (k, v) in &a {
3571 assert_eq!(
3572 v, &b[k],
3573 "rollup bytes diverged between write-through and rebuild_all for {k} \
3574 (a skip-version inflates rebuild_all's (N) above the jsonl record \
3575 count, which write-through then rewrites):\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
3576 b[k]
3577 );
3578 }
3579 }
3580
3581 #[cfg(unix)]
3586 #[test]
3587 fn non_utf8_path_component_is_kept_not_dropped() {
3588 use std::ffi::OsStr;
3589 use std::os::unix::ffi::OsStrExt;
3590 let mut leaf = b"caf".to_vec();
3592 leaf.push(0xE9);
3593 leaf.extend_from_slice(b".md");
3594 let p = Path::new("sources/emails").join(OsStr::from_bytes(&leaf));
3595 let unix = path_to_unix(&p);
3596 assert_ne!(
3599 unix, "sources/emails",
3600 "non-UTF-8 leaf must not be dropped, collapsing the path to its parent dir"
3601 );
3602 assert!(
3603 unix.starts_with("sources/emails/caf"),
3604 "the lossy leaf must remain under its folder: {unix}"
3605 );
3606 }
3607
3608 #[test]
3611 fn loose_file_is_catalogued_in_layer_jsonl_not_type_folder() {
3612 let (_d, store) = mk_store();
3613 write_doc(
3615 &store,
3616 "records/contacts/alice.md",
3617 "contact",
3618 Some("Alice"),
3619 Some("2026-06-01T08:00:00Z"),
3620 "id: alice\n",
3621 );
3622 write_doc(
3623 &store,
3624 "records/loose.md",
3625 "contact",
3626 Some("Loose"),
3627 Some("2026-06-01T08:00:00Z"),
3628 "id: loose\n",
3629 );
3630 Index::rebuild_all(&store).unwrap();
3631
3632 assert!(
3635 exists(&store, "records/index.jsonl"),
3636 "layer jsonl must exist when loose files are present"
3637 );
3638 let layer_jsonl = read(&store, "records/index.jsonl");
3639 assert!(
3640 layer_jsonl.contains("records/loose.md"),
3641 "layer jsonl must list the loose file, got:\n{layer_jsonl}"
3642 );
3643 assert!(
3644 !layer_jsonl.contains("records/contacts/alice.md"),
3645 "layer jsonl must NOT list type-folder files"
3646 );
3647 let tf_jsonl = read(&store, "records/contacts/index.jsonl");
3648 assert!(tf_jsonl.contains("records/contacts/alice.md"));
3649 assert!(!tf_jsonl.contains("records/loose.md"));
3650
3651 let layer_md = read(&store, "records/index.md");
3653 assert!(
3654 layer_md.contains("records/contacts/index"),
3655 "layer md must roll up the type-folder, got:\n{layer_md}"
3656 );
3657 assert!(
3658 !layer_md.contains("records/loose"),
3659 "layer md must stay a rollup, not list loose files, got:\n{layer_md}"
3660 );
3661 }
3662
3663 #[test]
3664 fn loose_file_write_through_equals_rebuild() {
3665 let (_d1, wt) = mk_store();
3666 let (_d2, rb) = mk_store();
3667 for s in [&wt, &rb] {
3668 write_doc(
3669 s,
3670 "records/contacts/alice.md",
3671 "contact",
3672 Some("Alice"),
3673 Some("2026-06-01T08:00:00Z"),
3674 "id: alice\n",
3675 );
3676 write_doc(
3677 s,
3678 "records/loose.md",
3679 "contact",
3680 Some("Loose"),
3681 Some("2026-06-02T08:00:00Z"),
3682 "id: loose\n",
3683 );
3684 }
3685 Index::on_write(&wt, Path::new("records/contacts/alice.md")).unwrap();
3687 Index::on_write(&wt, Path::new("records/loose.md")).unwrap();
3688 Index::rebuild_all(&rb).unwrap();
3689
3690 let a = snapshot_artifacts(&wt);
3691 let b = snapshot_artifacts(&rb);
3692 assert_eq!(
3693 a.keys().collect::<Vec<_>>(),
3694 b.keys().collect::<Vec<_>>(),
3695 "loose-file loop and sweep must produce the same artifact set"
3696 );
3697 for (k, v) in &a {
3698 assert_eq!(
3699 v, &b[k],
3700 "loose-file artifact {k} differs between loop and sweep"
3701 );
3702 }
3703 }
3704
3705 #[test]
3706 fn removing_last_loose_file_clears_layer_jsonl() {
3707 let (_d, store) = mk_store();
3708 write_doc(
3709 &store,
3710 "records/loose.md",
3711 "contact",
3712 Some("Loose"),
3713 Some("2026-06-01T08:00:00Z"),
3714 "id: loose\n",
3715 );
3716 Index::on_write(&store, Path::new("records/loose.md")).unwrap();
3717 assert!(
3718 exists(&store, "records/index.jsonl"),
3719 "layer jsonl present after a loose write"
3720 );
3721 fs::remove_file(store.root.join("records/loose.md")).unwrap();
3722 Index::on_remove(&store, Path::new("records/loose.md")).unwrap();
3723 assert!(
3724 !exists(&store, "records/index.jsonl"),
3725 "layer jsonl must be removed once the last loose file is gone"
3726 );
3727 }
3728
3729 #[test]
3732 fn concurrent_writes_to_different_type_folders_match_rebuild() {
3733 use std::sync::Arc;
3734 use std::thread;
3735
3736 let (_d, store) = mk_store();
3745 let folders = ["records/contacts", "records/companies"];
3746 let n = 12usize;
3747
3748 for (fi, folder) in folders.iter().enumerate() {
3751 for i in 0..n {
3752 write_doc(
3753 &store,
3754 &format!("{folder}/f{fi}_{i}.md"),
3755 "contact",
3756 Some(&format!("Summary {fi}-{i}")),
3757 Some(&format!("2026-06-{:02}T08:00:00Z", i + 1)),
3758 &format!("id: f{fi}_{i}\n"),
3759 );
3760 }
3761 }
3762
3763 let store = Arc::new(store);
3764 let handles: Vec<_> = folders
3765 .iter()
3766 .enumerate()
3767 .map(|(fi, folder)| {
3768 let store = Arc::clone(&store);
3769 let folder = folder.to_string();
3770 thread::spawn(move || {
3771 for i in 0..n {
3772 let rel = format!("{folder}/f{fi}_{i}.md");
3773 Index::on_write(&store, Path::new(&rel)).unwrap();
3774 }
3775 })
3776 })
3777 .collect();
3778 for h in handles {
3779 h.join().unwrap();
3780 }
3781
3782 let got = snapshot_artifacts(&store);
3785 Index::rebuild_all(&store).unwrap();
3786 let want = snapshot_artifacts(&store);
3787
3788 assert_eq!(
3789 got.keys().collect::<Vec<_>>(),
3790 want.keys().collect::<Vec<_>>(),
3791 "artifact set after concurrent write-through must match rebuild"
3792 );
3793 for (k, v) in &want {
3794 assert_eq!(
3795 &got[k], v,
3796 "rollup artifact {k} diverged from rebuild after concurrent writes"
3797 );
3798 }
3799 }
3800}