1use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::path::{Component, Path, PathBuf};
43
44use chrono::{DateTime, FixedOffset, NaiveDateTime};
45use serde_norway::Value;
46
47use crate::parser::{Schema, Shape};
48use crate::store::Store;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Severity {
54 Error,
56 Warning,
58 Info,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Issue {
67 pub severity: Severity,
69 pub code: &'static str,
71 pub file: PathBuf,
73 pub line: Option<u32>,
75 pub key: Option<String>,
77 pub message: String,
79 pub suggestion: Option<String>,
81 pub related: Vec<PathBuf>,
83}
84
85impl Issue {
86 pub fn is_error(&self) -> bool {
89 matches!(self.severity, Severity::Error)
90 }
91}
92
93pub mod codes {
97 pub const NOT_A_STORE: &str = "NOT_A_STORE";
99 pub const DB_MD_BAD_TYPE: &str = "DB_MD_BAD_TYPE";
101 pub const DB_MD_MISSING_FIELD: &str = "DB_MD_MISSING_FIELD";
103 pub const DB_MD_UNKNOWN_SECTION: &str = "DB_MD_UNKNOWN_SECTION";
105 pub const DB_MD_SCHEMA_FIELD: &str = "DB_MD_SCHEMA_FIELD";
108 pub const FM_MISSING_TYPE: &str = "FM_MISSING_TYPE";
110 pub const FM_MISSING_CREATED: &str = "FM_MISSING_CREATED";
112 pub const FM_MISSING_UPDATED: &str = "FM_MISSING_UPDATED";
114 pub const FM_UNREADABLE: &str = "FM_UNREADABLE";
116 pub const FM_MALFORMED_YAML: &str = "FM_MALFORMED_YAML";
118 pub const FM_BAD_TIMESTAMP: &str = "FM_BAD_TIMESTAMP";
120 pub const FM_BAD_META_TYPE: &str = "FM_BAD_META_TYPE";
122 pub const FM_BAD_ID: &str = "FM_BAD_ID";
126 pub const SUMMARY_MISSING: &str = "SUMMARY_MISSING";
128 pub const SUMMARY_EMPTY: &str = "SUMMARY_EMPTY";
130 pub const SUMMARY_MULTILINE: &str = "SUMMARY_MULTILINE";
132 pub const SUMMARY_TOO_LONG: &str = "SUMMARY_TOO_LONG";
134 pub const WIKI_LINK_SHORT_FORM: &str = "WIKI_LINK_SHORT_FORM";
136 pub const WIKI_LINK_BROKEN: &str = "WIKI_LINK_BROKEN";
138 pub const WIKI_LINK_AMBIGUOUS: &str = "WIKI_LINK_AMBIGUOUS";
140 pub const WIKI_LINK_HAS_EXTENSION: &str = "WIKI_LINK_HAS_EXTENSION";
142 pub const WIKI_LINK_FLOW_FORM_LIST: &str = "WIKI_LINK_FLOW_FORM_LIST";
144 pub const DUP_ID: &str = "DUP_ID";
146 pub const DUP_UNIQUE_KEY: &str = "DUP_UNIQUE_KEY";
148 pub const SCHEMA_MISSING_REQUIRED: &str = "SCHEMA_MISSING_REQUIRED";
150 pub const SCHEMA_SHAPE_MISMATCH: &str = "SCHEMA_SHAPE_MISMATCH";
152 pub const SCHEMA_LINK_PREFIX_MISMATCH: &str = "SCHEMA_LINK_PREFIX_MISMATCH";
154 pub const SCHEMA_ENUM_VIOLATION: &str = "SCHEMA_ENUM_VIOLATION";
156 pub const POLICY_FROZEN_PAGE: &str = "POLICY_FROZEN_PAGE";
158 pub const POLICY_IGNORED_TYPE_PRESENT: &str = "POLICY_IGNORED_TYPE_PRESENT";
160 pub const POLICY_IGNORED_TYPE_DERIVED: &str = "POLICY_IGNORED_TYPE_DERIVED";
162 pub const LOG_BAD_TIMESTAMP: &str = "LOG_BAD_TIMESTAMP";
164 pub const LOG_UNKNOWN_KIND: &str = "LOG_UNKNOWN_KIND";
166 pub const LOG_OUT_OF_ORDER: &str = "LOG_OUT_OF_ORDER";
168 pub const INDEX_MISSING: &str = "INDEX_MISSING";
170 pub const INDEX_STALE_ENTRY: &str = "INDEX_STALE_ENTRY";
172 pub const INDEX_MISSING_ENTRY: &str = "INDEX_MISSING_ENTRY";
174 pub const INDEX_ORPHAN: &str = "INDEX_ORPHAN";
176 pub const INDEX_WRONG_SCOPE: &str = "INDEX_WRONG_SCOPE";
178 pub const INDEX_SUMMARY_MISMATCH: &str = "INDEX_SUMMARY_MISMATCH";
180 pub const INDEX_JSONL_MISSING: &str = "INDEX_JSONL_MISSING";
182 pub const INDEX_JSONL_DESYNC: &str = "INDEX_JSONL_DESYNC";
185 pub const INDEX_JSONL_STALE: &str = "INDEX_JSONL_STALE";
187 pub const TAGS_MALFORMED: &str = "TAGS_MALFORMED";
189 pub const ASSET_MANIFEST_MALFORMED: &str = "ASSET_MANIFEST_MALFORMED";
191 pub const ASSET_UNDECLARED: &str = "ASSET_UNDECLARED";
194 pub const ASSET_WRAPPER_BROKEN: &str = "ASSET_WRAPPER_BROKEN";
196 pub const ASSET_MANIFEST_ORPHAN: &str = "ASSET_MANIFEST_ORPHAN";
198 pub const ASSET_PATH_IS_CONTENT: &str = "ASSET_PATH_IS_CONTENT";
200}
201
202const MAX_SUMMARY_LEN: usize = 200;
204
205const RECOGNIZED_LOG_KINDS: &[&str] = &[
208 "ingest",
209 "create",
210 "update",
211 "delete",
212 "rename",
213 "link",
214 "validate",
215 "index-rebuild",
216 "contradiction",
217];
218
219pub fn validate_working_set(
245 store: &Store,
246 since: Option<DateTime<FixedOffset>>,
247) -> crate::Result<Vec<Issue>> {
248 if !store_marker_present(store) {
249 return Ok(vec![not_a_store_issue(store)]);
250 }
251
252 let cutoff = match since {
253 Some(ts) => Some(ts),
254 None => last_validate_at(store),
255 };
256
257 let changed = changed_objects_since(store, cutoff);
259 if changed.is_empty() && since.is_none() {
260 return validate_content_sweep(store);
261 }
262
263 let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
274 let mut working: BTreeSet<PathBuf> = changed;
275 for linker in store.find_links_to_any(&changed_targets)? {
276 working.insert(linker);
277 }
278
279 let mut issues = Vec::new();
280 for rel in &working {
281 let abs = store.root.join(rel);
282 if !abs.is_file() {
285 continue;
286 }
287 check_content_file(store, rel, &abs, None, &mut issues);
292 }
293 issues.sort_by(issue_order);
294 Ok(issues)
295}
296
297fn validate_content_sweep(store: &Store) -> crate::Result<Vec<Issue>> {
298 let mut issues = Vec::new();
299 for rel in store.walk()? {
300 let abs = store.root.join(&rel);
301 check_content_file(store, &rel, &abs, None, &mut issues);
302 }
303 issues.sort_by(issue_order);
304 Ok(issues)
305}
306
307pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
312 if !store_marker_present(store) {
313 return Ok(vec![not_a_store_issue(store)]);
314 }
315
316 let mut issues = Vec::new();
317
318 check_db_md(store, &mut issues);
322
323 let files = walk_content_files(&store.root);
324
325 let basenames = build_basename_index(&files);
330
331 let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
333 for rel in &files {
334 let abs = store.root.join(rel);
335 if let Some(p) = check_content_file(store, rel, &abs, Some(&basenames), &mut issues) {
336 parsed.push((rel.clone(), p));
337 }
338 }
339
340 check_duplicates(store, &parsed, &mut issues);
342
343 check_indexes(store, &files, &mut issues);
345
346 check_log(store, &mut issues);
348
349 check_assets(store, &parsed, &mut issues);
354
355 issues.sort_by(issue_order);
356 Ok(issues)
357}
358
359struct Parsed {
368 fm: Option<BTreeMap<String, Value>>,
371 fm_yaml: String,
374}
375
376fn check_content_file(
381 store: &Store,
382 rel: &Path,
383 abs: &Path,
384 basenames: Option<&BasenameIndex>,
385 issues: &mut Vec<Issue>,
386) -> Option<Parsed> {
387 let text = match std::fs::read_to_string(abs) {
388 Ok(t) => t,
389 Err(e) => {
390 let detail = if e.kind() == std::io::ErrorKind::InvalidData {
398 "file is not valid UTF-8 text".to_string()
399 } else {
400 format!("file could not be read: {e}")
401 };
402 push(
403 issues,
404 Severity::Error,
405 codes::FM_UNREADABLE,
406 rel,
407 None,
408 None,
409 format!("content file is unreadable: {detail}"),
410 Some(
411 "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
412 .into(),
413 ),
414 vec![],
415 );
416 return None;
417 }
418 };
419
420 let is_content = is_content_file(rel);
421
422 let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
423 Some(split) => split,
424 None => {
425 if is_content {
429 push(
430 issues,
431 Severity::Error,
432 codes::FM_MISSING_TYPE,
433 rel,
434 None,
435 Some("type".into()),
436 "content file has no frontmatter `type:`".into(),
437 Some("add a YAML frontmatter block with `type:`".into()),
438 vec![],
439 );
440 push(
441 issues,
442 Severity::Error,
443 codes::SUMMARY_MISSING,
444 rel,
445 None,
446 Some("summary".into()),
447 "content file has no `summary`".into(),
448 Some("run `dbmd fm init`".into()),
449 vec![],
450 );
451 }
452 return None;
453 }
454 };
455
456 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
458 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
459 Ok(Value::Null) => Some(BTreeMap::new()),
461 Ok(_) => {
462 push(
466 issues,
467 Severity::Error,
468 codes::FM_MALFORMED_YAML,
469 rel,
470 Some(1),
471 None,
472 "frontmatter is not a YAML mapping".into(),
473 Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
474 vec![],
475 );
476 None
477 }
478 Err(e) => {
479 push(
482 issues,
483 Severity::Error,
484 codes::FM_MALFORMED_YAML,
485 rel,
486 Some(1),
487 None,
488 format!("frontmatter block isn't valid YAML: {e}"),
489 Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
490 vec![],
491 );
492 None
493 }
494 };
495
496 if let Some(map) = &fm {
497 check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
499 }
500
501 if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
523 check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
524 }
525
526 Some(Parsed { fm, fm_yaml })
527}
528
529fn check_frontmatter(
531 store: &Store,
532 rel: &Path,
533 fm: &BTreeMap<String, Value>,
534 fm_yaml: &str,
535 basenames: Option<&BasenameIndex>,
536 issues: &mut Vec<Issue>,
537 is_content: bool,
538) {
539 let type_ = fm.get("type").and_then(scalar_string);
540
541 if is_content && type_.is_none() {
543 push(
544 issues,
545 Severity::Error,
546 codes::FM_MISSING_TYPE,
547 rel,
548 fm_key_line_or_top(fm_yaml, "type"),
549 Some("type".into()),
550 "content file has no `type:`".into(),
551 Some("add a `type:` field (e.g. `type: contact`)".into()),
552 vec![],
553 );
554 }
555
556 if is_content {
561 if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
570 match scalar_string(v) {
571 Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
572 Some(mt) => push(
573 issues,
574 Severity::Error,
575 codes::FM_BAD_META_TYPE,
576 rel,
577 fm_key_line_or_top(fm_yaml, "meta-type"),
578 Some("meta-type".into()),
579 format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
580 Some(
581 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
582 .into(),
583 ),
584 vec![],
585 ),
586 None => push(
587 issues,
588 Severity::Error,
589 codes::FM_BAD_META_TYPE,
590 rel,
591 fm_key_line_or_top(fm_yaml, "meta-type"),
592 Some("meta-type".into()),
593 "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
594 string, found a list or mapping"
595 .to_string(),
596 Some(
597 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
598 .into(),
599 ),
600 vec![],
601 ),
602 }
603 }
604 }
605
606 if is_content {
617 if let Some(v) = fm.get("id").filter(|v| !v.is_null()) {
618 let problem = match scalar_string(v) {
619 Some(id) if id.trim().is_empty() => Some("`id` is empty".to_string()),
620 Some(id) if id.chars().any(char::is_whitespace) => {
621 Some(format!("`id` {id:?} contains whitespace"))
622 }
623 Some(_) => None,
624 None => Some(
625 "`id` is not a scalar (found a list or mapping), so duplicate detection \
626 (DUP_ID) cannot see it"
627 .to_string(),
628 ),
629 };
630 if let Some(message) = problem {
631 push(
632 issues,
633 Severity::Warning,
634 codes::FM_BAD_ID,
635 rel,
636 fm_key_line_or_top(fm_yaml, "id"),
637 Some("id".into()),
638 message,
639 Some(
640 "use one opaque token with no whitespace — the recommended form is a \
641 lowercase ULID (`dbmd write` mints one) — or drop `id` to fall back to \
642 filename identity"
643 .into(),
644 ),
645 vec![],
646 );
647 }
648 }
649 }
650
651 if is_content {
653 check_summary(rel, fm, fm_yaml, issues);
654 }
655
656 if is_content {
660 for (key, missing_code) in [
661 ("created", codes::FM_MISSING_CREATED),
662 ("updated", codes::FM_MISSING_UPDATED),
663 ] {
664 let value = fm.get(key);
669 let missing = value.is_none() || value.is_some_and(Value::is_null);
670 if missing {
671 push(
672 issues,
673 Severity::Error,
674 missing_code,
675 rel,
676 fm_key_line_or_top(fm_yaml, key),
677 Some(key.into()),
678 format!("content file has no `{key}:` timestamp"),
679 Some(format!(
680 "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
681 )),
682 vec![],
683 );
684 } else if let Some(v) = value {
685 match scalar_string(v) {
691 Some(s) if is_iso8601(&s) => {}
692 Some(s) => push(
693 issues,
694 Severity::Error,
695 codes::FM_BAD_TIMESTAMP,
696 rel,
697 fm_key_line(fm_yaml, key),
698 Some(key.into()),
699 format!("`{key}` is not ISO-8601: {s:?}"),
700 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
701 vec![],
702 ),
703 None => push(
704 issues,
705 Severity::Error,
706 codes::FM_BAD_TIMESTAMP,
707 rel,
708 fm_key_line(fm_yaml, key),
709 Some(key.into()),
710 format!(
711 "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
712 ),
713 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
714 vec![],
715 ),
716 }
717 }
718 }
719 }
720 if let Some(tags) = fm.get("tags") {
722 if !is_flat_scalar_list(tags) {
723 push(
724 issues,
725 Severity::Warning,
726 codes::TAGS_MALFORMED,
727 rel,
728 fm_key_line(fm_yaml, "tags"),
729 Some("tags".into()),
730 "`tags` must be a flat YAML list of short scalar labels".into(),
731 Some("use block form: one `- <tag>` per line".into()),
732 vec![],
733 );
734 }
735 }
736
737 for key in detect_flow_form_link_lists(fm_yaml) {
739 push(
740 issues,
741 Severity::Error,
742 codes::WIKI_LINK_FLOW_FORM_LIST,
743 rel,
744 fm_key_line(fm_yaml, &key),
745 Some(key.clone()),
746 format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
747 Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
748 vec![],
749 );
750 }
751
752 let schema_link_keys: BTreeSet<String> =
757 effective_schema(store, type_.as_deref().unwrap_or(""))
758 .map(|s| {
759 s.fields
760 .iter()
761 .filter(|f| f.link_prefix.is_some())
762 .map(|f| f.name.clone())
763 .collect()
764 })
765 .unwrap_or_default();
766 for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
767 if schema_link_keys.contains(&key) {
768 continue;
769 }
770 check_wiki_link(
771 store,
772 rel,
773 &link,
774 Some(link.line),
775 Some(&key),
776 basenames,
777 issues,
778 );
779 }
780
781 if let Some(t) = &type_ {
783 if store.config.ignored_types.iter().any(|it| it == t) {
784 push(
785 issues,
786 Severity::Info,
787 codes::POLICY_IGNORED_TYPE_PRESENT,
788 rel,
789 fm_key_line(fm_yaml, "type"),
790 Some("type".into()),
791 format!("file has ignored type `{t}` (per DB.md ## Policies)"),
792 Some(
793 "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
794 .into(),
795 ),
796 vec![PathBuf::from("DB.md")],
798 );
799 }
800 let meta_type = fm
806 .get("meta-type")
807 .and_then(scalar_string)
808 .unwrap_or_else(|| "fact".to_string());
809 for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
810 if let Some(hit) =
811 derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
812 {
813 push(
814 issues,
815 Severity::Warning,
816 codes::POLICY_IGNORED_TYPE_DERIVED,
817 rel,
818 Some(link.line),
819 Some("derived_from".into()),
820 format!(
821 "conclusion record derives from ignored-type record `{}` (type `{}`)",
822 hit.target, hit.target_type
823 ),
824 Some(
825 "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
826 .into(),
827 ),
828 vec![
831 PathBuf::from(format!("{}.md", hit.target)),
832 PathBuf::from("DB.md"),
833 ],
834 );
835 }
836 }
837 }
838
839 if let Some(t) = &type_ {
841 if let Some(schema) = effective_schema(store, t) {
842 check_schema(store, rel, fm, fm_yaml, &schema, issues);
843 }
844 }
845}
846
847fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
849 let line = fm_key_line(fm_yaml, "summary");
850 match fm.get("summary") {
851 None => push(
852 issues,
853 Severity::Error,
854 codes::SUMMARY_MISSING,
855 rel,
856 fm_key_line_or_top(fm_yaml, "summary"),
859 Some("summary".into()),
860 "content file has no `summary`".into(),
861 Some("run `dbmd fm init`".into()),
862 vec![],
863 ),
864 Some(v) => {
865 let s = scalar_string(v).unwrap_or_default();
866 if s.trim().is_empty() {
867 push(
868 issues,
869 Severity::Error,
870 codes::SUMMARY_EMPTY,
871 rel,
872 line,
873 Some("summary".into()),
874 "`summary` is present but empty".into(),
875 Some("write a one-line summary, or run `dbmd fm init`".into()),
876 vec![],
877 );
878 } else if s.contains('\n') {
879 push(
880 issues,
881 Severity::Error,
882 codes::SUMMARY_MULTILINE,
883 rel,
884 line,
885 Some("summary".into()),
886 "`summary` must be one line (contains a newline)".into(),
887 Some("collapse the summary to a single line".into()),
888 vec![],
889 );
890 } else if s.chars().count() > MAX_SUMMARY_LEN {
891 push(
892 issues,
893 Severity::Warning,
894 codes::SUMMARY_TOO_LONG,
895 rel,
896 line,
897 Some("summary".into()),
898 format!(
899 "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
900 s.chars().count()
901 ),
902 Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
903 vec![],
904 );
905 }
906 }
907 }
908}
909
910fn check_body_wiki_links(
912 store: &Store,
913 rel: &Path,
914 body: &str,
915 fm_end_line: u32,
916 basenames: Option<&BasenameIndex>,
917 issues: &mut Vec<Issue>,
918) {
919 for link in extract_wiki_links(body) {
920 let abs_line = fm_end_line + link.line;
923 check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
924 }
925}
926
927type BasenameIndex = HashMap<String, Vec<PathBuf>>;
935
936fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
939 let mut idx: BasenameIndex = HashMap::new();
940 for rel in files {
941 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
942 idx.entry(stem.to_string()).or_default().push(rel.clone());
943 }
944 }
945 idx
946}
947
948fn check_wiki_link(
953 store: &Store,
954 rel: &Path,
955 link: &Link,
956 line: Option<u32>,
957 key: Option<&str>,
958 basenames: Option<&BasenameIndex>,
959 issues: &mut Vec<Issue>,
960) {
961 let bare = link.target.trim_end_matches(".md");
962
963 if !is_full_store_path(bare) {
966 if !bare.contains('/') {
971 if let Some(idx) = basenames {
972 if let Some(matches) = idx.get(bare) {
973 if matches.len() >= 2 {
974 let mut related = matches.clone();
975 related.sort();
976 push(
977 issues,
978 Severity::Error,
979 codes::WIKI_LINK_AMBIGUOUS,
980 rel,
981 line,
982 key.map(str::to_string),
983 format!(
984 "short-form wiki-link `[[{}]]` matches multiple files",
985 link.target
986 ),
987 Some("use the full store-relative path to disambiguate".into()),
988 related,
989 );
990 return;
991 }
992 }
993 }
994 }
995 push(
996 issues,
997 Severity::Error,
998 codes::WIKI_LINK_SHORT_FORM,
999 rel,
1000 line,
1001 key.map(str::to_string),
1002 format!(
1003 "wiki-link `[[{}]]` is not a full store-relative path",
1004 link.target
1005 ),
1006 short_form_suggestion(bare),
1007 vec![],
1008 );
1009 return;
1011 }
1012
1013 if link.target.ends_with(".md") {
1015 push(
1016 issues,
1017 Severity::Warning,
1018 codes::WIKI_LINK_HAS_EXTENSION,
1019 rel,
1020 line,
1021 key.map(str::to_string),
1022 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1023 Some(format!("drop the extension: [[{bare}]]")),
1024 vec![],
1025 );
1026 }
1027
1028 match resolve_wiki_target(store, bare) {
1033 TargetResolution::Exists => {}
1034 TargetResolution::Missing => push(
1035 issues,
1036 Severity::Error,
1037 codes::WIKI_LINK_BROKEN,
1038 rel,
1039 line,
1040 key.map(str::to_string),
1041 format!("wiki-link target `{bare}` doesn't exist"),
1042 Some(format!(
1043 "create `{bare}.md`, or point the link at an existing file"
1044 )),
1045 vec![],
1046 ),
1047 TargetResolution::Unsafe => push(
1048 issues,
1049 Severity::Error,
1050 codes::WIKI_LINK_BROKEN,
1051 rel,
1052 line,
1053 key.map(str::to_string),
1054 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1055 Some("use a full store-relative path under sources/ or records/".into()),
1056 vec![],
1057 ),
1058 }
1059}
1060
1061fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1072 store.config.schemas.get(type_).cloned()
1073}
1074
1075fn check_schema(
1077 store: &Store,
1078 rel: &Path,
1079 fm: &BTreeMap<String, Value>,
1080 fm_yaml: &str,
1081 schema: &Schema,
1082 issues: &mut Vec<Issue>,
1083) {
1084 for spec in &schema.fields {
1085 let present = fm.get(&spec.name);
1086 let line = fm_key_line(fm_yaml, &spec.name);
1087
1088 let is_empty = match present {
1096 None => true,
1097 Some(v) => is_empty_value(v),
1098 };
1099 if spec.required && is_empty {
1100 push(
1101 issues,
1102 Severity::Error,
1103 codes::SCHEMA_MISSING_REQUIRED,
1104 rel,
1105 fm_key_line_or_top(fm_yaml, &spec.name),
1108 Some(spec.name.clone()),
1109 format!("required field `{}` is absent or empty", spec.name),
1110 Some(format!("set `{}` to a non-empty value", spec.name)),
1111 vec![],
1112 );
1113 continue;
1114 }
1115 let Some(value) = present else { continue };
1116
1117 let value_empty = value.is_null()
1123 || scalar_string(value)
1124 .map(|s| s.trim().is_empty())
1125 .unwrap_or(false);
1126 if !spec.required && value_empty {
1127 continue;
1128 }
1129
1130 if let Some(prefix) = &spec.link_prefix {
1133 check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1134 continue; }
1136
1137 if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1144 push(
1145 issues,
1146 Severity::Error,
1147 codes::SCHEMA_SHAPE_MISMATCH,
1148 rel,
1149 line,
1150 Some(spec.name.clone()),
1151 format!(
1152 "`{}` must be a scalar value, found a list or mapping",
1153 spec.name
1154 ),
1155 Some(format!("set `{}` to a single scalar value", spec.name)),
1156 vec![],
1157 );
1158 continue;
1159 }
1160
1161 if let Some(allowed) = &spec.enum_values {
1163 if let Some(s) = scalar_string(value) {
1164 if !allowed.iter().any(|a| a == &s) {
1165 push(
1166 issues,
1167 Severity::Error,
1168 codes::SCHEMA_ENUM_VIOLATION,
1169 rel,
1170 line,
1171 Some(spec.name.clone()),
1172 format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1173 Some(format!("use one of: {}", allowed.join(", "))),
1174 vec![],
1175 );
1176 }
1177 }
1178 continue;
1179 }
1180
1181 if let Some(shape) = spec.shape {
1183 check_schema_shape(rel, &spec.name, value, shape, line, issues);
1184 }
1185 }
1186}
1187
1188fn check_schema_link(
1193 store: &Store,
1194 rel: &Path,
1195 field: &str,
1196 fm_yaml: &str,
1197 prefix: &Path,
1198 line: Option<u32>,
1199 issues: &mut Vec<Issue>,
1200) {
1201 let prefix_str = prefix.to_string_lossy();
1202 let prefix_str = prefix_str.trim_end_matches('/');
1203 let suggestion = |target_leaf: &str| {
1204 Some(format!(
1205 "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1206 ))
1207 };
1208
1209 let links = frontmatter_links_for_key(fm_yaml, field, 2);
1210 if links.is_empty() {
1211 let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1213 let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1214 let leaf = slugish(raw);
1215 push(
1216 issues,
1217 Severity::Error,
1218 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1219 rel,
1220 line,
1221 Some(field.to_string()),
1222 format!(
1223 "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1224 ),
1225 suggestion(&leaf),
1226 vec![],
1227 );
1228 return;
1229 }
1230
1231 for link in links {
1232 if link.target.ends_with(".md") {
1233 let bare = link.target.trim_end_matches(".md");
1234 push(
1235 issues,
1236 Severity::Warning,
1237 codes::WIKI_LINK_HAS_EXTENSION,
1238 rel,
1239 Some(link.line),
1240 Some(field.to_string()),
1241 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1242 Some(format!("drop the extension: [[{bare}]]")),
1243 vec![],
1244 );
1245 }
1246 let bare = link.target.trim_end_matches(".md");
1247 if !path_under_prefix(bare, prefix_str) {
1248 let leaf = bare.rsplit('/').next().unwrap_or(bare);
1249 push(
1250 issues,
1251 Severity::Error,
1252 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1253 rel,
1254 line,
1255 Some(field.to_string()),
1256 format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1257 suggestion(leaf),
1258 vec![],
1259 );
1260 } else {
1261 match resolve_wiki_target(store, bare) {
1266 TargetResolution::Exists => {}
1267 TargetResolution::Missing => push(
1268 issues,
1269 Severity::Error,
1270 codes::WIKI_LINK_BROKEN,
1271 rel,
1272 line,
1273 Some(field.to_string()),
1274 format!("wiki-link target `{bare}` doesn't exist"),
1275 Some(format!(
1276 "create `{bare}.md`, or point the link at an existing file"
1277 )),
1278 vec![],
1279 ),
1280 TargetResolution::Unsafe => push(
1281 issues,
1282 Severity::Error,
1283 codes::WIKI_LINK_BROKEN,
1284 rel,
1285 line,
1286 Some(field.to_string()),
1287 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1288 Some("use a full store-relative path under sources/ or records/".into()),
1289 vec![],
1290 ),
1291 }
1292 }
1293 }
1294}
1295
1296fn check_schema_shape(
1298 rel: &Path,
1299 field: &str,
1300 value: &Value,
1301 shape: Shape,
1302 line: Option<u32>,
1303 issues: &mut Vec<Issue>,
1304) {
1305 let s = scalar_string(value).unwrap_or_default();
1306 let ok = match shape {
1307 Shape::String => true, Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1309 Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1310 Shape::Date => is_iso8601_date_or_datetime(&s),
1311 Shape::Email => is_email(&s),
1312 Shape::Currency => is_currency(&s),
1313 Shape::Url => is_url(&s),
1314 };
1315 if !ok {
1316 push(
1317 issues,
1318 Severity::Error,
1319 codes::SCHEMA_SHAPE_MISMATCH,
1320 rel,
1321 line,
1322 Some(field.to_string()),
1323 format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1324 Some(shape_suggestion(shape)),
1325 vec![],
1326 );
1327 }
1328}
1329
1330fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1349 let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1352 .iter()
1353 .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1354 .collect();
1355
1356 let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1358 for (rel, p) in parsed {
1359 if let Some(map) = &p.fm {
1360 if let Some(id) = map.get("id").and_then(scalar_string) {
1361 if !id.trim().is_empty() {
1362 by_id.entry(id).or_default().push(rel.clone());
1363 }
1364 }
1365 }
1366 }
1367 for (id, files) in &by_id {
1368 if files.len() > 1 {
1369 let (reported, related) = canonical_and_related(files);
1370 let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1371 push(
1372 issues,
1373 Severity::Error,
1374 codes::DUP_ID,
1375 &reported,
1376 line,
1377 Some("id".into()),
1378 format!("id {id:?} is declared by more than one file"),
1379 Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1380 related,
1381 );
1382 }
1383 }
1384
1385 for (type_name, schema) in &store.config.schemas {
1390 for key_fields in &schema.unique_keys {
1391 soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1392 }
1393 }
1394}
1395
1396fn soft_dup(
1405 parsed: &[(PathBuf, Parsed)],
1406 issues: &mut Vec<Issue>,
1407 type_: &str,
1408 key_fields: &[String],
1409 fm_yaml_of: &HashMap<&PathBuf, &str>,
1410) {
1411 if key_fields.is_empty() {
1412 return;
1413 }
1414 let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1415 for (rel, p) in parsed {
1416 let is_type =
1417 p.fm.as_ref()
1418 .and_then(|m| m.get("type"))
1419 .and_then(scalar_string)
1420 .map(|t| t == type_)
1421 .unwrap_or(false);
1422 if !is_type {
1423 continue;
1424 }
1425 if let Some(key) = dedup_key(p, key_fields) {
1426 groups.entry(key).or_default().push(rel.clone());
1427 }
1428 }
1429 let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1432 .values()
1433 .filter(|files| files.len() > 1)
1434 .map(|files| canonical_and_related(files))
1435 .collect();
1436 collisions.sort_by(|a, b| a.0.cmp(&b.0));
1437
1438 let fields_disp = key_fields.join(", ");
1439 for (reported, related) in collisions {
1440 let (line, key) = if key_fields.len() == 1 {
1443 (
1444 fm_yaml_of
1445 .get(&reported)
1446 .and_then(|y| fm_key_line(y, &key_fields[0])),
1447 Some(key_fields[0].clone()),
1448 )
1449 } else {
1450 (Some(1), None)
1451 };
1452 let n = related.len();
1453 push(
1454 issues,
1455 Severity::Warning,
1456 codes::DUP_UNIQUE_KEY,
1457 &reported,
1458 line,
1459 key,
1460 format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1461 Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1462 related,
1463 );
1464 }
1465}
1466
1467fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1471 let mut out = Vec::with_capacity(key_fields.len());
1472 for f in key_fields {
1473 out.push(dedup_token(p, f)?);
1474 }
1475 Some(out)
1476}
1477
1478fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1483 let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1486 if !links.is_empty() {
1487 let set: BTreeSet<String> = links
1488 .into_iter()
1489 .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1490 .filter(|t| !t.is_empty())
1491 .collect();
1492 return if set.is_empty() {
1493 None
1494 } else {
1495 Some(set.into_iter().collect::<Vec<_>>().join(","))
1496 };
1497 }
1498 match p.fm.as_ref()?.get(field) {
1499 Some(Value::Sequence(items)) => {
1500 let set: BTreeSet<String> = items
1501 .iter()
1502 .filter_map(scalar_string)
1503 .map(|s| s.trim().to_lowercase())
1504 .filter(|t| !t.is_empty())
1505 .collect();
1506 if set.is_empty() {
1507 None
1508 } else {
1509 Some(set.into_iter().collect::<Vec<_>>().join(","))
1510 }
1511 }
1512 Some(v) => {
1513 let s = scalar_string(v)?.trim().to_lowercase();
1514 if s.is_empty() {
1515 None
1516 } else {
1517 Some(s)
1518 }
1519 }
1520 None => None,
1521 }
1522}
1523
1524fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1529 let mut sorted = files.to_vec();
1530 sorted.sort();
1531 let reported = sorted[0].clone();
1532 let related = sorted[1..].to_vec();
1533 (reported, related)
1534}
1535
1536fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1542 let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1546 for rel in files {
1547 if let Some(tf) = type_folder_of(rel) {
1548 type_folders.entry(tf).or_default().push(rel.clone());
1549 }
1550 }
1551
1552 let mut layers_with_type_folders: BTreeSet<&'static str> = BTreeSet::new();
1564 for tf in type_folders.keys() {
1565 match tf.iter().next().and_then(|s| s.to_str()) {
1566 Some("sources") => {
1567 layers_with_type_folders.insert("sources");
1568 }
1569 Some("records") => {
1570 layers_with_type_folders.insert("records");
1571 }
1572 _ => {}
1573 }
1574 }
1575
1576 if !type_folders.is_empty() {
1578 let root_index = store.root.join("index.md");
1579 if !root_index.is_file() {
1580 push(
1581 issues,
1582 Severity::Error,
1583 codes::INDEX_MISSING,
1584 Path::new("index.md"),
1585 None,
1586 None,
1587 "store has files but no root `index.md`".into(),
1588 Some("run `dbmd index rebuild`".into()),
1589 vec![],
1590 );
1591 } else {
1592 check_index_scope(store, Path::new("index.md"), "root", None, issues);
1593 }
1594 }
1595
1596 for layer in &layers_with_type_folders {
1598 let layer_index_rel = PathBuf::from(layer).join("index.md");
1599 let abs = store.root.join(&layer_index_rel);
1600 if !abs.is_file() {
1601 push(
1602 issues,
1603 Severity::Error,
1604 codes::INDEX_MISSING,
1605 &layer_index_rel,
1606 None,
1607 None,
1608 format!("layer `{layer}/` has files but no `index.md`"),
1609 Some("run `dbmd index rebuild`".into()),
1610 vec![],
1611 );
1612 } else {
1613 check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1614 }
1615 }
1616
1617 for (tf, members) in &type_folders {
1619 let index_md_rel = tf.join("index.md");
1620 let index_md_abs = store.root.join(&index_md_rel);
1621 let index_md_present = index_md_abs.is_file();
1622 if !index_md_present {
1623 push(
1629 issues,
1630 Severity::Error,
1631 codes::INDEX_MISSING,
1632 tf,
1633 None,
1634 None,
1635 format!("non-empty folder `{}` has no index.md", tf.display()),
1636 Some(format!(
1637 "run `dbmd index rebuild --folder {}`",
1638 tf.display()
1639 )),
1640 vec![],
1641 );
1642 continue;
1643 }
1644
1645 check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1646 check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1647
1648 let jsonl_rel = tf.join("index.jsonl");
1652 let jsonl_abs = store.root.join(&jsonl_rel);
1653 if !jsonl_abs.is_file() {
1654 push(
1655 issues,
1656 Severity::Error,
1657 codes::INDEX_JSONL_MISSING,
1658 &jsonl_rel,
1659 None,
1660 None,
1661 format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1662 Some("run `dbmd index rebuild`".into()),
1663 vec![],
1664 );
1665 } else {
1666 check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1667 }
1668 }
1669
1670 let mut loose_by_layer: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1678 for rel in files {
1679 if !is_content_file(rel) || type_folder_of(rel).is_some() {
1680 continue;
1681 }
1682 if let Some(layer_dir) = loose_layer_dir(rel) {
1683 loose_by_layer
1684 .entry(layer_dir)
1685 .or_default()
1686 .push(rel.clone());
1687 }
1688 }
1689 for (layer_dir, members) in &loose_by_layer {
1690 let jsonl_rel = layer_dir.join("index.jsonl");
1691 if !store.root.join(&jsonl_rel).is_file() {
1692 push(
1693 issues,
1694 Severity::Error,
1695 codes::INDEX_JSONL_MISSING,
1696 &jsonl_rel,
1697 None,
1698 None,
1699 format!(
1700 "loose files at `{}/` are not catalogued — the layer has no `index.jsonl`",
1701 layer_dir.display()
1702 ),
1703 Some("run `dbmd index rebuild`".into()),
1704 members.clone(),
1705 );
1706 } else {
1707 check_type_folder_index_jsonl(store, layer_dir, &jsonl_rel, members, issues);
1711 }
1712 }
1713
1714 for rel in walk_index_files(&store.root) {
1716 let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1717 let parent_str = parent.to_string_lossy().to_string();
1718 let is_canonical = parent_str.is_empty() || matches!(parent_str.as_str(), "sources" | "records")
1720 || type_folders.contains_key(&parent);
1721 if !is_canonical {
1722 push(
1723 issues,
1724 Severity::Warning,
1725 codes::INDEX_ORPHAN,
1726 &rel,
1727 None,
1728 None,
1729 format!(
1730 "`{}` sits in an empty or non-canonical folder",
1731 rel.display()
1732 ),
1733 Some("remove it, or run `dbmd index rebuild`".into()),
1734 vec![],
1735 );
1736 }
1737 }
1738}
1739
1740fn check_type_folder_index_md(
1744 store: &Store,
1745 tf: &Path,
1746 index_rel: &Path,
1747 members: &[PathBuf],
1748 issues: &mut Vec<Issue>,
1749) {
1750 let abs = store.root.join(index_rel);
1751 let Ok(text) = std::fs::read_to_string(&abs) else {
1752 return;
1753 };
1754 let entries = parse_index_entries(&text);
1755
1756 let listed: BTreeSet<PathBuf> = entries
1757 .iter()
1758 .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1759 .collect();
1760
1761 for entry in &entries {
1763 let bare = entry.target.trim_end_matches(".md");
1764 let target_abs = match resolved_target_abs(store, bare) {
1767 Some(abs) => abs,
1768 None => {
1769 if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1770 push(
1771 issues,
1772 Severity::Error,
1773 codes::INDEX_STALE_ENTRY,
1774 index_rel,
1775 Some(entry.line),
1776 None,
1777 format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1778 Some("run `dbmd index rebuild`".into()),
1779 vec![],
1780 );
1781 } else {
1782 push(
1783 issues,
1784 Severity::Error,
1785 codes::INDEX_STALE_ENTRY,
1786 index_rel,
1787 Some(entry.line),
1788 None,
1789 format!("index entry `[[{bare}]]` points at a missing file"),
1790 Some("run `dbmd index rebuild`".into()),
1791 vec![PathBuf::from(format!("{bare}.md"))],
1795 );
1796 }
1797 continue;
1798 }
1799 };
1800 if let Some(expected) = read_summary(&target_abs) {
1807 match &entry.summary_text {
1808 Some(text_part)
1819 if crate::summary::collapse_whitespace(text_part)
1820 != crate::summary::collapse_whitespace(&expected) =>
1821 {
1822 push(
1823 issues,
1824 Severity::Error,
1825 codes::INDEX_SUMMARY_MISMATCH,
1826 index_rel,
1827 Some(entry.line),
1828 None,
1829 format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1830 Some("run `dbmd index rebuild`".into()),
1831 vec![PathBuf::from(format!("{bare}.md"))],
1832 );
1833 }
1834 None if !expected.trim().is_empty() => {
1835 push(
1836 issues,
1837 Severity::Error,
1838 codes::INDEX_SUMMARY_MISMATCH,
1839 index_rel,
1840 Some(entry.line),
1841 None,
1842 format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1843 Some("run `dbmd index rebuild`".into()),
1844 vec![PathBuf::from(format!("{bare}.md"))],
1845 );
1846 }
1847 _ => {}
1848 }
1849 }
1850 }
1851
1852 let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1856 if content_members.len() <= 500 {
1857 for m in content_members {
1858 let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1859 if !listed.contains(&bare) {
1860 push(
1861 issues,
1862 Severity::Error,
1863 codes::INDEX_MISSING_ENTRY,
1864 index_rel,
1865 None,
1866 None,
1867 format!(
1868 "file `{}` is not listed in its folder's `index.md`",
1869 m.display()
1870 ),
1871 Some("run `dbmd index rebuild`".into()),
1872 vec![(*m).clone()],
1873 );
1874 }
1875 }
1876 }
1877 let _ = tf;
1878}
1879
1880fn check_type_folder_index_jsonl(
1884 store: &Store,
1885 tf: &Path,
1886 jsonl_rel: &Path,
1887 members: &[PathBuf],
1888 issues: &mut Vec<Issue>,
1889) {
1890 let abs = store.root.join(jsonl_rel);
1891 let Ok(text) = std::fs::read_to_string(&abs) else {
1892 return;
1893 };
1894
1895 let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1897 for (i, line) in text.lines().enumerate() {
1898 let line = line.trim();
1899 if line.is_empty() {
1900 continue;
1901 }
1902 let rec: serde_json::Value = match serde_json::from_str(line) {
1903 Ok(v) => v,
1904 Err(e) => {
1905 push(
1906 issues,
1907 Severity::Error,
1908 codes::INDEX_JSONL_DESYNC,
1909 jsonl_rel,
1910 Some((i + 1) as u32),
1911 None,
1912 format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
1913 Some("run `dbmd index rebuild`".into()),
1914 vec![],
1915 );
1916 continue;
1917 }
1918 };
1919 if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
1920 if !is_safe_store_relative_path(Path::new(path)) {
1921 push(
1922 issues,
1923 Severity::Error,
1924 codes::INDEX_JSONL_DESYNC,
1925 jsonl_rel,
1926 Some((i + 1) as u32),
1927 None,
1928 format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
1929 Some("run `dbmd index rebuild`".into()),
1930 vec![],
1931 );
1932 continue;
1933 }
1934 records.insert(PathBuf::from(path), rec);
1935 }
1936 }
1937
1938 let member_set: BTreeSet<PathBuf> = members
1939 .iter()
1940 .filter(|m| is_content_file(m))
1941 .cloned()
1942 .collect();
1943
1944 for path in records.keys() {
1946 let target_abs = store.root.join(path);
1947 if !target_abs.is_file() {
1948 push(
1949 issues,
1950 Severity::Error,
1951 codes::INDEX_JSONL_DESYNC,
1952 jsonl_rel,
1953 None,
1954 None,
1955 format!(
1956 "`index.jsonl` record points at missing file `{}`",
1957 path.display()
1958 ),
1959 Some("run `dbmd index rebuild`".into()),
1960 vec![],
1961 );
1962 }
1963 }
1964
1965 for m in &member_set {
1967 if !records.contains_key(m) {
1968 push(
1969 issues,
1970 Severity::Error,
1971 codes::INDEX_JSONL_DESYNC,
1972 jsonl_rel,
1973 None,
1974 None,
1975 format!(
1976 "file `{}` is missing from the complete `index.jsonl`",
1977 m.display()
1978 ),
1979 Some("run `dbmd index rebuild`".into()),
1980 vec![m.clone()],
1981 );
1982 }
1983 }
1984
1985 for (path, rec) in &records {
1999 let target_abs = store.root.join(path);
2000 if !target_abs.is_file() {
2001 continue;
2002 }
2003 let Ok(expected) = crate::index::IndexRecord::expected_from_file(&target_abs, path.clone())
2004 else {
2005 continue; };
2007 let Ok(expected_json) = serde_json::to_value(&expected) else {
2008 continue;
2009 };
2010 let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
2011 continue;
2012 };
2013
2014 let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
2017 for key in have.keys().chain(want.keys()) {
2018 if key == "path" {
2019 continue;
2020 }
2021 if have.get(key) != want.get(key) {
2022 mismatched_keys.insert(key);
2023 }
2024 }
2025
2026 if !mismatched_keys.is_empty() {
2027 let keys: Vec<&str> = mismatched_keys.into_iter().collect();
2028 push(
2029 issues,
2030 Severity::Error,
2031 codes::INDEX_JSONL_STALE,
2032 jsonl_rel,
2033 None,
2034 Some(keys.join(",")),
2035 format!(
2036 "`index.jsonl` record for `{}` is stale ({})",
2037 path.display(),
2038 keys.join(", ")
2039 ),
2040 Some("run `dbmd index rebuild`".into()),
2041 vec![path.clone()],
2042 );
2043 }
2044 }
2045 let _ = tf;
2046}
2047
2048fn check_index_scope(
2050 store: &Store,
2051 index_rel: &Path,
2052 expected_scope: &str,
2053 expected_folder: Option<&str>,
2054 issues: &mut Vec<Issue>,
2055) {
2056 let abs = store.root.join(index_rel);
2057 let Ok(text) = std::fs::read_to_string(&abs) else {
2058 return;
2059 };
2060 let Some((yaml, _, _)) = split_frontmatter(&text) else {
2061 return;
2062 };
2063 let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
2064 return;
2065 };
2066 let fm = yaml_map_to_btree(&map);
2067
2068 if let Some(scope) = fm.get("scope").and_then(scalar_string) {
2069 let scope_ok =
2071 scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
2072 if !scope_ok {
2073 push(
2074 issues,
2075 Severity::Warning,
2076 codes::INDEX_WRONG_SCOPE,
2077 index_rel,
2078 fm_key_line(&yaml, "scope"),
2079 Some("scope".into()),
2080 format!(
2081 "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
2082 ),
2083 Some(format!("set `scope: {expected_scope}`")),
2084 vec![],
2085 );
2086 }
2087 }
2088 if let Some(expected) = expected_folder {
2090 if let Some(folder) = fm.get("folder").and_then(scalar_string) {
2091 if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
2092 push(
2093 issues,
2094 Severity::Warning,
2095 codes::INDEX_WRONG_SCOPE,
2096 index_rel,
2097 fm_key_line(&yaml, "folder"),
2098 Some("folder".into()),
2099 format!("index `folder: {folder}` doesn't match location `{expected}`"),
2100 Some(format!("set `folder: {expected}`")),
2101 vec![],
2102 );
2103 }
2104 }
2105 }
2106}
2107
2108fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2127 let mut prev: Option<DateTime<FixedOffset>> = None;
2128 for rel in log_files_chronological(store) {
2129 check_log_file(store, &rel, &mut prev, issues);
2130 }
2131}
2132
2133fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2137 let mut files: Vec<PathBuf> = Vec::new();
2138 let archive_dir = store.root.join("log");
2139 if let Ok(entries) = std::fs::read_dir(&archive_dir) {
2140 let mut archives: Vec<PathBuf> = entries
2141 .flatten()
2142 .map(|e| e.path())
2143 .filter(|p| {
2144 p.is_file()
2145 && p.file_name()
2146 .and_then(|s| s.to_str())
2147 .and_then(|n| n.strip_suffix(".md"))
2148 .is_some_and(is_year_month_archive)
2149 })
2150 .filter_map(|p| p.strip_prefix(&store.root).ok().map(Path::to_path_buf))
2151 .collect();
2152 archives.sort();
2154 files.extend(archives);
2155 }
2156 if store.root.join("log.md").is_file() {
2158 files.push(PathBuf::from("log.md"));
2159 }
2160 files
2161}
2162
2163fn check_log_file(
2167 store: &Store,
2168 log_rel: &Path,
2169 prev: &mut Option<DateTime<FixedOffset>>,
2170 issues: &mut Vec<Issue>,
2171) {
2172 let abs = store.root.join(log_rel);
2173 let Ok(text) = std::fs::read_to_string(&abs) else {
2174 return;
2175 };
2176
2177 for (i, line) in text.lines().enumerate() {
2178 if !line.starts_with("## [") {
2179 continue;
2180 }
2181 let line_no = (i + 1) as u32;
2182 match parse_log_header(line) {
2183 None => push(
2184 issues,
2185 Severity::Error,
2186 codes::LOG_BAD_TIMESTAMP,
2187 log_rel,
2188 Some(line_no),
2189 None,
2190 format!("log entry header has an unparseable timestamp: {line:?}"),
2191 Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2192 vec![],
2193 ),
2194 Some((ts, kind, _object)) => {
2195 if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2196 push(
2197 issues,
2198 Severity::Warning,
2199 codes::LOG_UNKNOWN_KIND,
2200 log_rel,
2201 Some(line_no),
2202 None,
2203 format!("log entry kind `{kind}` is not recognized"),
2204 Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2205 vec![],
2206 );
2207 }
2208 if let Some(p) = *prev {
2209 if ts < p {
2210 push(
2211 issues,
2212 Severity::Warning,
2213 codes::LOG_OUT_OF_ORDER,
2214 log_rel,
2215 Some(line_no),
2216 None,
2217 "log entry is older than the entry above it (possible rewrite)".into(),
2218 Some("append corrective entries; never reorder past ones".into()),
2219 vec![],
2220 );
2221 }
2222 }
2223 *prev = Some(ts);
2224 }
2225 }
2226 }
2227}
2228
2229#[derive(Debug)]
2235struct Link {
2236 target: String,
2237 line: u32,
2238}
2239
2240fn store_marker_present(store: &Store) -> bool {
2244 let want = store.root.join("DB.md");
2245 if !want.is_file() {
2246 return false;
2247 }
2248 match std::fs::read_dir(&store.root) {
2250 Ok(entries) => entries
2251 .flatten()
2252 .any(|e| e.file_name().to_str() == Some("DB.md")),
2253 Err(_) => true, }
2255}
2256
2257fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2268 let rel = Path::new("DB.md");
2269 let abs = store.root.join("DB.md");
2270 let Ok(text) = std::fs::read_to_string(&abs) else {
2271 return; };
2273
2274 let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2275 push(
2279 issues,
2280 Severity::Error,
2281 codes::DB_MD_BAD_TYPE,
2282 rel,
2283 Some(1),
2284 Some("type".into()),
2285 "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2286 Some("add a `---` frontmatter block with `type: db-md`".into()),
2287 vec![],
2288 );
2289 for field in ["scope", "owner"] {
2290 push(
2291 issues,
2292 Severity::Error,
2293 codes::DB_MD_MISSING_FIELD,
2294 rel,
2295 Some(1),
2296 Some(field.into()),
2297 format!("DB.md frontmatter is missing required field `{field}`"),
2298 Some(format!("add `{field}:` to the DB.md frontmatter")),
2299 vec![],
2300 );
2301 }
2302 return;
2303 };
2304
2305 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2308 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2309 Ok(Value::Null) => Some(BTreeMap::new()),
2310 _ => None,
2311 };
2312
2313 match &fm {
2314 Some(map) => {
2315 let type_ = map.get("type").and_then(scalar_string);
2317 if type_.as_deref() != Some("db-md") {
2318 let (line, msg) = match &type_ {
2319 Some(t) => (
2320 fm_key_line(&fm_yaml, "type"),
2321 format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2322 ),
2323 None => (
2324 Some(1),
2325 "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2326 ),
2327 };
2328 push(
2329 issues,
2330 Severity::Error,
2331 codes::DB_MD_BAD_TYPE,
2332 rel,
2333 line,
2334 Some("type".into()),
2335 msg,
2336 Some("set `type: db-md` in the DB.md frontmatter".into()),
2337 vec![],
2338 );
2339 }
2340
2341 for field in ["scope", "owner"] {
2343 let present = map
2344 .get(field)
2345 .and_then(scalar_string)
2346 .map(|s| !s.trim().is_empty())
2347 .unwrap_or(false);
2348 if !present {
2349 push(
2350 issues,
2351 Severity::Error,
2352 codes::DB_MD_MISSING_FIELD,
2353 rel,
2354 fm_key_line_or_top(&fm_yaml, field),
2357 Some(field.into()),
2358 format!("DB.md frontmatter is missing required field `{field}`"),
2359 Some(format!("add `{field}:` to the DB.md frontmatter")),
2360 vec![],
2361 );
2362 }
2363 }
2364 }
2365 None => {
2366 push(
2369 issues,
2370 Severity::Error,
2371 codes::DB_MD_BAD_TYPE,
2372 rel,
2373 Some(1),
2374 Some("type".into()),
2375 "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2376 Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2377 vec![],
2378 );
2379 for field in ["scope", "owner"] {
2380 push(
2381 issues,
2382 Severity::Error,
2383 codes::DB_MD_MISSING_FIELD,
2384 rel,
2385 Some(1),
2386 Some(field.into()),
2387 format!("DB.md frontmatter is missing required field `{field}`"),
2388 Some(format!("add `{field}:` to the DB.md frontmatter")),
2389 vec![],
2390 );
2391 }
2392 }
2393 }
2394
2395 for section in crate::parser::extract_sections(&body) {
2409 if section.level != 2 {
2410 continue;
2411 }
2412 let name = section.heading.trim().to_ascii_lowercase();
2413 if matches!(
2414 name.as_str(),
2415 "agent instructions" | "policies" | "schemas" | "folders"
2416 ) {
2417 continue;
2418 }
2419 let file_line = fm_end_line + section.line;
2422 push(
2423 issues,
2424 Severity::Warning,
2425 codes::DB_MD_UNKNOWN_SECTION,
2426 rel,
2427 Some(file_line),
2428 None,
2429 format!(
2430 "DB.md has an unrecognized `## {}` section",
2431 section.heading.trim()
2432 ),
2433 Some(
2434 "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2435 `## Folders` — remove or rename this heading"
2436 .into(),
2437 ),
2438 vec![],
2439 );
2440 }
2441
2442 check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2447}
2448
2449fn check_db_md_schemas(
2456 store: &Store,
2457 rel: &Path,
2458 body: &str,
2459 fm_end_line: u32,
2460 issues: &mut Vec<Issue>,
2461) {
2462 if store.config.schemas.is_empty() {
2463 return;
2464 }
2465
2466 let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2471 let mut current_h2: Option<String> = None;
2472 for section in crate::parser::extract_sections(body) {
2473 match section.level {
2474 2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2475 3 if current_h2.as_deref() == Some("schemas") => {
2476 type_line
2479 .entry(section.heading.trim().to_string())
2480 .or_insert(fm_end_line + section.line);
2481 }
2482 _ => {}
2483 }
2484 }
2485
2486 for (type_name, schema) in &store.config.schemas {
2487 let line = type_line.get(type_name).copied();
2488 let mut seen: BTreeSet<String> = BTreeSet::new();
2489 for field in &schema.fields {
2490 let name = field.name.trim();
2491
2492 if name.is_empty() {
2496 push(
2497 issues,
2498 Severity::Warning,
2499 codes::DB_MD_SCHEMA_FIELD,
2500 rel,
2501 line,
2502 None,
2503 format!("`### {type_name}` has a schema field bullet with no field name"),
2504 Some(
2505 "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2506 .into(),
2507 ),
2508 vec![],
2509 );
2510 continue;
2511 }
2512
2513 if !seen.insert(name.to_string()) {
2517 push(
2518 issues,
2519 Severity::Warning,
2520 codes::DB_MD_SCHEMA_FIELD,
2521 rel,
2522 line,
2523 Some(name.to_string()),
2524 format!("`### {type_name}` declares field `{name}` more than once"),
2525 Some(
2526 "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2527 ),
2528 vec![],
2529 );
2530 }
2531
2532 for modifier in &field.unknown_modifiers {
2537 let modifier = modifier.trim();
2538 if modifier.is_empty() {
2539 continue;
2540 }
2541 push(
2542 issues,
2543 Severity::Info,
2544 codes::DB_MD_SCHEMA_FIELD,
2545 rel,
2546 line,
2547 Some(name.to_string()),
2548 format!(
2549 "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2550 ),
2551 Some(
2552 "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2553 .into(),
2554 ),
2555 vec![],
2556 );
2557 }
2558 }
2559
2560 let mut declared: BTreeMap<&str, bool> = BTreeMap::new();
2569 for f in &schema.fields {
2570 let e = declared.entry(f.name.trim()).or_insert(false);
2571 *e = *e || f.required;
2572 }
2573 let mut flagged: BTreeSet<&str> = BTreeSet::new();
2574 for key_fields in &schema.unique_keys {
2575 for field in key_fields {
2576 let name = field.trim();
2577 if name.is_empty()
2578 || declared.get(name).copied() == Some(true)
2579 || !flagged.insert(name)
2580 {
2581 continue;
2582 }
2583 let message = if declared.contains_key(name) {
2584 format!(
2585 "`### {type_name}` `unique:` key field `{name}` is not `required` — a record missing or leaving it empty is silently skipped by the unique check"
2586 )
2587 } else {
2588 format!(
2589 "`### {type_name}` `unique:` key field `{name}` is not declared in the schema, so it can never be `required` — a record missing it is silently skipped by the unique check"
2590 )
2591 };
2592 push(
2593 issues,
2594 Severity::Warning,
2595 codes::DB_MD_SCHEMA_FIELD,
2596 rel,
2597 line,
2598 Some(name.to_string()),
2599 message,
2600 Some(format!(
2601 "mark `{name}` `required` in `### {type_name}`, or build the `unique:` key from required fields only"
2602 )),
2603 vec![],
2604 );
2605 }
2606 }
2607 }
2608}
2609
2610fn not_a_store_issue(store: &Store) -> Issue {
2612 Issue {
2613 severity: Severity::Error,
2614 code: codes::NOT_A_STORE,
2615 file: store.root.clone(),
2616 line: None,
2617 key: None,
2618 message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2619 suggestion: Some("create a `DB.md` at the store root".into()),
2620 related: vec![],
2621 }
2622}
2623
2624fn is_content_file(rel: &Path) -> bool {
2627 if !is_safe_store_relative_path(rel) {
2633 return false;
2634 }
2635 let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2636 return false;
2637 };
2638 if !matches!(first, "sources" | "records") {
2639 return false;
2640 }
2641 let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2642 if matches!(name, "index.md" | "index.jsonl") {
2648 return false;
2649 }
2650 name.ends_with(".md")
2651}
2652
2653fn is_root_meta_file(rel: &Path) -> bool {
2660 let mut comps = rel.components();
2661 let Some(Component::Normal(only)) = comps.next() else {
2662 return false;
2663 };
2664 if comps.next().is_some() {
2665 return false; }
2667 matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2668}
2669
2670fn is_index_catalog_file(rel: &Path) -> bool {
2678 matches!(
2679 rel.file_name().and_then(|n| n.to_str()),
2680 Some("index.md") | Some("index.jsonl")
2681 )
2682}
2683
2684fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2688 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2693 let mut lines = text.lines();
2694 let first = lines.next()?;
2695 if first.trim_end() != "---" {
2696 return None;
2697 }
2698 let mut yaml = String::new();
2699 let mut close_line: Option<u32> = None;
2700 let mut current = 1u32;
2702 for line in lines {
2703 current += 1;
2704 if line.trim_end() == "---" {
2705 close_line = Some(current);
2706 break;
2707 }
2708 yaml.push_str(line);
2709 yaml.push('\n');
2710 }
2711 let close_line = close_line?;
2712 let body: String = text
2714 .lines()
2715 .skip(close_line as usize)
2716 .collect::<Vec<_>>()
2717 .join("\n");
2718 Some((yaml, body, close_line))
2719}
2720
2721fn read_summary(abs: &Path) -> Option<String> {
2723 let text = std::fs::read_to_string(abs).ok()?;
2724 let (yaml, _, _) = split_frontmatter(&text)?;
2725 let value: Value = serde_norway::from_str(&yaml).ok()?;
2726 if let Value::Mapping(m) = value {
2727 m.get(Value::String("summary".into()))
2728 .and_then(scalar_string)
2729 } else {
2730 None
2731 }
2732}
2733
2734fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2737 let mut out = BTreeMap::new();
2738 for (k, v) in map {
2739 if let Value::String(s) = k {
2740 out.insert(s.clone(), v.clone());
2741 }
2742 }
2743 out
2744}
2745
2746fn scalar_string(v: &Value) -> Option<String> {
2749 match v {
2750 Value::String(s) => Some(s.clone()),
2751 Value::Number(n) => Some(n.to_string()),
2752 Value::Bool(b) => Some(b.to_string()),
2753 _ => None,
2754 }
2755}
2756
2757fn is_empty_value(v: &Value) -> bool {
2764 match v {
2765 Value::Null => true,
2766 Value::Sequence(items) => items.is_empty(),
2767 Value::Mapping(map) => map.is_empty(),
2768 other => scalar_string(other)
2769 .map(|s| s.trim().is_empty())
2770 .unwrap_or(true),
2771 }
2772}
2773
2774fn is_flat_scalar_list(v: &Value) -> bool {
2777 match v {
2778 Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2779 _ => false,
2780 }
2781}
2782
2783fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2793 let mut out = Vec::new();
2794 for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2795 for link in links {
2796 out.push((key.clone(), link));
2797 }
2798 }
2799 out
2800}
2801
2802fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2806 for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2807 if k == key {
2808 return links;
2809 }
2810 }
2811 Vec::new()
2812}
2813
2814fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2818 for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2819 if k == key {
2820 return Some(value_text);
2821 }
2822 }
2823 None
2824}
2825
2826fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2833 let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2834 let mut current: Option<(String, String, Vec<Link>)> = None;
2835
2836 for (idx, raw_line) in fm_yaml.lines().enumerate() {
2837 let file_line = fm_start_line + idx as u32;
2838 let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2839 let trimmed = raw_line.trim();
2840
2841 let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2844 top_level_key(raw_line)
2845 } else {
2846 None
2847 };
2848
2849 if let Some((key, after)) = new_key {
2850 if let Some(done) = current.take() {
2851 blocks.push(done);
2852 }
2853 let mut links = Vec::new();
2854 collect_line_links(after, file_line, &mut links);
2855 current = Some((key, after.trim().to_string(), links));
2856 } else if let Some((_k, value_text, links)) = current.as_mut() {
2857 if !value_text.is_empty() {
2859 value_text.push('\n');
2860 }
2861 value_text.push_str(trimmed);
2862 collect_line_links(raw_line, file_line, links);
2863 }
2864 }
2865 if let Some(done) = current.take() {
2866 blocks.push(done);
2867 }
2868 blocks
2869}
2870
2871fn top_level_key(line: &str) -> Option<(String, &str)> {
2874 let (key, rest) = line.split_once(':')?;
2875 let key = key.trim();
2876 if key.is_empty()
2877 || !key
2878 .chars()
2879 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2880 {
2881 return None;
2882 }
2883 Some((key.to_string(), rest))
2884}
2885
2886fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2889 let bytes = s.as_bytes();
2890 let mut i = 0;
2891 while i + 1 < bytes.len() {
2892 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2893 if let Some(close) = s[i + 2..].find("]]") {
2894 let inner = &s[i + 2..i + 2 + close];
2895 let target = inner
2898 .trim_start_matches('[')
2899 .split('|')
2900 .next()
2901 .unwrap_or(inner)
2902 .trim()
2903 .to_string();
2904 if !target.is_empty() {
2905 links.push(Link {
2906 target,
2907 line: file_line,
2908 });
2909 }
2910 i = i + 2 + close + 2;
2911 continue;
2912 }
2913 }
2914 i += 1;
2915 }
2916}
2917
2918fn extract_wiki_links(body: &str) -> Vec<Link> {
2930 let mut out = Vec::new();
2931 let mut fence: Option<(u8, usize)> = None;
2932 for (idx, line) in body.lines().enumerate() {
2933 let content = line.trim_end_matches('\r');
2934 if let Some(f) = fence {
2935 if fence_closes(content, f) {
2939 fence = None;
2940 }
2941 continue;
2942 }
2943 if let Some(opened) = fence_opens(content) {
2944 fence = Some(opened);
2945 continue;
2946 }
2947 let line_no = (idx + 1) as u32;
2948 let bytes = line.as_bytes();
2949 let mut i = 0;
2950 while i + 1 < bytes.len() {
2951 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2952 if let Some(close) = line[i + 2..].find("]]") {
2953 let inner = &line[i + 2..i + 2 + close];
2954 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
2955 if !target.is_empty() && !target.starts_with('[') {
2963 out.push(Link {
2964 target,
2965 line: line_no,
2966 });
2967 }
2968 i = i + 2 + close + 2;
2969 continue;
2970 }
2971 }
2972 i += 1;
2973 }
2974 }
2975 out
2976}
2977
2978fn fence_opens(line: &str) -> Option<(u8, usize)> {
2984 let indent = line.len() - line.trim_start_matches(' ').len();
2985 if indent > 3 {
2986 return None;
2987 }
2988 let rest = &line[indent..];
2989 let byte = rest.bytes().next()?;
2990 if byte != b'`' && byte != b'~' {
2991 return None;
2992 }
2993 let run = rest.len() - rest.trim_start_matches(byte as char).len();
2994 if run < 3 {
2995 return None;
2996 }
2997 if byte == b'`' && rest[run..].contains('`') {
2999 return None;
3000 }
3001 Some((byte, run))
3002}
3003
3004fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
3009 let (byte, open_len) = fence;
3010 let indent = line.len() - line.trim_start_matches(' ').len();
3011 if indent > 3 {
3012 return false;
3013 }
3014 let rest = &line[indent..];
3015 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3016 if run < open_len {
3017 return false;
3018 }
3019 rest[run..].trim().is_empty()
3020}
3021
3022fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
3039 let mut out = Vec::new();
3040 for line in fm_yaml.lines() {
3041 if line.starts_with(' ') || line.starts_with('\t') {
3043 continue;
3044 }
3045 let Some((key, rest)) = line.split_once(':') else {
3046 continue;
3047 };
3048 let key = key.trim();
3049 if key.is_empty()
3050 || key.starts_with('#')
3051 || key.starts_with('-')
3052 || !key
3053 .chars()
3054 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
3055 {
3056 continue;
3057 }
3058 let rest = rest.trim();
3059 if !rest.starts_with('[') {
3062 continue;
3063 }
3064 if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
3069 let nested = items.iter().any(|item| match item {
3070 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
3071 _ => false,
3072 });
3073 if nested {
3074 out.push(key.to_string());
3075 }
3076 }
3077 }
3078 out
3079}
3080
3081fn is_full_store_path(bare: &str) -> bool {
3084 let mut parts = bare.splitn(2, '/');
3085 let first = parts.next().unwrap_or("");
3086 let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
3087 matches!(first, "sources" | "records") && has_rest
3088}
3089
3090fn is_safe_store_relative_path(path: &Path) -> bool {
3094 let mut saw_component = false;
3095 for component in path.components() {
3096 match component {
3097 Component::Normal(_) => saw_component = true,
3098 Component::CurDir => {}
3099 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
3100 }
3101 }
3102 saw_component
3103}
3104
3105fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
3106 let path = Path::new(bare);
3107 if !is_safe_store_relative_path(path) {
3108 return None;
3109 }
3110 Some(PathBuf::from(format!("{bare}.md")))
3111}
3112
3113enum TargetResolution {
3115 Exists,
3117 Missing,
3119 Unsafe,
3121}
3122
3123fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
3132 if !is_safe_store_relative_path(Path::new(bare)) {
3136 return TargetResolution::Unsafe;
3137 }
3138 match resolved_target_abs(store, bare) {
3139 Some(_) => TargetResolution::Exists,
3140 None => TargetResolution::Missing,
3141 }
3142}
3143
3144fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
3170 if !is_safe_store_relative_path(Path::new(bare)) {
3171 return None;
3172 }
3173 let literal = store.root.join(bare);
3176 if literal.is_file() && disk_case_matches(store, &literal, bare) {
3177 return Some(literal);
3178 }
3179 let with_md_rel = format!("{bare}.md");
3181 let with_md = store.root.join(&with_md_rel);
3182 if with_md.is_file() && disk_case_matches(store, &with_md, &with_md_rel) {
3183 return Some(with_md);
3184 }
3185 None
3186}
3187
3188fn disk_case_matches(store: &Store, abs: &Path, requested: &str) -> bool {
3205 let Ok(canon_abs) = abs.canonicalize() else {
3206 return true; };
3208 let Ok(canon_root) = store.root.canonicalize() else {
3213 return true;
3214 };
3215 let Ok(disk_rel) = canon_abs.strip_prefix(&canon_root) else {
3216 return true;
3221 };
3222 disk_rel == Path::new(requested)
3225}
3226
3227fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3229 let prefix = prefix.trim_end_matches('/');
3230 bare == prefix || bare.starts_with(&format!("{prefix}/"))
3231}
3232
3233fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3237 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3238 if comps.len() < 3 {
3239 return None; }
3241 if !matches!(comps[0], "sources" | "records") {
3242 return None;
3243 }
3244 Some(PathBuf::from(comps[0]).join(comps[1]))
3245}
3246
3247fn loose_layer_dir(rel: &Path) -> Option<PathBuf> {
3252 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3253 if comps.len() != 2 || !matches!(comps[0], "sources" | "records") {
3254 return None;
3255 }
3256 Some(PathBuf::from(comps[0]))
3257}
3258
3259fn walk_content_files(root: &Path) -> Vec<PathBuf> {
3274 let mut out = Vec::new();
3275 for layer in ["sources", "records"] {
3276 let base = root.join(layer);
3277 if !base.is_dir() {
3278 continue;
3279 }
3280 for entry in walkdir::WalkDir::new(&base)
3281 .follow_links(true)
3292 .into_iter()
3293 .filter_entry(|e| {
3294 let name = e.file_name().to_str().unwrap_or("");
3295 !name.starts_with('.')
3296 })
3297 .flatten()
3298 {
3299 if !entry.file_type().is_file() {
3300 continue;
3301 }
3302 let name = entry.file_name().to_str().unwrap_or("");
3303 if name.ends_with(".md") && name != "index.md" {
3304 if let Ok(rel) = entry.path().strip_prefix(root) {
3305 out.push(rel.to_path_buf());
3306 }
3307 }
3308 }
3309 }
3310 out.sort();
3311 out
3312}
3313
3314fn walk_index_files(root: &Path) -> Vec<PathBuf> {
3321 let mut out = Vec::new();
3322 if root.join("index.md").is_file() {
3323 out.push(PathBuf::from("index.md"));
3324 }
3325 for layer in ["sources", "records"] {
3326 let base = root.join(layer);
3327 if !base.is_dir() {
3328 continue;
3329 }
3330 for entry in walkdir::WalkDir::new(&base)
3331 .follow_links(true)
3342 .into_iter()
3343 .filter_entry(|e| {
3344 let name = e.file_name().to_str().unwrap_or("");
3345 !name.starts_with('.')
3346 })
3347 .flatten()
3348 {
3349 if entry.file_type().is_file() && entry.file_name().to_str() == Some("index.md") {
3350 if let Ok(rel) = entry.path().strip_prefix(root) {
3351 out.push(rel.to_path_buf());
3352 }
3353 }
3354 }
3355 }
3356 out.sort();
3357 out
3358}
3359
3360struct IndexEntry {
3363 target: String,
3364 summary_text: Option<String>,
3365 line: u32,
3366}
3367
3368fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3373 let mut out = Vec::new();
3374 let mut in_more = false;
3375 for (idx, line) in text.lines().enumerate() {
3376 let trimmed = line.trim_start();
3377 if trimmed.starts_with("## More") {
3378 in_more = true;
3379 continue;
3380 }
3381 if in_more {
3382 continue;
3383 }
3384 if !trimmed.starts_with("- ") {
3385 continue;
3386 }
3387 let Some(open) = trimmed.find("[[") else {
3389 continue;
3390 };
3391 let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3392 continue;
3393 };
3394 let inner = &trimmed[open + 2..open + 2 + close_rel];
3395 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3396
3397 let after = &trimmed[open + 2 + close_rel + 2..];
3399 let summary_text = extract_index_entry_summary(after);
3400
3401 out.push(IndexEntry {
3402 target,
3403 summary_text,
3404 line: (idx + 1) as u32,
3405 });
3406 }
3407 out
3408}
3409
3410fn extract_index_entry_summary(after: &str) -> Option<String> {
3416 let mut s = after.trim();
3417 if s.starts_with('(') {
3419 if let Some(close) = s.find(')') {
3420 s = s[close + 1..].trim_start();
3421 }
3422 }
3423 let s = if let Some(rest) = s.strip_prefix('—') {
3425 rest.trim()
3426 } else if let Some(rest) = s.strip_prefix('-') {
3427 rest.trim()
3428 } else {
3429 return None;
3430 };
3431 if s.is_empty() {
3432 return None;
3433 }
3434 let s = match s.rsplit_once(" · ") {
3449 Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3450 _ => s,
3451 };
3452 Some(s.to_string())
3453}
3454
3455fn is_tag_suffix(s: &str) -> bool {
3460 let mut any = false;
3461 for tok in s.split_whitespace() {
3462 if !tok.starts_with('#') || tok.len() < 2 {
3463 return false;
3464 }
3465 any = true;
3466 }
3467 any
3468}
3469
3470fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3474 let rest = line.strip_prefix("## [")?;
3475 let close = rest.find(']')?;
3476 let ts_str = &rest[..close];
3477 let tail = rest[close + 1..].trim();
3478
3479 let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3482 let offset = FixedOffset::east_opt(0)?;
3483 let ts = naive.and_local_timezone(offset).single()?;
3484
3485 let (kind, object) = match tail.split_once('|') {
3487 Some((k, o)) => {
3488 let o = o.trim();
3489 (
3490 k.trim().to_string(),
3491 if o.is_empty() {
3492 None
3493 } else {
3494 Some(o.to_string())
3495 },
3496 )
3497 }
3498 None => (tail.to_string(), None),
3499 };
3500 if kind.is_empty() {
3501 return None;
3502 }
3503 Some((ts, kind, object))
3504}
3505
3506fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3516 let mut files = vec![store.root.join("log.md")];
3517 let archive_dir = store.root.join("log");
3518 if let Ok(entries) = std::fs::read_dir(&archive_dir) {
3519 let mut archives: Vec<PathBuf> = entries
3520 .flatten()
3521 .map(|e| e.path())
3522 .filter(|p| {
3523 p.is_file()
3524 && p.file_name()
3525 .and_then(|s| s.to_str())
3526 .and_then(|n| n.strip_suffix(".md"))
3527 .is_some_and(is_year_month_archive)
3528 })
3529 .collect();
3530 archives.sort();
3534 files.extend(archives);
3535 }
3536 files
3537}
3538
3539fn is_year_month_archive(s: &str) -> bool {
3542 let b = s.as_bytes();
3543 b.len() == 7
3544 && b[..4].iter().all(u8::is_ascii_digit)
3545 && b[4] == b'-'
3546 && b[5..7].iter().all(u8::is_ascii_digit)
3547}
3548
3549fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3555 let mut latest: Option<DateTime<FixedOffset>> = None;
3556 for file in log_files_for_working_set(store) {
3557 let Ok(text) = std::fs::read_to_string(&file) else {
3558 continue;
3559 };
3560 for line in text.lines() {
3561 if !line.starts_with("## [") {
3562 continue;
3563 }
3564 if let Some((ts, kind, _)) = parse_log_header(line) {
3565 if kind == "validate" {
3566 latest = Some(match latest {
3567 Some(p) if p >= ts => p,
3568 _ => ts,
3569 });
3570 }
3571 }
3572 }
3573 }
3574 latest
3575}
3576
3577fn changed_objects_since(
3588 store: &Store,
3589 cutoff: Option<DateTime<FixedOffset>>,
3590) -> BTreeSet<PathBuf> {
3591 let mut out = BTreeSet::new();
3592 for file in log_files_for_working_set(store) {
3593 let Ok(text) = std::fs::read_to_string(&file) else {
3594 continue;
3595 };
3596 for line in text.lines() {
3597 if !line.starts_with("## [") {
3598 continue;
3599 }
3600 let Some((ts, kind, object)) = parse_log_header(line) else {
3601 continue;
3602 };
3603 if let Some(c) = cutoff {
3604 if ts < c {
3605 continue;
3606 }
3607 }
3608 if !matches!(
3609 kind.as_str(),
3610 "create" | "update" | "ingest" | "rename" | "delete" | "link"
3611 ) {
3612 continue;
3613 }
3614 if let Some(obj) = object {
3615 let bare = obj
3617 .trim()
3618 .trim_start_matches("[[")
3619 .trim_end_matches("]]")
3620 .split('|')
3621 .next()
3622 .unwrap_or("")
3623 .trim()
3624 .trim_end_matches(".md")
3625 .to_string();
3626 if bare.is_empty() {
3627 continue;
3628 }
3629 if let Some(rel) = safe_md_target_rel(&bare) {
3639 out.insert(rel);
3640 }
3641 }
3642 }
3643 }
3644 out
3645}
3646
3647#[derive(Debug, Clone, PartialEq, Eq)]
3652pub struct DerivedFromIgnored {
3653 pub target: String,
3656 pub target_type: String,
3659}
3660
3661pub fn derived_from_ignored_type<I, S>(
3675 store: &Store,
3676 meta_type: &str,
3677 derived_from_targets: I,
3678) -> Option<DerivedFromIgnored>
3679where
3680 I: IntoIterator<Item = S>,
3681 S: AsRef<str>,
3682{
3683 if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3684 return None;
3685 }
3686 for target in derived_from_targets {
3687 let target = target.as_ref();
3688 if let Some(target_type) = link_target_type(store, target) {
3689 if store.config.ignored_types.contains(&target_type) {
3690 return Some(DerivedFromIgnored {
3691 target: target.to_string(),
3692 target_type,
3693 });
3694 }
3695 }
3696 }
3697 None
3698}
3699
3700fn link_target_type(store: &Store, target: &str) -> Option<String> {
3702 let bare = target.trim_end_matches(".md");
3703 let abs = store.root.join(safe_md_target_rel(bare)?);
3704 let text = std::fs::read_to_string(&abs).ok()?;
3705 let (yaml, _, _) = split_frontmatter(&text)?;
3706 let value: Value = serde_norway::from_str(&yaml).ok()?;
3707 if let Value::Mapping(m) = value {
3708 m.get(Value::String("type".into())).and_then(scalar_string)
3709 } else {
3710 None
3711 }
3712}
3713
3714fn is_iso8601(s: &str) -> bool {
3719 DateTime::parse_from_rfc3339(s.trim()).is_ok()
3720}
3721
3722fn is_iso8601_date_or_datetime(s: &str) -> bool {
3726 let s = s.trim();
3727 if DateTime::parse_from_rfc3339(s).is_ok() {
3728 return true;
3729 }
3730 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3731}
3732
3733fn is_email(s: &str) -> bool {
3738 let s = s.trim();
3739 let Some((local, domain)) = s.split_once('@') else {
3740 return false;
3741 };
3742 !local.is_empty()
3743 && !domain.contains('@')
3744 && domain.contains('.')
3745 && !domain.starts_with('.')
3746 && !domain.ends_with('.')
3747 && !domain.contains(' ')
3748 && !local.contains(' ')
3749}
3750
3751fn is_currency(s: &str) -> bool {
3758 let mut t = s.trim();
3759 for sym in ["$", "€", "£", "¥"] {
3761 if let Some(rest) = t.strip_prefix(sym) {
3762 t = rest.trim_start();
3763 break;
3764 }
3765 }
3766 if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3770 if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3771 t = rest.trim_start();
3772 }
3773 }
3774
3775 let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3776 is_plain_amount(cleaned.trim())
3777}
3778
3779fn is_plain_amount(s: &str) -> bool {
3782 let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3783 let (int_part, frac_part) = match digits.split_once('.') {
3784 Some((i, f)) => (i, Some(f)),
3785 None => (digits, None),
3786 };
3787 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3788 return false;
3789 }
3790 match frac_part {
3791 None => true,
3792 Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3793 }
3794}
3795
3796fn is_url(s: &str) -> bool {
3802 let s = s.trim();
3803 for scheme in ["http://", "https://"] {
3804 if let Some(rest) = s.strip_prefix(scheme) {
3805 return !rest.is_empty();
3806 }
3807 }
3808 false
3809}
3810
3811fn shape_suggestion(shape: Shape) -> String {
3813 match shape {
3814 Shape::String => "use a scalar string".into(),
3815 Shape::Int => "use an integer".into(),
3816 Shape::Bool => "use `true` or `false`".into(),
3817 Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3818 Shape::Email => "use a `<local>@<domain>` address".into(),
3819 Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3820 Shape::Url => "use an http(s) URL".into(),
3821 }
3822}
3823
3824fn short_form_suggestion(bare: &str) -> Option<String> {
3827 Some(format!(
3828 "use a full store-relative path, e.g. [[records/contacts/{}]]",
3829 slugish(bare)
3830 ))
3831}
3832
3833fn slugish(s: &str) -> String {
3835 s.trim()
3836 .to_lowercase()
3837 .chars()
3838 .map(|c| if c.is_whitespace() { '-' } else { c })
3839 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3840 .collect()
3841}
3842
3843fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3849 use crate::assets;
3850
3851 let manifest_rel = Path::new(assets::MANIFEST_FILE);
3852 let manifest_abs = store.root.join(assets::MANIFEST_FILE);
3853
3854 let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3856 if let Ok(text) = std::fs::read_to_string(&manifest_abs) {
3857 for (i, line) in text.lines().enumerate() {
3858 if line.trim().is_empty() {
3859 continue;
3860 }
3861 match serde_json::from_str::<assets::AssetRecord>(line) {
3862 Ok(rec) => {
3863 manifest.insert(rec.path.clone(), rec);
3864 }
3865 Err(e) => push(
3866 issues,
3867 Severity::Error,
3868 codes::ASSET_MANIFEST_MALFORMED,
3869 manifest_rel,
3870 Some((i as u32) + 1),
3871 None,
3872 format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3873 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3874 vec![],
3875 ),
3876 }
3877 }
3878 }
3879
3880 let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3883 for (rel, p) in parsed {
3884 let Some(map) = &p.fm else {
3885 continue;
3886 };
3887 for decl in assets::declarations_from_yaml_map(map) {
3888 let norm = match assets::normalize_asset_path(&decl.path) {
3889 Ok(n) => n,
3890 Err(_) => continue, };
3892 declared.insert(norm.clone());
3893 let is_md = Path::new(&norm)
3894 .extension()
3895 .and_then(|e| e.to_str())
3896 .map(|e| e.eq_ignore_ascii_case("md"))
3897 .unwrap_or(false);
3898 if is_md {
3899 push(
3900 issues,
3901 Severity::Warning,
3902 codes::ASSET_PATH_IS_CONTENT,
3903 rel,
3904 None,
3905 Some("asset".to_string()),
3906 format!("asset path `{norm}` points at a markdown content file"),
3907 Some("assets are raw binaries; reference a non-markdown path".to_string()),
3908 vec![PathBuf::from(&norm)],
3909 );
3910 }
3911 if !manifest.contains_key(&norm) {
3912 push(
3913 issues,
3914 Severity::Error,
3915 codes::ASSET_UNDECLARED,
3916 rel,
3917 None,
3918 Some("asset".to_string()),
3919 format!(
3920 "references asset `{norm}` with no record in {}",
3921 assets::MANIFEST_FILE
3922 ),
3923 Some("run `dbmd assets scan` to catalog it".to_string()),
3924 vec![PathBuf::from(&norm)],
3925 );
3926 }
3927 }
3928 }
3929
3930 for (path, rec) in &manifest {
3932 for w in &rec.wrappers {
3933 if !store.root.join(w).is_file() {
3934 push(
3935 issues,
3936 Severity::Error,
3937 codes::ASSET_WRAPPER_BROKEN,
3938 Path::new(path),
3939 None,
3940 None,
3941 format!("manifest record for `{path}` names a missing wrapper `{w}`"),
3942 Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
3943 vec![PathBuf::from(w)],
3944 );
3945 }
3946 }
3947 if !declared.contains(path) {
3948 push(
3949 issues,
3950 Severity::Warning,
3951 codes::ASSET_MANIFEST_ORPHAN,
3952 Path::new(path),
3953 None,
3954 None,
3955 format!(
3956 "`{path}` is in {} but no wrapper references it",
3957 assets::MANIFEST_FILE
3958 ),
3959 Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
3960 vec![],
3961 );
3962 }
3963 }
3964}
3965
3966#[allow(clippy::too_many_arguments)]
3968fn push(
3969 issues: &mut Vec<Issue>,
3970 severity: Severity,
3971 code: &'static str,
3972 file: &Path,
3973 line: Option<u32>,
3974 key: Option<String>,
3975 message: String,
3976 suggestion: Option<String>,
3977 related: Vec<PathBuf>,
3978) {
3979 issues.push(Issue {
3980 severity,
3981 code,
3982 file: file.to_path_buf(),
3983 line,
3984 key,
3985 message,
3986 suggestion,
3987 related,
3988 });
3989}
3990
3991fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
3994 for (i, line) in fm_yaml.lines().enumerate() {
3995 let trimmed = line.trim_start();
3996 if let Some(rest) = trimmed.strip_prefix(key) {
3998 if rest.starts_with(':') && line.starts_with(key) {
3999 return Some((i as u32) + 2);
4001 }
4002 }
4003 }
4004 None
4005}
4006
4007fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
4013 fm_key_line(fm_yaml, key).or(Some(1))
4014}
4015
4016fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
4019 a.file
4020 .cmp(&b.file)
4021 .then(a.line.cmp(&b.line))
4022 .then(a.code.cmp(b.code))
4023 .then(a.key.cmp(&b.key))
4024}
4025
4026#[cfg(test)]
4031mod tests {
4032 use super::*;
4033 use crate::parser::{Config, FieldSpec};
4034 use std::fs;
4035 use tempfile::TempDir;
4036
4037 #[test]
4038 fn split_frontmatter_tolerates_leading_bom() {
4039 let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
4044 let parsed = split_frontmatter(text);
4045 assert!(
4046 parsed.is_some(),
4047 "a leading BOM must not hide frontmatter from validate"
4048 );
4049 let (yaml, body, close_line) = parsed.unwrap();
4050 assert_eq!(yaml, "type: contact\nsummary: hi\n");
4051 assert_eq!(body, "body");
4052 assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
4053 }
4054
4055 struct Fixture {
4058 dir: TempDir,
4059 config: Config,
4060 }
4061
4062 impl Fixture {
4063 fn new() -> Self {
4068 let dir = TempDir::new().unwrap();
4069 fs::write(
4070 dir.path().join("DB.md"),
4071 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
4072 )
4073 .unwrap();
4074 for layer in ["sources", "records"] {
4075 fs::create_dir_all(dir.path().join(layer)).unwrap();
4076 }
4077 Fixture {
4078 dir,
4079 config: Config::default(),
4080 }
4081 }
4082
4083 fn bare() -> Self {
4085 let dir = TempDir::new().unwrap();
4086 Fixture {
4087 dir,
4088 config: Config::default(),
4089 }
4090 }
4091
4092 fn write(&self, rel: &str, contents: &str) {
4094 let abs = self.dir.path().join(rel);
4095 fs::create_dir_all(abs.parent().unwrap()).unwrap();
4096 fs::write(abs, contents).unwrap();
4097 }
4098
4099 fn store(&self) -> Store {
4100 Store {
4101 root: self.dir.path().to_path_buf(),
4102 config: self.config.clone(),
4103 }
4104 }
4105
4106 fn store_all(&self) -> Vec<Issue> {
4107 validate_all(&self.store()).unwrap()
4108 }
4109
4110 fn rebuild_indexes(&self) {
4117 crate::index::Index::rebuild_all(&self.store()).unwrap();
4118 }
4119 }
4120
4121 fn has(issues: &[Issue], code: &str) -> bool {
4123 issues.iter().any(|i| i.code == code)
4124 }
4125
4126 fn count(issues: &[Issue], code: &str) -> usize {
4128 issues.iter().filter(|i| i.code == code).count()
4129 }
4130
4131 fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
4133 issues
4134 .iter()
4135 .find(|i| i.code == code)
4136 .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
4137 }
4138
4139 fn valid_contact(summary: &str) -> String {
4141 format!(
4142 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{summary}\"\nname: A\n---\n\n# A\n"
4143 )
4144 }
4145
4146 #[test]
4149 fn not_a_store_when_db_md_absent() {
4150 let fx = Fixture::bare();
4151 let issues = fx.store_all();
4152 assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
4153 assert_eq!(issues[0].code, codes::NOT_A_STORE);
4154 assert!(issues[0].is_error());
4155 }
4156
4157 #[test]
4158 fn working_set_also_reports_not_a_store() {
4159 let fx = Fixture::bare();
4160 let issues = validate_working_set(&fx.store(), None).unwrap();
4161 assert!(has(&issues, codes::NOT_A_STORE));
4162 }
4163
4164 #[test]
4165 fn clean_store_has_no_issues() {
4166 let fx = Fixture::new();
4167 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4168 fx.rebuild_indexes();
4172 let issues = fx.store_all();
4173 assert!(
4174 issues.is_empty(),
4175 "expected a clean store, got: {issues:#?}"
4176 );
4177 }
4178
4179 #[test]
4187 fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
4188 let fx = Fixture::new();
4189 let body = |mt: &str| {
4190 format!(
4191 "---\ntype: profile\nmeta-type: {mt}\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4192 )
4193 };
4194
4195 for ok in ["fact", "operational", "conclusion"] {
4197 fx.write("records/profiles/ok.md", &body(ok));
4198 let issues = validate_working_set(&fx.store(), None).unwrap();
4199 assert!(
4200 !has(&issues, codes::FM_BAD_META_TYPE),
4201 "`meta-type: {ok}` must be accepted; got {issues:#?}"
4202 );
4203 }
4204 fx.write(
4205 "records/profiles/absent.md",
4206 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
4207 );
4208 assert!(
4209 !has(
4210 &validate_working_set(&fx.store(), None).unwrap(),
4211 codes::FM_BAD_META_TYPE
4212 ),
4213 "an absent meta-type is the default `fact` and must be accepted"
4214 );
4215
4216 for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
4218 let fx2 = Fixture::new();
4219 fx2.write("records/profiles/bad.md", &body(bad));
4220 let issues = validate_working_set(&fx2.store(), None).unwrap();
4221 assert!(
4222 has(&issues, codes::FM_BAD_META_TYPE),
4223 "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
4224 );
4225 }
4226 }
4227
4228 #[test]
4237 fn id_absent_slug_ulid_and_numeric_are_all_silent() {
4238 let body = |id_line: &str| {
4239 format!(
4240 "---\ntype: contact\n{id_line}created: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4241 )
4242 };
4243 for (case, id_line) in [
4244 ("absent", ""),
4245 ("slug", "id: sarah-chen\n"),
4246 ("ulid", "id: 01j5qc3v9k4ym8rwbn2tqe6f7d\n"),
4247 ("numeric-scalar", "id: 100\n"),
4248 ] {
4249 let fx = Fixture::new();
4250 fx.write("records/contacts/a.md", &body(id_line));
4251 let issues = validate_working_set(&fx.store(), None).unwrap();
4252 assert!(
4253 !has(&issues, codes::FM_BAD_ID),
4254 "id case `{case}` must be silent; got {issues:#?}"
4255 );
4256 }
4257 }
4258
4259 #[test]
4264 fn id_unusable_as_identifier_warns_fm_bad_id() {
4265 let body = |id_line: &str| {
4266 format!(
4267 "---\ntype: contact\n{id_line}\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4268 )
4269 };
4270 for bad in [
4271 "id: \"\"",
4272 "id: \" \"",
4273 "id: two words",
4274 "id: [a, b]",
4275 "id: {k: v}",
4276 ] {
4277 let fx = Fixture::new();
4278 fx.write("records/contacts/a.md", &body(bad));
4279 let issues = validate_working_set(&fx.store(), None).unwrap();
4280 let issue = issues
4281 .iter()
4282 .find(|i| i.code == codes::FM_BAD_ID)
4283 .unwrap_or_else(|| panic!("`{bad}` must fire FM_BAD_ID; got {issues:#?}"));
4284 assert!(
4285 matches!(issue.severity, Severity::Warning),
4286 "FM_BAD_ID is a warning (additive v0.4 — it must never block a store): {issue:#?}"
4287 );
4288 assert_eq!(issue.key.as_deref(), Some("id"));
4289 assert!(
4290 !issue.is_error(),
4291 "FM_BAD_ID must not fail validation: {issue:#?}"
4292 );
4293 }
4294 }
4295
4296 #[test]
4300 fn dup_id_fires_on_shared_ulid_ids() {
4301 let fx = Fixture::new();
4302 let rec = |name: &str| {
4303 format!(
4304 "---\ntype: contact\nid: 01j5qc3v9k4ym8rwbn2tqe6f7d\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: {name}\nname: {name}\n---\n\n# {name}\n"
4305 )
4306 };
4307 fx.write("records/contacts/a.md", &rec("A"));
4308 fx.write("records/contacts/b.md", &rec("B"));
4309 let issues = fx.store_all();
4310 assert_eq!(count(&issues, codes::DUP_ID), 1, "{issues:#?}");
4311 let issue = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4312 assert!(issue.is_error());
4313 assert!(!has(&issues, codes::FM_BAD_ID), "{issues:#?}");
4315 }
4316
4317 #[test]
4323 fn valid_db_md_emits_no_structure_issue() {
4324 let fx = Fixture::new();
4325 let issues = fx.store_all();
4326 assert!(
4327 !has(&issues, codes::DB_MD_BAD_TYPE)
4328 && !has(&issues, codes::DB_MD_MISSING_FIELD)
4329 && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4330 "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4331 );
4332 }
4333
4334 #[test]
4338 fn db_md_wrong_type_is_error() {
4339 let fx = Fixture::new();
4340 fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4341 let issues = fx.store_all();
4342 let i = find(&issues, codes::DB_MD_BAD_TYPE);
4343 assert!(i.is_error());
4344 assert_eq!(i.file, PathBuf::from("DB.md"));
4345 assert_eq!(i.key.as_deref(), Some("type"));
4346 assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4347 }
4348
4349 #[test]
4352 fn db_md_missing_scope_and_owner_each_report() {
4353 let fx = Fixture::new();
4354 fx.write("DB.md", "---\ntype: db-md\n---\n");
4355 let issues = fx.store_all();
4356 assert_eq!(
4357 count(&issues, codes::DB_MD_MISSING_FIELD),
4358 2,
4359 "both scope and owner absent → two issues: {issues:#?}"
4360 );
4361 let keys: BTreeSet<Option<String>> = issues
4362 .iter()
4363 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4364 .map(|i| i.key.clone())
4365 .collect();
4366 assert_eq!(
4367 keys,
4368 BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4369 "one issue keyed on each missing field"
4370 );
4371 for i in issues
4372 .iter()
4373 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4374 {
4375 assert!(i.is_error());
4376 assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4377 }
4378 }
4379
4380 #[test]
4384 fn db_md_blank_required_field_is_missing() {
4385 let fx = Fixture::new();
4386 fx.write(
4387 "DB.md",
4388 "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4389 );
4390 let issues = fx.store_all();
4391 let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4392 assert_eq!(i.key.as_deref(), Some("owner"));
4393 assert_eq!(
4394 i.line,
4395 Some(4),
4396 "a present-but-empty field anchors to its line"
4397 );
4398 assert!(
4399 count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4400 "scope is present and non-empty → only owner reported"
4401 );
4402 }
4403
4404 #[test]
4407 fn db_md_unknown_section_is_warning() {
4408 let fx = Fixture::new();
4409 fx.write(
4410 "DB.md",
4411 "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4415 );
4416 let issues = fx.store_all();
4417 let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4418 assert!(!i.is_error(), "unknown section is a warning, not an error");
4419 assert_eq!(i.severity, Severity::Warning);
4420 assert_eq!(
4421 i.line,
4422 Some(11),
4423 "anchors to the `## Glossary` heading line"
4424 );
4425 assert!(
4426 i.message.contains("Glossary"),
4427 "the message names the offending section: {}",
4428 i.message
4429 );
4430 assert_eq!(
4432 count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4433 1,
4434 "only the unrecognized section is flagged: {issues:#?}"
4435 );
4436 }
4437
4438 #[test]
4441 fn db_md_no_frontmatter_reports_type_and_both_fields() {
4442 let fx = Fixture::new();
4443 fx.write("DB.md", "# just a heading, no frontmatter\n");
4444 let issues = fx.store_all();
4445 assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4446 assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4447 }
4448
4449 #[test]
4452 fn missing_type_is_error() {
4453 let fx = Fixture::new();
4454 fx.write(
4455 "records/contacts/a.md",
4456 "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4457 );
4458 let issues = fx.store_all();
4459 assert!(has(&issues, codes::FM_MISSING_TYPE));
4460 assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4461 }
4462
4463 #[test]
4464 fn missing_universal_timestamps_are_errors_on_content_files() {
4465 let fx = Fixture::new();
4466 fx.write(
4467 "records/contacts/a.md",
4468 "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4469 );
4470 let issues = fx.store_all();
4471
4472 let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4473 assert_eq!(missing_created.key.as_deref(), Some("created"));
4474 assert!(missing_created.is_error());
4475
4476 let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4477 assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4478 assert!(missing_updated.is_error());
4479 }
4480
4481 #[test]
4482 fn meta_files_do_not_require_universal_timestamps() {
4483 let fx = Fixture::new();
4484 let issues = fx.store_all();
4485
4486 assert!(
4487 !has(&issues, codes::FM_MISSING_CREATED),
4488 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4489 );
4490 assert!(
4491 !has(&issues, codes::FM_MISSING_UPDATED),
4492 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4493 );
4494 }
4495
4496 #[test]
4497 fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4498 let fx = Fixture::new();
4499 fx.write(
4500 "records/profiles/a.md",
4501 "# Just a heading\n\nNo frontmatter here.\n",
4502 );
4503 let issues = fx.store_all();
4504 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4505 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4506 }
4507
4508 #[test]
4509 fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4510 let fx = Fixture::new();
4511 fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4512 let issues = fx.store_all();
4513 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4514 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4515 }
4516
4517 #[test]
4518 fn malformed_yaml_is_error_and_suppresses_field_checks() {
4519 let fx = Fixture::new();
4520 fx.write(
4522 "records/contacts/a.md",
4523 "---\ntype: contact\n bad: : : :\n: : nope\n---\n\nbody\n",
4524 );
4525 let issues = fx.store_all();
4526 let issue = find(&issues, codes::FM_MALFORMED_YAML);
4527 assert!(issue.is_error());
4528 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4529 assert!(
4532 !has(&issues, codes::SUMMARY_MISSING),
4533 "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4534 );
4535 }
4536
4537 #[test]
4538 fn bad_created_timestamp_is_error() {
4539 let fx = Fixture::new();
4540 fx.write(
4541 "records/contacts/a.md",
4542 "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4543 );
4544 let issues = fx.store_all();
4545 let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4546 assert_eq!(issue.key.as_deref(), Some("created"));
4547 assert!(issue.is_error());
4548 }
4549
4550 #[test]
4551 fn date_only_created_is_rejected_but_type_date_field_accepted() {
4552 let fx = Fixture::new();
4553 fx.write(
4556 "records/contacts/a.md",
4557 "---\ntype: contact\ncreated: 2026-05-22\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\nlast_touch: 2026-05-22\n---\n\n# A\n",
4558 );
4559 let issues = fx.store_all();
4560 let created_issues: Vec<_> = issues
4561 .iter()
4562 .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4563 .collect();
4564 assert_eq!(
4565 created_issues.len(),
4566 1,
4567 "date-only `created` must fail: {issues:#?}"
4568 );
4569 assert!(
4570 !issues.iter().any(
4571 |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4572 ),
4573 "date-only `last_touch` is valid: {issues:#?}"
4574 );
4575 }
4576
4577 #[test]
4580 fn summary_missing_empty_multiline_toolong() {
4581 let fx = Fixture::new();
4582 fx.write(
4583 "records/profiles/missing.md",
4584 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4585 );
4586 fx.write(
4587 "records/profiles/empty.md",
4588 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \" \"\n---\n\nbody\n",
4589 );
4590 let long = "x".repeat(201);
4591 fx.write(
4592 "records/profiles/long.md",
4593 &format!("---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{long}\"\n---\n\nbody\n"),
4594 );
4595 let issues = fx.store_all();
4596 assert!(has(&issues, codes::SUMMARY_MISSING));
4597 assert_eq!(
4598 find(&issues, codes::SUMMARY_MISSING).file,
4599 PathBuf::from("records/profiles/missing.md")
4600 );
4601 assert!(has(&issues, codes::SUMMARY_EMPTY));
4602 assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4603 assert_eq!(
4604 find(&issues, codes::SUMMARY_TOO_LONG).severity,
4605 Severity::Warning
4606 );
4607 }
4608
4609 #[test]
4610 fn summary_multiline_via_yaml_block_scalar() {
4611 let fx = Fixture::new();
4612 fx.write(
4614 "records/profiles/a.md",
4615 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: |\n line one\n line two\n---\n\nbody\n",
4616 );
4617 let issues = fx.store_all();
4618 assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4619 }
4620
4621 #[test]
4622 fn summary_exactly_200_chars_is_ok() {
4623 let fx = Fixture::new();
4624 let s = "y".repeat(200);
4625 fx.write(
4626 "records/profiles/a.md",
4627 &format!("---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{s}\"\n---\n\nbody\n"),
4628 );
4629 let issues = fx.store_all();
4630 assert!(
4631 !has(&issues, codes::SUMMARY_TOO_LONG),
4632 "200 is the bound, inclusive: {issues:#?}"
4633 );
4634 }
4635
4636 #[test]
4637 fn meta_files_need_no_summary() {
4638 let fx = Fixture::new();
4639 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4642 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4643 fx.write(
4644 "records/index.md",
4645 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4646 );
4647 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4648 fx.write(
4649 "records/contacts/index.jsonl",
4650 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4651 );
4652 fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4653 let issues = fx.store_all();
4654 assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4655 }
4656
4657 #[test]
4660 fn nested_tags_warns_flat_tags_ok() {
4661 let fx = Fixture::new();
4662 fx.write(
4663 "records/contacts/nested.md",
4664 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ntags:\n - good\n - [nested, list]\n---\n\n# A\n",
4665 );
4666 fx.write(
4667 "records/contacts/flat.md",
4668 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ntags: [customer, vip]\n---\n\n# A\n",
4669 );
4670 let issues = fx.store_all();
4671 let tag_issues: Vec<_> = issues
4672 .iter()
4673 .filter(|i| i.code == codes::TAGS_MALFORMED)
4674 .collect();
4675 assert_eq!(
4676 tag_issues.len(),
4677 1,
4678 "only the nested-tags file should warn: {issues:#?}"
4679 );
4680 assert_eq!(
4681 tag_issues[0].file,
4682 PathBuf::from("records/contacts/nested.md")
4683 );
4684 assert_eq!(tag_issues[0].severity, Severity::Warning);
4685 }
4686
4687 #[test]
4690 fn short_form_wiki_link_is_error() {
4691 let fx = Fixture::new();
4692 let mut body = valid_contact("links to a short form");
4693 body.push_str("\nSee [[sarah-chen]] for details.\n");
4694 fx.write("records/contacts/a.md", &body);
4695 let issues = fx.store_all();
4696 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4697 assert!(issue.is_error());
4698 assert!(issue.message.contains("sarah-chen"));
4699 assert!(
4701 !issues
4702 .iter()
4703 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4704 "short-form should suppress broken: {issues:#?}"
4705 );
4706 }
4707
4708 #[test]
4709 fn broken_full_path_wiki_link_is_error() {
4710 let fx = Fixture::new();
4711 let mut body = valid_contact("links to a missing file");
4712 body.push_str("\nSee [[records/contacts/ghost]].\n");
4713 fx.write("records/contacts/a.md", &body);
4714 let issues = fx.store_all();
4715 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4716 assert!(issue.is_error());
4717 assert!(issue.message.contains("records/contacts/ghost"));
4718 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4719 }
4720
4721 #[test]
4722 fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4723 let fx = Fixture::new();
4724 let mut body = valid_contact("links with traversal");
4725 body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4726 fx.write("records/contacts/a.md", &body);
4727 let issues = fx.store_all();
4728 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4729 assert!(issue.message.contains("not a safe store-relative path"));
4730 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4731 }
4732
4733 #[test]
4734 fn valid_full_path_wiki_link_passes() {
4735 let fx = Fixture::new();
4736 fx.write("records/contacts/target.md", &valid_contact("target"));
4737 let mut body = valid_contact("links to target");
4738 body.push_str("\nSee [[records/contacts/target]].\n");
4739 fx.write("records/contacts/a.md", &body);
4740 let issues = fx.store_all();
4741 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4742 assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4743 }
4744
4745 #[test]
4746 fn md_extension_wiki_link_warns_and_resolves() {
4747 let fx = Fixture::new();
4748 fx.write("records/contacts/target.md", &valid_contact("target"));
4749 let mut body = valid_contact("links with extension");
4750 body.push_str("\nSee [[records/contacts/target.md]].\n");
4751 fx.write("records/contacts/a.md", &body);
4752 let issues = fx.store_all();
4753 let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4754 assert_eq!(issue.severity, Severity::Warning);
4755 assert_eq!(
4756 issue.suggestion.as_deref(),
4757 Some("drop the extension: [[records/contacts/target]]")
4758 );
4759 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4761 }
4762
4763 #[test]
4764 fn wiki_links_in_code_fences_are_ignored() {
4765 let fx = Fixture::new();
4766 let mut body = valid_contact("has a fenced example");
4767 body.push_str("\n```\n[[sarah-chen]]\n```\n");
4768 fx.write("records/contacts/a.md", &body);
4769 let issues = fx.store_all();
4770 assert!(
4771 !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4772 "fenced wiki-links must be ignored: {issues:#?}"
4773 );
4774 }
4775
4776 #[test]
4777 fn flow_form_link_list_in_frontmatter_is_error() {
4778 let fx = Fixture::new();
4779 fx.write(
4780 "records/meetings/m.md",
4781 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a meeting\ndate: 2026-05-22\nattendees: [[[records/contacts/a]], [[records/contacts/b]]]\n---\n\n# M\n",
4782 );
4783 let issues = fx.store_all();
4784 let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4785 assert!(issue.is_error());
4786 assert_eq!(issue.key.as_deref(), Some("attendees"));
4787 }
4788
4789 #[test]
4790 fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4791 let fx = Fixture::new();
4792 fx.write("records/contacts/a.md", &valid_contact("a"));
4793 fx.write("records/contacts/b.md", &valid_contact("b"));
4794 fx.write(
4795 "records/meetings/m.md",
4796 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a meeting\ndate: 2026-05-22\nattendees:\n - [[records/contacts/a]]\n - [[records/contacts/b]]\n---\n\n# M\n",
4797 );
4798 let issues = fx.store_all();
4799 assert!(
4800 !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4801 "{issues:#?}"
4802 );
4803 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4805 }
4806
4807 #[test]
4808 fn frontmatter_short_form_link_field_is_error() {
4809 let fx = Fixture::new();
4810 fx.write(
4813 "records/synthesis/a.md",
4814 "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: \"[[sarah-chen]]\"\n---\n\n# A\n",
4815 );
4816 let issues = fx.store_all();
4817 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4818 assert!(issue.is_error());
4819 assert_eq!(issue.key.as_deref(), Some("related"));
4820 }
4821
4822 #[test]
4823 fn unquoted_frontmatter_link_is_recognized() {
4824 let fx = Fixture::new();
4829 fx.write(
4830 "records/synthesis/short.md",
4831 "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: [[sarah-chen]]\n---\n\n# A\n",
4832 );
4833 fx.write(
4834 "records/synthesis/broken.md",
4835 "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: [[records/contacts/ghost]]\n---\n\n# A\n",
4836 );
4837 let issues = fx.store_all();
4838 assert!(
4839 issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4840 && i.file == Path::new("records/synthesis/short.md")
4841 && i.key.as_deref() == Some("related")),
4842 "unquoted short-form frontmatter link must be caught: {issues:#?}"
4843 );
4844 assert!(
4845 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4846 && i.file == Path::new("records/synthesis/broken.md")),
4847 "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4848 );
4849 }
4850
4851 #[test]
4852 fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4853 let mut fx = Fixture::new();
4858 fx.config.schemas.insert(
4859 "contact".into(),
4860 Schema {
4861 fields: vec![FieldSpec {
4862 name: "company".into(),
4863 link_prefix: Some(PathBuf::from("records/companies")),
4864 ..Default::default()
4865 }],
4866 ..Default::default()
4867 },
4868 );
4869 fx.write(
4870 "records/contacts/a.md",
4871 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ncompany: \"[[northstar]]\"\n---\n\n# A\n",
4872 );
4873 let issues = fx.store_all();
4874 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4875 assert_eq!(issue.key.as_deref(), Some("company"));
4876 assert!(
4878 !issues
4879 .iter()
4880 .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4881 && i.key.as_deref() == Some("company")),
4882 "schema link fields are checked once, by the schema path: {issues:#?}"
4883 );
4884 }
4885
4886 #[test]
4887 fn schema_link_field_with_md_extension_still_warns() {
4888 let mut fx = Fixture::new();
4889 fx.config.schemas.insert(
4890 "contact".into(),
4891 Schema {
4892 fields: vec![FieldSpec {
4893 name: "company".into(),
4894 link_prefix: Some(PathBuf::from("records/companies")),
4895 ..Default::default()
4896 }],
4897 ..Default::default()
4898 },
4899 );
4900 fx.write(
4901 "records/companies/acme.md",
4902 "---\ntype: company\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: Acme\nname: Acme\n---\n\n# Acme\n",
4903 );
4904 fx.write(
4905 "records/contacts/a.md",
4906 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ncompany: \"[[records/companies/acme.md]]\"\n---\n\n# A\n",
4907 );
4908 let issues = fx.store_all();
4909 let issue = issues
4910 .iter()
4911 .find(|i| {
4912 i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
4913 })
4914 .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
4915 assert_eq!(issue.severity, Severity::Warning);
4916 assert!(
4917 !issues
4918 .iter()
4919 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
4920 "extensionless existence check should still find acme.md: {issues:#?}"
4921 );
4922 }
4923
4924 #[test]
4927 fn explicit_schema_required_shape_enum() {
4928 let fx = {
4929 let mut fx = Fixture::new();
4930 let schema = Schema {
4933 fields: vec![
4934 FieldSpec {
4935 name: "name".into(),
4936 required: true,
4937 ..Default::default()
4938 },
4939 FieldSpec {
4940 name: "email".into(),
4941 required: true,
4942 shape: Some(Shape::Email),
4943 ..Default::default()
4944 },
4945 FieldSpec {
4946 name: "status".into(),
4947 enum_values: Some(vec!["active".into(), "inactive".into()]),
4948 ..Default::default()
4949 },
4950 ],
4951 ..Default::default()
4952 };
4953 fx.config.schemas.insert("contact".into(), schema);
4954 fx
4955 };
4956 fx.write(
4957 "records/contacts/a.md",
4958 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nemail: not-an-email\nstatus: archived\n---\n\n# A\n",
4959 );
4960 let issues = fx.store_all();
4961 assert!(
4963 issues
4964 .iter()
4965 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
4966 && i.key.as_deref() == Some("name")),
4967 "{issues:#?}"
4968 );
4969 assert!(
4971 issues.iter().any(
4972 |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
4973 ),
4974 "{issues:#?}"
4975 );
4976 assert!(
4978 issues
4979 .iter()
4980 .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
4981 && i.key.as_deref() == Some("status")),
4982 "{issues:#?}"
4983 );
4984 }
4985
4986 #[test]
4987 fn schema_without_link_field_allows_plain_value() {
4988 let mut fx = Fixture::new();
4992 fx.config.schemas.insert(
4993 "contact".into(),
4994 Schema {
4995 fields: vec![FieldSpec {
4996 name: "name".into(),
4997 required: true,
4998 ..Default::default()
4999 }],
5000 ..Default::default()
5001 },
5002 );
5003 fx.write(
5004 "records/contacts/a.md",
5005 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: Sarah\ncompany: \"Acme Co\"\n---\n\n# Sarah\n",
5006 );
5007 let issues = fx.store_all();
5008 assert!(
5009 !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
5010 "no declared link field for `company` → a plain value is fine: {issues:#?}"
5011 );
5012 }
5013
5014 #[test]
5015 fn schema_link_field_plain_value_is_prefix_mismatch() {
5016 let mut fx = Fixture::new();
5019 fx.config.schemas.insert(
5020 "contact".into(),
5021 Schema {
5022 fields: vec![FieldSpec {
5023 name: "company".into(),
5024 link_prefix: Some(PathBuf::from("records/companies")),
5025 ..Default::default()
5026 }],
5027 ..Default::default()
5028 },
5029 );
5030 fx.write(
5031 "records/contacts/a.md",
5032 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: Sarah\ncompany: \"Acme Co\"\n---\n\n# Sarah\n",
5033 );
5034 let issues = fx.store_all();
5035 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5036 assert_eq!(issue.key.as_deref(), Some("company"));
5037 assert!(issue
5038 .suggestion
5039 .as_deref()
5040 .unwrap()
5041 .contains("records/companies/"));
5042 }
5043
5044 #[test]
5045 fn schema_shape_int_and_url_and_currency() {
5046 let mut fx = Fixture::new();
5047 fx.config.schemas.insert(
5048 "widget".into(),
5049 Schema {
5050 fields: vec![
5051 FieldSpec {
5052 name: "qty".into(),
5053 shape: Some(Shape::Int),
5054 ..Default::default()
5055 },
5056 FieldSpec {
5057 name: "site".into(),
5058 shape: Some(Shape::Url),
5059 ..Default::default()
5060 },
5061 FieldSpec {
5062 name: "price".into(),
5063 shape: Some(Shape::Currency),
5064 ..Default::default()
5065 },
5066 ],
5067 ..Default::default()
5068 },
5069 );
5070 fx.write(
5073 "records/widgets/ok.md",
5074 "---\ntype: widget\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: ok\nqty: 5\nsite: https://example.com\nprice: \"USD 1,234.50\"\n---\n\n# ok\n",
5075 );
5076 fx.write(
5080 "records/widgets/bad.md",
5081 "---\ntype: widget\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: bad\nqty: five\nsite: ftp://nope\nprice: inf\n---\n\n# bad\n",
5082 );
5083 let issues = fx.store_all();
5084 let bad_shape: Vec<_> = issues
5085 .iter()
5086 .filter(|i| {
5087 i.code == codes::SCHEMA_SHAPE_MISMATCH
5088 && i.file == Path::new("records/widgets/bad.md")
5089 })
5090 .map(|i| i.key.clone().unwrap_or_default())
5091 .collect();
5092 assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
5093 assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
5094 assert!(
5095 bad_shape.contains(&"price".to_string()),
5096 "inf must be rejected as currency: {issues:#?}"
5097 );
5098 assert!(
5099 !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
5100 && i.file == Path::new("records/widgets/ok.md")),
5101 "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
5102 );
5103 }
5104
5105 #[test]
5106 fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
5107 let mut fx = Fixture::new();
5108 fx.config.schemas.insert(
5109 "contact".into(),
5110 Schema {
5111 fields: vec![
5112 FieldSpec {
5113 name: "email".into(),
5114 required: true,
5115 shape: Some(Shape::Email),
5116 ..Default::default()
5117 },
5118 FieldSpec {
5119 name: "status".into(),
5120 enum_values: Some(vec!["active".into(), "inactive".into()]),
5121 ..Default::default()
5122 },
5123 ],
5124 ..Default::default()
5125 },
5126 );
5127 fx.write(
5131 "records/contacts/bad.md",
5132 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: bad\nemail:\n - a@b.com\n - c@d.com\nstatus:\n - active\n---\n\n# bad\n",
5133 );
5134 let issues = fx.store_all();
5135 let mismatched: Vec<_> = issues
5136 .iter()
5137 .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
5138 .map(|i| i.key.clone().unwrap_or_default())
5139 .collect();
5140 assert!(
5141 mismatched.contains(&"email".to_string()),
5142 "list-valued required email must flag: {issues:#?}"
5143 );
5144 assert!(
5145 mismatched.contains(&"status".to_string()),
5146 "list-valued enum must flag: {issues:#?}"
5147 );
5148 }
5149
5150 #[test]
5151 fn is_currency_accepts_codes_and_rejects_non_numeric() {
5152 for ok in [
5154 "100",
5155 "1234.56",
5156 "$1,234.50",
5157 "USD 100", "usd 100", "EUR 9.50",
5160 "£12",
5161 "¥1000",
5162 "-5.00", "+5",
5164 "1,000,000",
5165 ] {
5166 assert!(is_currency(ok), "expected currency: {ok:?}");
5167 }
5168 for bad in [
5171 "inf", "-inf", "infinity", "NaN", "nan", "12.999", "1.2345", "USD", "$", "free", "", " ", "1e3", "1.", ".5", "1 000", "USDD 100", ] {
5182 assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
5183 }
5184 }
5185
5186 #[test]
5189 fn ignored_type_present_is_info() {
5190 let mut fx = Fixture::new();
5191 fx.config.ignored_types.push("temp".into());
5192 fx.write(
5193 "records/temps/x.md",
5194 "---\ntype: temp\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a temp\n---\n\n# x\n",
5195 );
5196 let issues = fx.store_all();
5197 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
5198 assert_eq!(issue.severity, Severity::Info);
5199 assert!(!issue.is_error());
5200 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5201 }
5202
5203 #[test]
5204 fn conclusion_record_derived_from_ignored_type_warns() {
5205 let mut fx = Fixture::new();
5206 fx.config.ignored_types.push("temp".into());
5207 fx.write(
5208 "records/temps/x.md",
5209 "---\ntype: temp\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a temp\n---\n\n# x\n",
5210 );
5211 fx.write(
5215 "records/synthesis/t.md",
5216 "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: derived\nderived_from: \"[[records/temps/x]]\"\n---\n\n# t\n",
5217 );
5218 let issues = fx.store_all();
5219 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
5220 assert_eq!(issue.severity, Severity::Warning);
5221 assert_eq!(issue.key.as_deref(), Some("derived_from"));
5222 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5223 }
5224
5225 #[test]
5233 fn derived_from_ignored_type_is_the_shared_policy_decision() {
5234 let mut fx = Fixture::new();
5235 fx.config.ignored_types.push("secret".into());
5236 fx.write(
5238 "records/secrets/s.md",
5239 "---\ntype: secret\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: hush\n---\n\n# s\n",
5240 );
5241 fx.write(
5243 "records/contacts/c.md",
5244 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: ok\nname: C\n---\n\n# c\n",
5245 );
5246 let store = fx.store();
5247
5248 let hit =
5252 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
5253 .expect("conclusion → ignored-type record must match");
5254 assert_eq!(hit.target, "records/secrets/s");
5255 assert_eq!(hit.target_type, "secret");
5256
5257 assert_eq!(
5260 derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
5261 None,
5262 "only conclusion derivation is policed"
5263 );
5264
5265 assert_eq!(
5267 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
5268 None,
5269 "deriving from a non-ignored type is allowed"
5270 );
5271
5272 let hit = derived_from_ignored_type(
5274 &store,
5275 "conclusion",
5276 ["records/contacts/c", "records/secrets/s"],
5277 )
5278 .expect("a later ignored-type target must still be found");
5279 assert_eq!(hit.target, "records/secrets/s");
5280
5281 fx.config.ignored_types.clear();
5283 let store = fx.store();
5284 assert_eq!(
5285 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
5286 None,
5287 "an empty ignored-types policy short-circuits"
5288 );
5289 }
5290
5291 #[test]
5294 fn dup_id_is_hard_error_with_related() {
5295 let fx = Fixture::new();
5296 fx.write(
5297 "records/contacts/a.md",
5298 "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a\nname: A\n---\n\n# A\n",
5299 );
5300 fx.write(
5301 "records/contacts/b.md",
5302 "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: b\nname: B\n---\n\n# B\n",
5303 );
5304 let issues = fx.store_all();
5305 assert_eq!(
5308 count(&issues, codes::DUP_ID),
5309 1,
5310 "one issue per group: {issues:#?}"
5311 );
5312 let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
5313 assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
5314 assert!(a.is_error());
5315 assert_eq!(a.key.as_deref(), Some("id"));
5316 assert_eq!(
5317 a.line,
5318 Some(3),
5319 "anchors to the `id` line on the reported file"
5320 );
5321 assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5322 }
5323
5324 #[test]
5325 fn dup_id_not_fired_in_working_set() {
5326 let fx = Fixture::new();
5328 fx.write(
5329 "records/contacts/a.md",
5330 "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a\nname: A\n---\n\n# A\n",
5331 );
5332 fx.write(
5333 "records/contacts/b.md",
5334 "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: b\nname: B\n---\n\n# B\n",
5335 );
5336 fx.write(
5338 "log.md",
5339 "---\ntype: log\n---\n\n## [2026-05-22 10:00] create | records/contacts/a\nx\n\n## [2026-05-22 10:01] create | records/contacts/b\nx\n",
5340 );
5341 let issues = validate_working_set(&fx.store(), None).unwrap();
5342 assert!(
5343 !has(&issues, codes::DUP_ID),
5344 "DUP_ID is --all only: {issues:#?}"
5345 );
5346 }
5347
5348 #[test]
5349 fn dup_unique_key_single_field_is_warning() {
5350 let mut fx = Fixture::new();
5351 fx.config.schemas.insert(
5353 "contact".into(),
5354 Schema {
5355 unique_keys: vec![vec!["email".into()]],
5356 ..Default::default()
5357 },
5358 );
5359 for (f, name) in [("a", "A"), ("b", "B")] {
5360 fx.write(
5361 &format!("records/contacts/{f}.md"),
5362 &format!("---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: s\nname: {name}\nemail: dup@x.com\n---\n\n# {name}\n"),
5363 );
5364 }
5365 let issues = fx.store_all();
5366 assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5369 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5370 assert_eq!(dup.severity, Severity::Warning);
5371 assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5372 assert_eq!(dup.key.as_deref(), Some("email"));
5373 assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5374 }
5375
5376 #[test]
5377 fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5378 let mut fx = Fixture::new();
5379 fx.config.schemas.insert(
5381 "expense".into(),
5382 Schema {
5383 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5384 ..Default::default()
5385 },
5386 );
5387 fx.write("records/companies/acme.md", "---\ntype: company\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: c\nname: Acme\n---\n# A\n");
5388 let exp = |f: &str, amount: &str| {
5389 format!(
5390 "---\ntype: expense\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: e\ndate: 2026-05-01\namount: {amount}\nvendor: \"[[records/companies/acme]]\"\n---\n\n# {f}\n"
5391 )
5392 };
5393 fx.write("records/expenses/e1.md", &exp("e1", "100"));
5394 fx.write("records/expenses/e2.md", &exp("e2", "100"));
5395 fx.write("records/expenses/e3.md", &exp("e3", "200")); let issues = fx.store_all();
5397 assert_eq!(
5400 count(&issues, codes::DUP_UNIQUE_KEY),
5401 1,
5402 "only e1+e2 collide, one issue: {issues:#?}"
5403 );
5404 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5405 assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5406 assert_eq!(
5407 dup.line,
5408 Some(1),
5409 "compound-key collision anchors to line 1"
5410 );
5411 assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5412 assert!(
5413 !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5414 && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5415 "e3 differs on amount and must not collide: {issues:#?}"
5416 );
5417 }
5418
5419 #[test]
5420 fn dup_unique_key_list_field_is_order_independent() {
5421 let mut fx = Fixture::new();
5422 fx.config.schemas.insert(
5424 "meeting".into(),
5425 Schema {
5426 unique_keys: vec![vec!["date".into(), "attendees".into()]],
5427 ..Default::default()
5428 },
5429 );
5430 fx.write("records/contacts/a.md", &valid_contact("a"));
5431 fx.write("records/contacts/b.md", &valid_contact("b"));
5432 let m = |f: &str, order: &str| {
5433 let attendees = if order == "ab" {
5434 " - [[records/contacts/a]]\n - [[records/contacts/b]]"
5435 } else {
5436 " - [[records/contacts/b]]\n - [[records/contacts/a]]"
5437 };
5438 format!(
5439 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\nattendees:\n{attendees}\n---\n\n# {f}\n"
5440 )
5441 };
5442 fx.write("records/meetings/m1.md", &m("m1", "ab"));
5443 fx.write("records/meetings/m2.md", &m("m2", "ba"));
5444 let issues = fx.store_all();
5445 assert_eq!(
5448 count(&issues, codes::DUP_UNIQUE_KEY),
5449 1,
5450 "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5451 );
5452 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5453 assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5454 assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5455 }
5456
5457 #[test]
5460 fn missing_indexes_at_all_three_levels() {
5461 let fx = Fixture::new();
5462 fx.write("records/contacts/a.md", &valid_contact("a"));
5463 let issues = fx.store_all();
5464 let missing_files: BTreeSet<PathBuf> = issues
5468 .iter()
5469 .filter(|i| i.code == codes::INDEX_MISSING)
5470 .map(|i| i.file.clone())
5471 .collect();
5472 assert!(
5473 missing_files.contains(&PathBuf::from("index.md")),
5474 "{issues:#?}"
5475 );
5476 assert!(
5477 missing_files.contains(&PathBuf::from("records/index.md")),
5478 "{issues:#?}"
5479 );
5480 assert!(
5481 missing_files.contains(&PathBuf::from("records/contacts")),
5482 "{issues:#?}"
5483 );
5484 assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5487 }
5488
5489 #[test]
5490 fn index_stale_entry_and_missing_entry() {
5491 let fx = Fixture::new();
5492 fx.write(
5493 "records/contacts/present.md",
5494 &valid_contact("present contact"),
5495 );
5496 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5498 fx.write(
5499 "records/index.md",
5500 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5501 );
5502 fx.write(
5504 "records/contacts/index.md",
5505 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5506 );
5507 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5508 let issues = fx.store_all();
5509 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5510 assert!(stale.message.contains("ghost"));
5511 assert!(stale.is_error());
5512 let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5513 assert!(
5514 missing.message.contains("present.md"),
5515 "{}",
5516 missing.message
5517 );
5518 }
5519
5520 #[test]
5521 fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5522 let fx = Fixture::new();
5523 fx.write("records/contacts/a.md", &valid_contact("a"));
5524 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5525 fx.write(
5526 "records/index.md",
5527 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5528 );
5529 fx.write(
5530 "records/contacts/index.md",
5531 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5532 );
5533 fx.write(
5534 "records/contacts/index.jsonl",
5535 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5536 );
5537 let issues = fx.store_all();
5538 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5539 assert!(stale.message.contains("not a safe store-relative path"));
5540 }
5541
5542 #[test]
5543 fn index_summary_mismatch() {
5544 let fx = Fixture::new();
5545 fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5546 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5547 fx.write(
5548 "records/index.md",
5549 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5550 );
5551 fx.write(
5552 "records/contacts/index.md",
5553 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5554 );
5555 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5556 let issues = fx.store_all();
5557 let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5558 assert!(issue.is_error());
5559 assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5560 }
5561
5562 #[test]
5563 fn index_summary_match_passes() {
5564 let fx = Fixture::new();
5565 fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5566 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5567 fx.write(
5568 "records/index.md",
5569 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5570 );
5571 fx.write(
5572 "records/contacts/index.md",
5573 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5574 );
5575 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5576 let issues = fx.store_all();
5577 assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5578 }
5579
5580 #[test]
5581 fn index_entry_with_tag_suffix_matches_summary() {
5582 let fx = Fixture::new();
5583 fx.write("records/contacts/a.md", &valid_contact("clean summary"));
5584 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5585 fx.write(
5586 "records/index.md",
5587 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5588 );
5589 fx.write(
5593 "records/contacts/index.md",
5594 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary · #customer\n",
5595 );
5596 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5597 let issues = fx.store_all();
5598 assert!(
5599 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5600 "tag suffix should be stripped: {issues:#?}"
5601 );
5602 }
5603
5604 #[test]
5605 fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5606 let fx = Fixture::new();
5613 fx.write(
5614 "records/contacts/a.md",
5615 &valid_contact("Standup notes · #standup"),
5616 );
5617 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5618 fx.write(
5619 "records/index.md",
5620 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5621 );
5622 fx.write(
5623 "records/contacts/index.md",
5624 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5625 );
5626 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5627 let issues = fx.store_all();
5628 assert!(
5629 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5630 "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5631 );
5632 }
5633
5634 #[test]
5635 fn index_jsonl_desync_missing_file_in_jsonl() {
5636 let fx = Fixture::new();
5637 fx.write("records/contacts/a.md", &valid_contact("a"));
5638 fx.write("records/contacts/b.md", &valid_contact("b"));
5639 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5640 fx.write(
5641 "records/index.md",
5642 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5643 );
5644 fx.write(
5645 "records/contacts/index.md",
5646 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5647 );
5648 fx.write(
5650 "records/contacts/index.jsonl",
5651 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5652 );
5653 let issues = fx.store_all();
5654 let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5655 assert!(desync.message.contains("b.md"), "{}", desync.message);
5656 }
5657
5658 #[test]
5659 fn index_jsonl_desync_record_points_at_missing_file() {
5660 let fx = Fixture::new();
5661 fx.write("records/contacts/a.md", &valid_contact("a"));
5662 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5663 fx.write(
5664 "records/index.md",
5665 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5666 );
5667 fx.write(
5668 "records/contacts/index.md",
5669 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5670 );
5671 fx.write(
5672 "records/contacts/index.jsonl",
5673 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5674 );
5675 let issues = fx.store_all();
5676 assert!(
5677 issues
5678 .iter()
5679 .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5680 "{issues:#?}"
5681 );
5682 }
5683
5684 #[test]
5685 fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5686 let fx = Fixture::new();
5687 fx.write("records/contacts/a.md", &valid_contact("a"));
5688 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5689 fx.write(
5690 "records/index.md",
5691 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5692 );
5693 fx.write(
5694 "records/contacts/index.md",
5695 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5696 );
5697 fx.write(
5698 "records/contacts/index.jsonl",
5699 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5700 );
5701 let issues = fx.store_all();
5702 assert!(
5703 issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5704 && i.message.contains("not a safe store-relative path")),
5705 "{issues:#?}"
5706 );
5707 }
5708
5709 #[test]
5710 fn index_jsonl_stale_summary() {
5711 let fx = Fixture::new();
5712 fx.write("records/contacts/a.md", &valid_contact("real summary"));
5713 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5714 fx.write(
5715 "records/index.md",
5716 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5717 );
5718 fx.write(
5719 "records/contacts/index.md",
5720 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5721 );
5722 fx.write(
5724 "records/contacts/index.jsonl",
5725 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5726 );
5727 let issues = fx.store_all();
5728 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5729 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5730 assert!(stale.key.as_deref().unwrap().contains("summary"));
5731 }
5732
5733 #[test]
5741 fn index_jsonl_stale_queryable_field_email() {
5742 let fx = Fixture::new();
5743 let contact = "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"a contact\"\nname: A\nemail: real@correct.com\n---\n\n# A\n";
5744 fx.write("records/contacts/a.md", contact);
5745 fx.rebuild_indexes();
5747 let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5748 let good = fs::read_to_string(&jsonl_path).unwrap();
5749 assert!(
5751 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5752 "freshly-rebuilt sidecar must not be stale"
5753 );
5754 assert!(
5756 good.contains("real@correct.com"),
5757 "sidecar projects email: {good}"
5758 );
5759 fx.write(
5760 "records/contacts/index.jsonl",
5761 &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5762 );
5763
5764 let issues = fx.store_all();
5765 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5766 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5767 let key = stale.key.as_deref().unwrap();
5770 assert!(
5771 key.contains("email"),
5772 "expected `email` in stale key, got {key:?}"
5773 );
5774 assert!(!key.contains("summary"), "summary still matches: {key:?}");
5775 assert!(!key.contains("type"), "type still matches: {key:?}");
5776 }
5777
5778 #[test]
5782 fn index_jsonl_stale_typed_and_list_fields() {
5783 let fx = Fixture::new();
5784 let expense = "---\ntype: expense\ncreated: 2026-05-20T08:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"office chairs\"\ntags: [furniture, q2]\namount: 1299\nvendor: Acme\ndate: 2026-05-20\n---\n\n# Expense\n";
5785 fx.write("records/expenses/e.md", expense);
5786 fx.rebuild_indexes();
5787 let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5788 let good = fs::read_to_string(&jsonl_path).unwrap();
5789 assert!(
5790 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5791 "freshly-rebuilt sidecar must not be stale"
5792 );
5793 let stale_line = good
5795 .replace("\"q2\"", "\"WRONG-TAG\"")
5796 .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5797 .replace("1299", "9999");
5798 fx.write("records/expenses/index.jsonl", &stale_line);
5799
5800 let issues = fx.store_all();
5801 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5802 let key = stale.key.as_deref().unwrap();
5803 for expected in ["amount", "tags", "updated"] {
5804 assert!(
5805 key.contains(expected),
5806 "expected `{expected}` in stale key, got {key:?}"
5807 );
5808 }
5809 }
5810
5811 #[test]
5812 fn index_orphan_in_noncanonical_folder() {
5813 let fx = Fixture::new();
5814 fx.write("records/contacts/a.md", &valid_contact("a"));
5815 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5817 fx.write(
5818 "records/index.md",
5819 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5820 );
5821 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5822 fx.write(
5823 "records/contacts/index.jsonl",
5824 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5825 );
5826 fx.write(
5828 "records/contacts/subfolder/index.md",
5829 "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5830 );
5831 let issues = fx.store_all();
5832 let orphan = find(&issues, codes::INDEX_ORPHAN);
5833 assert_eq!(orphan.severity, Severity::Warning);
5834 assert_eq!(
5835 orphan.file,
5836 PathBuf::from("records/contacts/subfolder/index.md")
5837 );
5838 }
5839
5840 #[test]
5841 fn index_wrong_scope() {
5842 let fx = Fixture::new();
5843 fx.write("records/contacts/a.md", &valid_contact("a"));
5844 fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5846 fx.write(
5847 "records/index.md",
5848 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5849 );
5850 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5851 fx.write(
5852 "records/contacts/index.jsonl",
5853 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5854 );
5855 let issues = fx.store_all();
5856 let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5857 assert_eq!(issue.severity, Severity::Warning);
5858 assert_eq!(issue.file, PathBuf::from("index.md"));
5859 }
5860
5861 #[test]
5862 fn capped_type_folder_index_does_not_flag_missing_entries() {
5863 let fx = Fixture::new();
5865 for i in 0..501 {
5866 fx.write(
5867 &format!("records/contacts/c{i:04}.md"),
5868 &valid_contact(&format!("contact {i}")),
5869 );
5870 }
5871 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
5872 fx.write(
5873 "records/index.md",
5874 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5875 );
5876 fx.write(
5878 "records/contacts/index.md",
5879 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/c0000]] — contact 0\n\n## More\n\nThis folder has 501 files.\n",
5880 );
5881 let mut jsonl = String::new();
5883 for i in 0..501 {
5884 jsonl.push_str(&format!(
5885 "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
5886 ));
5887 }
5888 fx.write("records/contacts/index.jsonl", &jsonl);
5889 let issues = fx.store_all();
5890 assert!(
5891 !has(&issues, codes::INDEX_MISSING_ENTRY),
5892 "over the cap, missing browse entries are expected: {issues:#?}"
5893 );
5894 assert!(
5896 !has(&issues, codes::INDEX_JSONL_DESYNC),
5897 "{:#?}",
5898 issues
5899 .iter()
5900 .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
5901 .collect::<Vec<_>>()
5902 );
5903 }
5904
5905 #[test]
5908 fn log_bad_timestamp_unknown_kind_out_of_order() {
5909 let fx = Fixture::new();
5910 fx.write(
5911 "log.md",
5912 concat!(
5913 "---\ntype: log\n---\n\n# Log\n\n",
5914 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5915 "## [2026-05-27 09:00] update | records/contacts/b\nx\n\n", "## [2026-05-27 11:00] frobnicate | records/contacts/c\nx\n\n", "## [not-a-date] create | records/contacts/d\nx\n", ),
5919 );
5920 let issues = fx.store_all();
5921 assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5922 assert_eq!(
5923 find(&issues, codes::LOG_OUT_OF_ORDER).severity,
5924 Severity::Warning
5925 );
5926 let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
5927 assert_eq!(unknown.severity, Severity::Warning);
5928 assert!(unknown.message.contains("frobnicate"));
5929 assert!(unknown
5930 .suggestion
5931 .as_deref()
5932 .is_some_and(|s| s.contains("create")));
5933 let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
5934 assert!(bad.is_error());
5935 }
5936
5937 #[test]
5938 fn log_validate_entry_without_object_is_well_formed() {
5939 let fx = Fixture::new();
5940 fx.write(
5941 "log.md",
5942 "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
5943 );
5944 let issues = fx.store_all();
5945 assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
5946 assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
5947 }
5948
5949 #[test]
5950 fn log_in_order_is_clean() {
5951 let fx = Fixture::new();
5952 fx.write(
5953 "log.md",
5954 concat!(
5955 "---\ntype: log\n---\n\n",
5956 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5957 "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
5958 ),
5959 );
5960 let issues = fx.store_all();
5961 assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5962 }
5963
5964 #[test]
5965 fn log_not_checked_in_working_set() {
5966 let fx = Fixture::new();
5968 fx.write(
5969 "log.md",
5970 concat!(
5971 "---\ntype: log\n---\n\n",
5972 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5973 "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
5974 ),
5975 );
5976 let issues = validate_working_set(&fx.store(), None).unwrap();
5977 assert!(
5978 !has(&issues, codes::LOG_OUT_OF_ORDER),
5979 "log ordering is --all only: {issues:#?}"
5980 );
5981 }
5982
5983 #[test]
5986 fn working_set_validates_only_changed_files() {
5987 let fx = Fixture::new();
5988 fx.write(
5991 "records/contacts/dirty.md",
5992 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
5993 );
5994 fx.write(
5995 "records/contacts/unlogged.md",
5996 "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
5997 );
5998 fx.write(
5999 "log.md",
6000 "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
6001 );
6002 let issues = validate_working_set(&fx.store(), None).unwrap();
6003 assert!(
6004 issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
6005 && i.file == Path::new("records/contacts/dirty.md")),
6006 "{issues:#?}"
6007 );
6008 assert!(
6009 !issues
6010 .iter()
6011 .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
6012 "unlogged file must not be in the working set: {issues:#?}"
6013 );
6014 }
6015
6016 #[test]
6017 fn working_set_includes_incoming_linkers_to_changed_path() {
6018 let fx = Fixture::new();
6019 fx.write(
6022 "records/profiles/linker.md",
6023 "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: links to a removed page\n---\n\nSee [[records/contacts/changed]].\n",
6024 );
6025 fx.write(
6027 "log.md",
6028 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
6029 );
6030 let issues = validate_working_set(&fx.store(), None).unwrap();
6031 assert!(
6032 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
6033 && i.file == Path::new("records/profiles/linker.md")),
6034 "incoming linker to a removed path must be validated: {issues:#?}"
6035 );
6036 }
6037
6038 #[test]
6039 fn working_set_respects_explicit_since_cutoff() {
6040 let fx = Fixture::new();
6041 fx.write(
6042 "records/contacts/old.md",
6043 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6044 );
6045 fx.write(
6046 "records/contacts/new.md",
6047 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6048 );
6049 fx.write(
6050 "log.md",
6051 concat!(
6052 "---\ntype: log\n---\n\n",
6053 "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
6054 "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
6055 ),
6056 );
6057 let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
6059 let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
6060 assert!(
6061 issues
6062 .iter()
6063 .any(|i| i.file == Path::new("records/contacts/new.md")),
6064 "{issues:#?}"
6065 );
6066 assert!(
6067 !issues
6068 .iter()
6069 .any(|i| i.file == Path::new("records/contacts/old.md")),
6070 "old change is before the cutoff: {issues:#?}"
6071 );
6072 }
6073
6074 #[test]
6075 fn working_set_default_since_is_last_validate_entry() {
6076 let fx = Fixture::new();
6077 fx.write(
6079 "records/contacts/before.md",
6080 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6081 );
6082 fx.write(
6083 "records/contacts/after.md",
6084 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6085 );
6086 fx.write(
6087 "log.md",
6088 concat!(
6089 "---\ntype: log\n---\n\n",
6090 "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
6091 "## [2026-05-21 10:00] validate\nPASS\n\n",
6092 "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
6093 ),
6094 );
6095 let issues = validate_working_set(&fx.store(), None).unwrap();
6096 assert!(
6097 issues
6098 .iter()
6099 .any(|i| i.file == Path::new("records/contacts/after.md")),
6100 "{issues:#?}"
6101 );
6102 assert!(
6103 !issues
6104 .iter()
6105 .any(|i| i.file == Path::new("records/contacts/before.md")),
6106 "change before the last validate entry is outside the default window: {issues:#?}"
6107 );
6108 }
6109
6110 #[test]
6113 fn issues_are_sorted_by_file_then_line() {
6114 let fx = Fixture::new();
6115 fx.write("records/profiles/z.md", "---\ntype: profile\nmeta-type: conclusion\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n");
6116 fx.write("records/profiles/a.md", "---\ntype: profile\nmeta-type: conclusion\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n");
6117 let issues = fx.store_all();
6118 let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
6119 let mut sorted = files.clone();
6120 sorted.sort();
6121 assert_eq!(
6122 files, sorted,
6123 "issues must be emitted in a stable file order"
6124 );
6125 }
6126
6127 #[test]
6130 fn frozen_page_is_not_a_validate_error() {
6131 let mut fx = Fixture::new();
6134 fx.config
6135 .frozen_pages
6136 .push(PathBuf::from("records/decisions/d.md"));
6137 fx.write(
6138 "records/decisions/d.md",
6139 "---\ntype: decision\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a finalized decision\n---\n\n# D\n",
6140 );
6141 let issues = fx.store_all();
6142 assert!(
6143 !has(&issues, codes::POLICY_FROZEN_PAGE),
6144 "frozen pages are enforced at write-time, not by validate: {issues:#?}"
6145 );
6146 }
6147
6148 #[test]
6149 fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
6150 let fx = Fixture::new();
6153 fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
6154 let mut body = valid_contact("links to sarah");
6155 body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
6156 fx.write("records/contacts/p.md", &body);
6157 let issues = fx.store_all();
6158 assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
6159 }
6160
6161 #[test]
6164 fn unknown_type_passes_through() {
6165 let fx = Fixture::new();
6169 fx.write(
6170 "records/proposals/x.md",
6171 "---\ntype: proposal\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a proposal\ncustom_field: anything\nbudget: 5000\n---\n\n# Proposal\n",
6172 );
6173 let issues = fx.store_all();
6174 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
6175 assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
6176 assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
6177 assert!(
6179 !issues
6180 .iter()
6181 .any(|i| i.key.as_deref() == Some("custom_field")
6182 || i.key.as_deref() == Some("budget")),
6183 "unknown fields are ambient context: {issues:#?}"
6184 );
6185 }
6186
6187 #[test]
6190 fn incoming_linker_scan_does_not_prefix_match() {
6191 let fx = Fixture::new();
6194 fx.write(
6195 "records/profiles/only-sarah-chen.md",
6196 "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nSee [[records/contacts/sarah-chen]].\n",
6197 );
6198 fx.write(
6200 "log.md",
6201 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
6202 );
6203 let issues = validate_working_set(&fx.store(), None).unwrap();
6204 assert!(
6205 !issues
6206 .iter()
6207 .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
6208 "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
6209 );
6210 }
6211
6212 #[test]
6213 fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
6214 let fx = Fixture::new();
6228 fx.write(
6231 "records/contacts/index.md",
6232 "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
6233 );
6234 fx.write(
6236 "log.md",
6237 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
6238 );
6239 let issues = validate_working_set(&fx.store(), None).unwrap();
6240 assert!(
6241 !issues
6242 .iter()
6243 .any(|i| i.file == Path::new("records/contacts/index.md")
6244 && i.code == codes::WIKI_LINK_BROKEN),
6245 "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
6246 working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
6247 );
6248 }
6249
6250 #[test]
6251 fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
6252 let fx = Fixture::new();
6261 fx.write(
6263 "records/profiles/refers-sarah.md",
6264 "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nSee [[records/contacts/sarah-chen]].\n",
6265 );
6266 fx.write(
6270 "records/meetings/2026/05/kickoff.md",
6271 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\ncompany: \"[[records/companies/acme]]\"\n---\n\n# Kickoff\n",
6272 );
6273 fx.write(
6275 "log.md",
6276 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n\n## [2026-05-22 10:05] delete | records/companies/acme\nremoved\n",
6277 );
6278
6279 let issues = validate_working_set(&fx.store(), None).unwrap();
6280 assert!(
6281 issues
6282 .iter()
6283 .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
6284 && i.code == codes::WIKI_LINK_BROKEN),
6285 "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
6286 );
6287 assert!(
6288 issues.iter().any(
6289 |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
6290 && i.code == codes::WIKI_LINK_BROKEN
6291 ),
6292 "linker to the SECOND deleted target (typed-field edge) must also be \
6293 pulled in and flagged — proves the scan covers the whole changed set, \
6294 not just one object: {issues:#?}"
6295 );
6296 }
6297
6298 #[test]
6299 fn frontmatter_block_sequence_links_each_get_their_own_line() {
6300 let fx = Fixture::new();
6302 fx.write(
6304 "records/meetings/m.md",
6305 "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\nparticipants:\n - [[records/contacts/ghost1]]\n - [[records/contacts/ghost2]]\n---\n\n# M\n",
6306 );
6307 let issues = fx.store_all();
6308 let broken_lines: BTreeSet<Option<u32>> = issues
6309 .iter()
6310 .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
6311 .map(|i| i.line)
6312 .collect();
6313 assert_eq!(
6314 broken_lines.len(),
6315 2,
6316 "two distinct broken-link lines: {issues:#?}"
6317 );
6318 }
6319
6320 #[test]
6323 fn null_created_is_missing_not_silently_passed() {
6324 let fx = Fixture::new();
6328 fx.write(
6329 "records/contacts/a.md",
6330 "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6331 );
6332 let issues = fx.store_all();
6333 assert!(
6334 has(&issues, codes::FM_MISSING_CREATED),
6335 "null `created:` must read as missing: {issues:#?}"
6336 );
6337 }
6338
6339 #[test]
6340 fn sequence_created_is_bad_timestamp() {
6341 let fx = Fixture::new();
6343 fx.write(
6344 "records/contacts/a.md",
6345 "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6346 );
6347 let issues = fx.store_all();
6348 assert!(
6349 issues
6350 .iter()
6351 .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6352 "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6353 );
6354 }
6355
6356 #[test]
6359 fn required_field_null_or_empty_collection_is_missing() {
6360 for value in ["", " []", " {}"] {
6365 let mut fx = Fixture::new();
6366 fx.config.schemas.insert(
6367 "contact".into(),
6368 Schema {
6369 fields: vec![FieldSpec {
6370 name: "name".into(),
6371 required: true,
6372 ..Default::default()
6373 }],
6374 ..Default::default()
6375 },
6376 );
6377 fx.write(
6378 "records/contacts/a.md",
6379 &format!(
6380 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname:{value}\n---\n\n# A\n"
6381 ),
6382 );
6383 let issues = fx.store_all();
6384 assert!(
6385 issues
6386 .iter()
6387 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6388 && i.key.as_deref() == Some("name")),
6389 "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6390 );
6391 }
6392 }
6393
6394 #[test]
6397 fn wiki_link_to_raw_source_file_resolves() {
6398 let fx = Fixture::new();
6402 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6403 fx.write(
6404 "records/contacts/a.md",
6405 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\nSee [[sources/emails/2026-05-22-elena.eml]] for context.\n",
6406 );
6407 let issues = fx.store_all();
6408 assert!(
6409 !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6410 "a link to an existing raw source file must not be broken: {issues:#?}"
6411 );
6412 }
6413
6414 #[test]
6417 fn wrong_case_wiki_link_is_broken_exact_case() {
6418 let fx = Fixture::new();
6424 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6425 let mut body = valid_contact("links with the wrong case");
6426 body.push_str("\nKnows [[records/contacts/BOB]].\n");
6427 fx.write("records/contacts/alice.md", &body);
6428 let issues = fx.store_all();
6429 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6430 assert!(issue.is_error());
6431 assert!(
6432 issue.message.contains("records/contacts/BOB"),
6433 "the wrong-case target must be named in the issue: {issues:#?}"
6434 );
6435 }
6436
6437 #[test]
6438 fn correct_case_wiki_link_still_resolves() {
6439 let fx = Fixture::new();
6443 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6444 let mut body = valid_contact("links with the right case");
6445 body.push_str("\nKnows [[records/contacts/bob]].\n");
6446 fx.write("records/contacts/alice.md", &body);
6447 let issues = fx.store_all();
6448 assert!(
6449 !issues
6450 .iter()
6451 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("contacts/bob")),
6452 "a correct-case link must resolve clean: {issues:#?}"
6453 );
6454 }
6455
6456 #[test]
6457 fn wrong_case_raw_source_wiki_link_is_broken() {
6458 let fx = Fixture::new();
6463 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6464 fx.write(
6465 "records/contacts/a.md",
6466 "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\nSee [[sources/emails/2026-05-22-ELENA.eml]] for context.\n",
6467 );
6468 let issues = fx.store_all();
6469 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6470 assert!(issue.is_error());
6471 assert!(
6472 issue.message.contains("2026-05-22-ELENA.eml"),
6473 "the wrong-case raw-source target must be flagged: {issues:#?}"
6474 );
6475 }
6476
6477 #[test]
6480 fn non_utf8_content_file_is_reported() {
6481 let fx = Fixture::new();
6485 let abs = fx.dir.path().join("records/notes/corrupt.md");
6486 fs::create_dir_all(abs.parent().unwrap()).unwrap();
6487 fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6488 let issues = validate_working_set(&fx.store(), None).unwrap();
6489 assert!(
6490 has(&issues, codes::FM_UNREADABLE),
6491 "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6492 );
6493 }
6494
6495 #[test]
6498 fn tilde_fence_containing_backtick_fence_does_not_invert() {
6499 let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6504 let links = extract_wiki_links(body);
6505 assert!(
6506 links.is_empty(),
6507 "wiki-link inside a nested code fence must be skipped: {links:?}"
6508 );
6509 }
6510
6511 #[test]
6514 fn all_sweep_visits_in_layer_log_folder() {
6515 let fx = Fixture::new();
6520 fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6521 let issues = fx.store_all();
6522 assert!(
6523 has(&issues, codes::FM_MISSING_TYPE),
6524 "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6525 );
6526 }
6527
6528 #[test]
6531 fn flow_form_link_list_with_spaces_is_flagged() {
6532 let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6536 assert!(
6537 keys.iter().any(|k| k == "attendees"),
6538 "spaced flow-form list must be detected: {keys:?}"
6539 );
6540 }
6541
6542 #[test]
6545 fn middot_hashtag_summary_tail_round_trips() {
6546 assert_eq!(
6552 extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6553 Some("Standup notes · #standup"),
6554 "a single-spaced middot tail is part of the summary, not a tag block"
6555 );
6556 assert_eq!(
6558 extract_index_entry_summary("— Renewal champion · #renewal #acme").as_deref(),
6559 Some("Renewal champion"),
6560 "the renderer's double-spaced ` · #tag` suffix is stripped"
6561 );
6562 }
6563
6564 #[test]
6567 fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6568 assert!(is_url("http://x"), "an 8-char http URL is valid");
6569 assert!(is_url("https://x"), "a 9-char https URL is valid");
6570 assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6571 assert!(!is_url("https://"), "a bare https scheme is rejected");
6572 }
6573
6574 #[test]
6575 fn email_shape_rejects_double_at() {
6576 assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6577 assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6578 assert!(is_email("sarah@acme.com"), "a normal address still passes");
6579 }
6580
6581 #[test]
6584 fn working_set_does_not_flag_log_md_body_links() {
6585 let fx = Fixture::new();
6591 fx.write("records/contacts/a.md", &valid_contact("A"));
6592 fx.write(
6593 "log.md",
6594 "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6595 );
6596 let issues = validate_working_set(&fx.store(), None).unwrap();
6597 assert!(
6598 !issues
6599 .iter()
6600 .any(|i| i.code == codes::WIKI_LINK_BROKEN
6601 && i.file == std::path::Path::new("log.md")),
6602 "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6603 );
6604 }
6605
6606 #[test]
6609 fn schema_duplicate_field_name_is_flagged() {
6610 let mut fx = Fixture::new();
6611 fx.config.schemas.insert(
6612 "contact".into(),
6613 Schema {
6614 fields: vec![
6615 FieldSpec {
6616 name: "name".into(),
6617 required: true,
6618 ..Default::default()
6619 },
6620 FieldSpec {
6621 name: "name".into(),
6622 ..Default::default()
6623 },
6624 ],
6625 ..Default::default()
6626 },
6627 );
6628 let issues = fx.store_all();
6629 assert!(
6630 issues
6631 .iter()
6632 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6633 "a duplicate schema field name must be flagged: {issues:#?}"
6634 );
6635 }
6636
6637 #[test]
6638 fn schema_unknown_modifier_is_info() {
6639 let mut fx = Fixture::new();
6640 fx.config.schemas.insert(
6641 "contact".into(),
6642 Schema {
6643 fields: vec![FieldSpec {
6644 name: "name".into(),
6645 unknown_modifiers: vec!["requierd".into()],
6646 ..Default::default()
6647 }],
6648 ..Default::default()
6649 },
6650 );
6651 let issues = fx.store_all();
6652 assert!(
6653 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6654 && i.severity == Severity::Info
6655 && i.key.as_deref() == Some("name")),
6656 "an unrecognized schema modifier must surface as Info: {issues:#?}"
6657 );
6658 }
6659
6660 #[test]
6666 fn schema_unique_key_optional_field_is_warning() {
6667 let mut fx = Fixture::new();
6668 fx.config.schemas.insert(
6669 "expense".into(),
6670 Schema {
6671 fields: vec![
6672 FieldSpec {
6673 name: "date".into(),
6674 required: true,
6675 ..Default::default()
6676 },
6677 FieldSpec {
6678 name: "amount".into(),
6679 required: true,
6680 ..Default::default()
6681 },
6682 FieldSpec {
6683 name: "vendor".into(),
6684 ..Default::default()
6685 },
6686 ],
6687 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
6688 ..Default::default()
6689 },
6690 );
6691 let issues = fx.store_all();
6692 assert!(
6693 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6694 && i.severity == Severity::Warning
6695 && i.key.as_deref() == Some("vendor")
6696 && i.message.contains("unique")),
6697 "a `unique:` key field not marked required must warn: {issues:#?}"
6698 );
6699 assert!(
6701 !issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6702 && matches!(i.key.as_deref(), Some("date") | Some("amount"))),
6703 "required key fields must not warn: {issues:#?}"
6704 );
6705 }
6706
6707 #[test]
6710 fn schema_unique_key_undeclared_field_is_warning() {
6711 let mut fx = Fixture::new();
6712 fx.config.schemas.insert(
6713 "expense".into(),
6714 Schema {
6715 fields: vec![FieldSpec {
6716 name: "date".into(),
6717 required: true,
6718 ..Default::default()
6719 }],
6720 unique_keys: vec![vec!["date".into(), "vendor".into()]],
6721 ..Default::default()
6722 },
6723 );
6724 let issues = fx.store_all();
6725 assert!(
6726 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6727 && i.severity == Severity::Warning
6728 && i.key.as_deref() == Some("vendor")
6729 && i.message.contains("not declared")),
6730 "a `unique:` key field absent from the schema must warn: {issues:#?}"
6731 );
6732 }
6733
6734 #[test]
6736 fn schema_unique_key_all_required_is_clean() {
6737 let mut fx = Fixture::new();
6738 fx.config.schemas.insert(
6739 "expense".into(),
6740 Schema {
6741 fields: vec![
6742 FieldSpec {
6743 name: "date".into(),
6744 required: true,
6745 ..Default::default()
6746 },
6747 FieldSpec {
6748 name: "amount".into(),
6749 required: true,
6750 ..Default::default()
6751 },
6752 ],
6753 unique_keys: vec![vec!["date".into(), "amount".into()]],
6754 ..Default::default()
6755 },
6756 );
6757 let issues = fx.store_all();
6758 assert!(
6759 !issues
6760 .iter()
6761 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.message.contains("unique")),
6762 "an all-required unique key must not warn: {issues:#?}"
6763 );
6764 }
6765
6766 #[test]
6772 fn every_code_constant_is_documented_in_spec() {
6773 let this_src = include_str!("validate.rs");
6777 let mut codes_in_module: Vec<String> = Vec::new();
6778 let mut in_codes_mod = false;
6779 for line in this_src.lines() {
6780 let t = line.trim();
6781 if t.starts_with("pub mod codes") {
6782 in_codes_mod = true;
6783 continue;
6784 }
6785 if in_codes_mod && line == "}" {
6787 break;
6788 }
6789 if in_codes_mod {
6790 if let Some(rest) = t.strip_prefix("pub const ") {
6791 let value = rest
6793 .split_once('=')
6794 .map(|(_, v)| v.trim())
6795 .and_then(|v| v.strip_prefix('"'))
6796 .and_then(|v| v.strip_suffix("\";"))
6797 .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6798 codes_in_module.push(value.to_string());
6799 }
6800 }
6801 }
6802 assert!(
6803 codes_in_module.len() >= 36,
6804 "parsed only {} code constants from `mod codes`; the parser likely \
6805 broke against a source-format change",
6806 codes_in_module.len()
6807 );
6808
6809 let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6811 let spec = fs::read_to_string(&spec_path)
6812 .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6813
6814 let missing: Vec<&String> = codes_in_module
6816 .iter()
6817 .filter(|code| !spec.contains(&format!("| `{code}` |")))
6818 .collect();
6819 assert!(
6820 missing.is_empty(),
6821 "validation codes emitted by the engine but absent from SPEC.md \
6822 § Validation (the declared complete vocabulary): {missing:?}"
6823 );
6824 }
6825
6826 const LOOSE_ALICE: &str = "---\ntype: contact\nid: alice\ncreated: 2026-06-01T08:00:00-07:00\nupdated: 2026-06-01T08:00:00-07:00\nsummary: Alice\n---\nbody\n";
6829 const LOOSE_BOB: &str = "---\ntype: contact\nid: bob\ncreated: 2026-06-01T08:00:00-07:00\nupdated: 2026-06-01T08:00:00-07:00\nsummary: Bob loose\n---\nbody\n";
6830
6831 #[test]
6832 fn loose_file_catalogued_in_layer_jsonl_validates_clean() {
6833 let fx = Fixture::new();
6834 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6835 fx.write("records/bob.md", LOOSE_BOB); fx.rebuild_indexes();
6837 let issues = fx.store_all();
6838 assert!(
6839 issues.is_empty(),
6840 "a rebuilt store with a catalogued loose file must validate clean, got: {issues:?}"
6841 );
6842 }
6843
6844 #[test]
6845 fn loose_file_with_missing_layer_jsonl_is_index_jsonl_missing() {
6846 let fx = Fixture::new();
6847 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6848 fx.write("records/bob.md", LOOSE_BOB);
6849 fx.rebuild_indexes();
6850 fs::remove_file(fx.dir.path().join("records/index.jsonl")).unwrap();
6852 let issues = fx.store_all();
6853 assert!(
6854 has(&issues, codes::INDEX_JSONL_MISSING),
6855 "a loose file with no layer index.jsonl must raise INDEX_JSONL_MISSING, got: {issues:?}"
6856 );
6857 }
6858}