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(store, &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(store, &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(store, &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 .follow_links(true)
628 .into_iter()
629 .filter_entry(|entry| store.owns_path(entry.path()))
630 .filter_map(|e| e.ok())
631 {
632 let p = entry.path();
633 if is_index_artifact(p) && is_deletable_catalog_artifact(p) {
634 remove_if_exists(p)?;
635 }
636 }
637 if walk_type_folder_files(store, &tf_abs).is_empty() {
641 let md = tf_abs.join("index.md");
642 if is_deletable_catalog_artifact(&md) {
643 remove_if_exists(&md)?;
644 }
645 remove_if_exists(&tf_abs.join("index.jsonl"))?;
646 }
647 }
648 }
649 Ok(())
650 }
651}
652
653fn write_type_folder_artifacts(
661 store: &Store,
662 folder: &Path,
663 records: &[IndexRecord],
664) -> crate::Result<()> {
665 let folder_abs = store.root.join(folder);
666 let md_path = folder_abs.join("index.md");
667 let jsonl_path = folder_abs.join("index.jsonl");
668 if records.is_empty() {
669 remove_if_exists(&md_path)?;
670 remove_if_exists(&jsonl_path)?;
671 return Ok(());
672 }
673 let idx = Index {
674 level: IndexLevel::TypeFolder(folder.to_path_buf()),
675 records: records.to_vec(),
676 child_counts: BTreeMap::new(),
677 };
678 write_atomic(&md_path, idx.to_markdown())?;
679 write_atomic(&jsonl_path, idx.to_jsonl())?;
680 Ok(())
681}
682
683fn update_parents(store: &Store, folder: &Path) -> crate::Result<()> {
696 let _root_lock = FolderLock::acquire(&store.root);
729 let stats = collect_child_stats(store, &Layer::all())?;
730
731 let layer = folder
732 .components()
733 .next()
734 .and_then(|c| c.as_os_str().to_str())
735 .and_then(layer_from_dir_name);
736 if let Some(layer) = layer {
737 let p = store.root.join(layer_dir_name(layer)).join("index.md");
738 if layer_has_children(&stats, layer) {
739 write_atomic(
740 &p,
741 render_layer_md_from_stats(layer, &stats, &store.config.folders),
742 )?;
743 } else {
744 remove_if_exists(&p)?;
745 }
746 }
747 let rp = store.root.join("index.md");
748 if stats.values().any(|s| s.count > 0) {
749 write_atomic(
750 &rp,
751 render_root_md_from_stats(&stats, &store.config.folders),
752 )?;
753 } else {
754 remove_if_exists(&rp)?;
755 }
756 Ok(())
757}
758
759fn layer_has_children(stats: &BTreeMap<PathBuf, FolderStat>, layer: Layer) -> bool {
761 let prefix = format!("{}/", layer_dir_name(layer));
762 stats
763 .iter()
764 .any(|(tf, s)| s.count > 0 && path_to_unix(tf).starts_with(&prefix))
765}
766
767fn render_layer_md_from_stats(
772 layer: Layer,
773 stats: &BTreeMap<PathBuf, FolderStat>,
774 folders: &BTreeMap<String, FolderMeta>,
775) -> String {
776 let layer_dir = layer_dir_name(layer);
777 let prefix = format!("{layer_dir}/");
778 let mut max_upd: Option<DateTime<FixedOffset>> = None;
779 let mut entries = String::new();
780 for (tf, stat) in stats {
781 if stat.count == 0 || !path_to_unix(tf).starts_with(&prefix) {
782 continue;
783 }
784 if let Some(u) = stat.newest.as_ref().and_then(|r| r.updated) {
785 max_upd = Some(match max_upd {
786 Some(cur) if cur >= u => cur,
787 _ => u,
788 });
789 }
790 let tf_unix = path_to_unix(tf);
791 let (display, description) = folder_label(&tf_unix, folder_basename(tf), folders);
792 entries.push_str(&folder_entry(&tf_unix, &display, stat.count, description));
793 }
794 let mut s = String::new();
795 s.push_str("---\n");
796 s.push_str("type: index\n");
797 s.push_str("scope: layer\n");
798 s.push_str(&format!("folder: {layer_dir}\n"));
799 if let Some(ts) = max_upd {
800 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
801 }
802 s.push_str("---\n\n");
803 s.push_str(&format!("# {layer_dir}\n\n"));
804 s.push_str(&entries);
805 s
806}
807
808fn render_root_md_from_stats(
810 stats: &BTreeMap<PathBuf, FolderStat>,
811 folders: &BTreeMap<String, FolderMeta>,
812) -> String {
813 let mut max_upd: Option<DateTime<FixedOffset>> = None;
814 for stat in stats.values() {
815 if stat.count == 0 {
816 continue;
817 }
818 if let Some(u) = stat.newest.as_ref().and_then(|r| r.updated) {
819 max_upd = Some(match max_upd {
820 Some(cur) if cur >= u => cur,
821 _ => u,
822 });
823 }
824 }
825 let mut s = String::new();
826 s.push_str("---\n");
827 s.push_str("type: index\n");
828 s.push_str("scope: root\n");
829 if let Some(ts) = max_upd {
830 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
831 }
832 s.push_str("---\n\n");
833 s.push_str(&format!("# {ROOT_TITLE}\n"));
834 for layer in Layer::all() {
835 let layer_dir = layer_dir_name(layer);
836 let prefix = format!("{layer_dir}/");
837 let children: Vec<(&PathBuf, usize)> = stats
838 .iter()
839 .filter(|(tf, s)| s.count > 0 && path_to_unix(tf).starts_with(&prefix))
840 .map(|(tf, s)| (tf, s.count))
841 .collect();
842 if children.is_empty() {
843 continue;
844 }
845 let total: usize = children.iter().map(|(_, n)| *n).sum();
846 s.push('\n');
847 s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
848 for (tf, n) in children {
849 let tf_unix = path_to_unix(tf);
850 let (display, description) = folder_label(&tf_unix, folder_basename(tf), folders);
851 s.push_str(&folder_entry(&tf_unix, &display, n, description));
852 }
853 }
854 s
855}
856
857fn render_layer_md_with_store(store: &Store, idx: &Index) -> String {
864 let layer = match idx.level {
865 IndexLevel::Layer(l) => l,
866 _ => unreachable!("render_layer_md_with_store called on non-layer"),
867 };
868 let layer_dir = layer_dir_name(layer);
869 let mut max_upd: Option<DateTime<FixedOffset>> = None;
870 let mut entries = String::new();
871 for (tf, n) in &idx.child_counts {
872 let recs = read_jsonl_records(&store.root.join(tf).join("index.jsonl")).unwrap_or_default();
873 let newest = recs.first();
874 if let Some(u) = newest.and_then(|r| r.updated) {
875 max_upd = Some(match max_upd {
876 Some(cur) if cur >= u => cur,
877 _ => u,
878 });
879 }
880 let tf_unix = path_to_unix(tf);
881 let (display, description) =
882 folder_label(&tf_unix, folder_basename(tf), &store.config.folders);
883 entries.push_str(&folder_entry(&tf_unix, &display, *n, description));
884 }
885 let mut s = String::new();
886 s.push_str("---\n");
887 s.push_str("type: index\n");
888 s.push_str("scope: layer\n");
889 s.push_str(&format!("folder: {layer_dir}\n"));
890 if let Some(ts) = max_upd {
891 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
892 }
893 s.push_str("---\n\n");
894 s.push_str(&format!("# {layer_dir}\n\n"));
895 s.push_str(&entries);
896 s
897}
898
899fn render_root_md_with_store(store: &Store, idx: &Index) -> String {
903 let mut max_upd: Option<DateTime<FixedOffset>> = None;
904 for tf in idx.child_counts.keys() {
905 let recs = read_jsonl_records(&store.root.join(tf).join("index.jsonl")).unwrap_or_default();
906 if let Some(u) = recs.first().and_then(|r| r.updated) {
907 max_upd = Some(match max_upd {
908 Some(cur) if cur >= u => cur,
909 _ => u,
910 });
911 }
912 }
913 let mut s = String::new();
914 s.push_str("---\n");
915 s.push_str("type: index\n");
916 s.push_str("scope: root\n");
917 if let Some(ts) = max_upd {
918 s.push_str(&format!("updated: {}\n", fmt_ts(&ts)));
919 }
920 s.push_str("---\n\n");
921 s.push_str(&format!("# {ROOT_TITLE}\n"));
922 for layer in Layer::all() {
923 let layer_dir = layer_dir_name(layer);
924 let prefix = format!("{layer_dir}/");
925 let children: Vec<(&PathBuf, &usize)> = idx
926 .child_counts
927 .iter()
928 .filter(|(tf, _)| path_to_unix(tf).starts_with(&prefix))
929 .collect();
930 if children.is_empty() {
931 continue;
932 }
933 let total: usize = children.iter().map(|(_, n)| **n).sum();
934 s.push('\n');
935 s.push_str(&format!("## {} ({total})\n", capitalize(layer_dir)));
936 for (tf, n) in children {
937 let tf_unix = path_to_unix(tf);
938 let (display, description) =
939 folder_label(&tf_unix, folder_basename(tf), &store.config.folders);
940 s.push_str(&folder_entry(&tf_unix, &display, *n, description));
941 }
942 }
943 s
944}
945
946fn format_md_entry(rec: &IndexRecord) -> String {
952 let path = wiki_target(&rec.path);
953 let summary = collapse_whitespace(&rec.summary);
962 let mut line = format!("- [[{path}]] — {summary}");
963 if !rec.tags.is_empty() {
964 let tags = rec
965 .tags
966 .iter()
967 .map(|t| format!("#{t}"))
968 .collect::<Vec<_>>()
969 .join(" ");
970 line.push_str(&format!(" · {tags}"));
971 }
972 line
973}
974
975fn more_footer(total: usize, type_: &str, layer: &str) -> String {
977 format!(
978 "## 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"
979 )
980}
981
982fn sort_records(records: &mut [IndexRecord]) {
986 records.sort_by(record_recency_cmp);
987}
988
989impl IndexRecord {
990 pub(crate) fn expected_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
1002 record_from_file(abs, rel)
1003 }
1004}
1005
1006fn record_from_file(abs: &Path, rel: PathBuf) -> crate::Result<IndexRecord> {
1009 let mut meta = read_frontmatter(abs)?;
1010 if rel.starts_with("records") {
1015 meta.fields
1016 .entry("meta-type".to_string())
1017 .or_insert_with(|| Value::String("fact".to_string()));
1018 }
1019 Ok(IndexRecord {
1020 path: rel,
1021 type_: meta.type_.unwrap_or_default(),
1022 summary: meta.summary.unwrap_or_else(|| MISSING_SUMMARY.to_string()),
1023 tags: meta.tags,
1024 links: meta.links,
1025 created: meta.created,
1026 updated: meta.updated,
1027 fields: meta.fields,
1028 })
1029}
1030
1031struct FileMeta {
1033 type_: Option<String>,
1034 summary: Option<String>,
1035 tags: Vec<String>,
1036 links: Vec<String>,
1037 created: Option<DateTime<FixedOffset>>,
1038 updated: Option<DateTime<FixedOffset>>,
1039 fields: BTreeMap<String, Value>,
1040}
1041
1042fn read_frontmatter(abs: &Path) -> crate::Result<FileMeta> {
1056 let bytes = fs::read(abs)?;
1057 let yaml = extract_frontmatter_block_lossy(&bytes).unwrap_or_default();
1058 let map: serde_norway::Mapping = if yaml.trim().is_empty() {
1059 serde_norway::Mapping::new()
1060 } else {
1061 serde_norway::from_str(&yaml).map_err(|e| {
1062 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1063 path: abs.to_path_buf(),
1064 message: format!("frontmatter YAML: {e}"),
1065 })
1066 })?
1067 };
1068
1069 let mut type_ = None;
1070 let mut summary = None;
1071 let mut tags = Vec::new();
1072 let mut links = Vec::new();
1073 let mut created = None;
1074 let mut updated = None;
1075 let mut fields = BTreeMap::new();
1076
1077 for (k, v) in map {
1078 let key = match k.as_str() {
1079 Some(s) => s.to_string(),
1080 None => continue,
1081 };
1082 match key.as_str() {
1083 "type" => type_ = scalar_string(&v),
1093 "summary" => summary = scalar_string(&v),
1094 "tags" => tags = yaml_string_list(&v),
1095 "links" => links = yaml_string_list(&v),
1096 "created" => created = v.as_str().and_then(parse_ts),
1097 "updated" => updated = v.as_str().and_then(parse_ts),
1098 "path" => {}
1102 _ => {
1103 fields.insert(key, yaml_to_json_value(&v));
1104 }
1105 }
1106 }
1107
1108 Ok(FileMeta {
1109 type_,
1110 summary,
1111 tags,
1112 links,
1113 created,
1114 updated,
1115 fields,
1116 })
1117}
1118
1119pub(crate) fn scalar_string(v: &serde_norway::Value) -> Option<String> {
1126 match v {
1127 serde_norway::Value::String(s) => Some(s.clone()),
1128 serde_norway::Value::Number(n) => Some(n.to_string()),
1129 serde_norway::Value::Bool(b) => Some(b.to_string()),
1130 _ => None,
1131 }
1132}
1133
1134fn extract_frontmatter_block_lossy(bytes: &[u8]) -> Option<String> {
1140 let text = String::from_utf8_lossy(bytes);
1145 extract_frontmatter_block(&text)
1146}
1147
1148fn extract_frontmatter_block(text: &str) -> Option<String> {
1151 let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
1152 let mut lines = trimmed.lines();
1153 let first = lines.next()?;
1154 if first.trim_end() != "---" {
1155 return None;
1156 }
1157 let mut block = String::new();
1158 for line in lines {
1159 if line.trim_end() == "---" {
1160 return Some(block);
1161 }
1162 block.push_str(line);
1163 block.push('\n');
1164 }
1165 None }
1167
1168fn yaml_string_list(v: &serde_norway::Value) -> Vec<String> {
1171 match v {
1172 serde_norway::Value::String(s) => vec![s.clone()],
1173 serde_norway::Value::Sequence(seq) => seq
1174 .iter()
1175 .filter_map(yaml_string_or_wiki_link_literal)
1176 .collect(),
1177 _ => Vec::new(),
1178 }
1179}
1180
1181fn yaml_string_or_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
1182 v.as_str()
1183 .map(str::to_string)
1184 .or_else(|| unquoted_wiki_link_literal(v))
1185}
1186
1187pub(crate) fn yaml_to_json_value(v: &serde_norway::Value) -> Value {
1194 if let Some(link) = unquoted_wiki_link_literal(v) {
1195 return Value::String(link);
1196 }
1197 match v {
1198 serde_norway::Value::String(s) => Value::String(s.clone()),
1199 serde_norway::Value::Bool(b) => Value::Bool(*b),
1200 serde_norway::Value::Number(n) => {
1201 serde_json::to_value(n).unwrap_or_else(|_| Value::String(n.to_string()))
1202 }
1203 serde_norway::Value::Sequence(seq) => {
1204 Value::Array(seq.iter().map(yaml_to_json_value).collect())
1205 }
1206 serde_norway::Value::Mapping(_) | serde_norway::Value::Tagged(_) => {
1207 serde_json::to_value(v).unwrap_or(Value::Null)
1208 }
1209 serde_norway::Value::Null => Value::Null,
1210 }
1211}
1212
1213fn unquoted_wiki_link_literal(v: &serde_norway::Value) -> Option<String> {
1214 let serde_norway::Value::Sequence(outer) = v else {
1215 return None;
1216 };
1217 if outer.len() != 1 {
1218 return None;
1219 }
1220 let serde_norway::Value::Sequence(inner) = &outer[0] else {
1221 return None;
1222 };
1223 let [serde_norway::Value::String(target)] = inner.as_slice() else {
1224 return None;
1225 };
1226 Some(format!("[[{target}]]"))
1227}
1228
1229pub(crate) fn parse_ts(s: &str) -> Option<DateTime<FixedOffset>> {
1233 DateTime::parse_from_rfc3339(s.trim()).ok()
1234}
1235
1236fn fmt_ts(ts: &DateTime<FixedOffset>) -> String {
1240 ts.to_rfc3339_opts(SecondsFormat::AutoSi, true)
1241}
1242
1243fn max_updated<'a>(
1245 it: impl Iterator<Item = Option<&'a DateTime<FixedOffset>>>,
1246) -> Option<DateTime<FixedOffset>> {
1247 let mut best: Option<DateTime<FixedOffset>> = None;
1248 for ts in it.flatten() {
1249 best = Some(match best {
1250 Some(cur) if cur >= *ts => cur,
1251 _ => *ts,
1252 });
1253 }
1254 best
1255}
1256
1257fn read_jsonl_records(jsonl: &Path) -> crate::Result<Vec<IndexRecord>> {
1261 let text = match fs::read_to_string(jsonl) {
1262 Ok(t) => t,
1263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1264 Err(e) => return Err(e.into()),
1265 };
1266 let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
1268 for (i, line) in text.lines().enumerate() {
1269 if line.trim().is_empty() {
1270 continue;
1271 }
1272 let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
1273 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1274 path: jsonl.to_path_buf(),
1275 message: format!("line {}: {e}", i + 1),
1276 })
1277 })?;
1278 by_path.insert(rec.path.clone(), rec);
1279 }
1280 let mut records: Vec<IndexRecord> = by_path.into_values().collect();
1281 sort_records(&mut records);
1282 Ok(records)
1283}
1284
1285#[derive(Debug, Clone, Default, PartialEq)]
1292struct FolderStat {
1293 count: usize,
1294 newest: Option<IndexRecord>,
1295}
1296
1297fn read_folder_stat(jsonl: &Path) -> crate::Result<FolderStat> {
1307 let text = match fs::read_to_string(jsonl) {
1308 Ok(t) => t,
1309 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FolderStat::default()),
1310 Err(e) => return Err(e.into()),
1311 };
1312 let mut by_path: BTreeMap<PathBuf, IndexRecord> = BTreeMap::new();
1315 for (i, line) in text.lines().enumerate() {
1316 if line.trim().is_empty() {
1317 continue;
1318 }
1319 let rec: IndexRecord = serde_json::from_str(line).map_err(|e| {
1320 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1321 path: jsonl.to_path_buf(),
1322 message: format!("line {}: {e}", i + 1),
1323 })
1324 })?;
1325 by_path.insert(rec.path.clone(), rec);
1326 }
1327 let count = by_path.len();
1328 let newest = by_path.into_values().min_by(record_recency_cmp);
1332 Ok(FolderStat { count, newest })
1333}
1334
1335fn record_recency_cmp(a: &IndexRecord, b: &IndexRecord) -> std::cmp::Ordering {
1340 match (b.updated, a.updated) {
1341 (Some(bu), Some(au)) => bu.cmp(&au),
1342 (Some(_), None) => std::cmp::Ordering::Greater, (None, Some(_)) => std::cmp::Ordering::Less, (None, None) => std::cmp::Ordering::Equal,
1345 }
1346 .then_with(|| a.path.cmp(&b.path))
1347}
1348
1349fn collect_child_stats(
1362 store: &Store,
1363 layers: &[Layer],
1364) -> crate::Result<BTreeMap<PathBuf, FolderStat>> {
1365 let mut stats = BTreeMap::new();
1366 for &layer in layers {
1367 for tf in type_folders_in_layer(store, layer) {
1368 let stat = read_folder_stat(&store.root.join(&tf).join("index.jsonl"))?;
1369 if stat.count > 0 {
1370 stats.insert(tf, stat);
1371 }
1372 }
1373 }
1374 Ok(stats)
1375}
1376
1377fn walk_type_folder_files(store: &Store, folder_abs: &Path) -> Vec<PathBuf> {
1380 let mut out = Vec::new();
1381 if !folder_abs.is_dir() {
1382 return out;
1383 }
1384 for entry in walkdir::WalkDir::new(folder_abs)
1385 .follow_links(true)
1386 .into_iter()
1387 .filter_entry(|e| !is_hidden(e.file_name()) && store.owns_path(e.path()))
1388 .filter_map(|e| e.ok())
1389 {
1390 if !entry.file_type().is_file() {
1391 continue;
1392 }
1393 let p = entry.path();
1394 if p.extension().and_then(|e| e.to_str()) != Some("md") {
1395 continue;
1396 }
1397 if p.file_name().and_then(|n| n.to_str()) == Some("index.md") {
1398 continue;
1399 }
1400 out.push(p.to_path_buf());
1401 }
1402 out
1403}
1404
1405fn type_folders_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1408 let layer_dir = store.root.join(layer_dir_name(layer));
1409 let mut out = Vec::new();
1410 let rd = match fs::read_dir(&layer_dir) {
1411 Ok(rd) => rd,
1412 Err(_) => return out,
1413 };
1414 for entry in rd.flatten() {
1415 if !entry.path().is_dir() || !store.owns_path(&entry.path()) {
1416 continue;
1417 }
1418 let name = entry.file_name();
1419 let name = match name.to_str() {
1420 Some(n) => n,
1421 None => continue,
1422 };
1423 if is_hidden(entry.file_name().as_os_str()) || name == "log" {
1424 continue;
1425 }
1426 out.push(PathBuf::from(layer_dir_name(layer)).join(name));
1427 }
1428 out.sort();
1429 out
1430}
1431
1432fn loose_layer_of(file_rel: &Path) -> Option<Layer> {
1438 let mut comps = file_rel.components();
1439 let layer = layer_from_dir_name(comps.next()?.as_os_str().to_str()?)?;
1440 comps.next()?; if comps.next().is_some() {
1442 return None; }
1444 Some(layer)
1445}
1446
1447fn loose_files_in_layer(store: &Store, layer: Layer) -> Vec<PathBuf> {
1451 let layer_dir = store.root.join(layer_dir_name(layer));
1452 let mut out = Vec::new();
1453 let rd = match fs::read_dir(&layer_dir) {
1454 Ok(rd) => rd,
1455 Err(_) => return out,
1456 };
1457 for entry in rd.flatten() {
1458 let p = entry.path();
1459 if !p.is_file() || !store.owns_path(&p) {
1460 continue;
1461 }
1462 if p.extension().and_then(|e| e.to_str()) != Some("md") {
1463 continue;
1464 }
1465 if is_index_artifact(&p) || is_hidden(entry.file_name().as_os_str()) {
1466 continue;
1467 }
1468 out.push(p);
1469 }
1470 out
1471}
1472
1473fn write_layer_jsonl(store: &Store, layer: Layer, records: &[IndexRecord]) -> crate::Result<()> {
1478 let path = store.root.join(layer_dir_name(layer)).join("index.jsonl");
1479 if records.is_empty() {
1480 remove_if_exists(&path)?;
1481 return Ok(());
1482 }
1483 let idx = Index {
1484 level: IndexLevel::Layer(layer),
1485 records: records.to_vec(),
1486 child_counts: BTreeMap::new(),
1487 };
1488 write_atomic(&path, idx.to_jsonl())
1489}
1490
1491fn apply_loose_change(
1496 store: &Store,
1497 layer: Layer,
1498 file_rel: &Path,
1499 removing: bool,
1500) -> crate::Result<()> {
1501 let layer_dir = store.root.join(layer_dir_name(layer));
1502 let _lock = FolderLock::acquire(&layer_dir);
1503 let jsonl = layer_dir.join("index.jsonl");
1504 let mut records = read_jsonl_records(&jsonl)?;
1505 records.retain(|r| r.path != file_rel);
1506 if !removing {
1507 records.push(record_from_file(
1508 &store.root.join(file_rel),
1509 file_rel.to_path_buf(),
1510 )?);
1511 }
1512 sort_records(&mut records);
1513 write_layer_jsonl(store, layer, &records)
1514}
1515
1516fn type_folder_of(file_rel: &Path) -> Option<PathBuf> {
1520 let mut comps = file_rel.components();
1521 let layer = comps.next()?.as_os_str().to_str()?;
1522 layer_from_dir_name(layer)?;
1523 let type_seg = comps.next()?.as_os_str().to_str()?;
1524 Some(PathBuf::from(layer).join(type_seg))
1525}
1526
1527fn rel_to_store(root: &Path, abs: &Path) -> Option<PathBuf> {
1529 abs.strip_prefix(root).ok().map(|p| p.to_path_buf())
1530}
1531
1532fn normalize_rel(p: &Path) -> PathBuf {
1535 let s = path_to_unix(p);
1536 let s = s.strip_prefix("./").unwrap_or(&s);
1537 PathBuf::from(s)
1538}
1539
1540fn is_index_artifact(p: &Path) -> bool {
1541 matches!(
1542 p.file_name().and_then(|n| n.to_str()),
1543 Some("index.md") | Some("index.jsonl")
1544 )
1545}
1546
1547fn is_deletable_catalog_artifact(p: &Path) -> bool {
1561 match p.file_name().and_then(|n| n.to_str()) {
1562 Some("index.jsonl") => true,
1563 Some("index.md") => match read_frontmatter(p) {
1564 Ok(meta) => meta.type_.as_deref().is_none_or(|t| t == "index"),
1566 Err(_) => true,
1568 },
1569 _ => false,
1570 }
1571}
1572
1573fn is_hidden(name: &std::ffi::OsStr) -> bool {
1574 name.to_str().map(|s| s.starts_with('.')).unwrap_or(false)
1575}
1576
1577fn layer_dir_name(layer: Layer) -> &'static str {
1578 match layer {
1579 Layer::Sources => "sources",
1580 Layer::Records => "records",
1581 }
1582}
1583
1584fn layer_from_dir_name(name: &str) -> Option<Layer> {
1587 match name {
1588 "sources" => Some(Layer::Sources),
1589 "records" => Some(Layer::Records),
1590 _ => None,
1591 }
1592}
1593
1594fn folder_basename(p: &Path) -> &str {
1596 p.file_name().and_then(|n| n.to_str()).unwrap_or("")
1597}
1598
1599fn wiki_target(p: &Path) -> String {
1603 let unix = path_to_unix(p);
1604 unix.strip_suffix(".md").unwrap_or(&unix).to_string()
1605}
1606
1607fn path_to_unix(p: &Path) -> String {
1619 p.components()
1620 .map(|c| c.as_os_str().to_string_lossy().into_owned())
1621 .collect::<Vec<_>>()
1622 .join("/")
1623}
1624
1625mod path_serde {
1631 use super::path_to_unix;
1632 use serde::{Deserialize, Deserializer, Serializer};
1633 use std::path::{Path, PathBuf};
1634
1635 pub fn serialize<S: Serializer>(p: &Path, s: S) -> Result<S::Ok, S::Error> {
1636 s.serialize_str(&path_to_unix(p))
1637 }
1638
1639 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<PathBuf, D::Error> {
1640 Ok(PathBuf::from(String::deserialize(d)?))
1641 }
1642}
1643
1644fn capitalize(s: &str) -> String {
1646 let mut chars = s.chars();
1647 match chars.next() {
1648 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1649 None => String::new(),
1650 }
1651}
1652
1653fn collapse_whitespace(s: &str) -> String {
1658 s.split_whitespace().collect::<Vec<_>>().join(" ")
1659}
1660
1661fn default_display(basename: &str) -> String {
1667 let spaced: String = basename
1668 .chars()
1669 .map(|c| if c == '-' || c == '_' { ' ' } else { c })
1670 .collect();
1671 capitalize(&spaced)
1672}
1673
1674fn folder_label<'a>(
1681 tf_unix: &str,
1682 basename: &str,
1683 folders: &'a BTreeMap<String, FolderMeta>,
1684) -> (String, Option<&'a str>) {
1685 let meta = folders.get(tf_unix);
1686 let display = meta
1687 .and_then(|m| m.display.as_deref())
1688 .map(str::to_string)
1689 .unwrap_or_else(|| default_display(basename));
1690 (display, meta.and_then(|m| m.description.as_deref()))
1691}
1692
1693fn folder_entry(tf_unix: &str, display: &str, count: usize, description: Option<&str>) -> String {
1696 match description {
1697 Some(d) => format!("- [[{tf_unix}/index|{display}]] ({count}) — {d}\n"),
1698 None => format!("- [[{tf_unix}/index|{display}]] ({count})\n"),
1699 }
1700}
1701
1702fn write_atomic(path: &Path, contents: String) -> crate::Result<()> {
1709 if let Some(parent) = path.parent() {
1710 fs::create_dir_all(parent)?;
1711 }
1712 let dir = path.parent().unwrap_or_else(|| Path::new("."));
1713 let mut tmp = tempfile_in(dir)?;
1714 tmp.write_all(contents.as_bytes())?;
1715 tmp.flush()?;
1716 tmp.persist(path)?;
1717 Ok(())
1718}
1719
1720fn remove_if_exists(path: &Path) -> crate::Result<()> {
1721 match fs::remove_file(path) {
1722 Ok(()) => Ok(()),
1723 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1724 Err(e) => Err(e.into()),
1725 }
1726}
1727
1728fn bad_index(path: &Path, msg: &str) -> crate::Error {
1729 crate::Error::Store(crate::store::StoreError::BadTypeIndex {
1730 path: path.to_path_buf(),
1731 message: msg.to_string(),
1732 })
1733}
1734
1735struct FolderLock {
1755 path: PathBuf,
1756 held: bool,
1757}
1758
1759impl FolderLock {
1760 fn acquire(folder_abs: &Path) -> Self {
1787 use std::time::{Duration, SystemTime};
1788 const SPIN: Duration = Duration::from_millis(10);
1789 const STALE_AFTER: Duration = Duration::from_secs(30);
1790
1791 let path = folder_abs.join(".index.lock");
1792 let _ = fs::create_dir_all(folder_abs);
1794 loop {
1795 match fs::OpenOptions::new()
1796 .write(true)
1797 .create_new(true)
1798 .open(&path)
1799 {
1800 Ok(_) => return FolderLock { path, held: true },
1801 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1802 let stale = fs::metadata(&path)
1805 .and_then(|m| m.modified())
1806 .ok()
1807 .and_then(|t| SystemTime::now().duration_since(t).ok())
1808 .map(|age| age > STALE_AFTER)
1809 .unwrap_or(false);
1810 if stale {
1811 let _ = fs::remove_file(&path);
1812 continue;
1813 }
1814 std::thread::sleep(SPIN);
1815 }
1816 Err(_) => return FolderLock { path, held: false },
1821 }
1822 }
1823 }
1824}
1825
1826impl Drop for FolderLock {
1827 fn drop(&mut self) {
1828 if self.held {
1829 let _ = fs::remove_file(&self.path);
1830 }
1831 }
1832}
1833
1834fn lock_folders(store: &Store, a: &Path, b: &Path) -> Vec<FolderLock> {
1840 if a == b {
1841 return vec![FolderLock::acquire(&store.root.join(a))];
1842 }
1843 let (first, second) = if a < b { (a, b) } else { (b, a) };
1844 vec![
1845 FolderLock::acquire(&store.root.join(first)),
1846 FolderLock::acquire(&store.root.join(second)),
1847 ]
1848}
1849
1850struct AtomicTemp {
1856 file: Option<fs::File>,
1857 path: PathBuf,
1858 persisted: bool,
1859}
1860
1861impl AtomicTemp {
1862 fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
1863 self.file.as_mut().expect("temp file open").write_all(bytes)
1864 }
1865 fn flush(&mut self) -> std::io::Result<()> {
1866 self.file.as_mut().expect("temp file open").flush()
1867 }
1868 fn persist(mut self, dest: &Path) -> std::io::Result<()> {
1869 if let Some(f) = self.file.take() {
1870 f.sync_all().ok();
1871 }
1873 fs::rename(&self.path, dest)?;
1874 self.persisted = true;
1875 Ok(())
1876 }
1877}
1878
1879impl Drop for AtomicTemp {
1880 fn drop(&mut self) {
1881 if !self.persisted {
1883 let _ = fs::remove_file(&self.path);
1884 }
1885 }
1886}
1887
1888fn tempfile_in(dir: &Path) -> std::io::Result<AtomicTemp> {
1889 use std::time::{SystemTime, UNIX_EPOCH};
1890 let nanos = SystemTime::now()
1891 .duration_since(UNIX_EPOCH)
1892 .map(|d| d.as_nanos())
1893 .unwrap_or(0);
1894 let pid = std::process::id();
1895 let counter = next_temp_counter();
1898 let name = format!(".dbmd-index-{pid}-{nanos}-{counter}.tmp");
1899 let path = dir.join(name);
1900 let file = fs::OpenOptions::new()
1901 .write(true)
1902 .create_new(true)
1903 .open(&path)?;
1904 Ok(AtomicTemp {
1905 file: Some(file),
1906 path,
1907 persisted: false,
1908 })
1909}
1910
1911fn next_temp_counter() -> u64 {
1912 use std::sync::atomic::{AtomicU64, Ordering};
1913 static C: AtomicU64 = AtomicU64::new(0);
1914 C.fetch_add(1, Ordering::Relaxed)
1915}
1916
1917#[cfg(test)]
1918mod tests {
1919 use super::*;
1920 use std::collections::BTreeSet;
1921 use std::fs;
1922 use tempfile::TempDir;
1923
1924 fn mk_store() -> (TempDir, Store) {
1929 let dir = TempDir::new().unwrap();
1930 fs::write(dir.path().join("DB.md"), "# test store\n").unwrap();
1931 let store = Store {
1932 root: dir.path().to_path_buf(),
1933 config: crate::parser::Config::default(),
1934 };
1935 (dir, store)
1936 }
1937
1938 fn write_raw(store: &Store, rel: &str, fm: &str, body: &str) {
1941 let abs = store.root.join(rel);
1942 fs::create_dir_all(abs.parent().unwrap()).unwrap();
1943 fs::write(&abs, format!("---\n{fm}\n---\n{body}")).unwrap();
1944 }
1945
1946 fn write_doc(
1948 store: &Store,
1949 rel: &str,
1950 type_: &str,
1951 summary: Option<&str>,
1952 updated: Option<&str>,
1953 extra_yaml: &str,
1954 ) {
1955 let mut fm = format!("type: {type_}\n");
1956 if let Some(s) = summary {
1957 fm.push_str(&format!("summary: {s}\n"));
1958 }
1959 if let Some(u) = updated {
1960 fm.push_str(&format!("updated: {u}\n"));
1961 }
1962 fm.push_str(extra_yaml);
1963 write_raw(store, rel, fm.trim_end(), "\nbody text\n");
1964 }
1965
1966 fn read(store: &Store, rel: &str) -> String {
1967 fs::read_to_string(store.root.join(rel)).unwrap()
1968 }
1969
1970 fn exists(store: &Store, rel: &str) -> bool {
1971 store.root.join(rel).exists()
1972 }
1973
1974 fn snapshot_artifacts(store: &Store) -> BTreeMap<String, String> {
1977 let mut out = BTreeMap::new();
1978 for entry in walkdir::WalkDir::new(&store.root)
1979 .into_iter()
1980 .filter_map(|e| e.ok())
1981 {
1982 let p = entry.path();
1983 if is_index_artifact(p) {
1984 let rel = path_to_unix(&rel_to_store(&store.root, p).unwrap());
1985 out.insert(rel, fs::read_to_string(p).unwrap());
1986 }
1987 }
1988 out
1989 }
1990
1991 #[test]
1994 fn type_folder_aggregates_across_shards_in_recency_order() {
1995 let (_d, store) = mk_store();
1996 write_doc(
1999 &store,
2000 "sources/emails/2026/05/b-old.md",
2001 "email",
2002 Some("Older mail"),
2003 Some("2026-05-01T09:00:00Z"),
2004 "",
2005 );
2006 write_doc(
2007 &store,
2008 "sources/emails/2026/06/c-new.md",
2009 "email",
2010 Some("Newest mail"),
2011 Some("2026-06-15T12:00:00Z"),
2012 "",
2013 );
2014 write_doc(
2015 &store,
2016 "sources/emails/2026/05/a-mid.md",
2017 "email",
2018 Some("Middle mail"),
2019 Some("2026-05-20T08:00:00Z"),
2020 "",
2021 );
2022
2023 let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
2024 let paths: Vec<String> = idx.records.iter().map(|r| path_to_unix(&r.path)).collect();
2025 assert_eq!(
2026 paths,
2027 vec![
2028 "sources/emails/2026/06/c-new.md",
2029 "sources/emails/2026/05/a-mid.md",
2030 "sources/emails/2026/05/b-old.md",
2031 ],
2032 "records must aggregate across shards, newest `updated` first"
2033 );
2034 }
2035
2036 #[test]
2037 fn type_folder_md_format_entries_tags_and_derived_updated() {
2038 let (_d, store) = mk_store();
2039 write_doc(
2040 &store,
2041 "records/contacts/sarah-chen.md",
2042 "contact",
2043 Some("Renewal champion at Acme"),
2044 Some("2026-05-27T10:00:00Z"),
2045 "tags:\n - renewal\n - acme\n",
2046 );
2047 write_doc(
2048 &store,
2049 "records/contacts/no-tags.md",
2050 "contact",
2051 Some("Plain contact"),
2052 Some("2026-05-26T10:00:00Z"),
2053 "",
2054 );
2055
2056 let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
2057 let md = idx.to_markdown();
2058
2059 assert!(md.starts_with(
2062 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\nupdated: 2026-05-27T10:00:00Z\n---\n\n# records/contacts\n"
2063 ), "frontmatter/heading wrong:\n{md}");
2064
2065 assert!(
2067 md.contains(
2068 "- [[records/contacts/sarah-chen]] — Renewal champion at Acme · #renewal #acme\n"
2069 ),
2070 "tagged entry wrong:\n{md}"
2071 );
2072 assert!(
2074 md.contains("- [[records/contacts/no-tags]] — Plain contact\n"),
2075 "untagged entry wrong:\n{md}"
2076 );
2077 assert!(
2078 !md.contains("Plain contact ·"),
2079 "untagged entry must not emit a tag separator"
2080 );
2081 assert!(!md.contains("## More"), "no footer expected under the cap");
2083 }
2084
2085 #[test]
2086 fn missing_summary_becomes_placeholder_not_invented() {
2087 let (_d, store) = mk_store();
2088 write_doc(
2089 &store,
2090 "records/notes/x.md",
2091 "note",
2092 None,
2093 Some("2026-05-27T10:00:00Z"),
2094 "",
2095 );
2096 let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
2097 assert_eq!(idx.records[0].summary, MISSING_SUMMARY);
2098 let md = idx.to_markdown();
2099 assert!(
2100 md.contains("- [[records/notes/x]] — (no summary)\n"),
2101 "missing summary must render the placeholder, not invent text:\n{md}"
2102 );
2103 }
2104
2105 #[test]
2108 fn jsonl_is_complete_structured_and_round_trips() {
2109 let (_d, store) = mk_store();
2110 write_doc(
2111 &store,
2112 "records/expenses/2026/05/e1.md",
2113 "expense",
2114 Some("Lunch with vendor"),
2115 Some("2026-05-10T10:00:00Z"),
2116 "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",
2117 );
2118 write_doc(
2119 &store,
2120 "records/expenses/2026/06/e2.md",
2121 "expense",
2122 Some("Cloud bill"),
2123 Some("2026-06-01T10:00:00Z"),
2124 "amount: 100\n",
2125 );
2126
2127 let idx = Index::build_type_folder(&store, Path::new("records/expenses")).unwrap();
2128 let jsonl = idx.to_jsonl();
2129 let lines: Vec<&str> = jsonl.lines().collect();
2130 assert_eq!(lines.len(), 2, "one JSON object per file, uncapped");
2131
2132 let r0: IndexRecord = serde_json::from_str(lines[0]).unwrap();
2134 assert_eq!(path_to_unix(&r0.path), "records/expenses/2026/06/e2.md");
2135 assert_eq!(
2136 r0, idx.records[0],
2137 "jsonl line must round-trip to the record"
2138 );
2139
2140 let r1: IndexRecord = serde_json::from_str(lines[1]).unwrap();
2143 assert_eq!(r1.type_, "expense");
2144 assert_eq!(r1.summary, "Lunch with vendor");
2145 assert_eq!(r1.tags, vec!["food".to_string()]);
2146 assert_eq!(
2147 r1.links,
2148 vec![
2149 "records/concepts/spend".to_string(),
2150 "[[records/concepts/renewal]]".to_string()
2151 ]
2152 );
2153 assert_eq!(
2154 r1.created,
2155 Some(DateTime::parse_from_rfc3339("2026-05-10T09:00:00Z").unwrap())
2156 );
2157 assert_eq!(r1.fields.get("status"), Some(&Value::from("paid")));
2158 assert_eq!(r1.fields.get("amount"), Some(&Value::from(42)));
2159 assert_eq!(
2160 r1.fields.get("company"),
2161 Some(&Value::from("[[records/companies/acme]]"))
2162 );
2163 assert_eq!(
2164 r1.fields.get("related"),
2165 Some(&serde_json::json!(["[[records/concepts/spend]]"]))
2166 );
2167 for reserved in [
2169 "path", "type", "summary", "tags", "links", "created", "updated",
2170 ] {
2171 assert!(
2172 !r1.fields.contains_key(reserved),
2173 "reserved key {reserved} must not appear in fields"
2174 );
2175 }
2176
2177 assert!(
2179 lines[1].starts_with(
2180 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","#
2181 ),
2182 "jsonl key order not stable:\n{}",
2183 lines[1]
2184 );
2185 assert!(
2190 lines[1].ends_with(r#""amount":42,"company":"[[records/companies/acme]]","meta-type":"fact","related":["[[records/concepts/spend]]"],"status":"paid"}"#),
2191 "extras must be sorted:\n{}",
2192 lines[1]
2193 );
2194 }
2195
2196 #[test]
2199 fn over_cap_md_shows_500_plus_footer_jsonl_holds_all() {
2200 let (_d, store) = mk_store();
2201 let total = MD_CAP + 7;
2202 for i in 0..total {
2203 let day = 1 + (i % 27);
2205 let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2206 let updated = format!("2026-05-{day:02}T00:00:{:02}Z", i % 60);
2207 write_doc(
2208 &store,
2209 &rel,
2210 "email",
2211 Some(&format!("mail {i}")),
2212 Some(&updated),
2213 "",
2214 );
2215 }
2216 let idx = Index::build_type_folder(&store, Path::new("sources/emails")).unwrap();
2217 assert_eq!(idx.records.len(), total, "jsonl/records keep every file");
2218
2219 let md = idx.to_markdown();
2220 let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
2221 assert_eq!(entry_lines, MD_CAP, "md browse view is capped at 500");
2222
2223 assert!(
2224 md.contains("## More\n\n"),
2225 "over-cap md needs a More footer"
2226 );
2227 assert!(
2228 md.contains(&format!(
2229 "This folder has {total} files. The 500 most recent are listed above.\n"
2230 )),
2231 "footer count wrong:\n{md}"
2232 );
2233 assert!(
2234 md.contains("Use `dbmd query --type email --in sources` for the complete catalog.\n"),
2235 "footer must infer type=email layer=sources:\n{md}"
2236 );
2237
2238 let jsonl = idx.to_jsonl();
2239 assert_eq!(jsonl.lines().count(), total, "jsonl is uncapped");
2240 }
2241
2242 #[test]
2245 fn sort_breaks_ties_by_path_and_puts_undated_last() {
2246 let mut recs = vec![
2247 rec("z/a.md", Some("2026-05-01T00:00:00Z")),
2248 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")), ];
2252 sort_records(&mut recs);
2253 let order: Vec<String> = recs.iter().map(|r| path_to_unix(&r.path)).collect();
2254 assert_eq!(order, vec!["b/d.md", "a/b.md", "z/a.md", "m/c.md"]);
2255 }
2256
2257 fn rec(path: &str, updated: Option<&str>) -> IndexRecord {
2258 IndexRecord {
2259 path: PathBuf::from(path),
2260 type_: "t".into(),
2261 summary: "s".into(),
2262 tags: vec![],
2263 links: vec![],
2264 created: None,
2265 updated: updated.map(|u| DateTime::parse_from_rfc3339(u).unwrap()),
2266 fields: BTreeMap::new(),
2267 }
2268 }
2269
2270 #[test]
2273 fn layer_index_lists_type_folders_with_counts() {
2274 let (_d, store) = mk_store();
2275 write_doc(
2276 &store,
2277 "records/contacts/a.md",
2278 "contact",
2279 Some("Contact A older"),
2280 Some("2026-05-01T00:00:00Z"),
2281 "",
2282 );
2283 write_doc(
2284 &store,
2285 "records/contacts/b.md",
2286 "contact",
2287 Some("Contact B newest"),
2288 Some("2026-05-09T00:00:00Z"),
2289 "",
2290 );
2291 write_doc(
2292 &store,
2293 "records/companies/x.md",
2294 "company",
2295 Some("Acme Inc"),
2296 Some("2026-05-05T00:00:00Z"),
2297 "",
2298 );
2299 Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
2301 Index::write_level(&store, &IndexLevel::TypeFolder("records/companies".into())).unwrap();
2302
2303 Index::write_level(&store, &IndexLevel::Layer(Layer::Records)).unwrap();
2304 let md = read(&store, "records/index.md");
2305
2306 assert!(
2307 md.starts_with("---\ntype: index\nscope: layer\nfolder: records\n"),
2308 "layer fm:\n{md}"
2309 );
2310 let companies_at = md.find("companies/index").unwrap();
2312 let contacts_at = md.find("contacts/index").unwrap();
2313 assert!(
2314 companies_at < contacts_at,
2315 "type folders must be alphabetical"
2316 );
2317 assert!(
2320 md.contains("- [[records/contacts/index|Contacts]] (2)\n"),
2321 "contacts entry:\n{md}"
2322 );
2323 assert!(
2324 md.contains("- [[records/companies/index|Companies]] (1)\n"),
2325 "companies entry:\n{md}"
2326 );
2327 assert!(
2329 !md.contains("Contact B newest") && !md.contains("Acme Inc"),
2330 "layer rollup must not quote a member summary:\n{md}"
2331 );
2332 assert!(
2334 md.contains("updated: 2026-05-09T00:00:00Z\n"),
2335 "layer updated must be max child:\n{md}"
2336 );
2337 }
2338
2339 #[test]
2340 fn folders_section_supplies_authored_display_and_description() {
2341 let (_d, mut store) = mk_store();
2345 store.config.folders.insert(
2346 "records/contacts".into(),
2347 crate::parser::FolderMeta {
2348 display: None,
2349 description: Some("people across customer + prospect accounts".into()),
2350 },
2351 );
2352 store.config.folders.insert(
2353 "sources/hubspot-exports".into(),
2354 crate::parser::FolderMeta {
2355 display: Some("HubSpot exports".into()),
2356 description: Some("deal + pipeline exports".into()),
2357 },
2358 );
2359 write_doc(
2360 &store,
2361 "records/contacts/a.md",
2362 "contact",
2363 Some("Contact A"),
2364 Some("2026-05-01T00:00:00Z"),
2365 "",
2366 );
2367 write_doc(
2369 &store,
2370 "records/companies/x.md",
2371 "company",
2372 Some("Acme Inc"),
2373 Some("2026-05-05T00:00:00Z"),
2374 "",
2375 );
2376 write_doc(
2377 &store,
2378 "sources/hubspot-exports/d.md",
2379 "hubspot-export",
2380 Some("a single deal export"),
2381 Some("2026-05-03T00:00:00Z"),
2382 "",
2383 );
2384
2385 Index::rebuild_all(&store).unwrap();
2386
2387 let records_layer = read(&store, "records/index.md");
2389 assert!(
2390 records_layer.contains("- [[records/contacts/index|Contacts]] (1) — people across customer + prospect accounts\n"),
2391 "authored description must surface:\n{records_layer}"
2392 );
2393 assert!(
2395 records_layer.contains("- [[records/companies/index|Companies]] (1)\n")
2396 && !records_layer.contains("Acme Inc"),
2397 "un-described folder is counts-only:\n{records_layer}"
2398 );
2399
2400 let sources_layer = read(&store, "sources/index.md");
2402 assert!(
2403 sources_layer.contains("- [[sources/hubspot-exports/index|HubSpot exports]] (1) — deal + pipeline exports\n"),
2404 "display override + description must surface:\n{sources_layer}"
2405 );
2406
2407 let root = read(&store, "index.md");
2409 assert!(
2410 root.contains("- [[records/contacts/index|Contacts]] (1) — people across customer + prospect accounts\n"),
2411 "root surfaces authored description:\n{root}"
2412 );
2413 assert!(
2414 root.contains("- [[sources/hubspot-exports/index|HubSpot exports]] (1) — deal + pipeline exports\n"),
2415 "root surfaces display override:\n{root}"
2416 );
2417 }
2418
2419 #[test]
2420 fn default_display_turns_separators_to_spaces_and_caps() {
2421 assert_eq!(default_display("contacts"), "Contacts");
2422 assert_eq!(default_display("hubspot-exports"), "Hubspot exports");
2423 assert_eq!(default_display("usage_exports"), "Usage exports");
2424 }
2425
2426 #[test]
2427 fn root_index_groups_layers_with_totals_and_per_type_counts() {
2428 let (_d, store) = mk_store();
2429 write_doc(
2430 &store,
2431 "sources/emails/2026/05/a.md",
2432 "email",
2433 Some("Mail"),
2434 Some("2026-05-01T00:00:00Z"),
2435 "",
2436 );
2437 write_doc(
2438 &store,
2439 "sources/docs/d.md",
2440 "doc",
2441 Some("Doc"),
2442 Some("2026-05-02T00:00:00Z"),
2443 "",
2444 );
2445 write_doc(
2446 &store,
2447 "records/contacts/c.md",
2448 "contact",
2449 Some("C"),
2450 Some("2026-05-03T00:00:00Z"),
2451 "",
2452 );
2453 Index::rebuild_all(&store).unwrap();
2456 let md = read(&store, "index.md");
2457
2458 assert!(
2459 md.starts_with("---\ntype: index\nscope: root\n"),
2460 "root fm:\n{md}"
2461 );
2462 assert!(md.contains("# Knowledge base index\n"), "root title:\n{md}");
2463 let sources_h = md
2465 .find("## Sources (2)")
2466 .expect("sources heading w/ total 2");
2467 let records_h = md
2468 .find("## Records (1)")
2469 .expect("records heading w/ total 1");
2470 assert!(sources_h < records_h, "Sources must precede Records");
2471 assert!(!md.contains("## Wiki"), "empty layer gets no section");
2472 assert!(
2474 md.contains("- [[sources/docs/index|Docs]] (1)\n"),
2475 "root docs entry:\n{md}"
2476 );
2477 assert!(
2478 md.contains("- [[sources/emails/index|Emails]] (1)\n"),
2479 "root emails entry:\n{md}"
2480 );
2481 assert!(
2482 md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
2483 "root contacts entry:\n{md}"
2484 );
2485 assert!(!md.contains("— "), "root entries carry no preview text");
2486 }
2487
2488 #[test]
2491 fn on_write_matches_rebuild_byte_for_byte() {
2492 let (_d1, wt) = mk_store();
2495 let (_d2, rb) = mk_store();
2496
2497 let docs: &[(&str, &str, &str, &str, &str)] = &[
2498 (
2499 "sources/emails/2026/05/e1.md",
2500 "email",
2501 "First mail",
2502 "2026-05-01T10:00:00Z",
2503 "tags:\n - inbox\n",
2504 ),
2505 (
2506 "sources/emails/2026/06/e2.md",
2507 "email",
2508 "Second mail",
2509 "2026-06-01T10:00:00Z",
2510 "",
2511 ),
2512 (
2513 "records/contacts/sarah.md",
2514 "contact",
2515 "Sarah",
2516 "2026-05-15T10:00:00Z",
2517 "links:\n - records/profiles/sarah\n",
2518 ),
2519 (
2520 "records/contacts/elena.md",
2521 "contact",
2522 "Elena",
2523 "2026-05-20T10:00:00Z",
2524 "status: active\n",
2525 ),
2526 (
2527 "records/profiles/sarah.md",
2528 "profile",
2529 "Sarah bio",
2530 "2026-05-21T10:00:00Z",
2531 "",
2532 ),
2533 ];
2534
2535 for (rel, t, sum, upd, extra) in docs {
2536 write_doc(&wt, rel, t, Some(sum), Some(upd), extra);
2537 write_doc(&rb, rel, t, Some(sum), Some(upd), extra);
2538 Index::on_write(&wt, Path::new(rel)).unwrap();
2539 }
2540 Index::rebuild_all(&rb).unwrap();
2541
2542 let a = snapshot_artifacts(&wt);
2543 let b = snapshot_artifacts(&rb);
2544 assert_eq!(
2545 a.keys().collect::<Vec<_>>(),
2546 b.keys().collect::<Vec<_>>(),
2547 "same set of index artifacts must exist"
2548 );
2549 for (k, v) in &a {
2550 assert_eq!(v, &b[k], "artifact {k} differs between write-through and rebuild:\n--- write-through ---\n{v}\n--- rebuild ---\n{}", b[k]);
2551 }
2552 assert!(a.contains_key("index.md"));
2554 assert!(a.contains_key("sources/emails/index.jsonl"));
2555 assert!(a.contains_key("records/contacts/index.md"));
2556 }
2557
2558 #[test]
2575 fn loop_op_does_not_walk_sibling_content_tree() {
2576 let (_d, store) = mk_store();
2577
2578 write_doc(
2582 &store,
2583 "records/companies/acme.md",
2584 "company",
2585 Some("Acme Inc"),
2586 Some("2026-05-05T00:00:00Z"),
2587 "",
2588 );
2589 write_doc(
2590 &store,
2591 "records/companies/globex.md",
2592 "company",
2593 Some("Globex"),
2594 Some("2026-05-06T00:00:00Z"),
2595 "",
2596 );
2597 assert!(
2598 !exists(&store, "records/companies/index.jsonl"),
2599 "precondition: companies must be un-indexed"
2600 );
2601
2602 write_doc(
2604 &store,
2605 "records/contacts/sarah.md",
2606 "contact",
2607 Some("Sarah"),
2608 Some("2026-05-15T00:00:00Z"),
2609 "",
2610 );
2611 Index::on_write(&store, Path::new("records/contacts/sarah.md")).unwrap();
2612
2613 let layer_md = read(&store, "records/index.md");
2615 let root_md = read(&store, "index.md");
2616 assert!(
2618 layer_md.contains("- [[records/contacts/index|Contacts]] (1)\n")
2619 && !layer_md.contains("Sarah"),
2620 "layer must reflect the written folder, counts only:\n{layer_md}"
2621 );
2622 assert!(
2623 root_md.contains("- [[records/contacts/index|Contacts]] (1)\n"),
2624 "root must reflect the written folder:\n{root_md}"
2625 );
2626
2627 assert!(
2631 !layer_md.contains("companies"),
2632 "loop op walked the sibling content tree: layer rollup counts un-indexed records/companies\n{layer_md}"
2633 );
2634 assert!(
2635 !root_md.contains("companies"),
2636 "loop op walked the sibling content tree: root rollup counts un-indexed records/companies\n{root_md}"
2637 );
2638 assert!(
2640 root_md.contains("## Records (1)"),
2641 "root layer total must count only the sidecar-indexed folder (1), not walked siblings (would be 3):\n{root_md}"
2642 );
2643
2644 let (_d2, rb) = mk_store();
2649 for (rel, t, s, u) in [
2650 (
2651 "records/companies/acme.md",
2652 "company",
2653 "Acme Inc",
2654 "2026-05-05T00:00:00Z",
2655 ),
2656 (
2657 "records/companies/globex.md",
2658 "company",
2659 "Globex",
2660 "2026-05-06T00:00:00Z",
2661 ),
2662 (
2663 "records/contacts/sarah.md",
2664 "contact",
2665 "Sarah",
2666 "2026-05-15T00:00:00Z",
2667 ),
2668 ] {
2669 write_doc(&rb, rel, t, Some(s), Some(u), "");
2670 }
2671 Index::on_write(&store, Path::new("records/companies/acme.md")).unwrap();
2672 Index::on_write(&store, Path::new("records/companies/globex.md")).unwrap();
2673 Index::rebuild_all(&rb).unwrap();
2674 let a = snapshot_artifacts(&store);
2675 let b = snapshot_artifacts(&rb);
2676 assert_eq!(
2677 a.keys().collect::<BTreeSet<_>>(),
2678 b.keys().collect::<BTreeSet<_>>(),
2679 "same artifact set after indexing both folders"
2680 );
2681 for (k, v) in &a {
2682 assert_eq!(
2683 v, &b[k],
2684 "after indexing the sibling too, loop result must equal rebuild for {k}"
2685 );
2686 }
2687 assert!(
2688 read(&store, "index.md").contains("## Records (3)"),
2689 "now that both folders are indexed, the root total is 3"
2690 );
2691 }
2692
2693 #[test]
2706 fn custom_type_at_shard_path_for_is_indexable_end_to_end() {
2707 let (_d1, wt) = mk_store();
2708 let (_d2, rb) = mk_store();
2709
2710 let rel = wt
2712 .shard_path_for(
2713 "profile",
2714 &crate::parser::Frontmatter::default(),
2715 "renewal-theme",
2716 )
2717 .unwrap();
2718 let rel_str = path_to_unix(&rel);
2719 assert!(
2722 type_folder_of(&rel).is_some(),
2723 "shard_path_for produced a path the index cannot file: {rel_str}"
2724 );
2725
2726 write_doc(
2727 &wt,
2728 &rel_str,
2729 "profile",
2730 Some("Renewal theme"),
2731 Some("2026-05-21T10:00:00Z"),
2732 "",
2733 );
2734 write_doc(
2735 &rb,
2736 &rel_str,
2737 "profile",
2738 Some("Renewal theme"),
2739 Some("2026-05-21T10:00:00Z"),
2740 "",
2741 );
2742
2743 Index::on_write(&wt, &rel)
2746 .expect("on_write must succeed for a toolkit-computed custom-type path");
2747 Index::rebuild_all(&rb).unwrap();
2748
2749 let page_link = wiki_target(&rel); let tf_md = read(&rb, "records/profile/index.md");
2756 assert!(
2757 tf_md.contains(&format!("[[{page_link}]]")),
2758 "type-folder index must list the page link, got:\n{tf_md}"
2759 );
2760 assert!(
2761 exists(&rb, "records/profile/index.jsonl"),
2762 "type-folder jsonl must exist"
2763 );
2764 assert!(
2765 read(&rb, "records/profile/index.jsonl").contains(&rel_str),
2766 "type-folder jsonl must contain the page row"
2767 );
2768 let layer_md = read(&rb, "records/index.md");
2771 assert!(
2772 layer_md.contains("records/profile/index"),
2773 "layer index must roll up the records/profile type-folder, got:\n{layer_md}"
2774 );
2775
2776 let a = snapshot_artifacts(&wt);
2778 let b = snapshot_artifacts(&rb);
2779 assert_eq!(
2780 a.keys().collect::<Vec<_>>(),
2781 b.keys().collect::<Vec<_>>(),
2782 "loop and sweep must produce the same artifact set"
2783 );
2784 for (k, v) in &a {
2785 assert_eq!(
2786 v, &b[k],
2787 "custom-type artifact {k} differs between on_write and rebuild"
2788 );
2789 }
2790 }
2791
2792 #[test]
2793 fn on_remove_then_rebuild_match_and_pull_in_next_over_cap() {
2794 let (_d1, wt) = mk_store();
2795 let (_d2, rb) = mk_store();
2796 let total = MD_CAP + 3; let mut all_rels = Vec::new();
2798 for i in 0..total {
2799 let rel = format!("sources/emails/2026/05/m-{i:04}.md");
2800 let updated = format!("2026-05-10T00:{:02}:{:02}Z", i / 60, i % 60);
2802 write_doc(
2803 &wt,
2804 &rel,
2805 "email",
2806 Some(&format!("mail {i}")),
2807 Some(&updated),
2808 "",
2809 );
2810 write_doc(
2811 &rb,
2812 &rel,
2813 "email",
2814 Some(&format!("mail {i}")),
2815 Some(&updated),
2816 "",
2817 );
2818 all_rels.push(rel);
2819 }
2820 Index::rebuild_all(&wt).unwrap();
2822 let newest = &all_rels[total - 1]; fs::remove_file(wt.root.join(newest)).unwrap();
2824 Index::on_remove(&wt, Path::new(newest)).unwrap();
2825
2826 fs::remove_file(rb.root.join(newest)).unwrap();
2828 Index::rebuild_all(&rb).unwrap();
2829
2830 let a = snapshot_artifacts(&wt);
2831 let b = snapshot_artifacts(&rb);
2832 for (k, v) in &a {
2833 assert_eq!(v, &b[k], "after remove, artifact {k} drifted from rebuild");
2834 }
2835
2836 let md = read(&wt, "sources/emails/index.md");
2839 assert_eq!(md.lines().filter(|l| l.starts_with("- [[")).count(), MD_CAP);
2840 assert!(
2842 !md.contains(&format!("[[{}]]", wiki_target(Path::new(newest)))),
2843 "removed file must not be listed in md"
2844 );
2845 let pulled_in = &all_rels[2];
2849 assert!(
2850 md.contains(&format!("[[{}]]", wiki_target(Path::new(pulled_in)))),
2851 "the 501st-most-recent must be pulled into the browse view after a removal"
2852 );
2853 assert!(
2854 md.contains(&format!("This folder has {} files.", total - 1)),
2855 "footer count must decrement:\n{}",
2856 md.lines().rev().take(4).collect::<Vec<_>>().join("\n")
2857 );
2858 let jsonl = read(&wt, "sources/emails/index.jsonl");
2859 assert_eq!(
2860 jsonl.lines().count(),
2861 total - 1,
2862 "jsonl loses exactly the removed file"
2863 );
2864 assert!(
2865 !jsonl.contains(&path_to_unix(Path::new(newest))),
2866 "removed file must be gone from the jsonl too"
2867 );
2868 }
2869
2870 #[test]
2871 fn on_rename_cross_folder_matches_rebuild() {
2872 let (_d1, wt) = mk_store();
2873 let (_d2, rb) = mk_store();
2874 let seed: &[(&str, &str, &str, &str)] = &[
2876 (
2877 "records/contacts/a.md",
2878 "contact",
2879 "A",
2880 "2026-05-01T00:00:00Z",
2881 ),
2882 (
2883 "records/contacts/b.md",
2884 "contact",
2885 "B",
2886 "2026-05-02T00:00:00Z",
2887 ),
2888 (
2889 "records/companies/x.md",
2890 "company",
2891 "X",
2892 "2026-05-03T00:00:00Z",
2893 ),
2894 ];
2895 for (rel, t, s, u) in seed {
2896 write_doc(&wt, rel, t, Some(s), Some(u), "");
2897 write_doc(&rb, rel, t, Some(s), Some(u), "");
2898 }
2899 Index::rebuild_all(&wt).unwrap();
2900
2901 let old = "records/contacts/b.md";
2904 let new = "records/companies/b.md";
2905 fs::create_dir_all(wt.root.join("records/companies")).unwrap();
2906 fs::rename(wt.root.join(old), wt.root.join(new)).unwrap();
2907 Index::on_rename(&wt, Path::new(old), Path::new(new)).unwrap();
2910
2911 fs::create_dir_all(rb.root.join("records/companies")).unwrap();
2913 fs::rename(rb.root.join(old), rb.root.join(new)).unwrap();
2914 Index::rebuild_all(&rb).unwrap();
2915
2916 let a = snapshot_artifacts(&wt);
2917 let b = snapshot_artifacts(&rb);
2918 assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
2919 for (k, v) in &a {
2920 assert_eq!(v, &b[k], "rename: artifact {k} drifted from rebuild");
2921 }
2922 let contacts = read(&wt, "records/contacts/index.md");
2924 assert!(!contacts.contains("records/contacts/b]]"));
2925 let companies = read(&wt, "records/companies/index.md");
2926 assert!(companies.contains("[[records/companies/b]]"));
2927 }
2928
2929 #[test]
2930 fn on_write_updates_existing_entry_in_place() {
2931 let (_d, store) = mk_store();
2932 write_doc(
2933 &store,
2934 "records/contacts/a.md",
2935 "contact",
2936 Some("Original"),
2937 Some("2026-05-01T00:00:00Z"),
2938 "",
2939 );
2940 Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2941 write_doc(
2943 &store,
2944 "records/contacts/a.md",
2945 "contact",
2946 Some("Revised"),
2947 Some("2026-05-09T00:00:00Z"),
2948 "",
2949 );
2950 Index::on_write(&store, Path::new("records/contacts/a.md")).unwrap();
2951
2952 let jsonl = read(&store, "records/contacts/index.jsonl");
2953 assert_eq!(
2954 jsonl.lines().count(),
2955 1,
2956 "upsert must not duplicate the line"
2957 );
2958 assert!(jsonl.contains("Revised"), "jsonl must reflect the update");
2959 assert!(
2960 !jsonl.contains("Original"),
2961 "stale line must be gone (compacted)"
2962 );
2963 let md = read(&store, "records/contacts/index.md");
2964 assert!(md.contains("- [[records/contacts/a]] — Revised\n"));
2965 assert!(
2966 md.contains("updated: 2026-05-09T00:00:00Z\n"),
2967 "index updated must track the newer member"
2968 );
2969 }
2970
2971 #[test]
2974 fn dry_run_emits_separators_and_writes_nothing() {
2975 let (_d, store) = mk_store();
2976 write_doc(
2977 &store,
2978 "sources/emails/2026/05/a.md",
2979 "email",
2980 Some("Mail"),
2981 Some("2026-05-01T00:00:00Z"),
2982 "",
2983 );
2984 let out = Index::render_dry_run(&store, &IndexLevel::TypeFolder("sources/emails".into()))
2985 .unwrap();
2986 assert!(
2987 out.contains("--- sources/emails/index.md ---\n"),
2988 "md separator:\n{out}"
2989 );
2990 assert!(
2991 out.contains("--- sources/emails/index.jsonl ---\n"),
2992 "jsonl separator:\n{out}"
2993 );
2994 assert!(
2995 out.contains("- [[sources/emails/2026/05/a]] — Mail"),
2996 "md body present"
2997 );
2998 assert!(
3000 !exists(&store, "sources/emails/index.md"),
3001 "dry-run must not write"
3002 );
3003 assert!(
3004 !exists(&store, "sources/emails/index.jsonl"),
3005 "dry-run must not write"
3006 );
3007 }
3008
3009 #[test]
3010 fn cleanup_removes_noncanonical_and_empty_indexes() {
3011 let (_d, store) = mk_store();
3012 write_doc(
3013 &store,
3014 "sources/emails/2026/05/a.md",
3015 "email",
3016 Some("Mail"),
3017 Some("2026-05-01T00:00:00Z"),
3018 "",
3019 );
3020 fs::write(
3022 store.root.join("sources/emails/2026/05/index.md"),
3023 "stale\n",
3024 )
3025 .unwrap();
3026 fs::write(
3027 store.root.join("sources/emails/2026/05/index.jsonl"),
3028 "stale\n",
3029 )
3030 .unwrap();
3031 fs::create_dir_all(store.root.join("records/empty")).unwrap();
3033 fs::write(store.root.join("records/empty/index.md"), "stale\n").unwrap();
3034
3035 Index::cleanup(&store).unwrap();
3036
3037 assert!(
3038 !exists(&store, "sources/emails/2026/05/index.md"),
3039 "shard index must be deleted"
3040 );
3041 assert!(
3042 !exists(&store, "sources/emails/2026/05/index.jsonl"),
3043 "shard jsonl must be deleted"
3044 );
3045 assert!(
3046 !exists(&store, "records/empty/index.md"),
3047 "empty-folder index must be deleted"
3048 );
3049 assert!(exists(&store, "sources/emails/2026/05/a.md"));
3051 }
3052
3053 #[test]
3054 fn rebuild_deletes_stale_indexes_for_emptied_folders() {
3055 let (_d, store) = mk_store();
3056 write_doc(
3057 &store,
3058 "records/contacts/a.md",
3059 "contact",
3060 Some("A"),
3061 Some("2026-05-01T00:00:00Z"),
3062 "",
3063 );
3064 Index::rebuild_all(&store).unwrap();
3065 assert!(exists(&store, "records/contacts/index.md"));
3066 assert!(exists(&store, "records/index.md"));
3067 assert!(exists(&store, "index.md"));
3068
3069 fs::remove_file(store.root.join("records/contacts/a.md")).unwrap();
3071 Index::rebuild_all(&store).unwrap();
3072 assert!(
3073 !exists(&store, "records/contacts/index.md"),
3074 "emptied type-folder index gone"
3075 );
3076 assert!(
3077 !exists(&store, "records/index.md"),
3078 "now-empty layer index gone"
3079 );
3080 assert!(!exists(&store, "index.md"), "now-empty root index gone");
3081 }
3082
3083 #[test]
3086 fn property_writethrough_equals_rebuild_under_mixed_ops() {
3087 let (_d1, wt) = mk_store();
3089 let (_d2, rb) = mk_store();
3090 let mut seed: u64 = 0x9E3779B97F4A7C15;
3091 let mut next = || {
3092 seed = seed
3093 .wrapping_mul(6364136223846793005)
3094 .wrapping_add(1442695040888963407);
3095 (seed >> 33) as u32
3096 };
3097
3098 let folders = ["sources/emails", "records/contacts", "records/profiles"];
3099 let types = ["email", "contact", "profile"];
3100 let mut live: Vec<String> = Vec::new(); for step in 0..120u32 {
3103 let r = next();
3104 let op = r % 10;
3105 if op < 6 || live.is_empty() {
3106 let fi = (next() as usize) % folders.len();
3108 let folder = folders[fi];
3109 let id = next() % 40;
3110 let rel = if folder == "sources/emails" {
3111 let month = 5 + (id % 2); format!("{folder}/2026/{month:02}/f-{id:02}.md")
3113 } else {
3114 format!("{folder}/f-{id:02}.md")
3115 };
3116 let updated = format!(
3118 "2026-05-{:02}T{:02}:{:02}:00Z",
3119 1 + (step % 27),
3120 step % 24,
3121 id % 60
3122 );
3123 let extra = if id % 3 == 0 {
3124 "tags:\n - x\n - y\n"
3125 } else {
3126 ""
3127 };
3128 write_doc(
3129 &wt,
3130 &rel,
3131 types[fi],
3132 Some(&format!("sum {step}")),
3133 Some(&updated),
3134 extra,
3135 );
3136 write_doc(
3137 &rb,
3138 &rel,
3139 types[fi],
3140 Some(&format!("sum {step}")),
3141 Some(&updated),
3142 extra,
3143 );
3144 Index::on_write(&wt, Path::new(&rel)).unwrap();
3145 if !live.contains(&rel) {
3146 live.push(rel);
3147 }
3148 } else if op < 8 {
3149 let idx = (next() as usize) % live.len();
3151 let rel = live.remove(idx);
3152 fs::remove_file(wt.root.join(&rel)).unwrap();
3153 fs::remove_file(rb.root.join(&rel)).ok();
3154 Index::on_remove(&wt, Path::new(&rel)).unwrap();
3155 } else {
3156 let idx = (next() as usize) % live.len();
3158 let old = live[idx].clone();
3159 let fi = (next() as usize) % folders.len();
3161 let folder = folders[fi];
3162 let id = 50 + (next() % 40);
3163 let new = if folder == "sources/emails" {
3164 format!("{folder}/2026/05/f-{id:02}.md")
3165 } else {
3166 format!("{folder}/f-{id:02}.md")
3167 };
3168 if new == old || live.contains(&new) {
3169 continue;
3170 }
3171 fs::create_dir_all(wt.root.join(&new).parent().unwrap()).unwrap();
3172 fs::create_dir_all(rb.root.join(&new).parent().unwrap()).unwrap();
3173 fs::rename(wt.root.join(&old), wt.root.join(&new)).unwrap();
3174 fs::rename(rb.root.join(&old), rb.root.join(&new)).unwrap();
3175 Index::on_rename(&wt, Path::new(&old), Path::new(&new)).unwrap();
3176 live[idx] = new;
3177 }
3178 }
3179
3180 Index::rebuild_all(&rb).unwrap();
3182 let a = snapshot_artifacts(&wt);
3183 let b = snapshot_artifacts(&rb);
3184 assert_eq!(
3185 a.keys().collect::<BTreeSet<_>>(),
3186 b.keys().collect::<BTreeSet<_>>(),
3187 "write-through and rebuild must produce the same set of artifacts"
3188 );
3189 for (k, v) in &a {
3190 assert_eq!(
3191 v, &b[k],
3192 "INVARIANT VIOLATED: artifact {k} differs after mixed ops\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
3193 b[k]
3194 );
3195 }
3196 assert!(
3197 !a.is_empty(),
3198 "the run must have produced at least one artifact"
3199 );
3200 }
3201
3202 #[test]
3208 fn cleanup_preserves_user_content_named_index_md_in_shard() {
3209 let (_d, store) = mk_store();
3210 write_doc(
3212 &store,
3213 "sources/emails/2026/06/index.md",
3214 "email",
3215 Some("Important imported mail"),
3216 Some("2026-06-11T04:23:25Z"),
3217 "",
3218 );
3219 Index::cleanup(&store).unwrap();
3220 assert!(
3221 exists(&store, "sources/emails/2026/06/index.md"),
3222 "cleanup must not delete a user content file named index.md"
3223 );
3224 Index::rebuild_all(&store).unwrap();
3226 assert!(
3227 exists(&store, "sources/emails/2026/06/index.md"),
3228 "rebuild_all must not delete a user content file named index.md"
3229 );
3230 let kept = read(&store, "sources/emails/2026/06/index.md");
3231 assert!(
3232 kept.contains("Important imported mail"),
3233 "the user's record content must be intact"
3234 );
3235 }
3236
3237 #[test]
3242 fn cleanup_keeps_canonical_type_folder_root_sidecars() {
3243 let (_d, store) = mk_store();
3244 write_doc(
3245 &store,
3246 "records/contacts/alice.md",
3247 "contact",
3248 Some("Alice"),
3249 Some("2026-05-01T00:00:00Z"),
3250 "",
3251 );
3252 Index::write_level(&store, &IndexLevel::TypeFolder("records/contacts".into())).unwrap();
3253 assert!(exists(&store, "records/contacts/index.md"));
3254 assert!(exists(&store, "records/contacts/index.jsonl"));
3255 Index::cleanup(&store).unwrap();
3256 assert!(
3257 exists(&store, "records/contacts/index.md"),
3258 "cleanup must keep the canonical type-folder index.md (non-empty folder)"
3259 );
3260 assert!(
3261 exists(&store, "records/contacts/index.jsonl"),
3262 "cleanup must keep the canonical type-folder index.jsonl (non-empty folder)"
3263 );
3264 }
3265
3266 #[test]
3272 fn on_write_ignores_index_artifact_no_phantom_row() {
3273 let (_d, store) = mk_store();
3274 write_doc(
3275 &store,
3276 "records/contacts/alice.md",
3277 "contact",
3278 Some("Alice"),
3279 Some("2026-05-01T00:00:00Z"),
3280 "",
3281 );
3282 Index::on_write(&store, Path::new("records/contacts/alice.md")).unwrap();
3283 let jsonl_before = read(&store, "records/contacts/index.jsonl");
3284 assert_eq!(jsonl_before.lines().count(), 1);
3285
3286 Index::on_write(&store, Path::new("records/contacts/index.md")).unwrap();
3288
3289 let jsonl_after = read(&store, "records/contacts/index.jsonl");
3290 assert_eq!(
3291 jsonl_after.lines().count(),
3292 1,
3293 "on_write on index.md must not add a phantom self-row"
3294 );
3295 assert!(
3296 !jsonl_after.contains("\"type\":\"index\""),
3297 "the catalog artifact must never appear as a catalogued row"
3298 );
3299 let root = read(&store, "index.md");
3301 assert!(
3302 root.contains("[[records/contacts/index|Contacts]] (1)"),
3303 "count must not inflate:\n{root}"
3304 );
3305 }
3306
3307 #[test]
3313 fn multiline_summary_is_single_lined_in_index_md() {
3314 let (_d, store) = mk_store();
3315 write_raw(
3317 &store,
3318 "records/notes/evil.md",
3319 "type: note\nupdated: 2026-06-10T00:00:00Z\nsummary: |-\n legit first line\n - [[records/secrets/fake|Click me]] — injected entry",
3320 "\nbody\n",
3321 );
3322 let idx = Index::build_type_folder(&store, Path::new("records/notes")).unwrap();
3323 let md = idx.to_markdown();
3324 let entry_lines = md.lines().filter(|l| l.starts_with("- [[")).count();
3326 assert_eq!(
3327 entry_lines, 1,
3328 "a multi-line summary must not produce extra entry lines:\n{md}"
3329 );
3330 assert!(
3331 md.contains(
3332 "- [[records/notes/evil]] — legit first line - [[records/secrets/fake|Click me]] — injected entry\n"
3333 ),
3334 "summary newlines must collapse to spaces inline:\n{md}"
3335 );
3336 }
3337
3338 #[test]
3346 fn non_string_scalar_summary_and_type_are_coerced_like_validator() {
3347 let (_d, store) = mk_store();
3348 write_raw(
3349 &store,
3350 "records/contacts/a.md",
3351 "type: contact\nupdated: 2026-05-01T00:00:00Z\nsummary: 2026",
3352 "\nbody\n",
3353 );
3354 let rec = record_from_file(
3355 &store.root.join("records/contacts/a.md"),
3356 PathBuf::from("records/contacts/a.md"),
3357 )
3358 .unwrap();
3359 assert_eq!(rec.summary, "2026");
3362 assert_eq!(rec.type_, "contact");
3363
3364 let idx = Index::build_type_folder(&store, Path::new("records/contacts")).unwrap();
3366 let md = idx.to_markdown();
3367 assert!(
3368 md.contains("- [[records/contacts/a]] — 2026\n"),
3369 "index entry must hold the coerced scalar, not the placeholder:\n{md}"
3370 );
3371
3372 write_raw(
3374 &store,
3375 "records/contacts/b.md",
3376 "type: true\nupdated: 2026-05-02T00:00:00Z\nsummary: hi",
3377 "\nbody\n",
3378 );
3379 let rec_b = record_from_file(
3380 &store.root.join("records/contacts/b.md"),
3381 PathBuf::from("records/contacts/b.md"),
3382 )
3383 .unwrap();
3384 assert_eq!(rec_b.type_, "true");
3385 }
3386
3387 #[test]
3395 fn non_utf8_body_does_not_abort_record_projection() {
3396 let (_d, store) = mk_store();
3397 let rel = "sources/emails/2026/06/x.md";
3398 let abs = store.root.join(rel);
3399 fs::create_dir_all(abs.parent().unwrap()).unwrap();
3400 let mut bytes: Vec<u8> =
3402 b"---\ntype: email\nupdated: 2026-06-11T00:00:00Z\nsummary: An imported email\n---\n\nCaf"
3403 .to_vec();
3404 bytes.push(0xE9);
3405 bytes.extend_from_slice(b" meeting notes\n");
3406 fs::write(&abs, bytes).unwrap();
3407
3408 let rec = record_from_file(&abs, PathBuf::from(rel))
3409 .expect("non-UTF-8 body must not abort the frontmatter read");
3410 assert_eq!(rec.summary, "An imported email");
3411 assert_eq!(rec.type_, "email");
3412
3413 Index::rebuild_all(&store).unwrap();
3415 assert!(
3416 exists(&store, "sources/emails/index.jsonl"),
3417 "rebuild must produce the catalog despite a non-UTF-8 body byte"
3418 );
3419 assert!(
3420 read(&store, "sources/emails/index.jsonl").contains("An imported email"),
3421 "the record must be catalogued"
3422 );
3423 }
3424
3425 #[test]
3434 fn rebuild_aborts_on_malformed_file_and_keeps_prior_catalogs() {
3435 let (_d, store) = mk_store();
3436 write_doc(
3437 &store,
3438 "records/contacts/alice.md",
3439 "contact",
3440 Some("Alice"),
3441 Some("2026-05-01T00:00:00Z"),
3442 "",
3443 );
3444 write_doc(
3445 &store,
3446 "records/companies/acme.md",
3447 "company",
3448 Some("Acme"),
3449 Some("2026-05-02T00:00:00Z"),
3450 "",
3451 );
3452
3453 Index::rebuild_all(&store).expect("clean rebuild succeeds");
3455 assert!(exists(&store, "records/contacts/index.jsonl"));
3456 assert!(exists(&store, "records/companies/index.jsonl"));
3457
3458 let bad = store.root.join("records/contacts/broken.md");
3460 fs::write(
3461 &bad,
3462 "---\ntype: contact\nsummary: \"unterminated\n---\nbody\n",
3463 )
3464 .unwrap();
3465
3466 Index::rebuild_all(&store)
3469 .expect_err("rebuild must abort, not silently skip, on a malformed file");
3470
3471 assert!(
3475 exists(&store, "records/companies/index.jsonl"),
3476 "an aborted rebuild must not destroy a clean sibling folder's catalog"
3477 );
3478 assert!(
3479 exists(&store, "records/contacts/index.jsonl"),
3480 "an aborted rebuild must not destroy the affected folder's prior catalog"
3481 );
3482 let contacts_jsonl = read(&store, "records/contacts/index.jsonl");
3483 assert!(contacts_jsonl.contains("records/contacts/alice.md"));
3484 }
3485
3486 #[test]
3499 fn rebuild_rollup_counts_equal_jsonl_records_and_write_through() {
3500 let (_d, store) = mk_store();
3501 write_doc(
3505 &store,
3506 "records/contacts/alice.md",
3507 "contact",
3508 Some("Alice"),
3509 Some("2026-05-01T00:00:00Z"),
3510 "",
3511 );
3512 write_doc(
3513 &store,
3514 "records/contacts/bob.md",
3515 "contact",
3516 Some("Bob"),
3517 Some("2026-05-02T00:00:00Z"),
3518 "",
3519 );
3520 Index::rebuild_all(&store).expect("clean rebuild succeeds");
3521
3522 let jsonl_lines = read(&store, "records/contacts/index.jsonl")
3524 .lines()
3525 .filter(|l| !l.trim().is_empty())
3526 .count();
3527 assert_eq!(jsonl_lines, 2, "two well-formed files ⇒ two jsonl records");
3528 let layer_md = read(&store, "records/index.md");
3529 let root_md = read(&store, "index.md");
3530 assert!(
3531 layer_md.contains("- [[records/contacts/index|Contacts]] (2)"),
3532 "layer rollup (N) must equal the jsonl record count (2), not a raw .md walk:\n{layer_md}"
3533 );
3534 assert!(
3535 root_md.contains("- [[records/contacts/index|Contacts]] (2)\n")
3536 && root_md.contains("## Records (2)"),
3537 "root rollup (N)/layer total must equal the jsonl record count (2):\n{root_md}"
3538 );
3539
3540 let (_d2, wt) = mk_store();
3547 write_doc(
3548 &wt,
3549 "records/contacts/alice.md",
3550 "contact",
3551 Some("Alice"),
3552 Some("2026-05-01T00:00:00Z"),
3553 "",
3554 );
3555 write_doc(
3556 &wt,
3557 "records/contacts/bob.md",
3558 "contact",
3559 Some("Bob"),
3560 Some("2026-05-02T00:00:00Z"),
3561 "",
3562 );
3563 Index::on_write(&wt, Path::new("records/contacts/alice.md")).unwrap();
3564 Index::on_write(&wt, Path::new("records/contacts/bob.md")).unwrap();
3565
3566 let a = snapshot_artifacts(&wt);
3567 let b = snapshot_artifacts(&store);
3568 assert_eq!(
3569 a.keys().collect::<BTreeSet<_>>(),
3570 b.keys().collect::<BTreeSet<_>>(),
3571 "write-through and rebuild_all must produce the same artifact set"
3572 );
3573 for (k, v) in &a {
3574 assert_eq!(
3575 v, &b[k],
3576 "rollup bytes diverged between write-through and rebuild_all for {k} \
3577 (a skip-version inflates rebuild_all's (N) above the jsonl record \
3578 count, which write-through then rewrites):\n--- write-through ---\n{v}\n--- rebuild ---\n{}",
3579 b[k]
3580 );
3581 }
3582 }
3583
3584 #[cfg(unix)]
3589 #[test]
3590 fn non_utf8_path_component_is_kept_not_dropped() {
3591 use std::ffi::OsStr;
3592 use std::os::unix::ffi::OsStrExt;
3593 let mut leaf = b"caf".to_vec();
3595 leaf.push(0xE9);
3596 leaf.extend_from_slice(b".md");
3597 let p = Path::new("sources/emails").join(OsStr::from_bytes(&leaf));
3598 let unix = path_to_unix(&p);
3599 assert_ne!(
3602 unix, "sources/emails",
3603 "non-UTF-8 leaf must not be dropped, collapsing the path to its parent dir"
3604 );
3605 assert!(
3606 unix.starts_with("sources/emails/caf"),
3607 "the lossy leaf must remain under its folder: {unix}"
3608 );
3609 }
3610
3611 #[test]
3614 fn loose_file_is_catalogued_in_layer_jsonl_not_type_folder() {
3615 let (_d, store) = mk_store();
3616 write_doc(
3618 &store,
3619 "records/contacts/alice.md",
3620 "contact",
3621 Some("Alice"),
3622 Some("2026-06-01T08:00:00Z"),
3623 "id: alice\n",
3624 );
3625 write_doc(
3626 &store,
3627 "records/loose.md",
3628 "contact",
3629 Some("Loose"),
3630 Some("2026-06-01T08:00:00Z"),
3631 "id: loose\n",
3632 );
3633 Index::rebuild_all(&store).unwrap();
3634
3635 assert!(
3638 exists(&store, "records/index.jsonl"),
3639 "layer jsonl must exist when loose files are present"
3640 );
3641 let layer_jsonl = read(&store, "records/index.jsonl");
3642 assert!(
3643 layer_jsonl.contains("records/loose.md"),
3644 "layer jsonl must list the loose file, got:\n{layer_jsonl}"
3645 );
3646 assert!(
3647 !layer_jsonl.contains("records/contacts/alice.md"),
3648 "layer jsonl must NOT list type-folder files"
3649 );
3650 let tf_jsonl = read(&store, "records/contacts/index.jsonl");
3651 assert!(tf_jsonl.contains("records/contacts/alice.md"));
3652 assert!(!tf_jsonl.contains("records/loose.md"));
3653
3654 let layer_md = read(&store, "records/index.md");
3656 assert!(
3657 layer_md.contains("records/contacts/index"),
3658 "layer md must roll up the type-folder, got:\n{layer_md}"
3659 );
3660 assert!(
3661 !layer_md.contains("records/loose"),
3662 "layer md must stay a rollup, not list loose files, got:\n{layer_md}"
3663 );
3664 }
3665
3666 #[test]
3667 fn loose_file_write_through_equals_rebuild() {
3668 let (_d1, wt) = mk_store();
3669 let (_d2, rb) = mk_store();
3670 for s in [&wt, &rb] {
3671 write_doc(
3672 s,
3673 "records/contacts/alice.md",
3674 "contact",
3675 Some("Alice"),
3676 Some("2026-06-01T08:00:00Z"),
3677 "id: alice\n",
3678 );
3679 write_doc(
3680 s,
3681 "records/loose.md",
3682 "contact",
3683 Some("Loose"),
3684 Some("2026-06-02T08:00:00Z"),
3685 "id: loose\n",
3686 );
3687 }
3688 Index::on_write(&wt, Path::new("records/contacts/alice.md")).unwrap();
3690 Index::on_write(&wt, Path::new("records/loose.md")).unwrap();
3691 Index::rebuild_all(&rb).unwrap();
3692
3693 let a = snapshot_artifacts(&wt);
3694 let b = snapshot_artifacts(&rb);
3695 assert_eq!(
3696 a.keys().collect::<Vec<_>>(),
3697 b.keys().collect::<Vec<_>>(),
3698 "loose-file loop and sweep must produce the same artifact set"
3699 );
3700 for (k, v) in &a {
3701 assert_eq!(
3702 v, &b[k],
3703 "loose-file artifact {k} differs between loop and sweep"
3704 );
3705 }
3706 }
3707
3708 #[test]
3709 fn removing_last_loose_file_clears_layer_jsonl() {
3710 let (_d, store) = mk_store();
3711 write_doc(
3712 &store,
3713 "records/loose.md",
3714 "contact",
3715 Some("Loose"),
3716 Some("2026-06-01T08:00:00Z"),
3717 "id: loose\n",
3718 );
3719 Index::on_write(&store, Path::new("records/loose.md")).unwrap();
3720 assert!(
3721 exists(&store, "records/index.jsonl"),
3722 "layer jsonl present after a loose write"
3723 );
3724 fs::remove_file(store.root.join("records/loose.md")).unwrap();
3725 Index::on_remove(&store, Path::new("records/loose.md")).unwrap();
3726 assert!(
3727 !exists(&store, "records/index.jsonl"),
3728 "layer jsonl must be removed once the last loose file is gone"
3729 );
3730 }
3731
3732 #[test]
3735 fn concurrent_writes_to_different_type_folders_match_rebuild() {
3736 use std::sync::Arc;
3737 use std::thread;
3738
3739 let (_d, store) = mk_store();
3748 let folders = ["records/contacts", "records/companies"];
3749 let n = 12usize;
3750
3751 for (fi, folder) in folders.iter().enumerate() {
3754 for i in 0..n {
3755 write_doc(
3756 &store,
3757 &format!("{folder}/f{fi}_{i}.md"),
3758 "contact",
3759 Some(&format!("Summary {fi}-{i}")),
3760 Some(&format!("2026-06-{:02}T08:00:00Z", i + 1)),
3761 &format!("id: f{fi}_{i}\n"),
3762 );
3763 }
3764 }
3765
3766 let store = Arc::new(store);
3767 let handles: Vec<_> = folders
3768 .iter()
3769 .enumerate()
3770 .map(|(fi, folder)| {
3771 let store = Arc::clone(&store);
3772 let folder = folder.to_string();
3773 thread::spawn(move || {
3774 for i in 0..n {
3775 let rel = format!("{folder}/f{fi}_{i}.md");
3776 Index::on_write(&store, Path::new(&rel)).unwrap();
3777 }
3778 })
3779 })
3780 .collect();
3781 for h in handles {
3782 h.join().unwrap();
3783 }
3784
3785 let got = snapshot_artifacts(&store);
3788 Index::rebuild_all(&store).unwrap();
3789 let want = snapshot_artifacts(&store);
3790
3791 assert_eq!(
3792 got.keys().collect::<Vec<_>>(),
3793 want.keys().collect::<Vec<_>>(),
3794 "artifact set after concurrent write-through must match rebuild"
3795 );
3796 for (k, v) in &want {
3797 assert_eq!(
3798 &got[k], v,
3799 "rollup artifact {k} diverged from rebuild after concurrent writes"
3800 );
3801 }
3802 }
3803}