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 NESTED_STORE: &str = "NESTED_STORE";
101 pub const DB_MD_BAD_TYPE: &str = "DB_MD_BAD_TYPE";
103 pub const DB_MD_MISSING_FIELD: &str = "DB_MD_MISSING_FIELD";
105 pub const DB_MD_UNKNOWN_SECTION: &str = "DB_MD_UNKNOWN_SECTION";
107 pub const DB_MD_SCHEMA_FIELD: &str = "DB_MD_SCHEMA_FIELD";
110 pub const FM_MISSING_TYPE: &str = "FM_MISSING_TYPE";
112 pub const FM_MISSING_CREATED: &str = "FM_MISSING_CREATED";
114 pub const FM_MISSING_UPDATED: &str = "FM_MISSING_UPDATED";
116 pub const FM_UNREADABLE: &str = "FM_UNREADABLE";
118 pub const FM_MALFORMED_YAML: &str = "FM_MALFORMED_YAML";
120 pub const FM_BAD_TIMESTAMP: &str = "FM_BAD_TIMESTAMP";
122 pub const FM_BAD_META_TYPE: &str = "FM_BAD_META_TYPE";
124 pub const FM_BAD_ID: &str = "FM_BAD_ID";
128 pub const FM_IN_BODY: &str = "FM_IN_BODY";
133 pub const SUMMARY_MISSING: &str = "SUMMARY_MISSING";
135 pub const SUMMARY_EMPTY: &str = "SUMMARY_EMPTY";
137 pub const SUMMARY_MULTILINE: &str = "SUMMARY_MULTILINE";
139 pub const SUMMARY_TOO_LONG: &str = "SUMMARY_TOO_LONG";
141 pub const WIKI_LINK_SHORT_FORM: &str = "WIKI_LINK_SHORT_FORM";
143 pub const WIKI_LINK_BROKEN: &str = "WIKI_LINK_BROKEN";
145 pub const WIKI_LINK_AMBIGUOUS: &str = "WIKI_LINK_AMBIGUOUS";
147 pub const WIKI_LINK_HAS_EXTENSION: &str = "WIKI_LINK_HAS_EXTENSION";
149 pub const WIKI_LINK_FLOW_FORM_LIST: &str = "WIKI_LINK_FLOW_FORM_LIST";
151 pub const DUP_ID: &str = "DUP_ID";
153 pub const DUP_UNIQUE_KEY: &str = "DUP_UNIQUE_KEY";
155 pub const SCHEMA_MISSING_REQUIRED: &str = "SCHEMA_MISSING_REQUIRED";
157 pub const SCHEMA_SHAPE_MISMATCH: &str = "SCHEMA_SHAPE_MISMATCH";
159 pub const SCHEMA_LINK_PREFIX_MISMATCH: &str = "SCHEMA_LINK_PREFIX_MISMATCH";
161 pub const SCHEMA_ENUM_VIOLATION: &str = "SCHEMA_ENUM_VIOLATION";
163 pub const POLICY_FROZEN_PAGE: &str = "POLICY_FROZEN_PAGE";
165 pub const POLICY_IGNORED_TYPE_PRESENT: &str = "POLICY_IGNORED_TYPE_PRESENT";
167 pub const POLICY_IGNORED_TYPE_DERIVED: &str = "POLICY_IGNORED_TYPE_DERIVED";
169 pub const LOG_BAD_TIMESTAMP: &str = "LOG_BAD_TIMESTAMP";
171 pub const LOG_UNKNOWN_KIND: &str = "LOG_UNKNOWN_KIND";
173 pub const LOG_OUT_OF_ORDER: &str = "LOG_OUT_OF_ORDER";
175 pub const INDEX_MISSING: &str = "INDEX_MISSING";
177 pub const INDEX_STALE_ENTRY: &str = "INDEX_STALE_ENTRY";
179 pub const INDEX_MISSING_ENTRY: &str = "INDEX_MISSING_ENTRY";
181 pub const INDEX_ORPHAN: &str = "INDEX_ORPHAN";
183 pub const INDEX_WRONG_SCOPE: &str = "INDEX_WRONG_SCOPE";
185 pub const INDEX_SUMMARY_MISMATCH: &str = "INDEX_SUMMARY_MISMATCH";
187 pub const INDEX_JSONL_MISSING: &str = "INDEX_JSONL_MISSING";
189 pub const INDEX_JSONL_DESYNC: &str = "INDEX_JSONL_DESYNC";
192 pub const INDEX_JSONL_STALE: &str = "INDEX_JSONL_STALE";
194 pub const TAGS_MALFORMED: &str = "TAGS_MALFORMED";
196 pub const ASSET_MANIFEST_MALFORMED: &str = "ASSET_MANIFEST_MALFORMED";
198 pub const ASSET_UNDECLARED: &str = "ASSET_UNDECLARED";
201 pub const ASSET_WRAPPER_BROKEN: &str = "ASSET_WRAPPER_BROKEN";
203 pub const ASSET_MANIFEST_ORPHAN: &str = "ASSET_MANIFEST_ORPHAN";
205 pub const ASSET_PATH_IS_CONTENT: &str = "ASSET_PATH_IS_CONTENT";
207}
208
209const MAX_SUMMARY_LEN: usize = 200;
211
212const RECOGNIZED_LOG_KINDS: &[&str] = &[
215 "ingest",
216 "create",
217 "update",
218 "delete",
219 "rename",
220 "link",
221 "validate",
222 "index-rebuild",
223 "contradiction",
224];
225
226pub fn validate_working_set(
252 store: &Store,
253 since: Option<DateTime<FixedOffset>>,
254) -> crate::Result<Vec<Issue>> {
255 if !store_marker_present(store) {
256 return Ok(vec![not_a_store_issue(store)]);
257 }
258
259 let cutoff = match since {
260 Some(ts) => Some(ts),
261 None => last_validate_at(store),
262 };
263
264 let changed = changed_objects_since(store, cutoff);
266 if changed.is_empty() && since.is_none() {
267 return validate_content_sweep(store);
268 }
269
270 let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
281 let mut working: BTreeSet<PathBuf> = changed;
282 for linker in store.find_links_to_any(&changed_targets)? {
283 working.insert(linker);
284 }
285
286 let mut issues = nested_store_issues(store)?;
287 for rel in &working {
288 if !store.regular_file_exists(rel).unwrap_or(false) {
291 continue;
292 }
293 check_content_file(store, rel, None, &mut issues);
298 }
299 issues.sort_by(issue_order);
300 Ok(issues)
301}
302
303fn validate_content_sweep(store: &Store) -> crate::Result<Vec<Issue>> {
304 let mut issues = nested_store_issues(store)?;
305 for rel in store.walk()? {
306 check_content_file(store, &rel, None, &mut issues);
307 }
308 issues.sort_by(issue_order);
309 Ok(issues)
310}
311
312fn nested_store_issues(store: &Store) -> crate::Result<Vec<Issue>> {
316 let mut issues = Vec::new();
317 for nested in store.nested_store_roots()? {
318 let marker = nested.join("DB.md");
319 push(
320 &mut issues,
321 Severity::Error,
322 codes::NESTED_STORE,
323 &marker,
324 None,
325 None,
326 format!(
327 "`{}` is a db.md store nested inside this store",
328 nested.display()
329 ),
330 Some(
331 "move the nested store outside this store, or run dbmd from the nested root"
332 .to_string(),
333 ),
334 vec![],
335 );
336 }
337 Ok(issues)
338}
339
340pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
345 if !store_marker_present(store) {
346 return Ok(vec![not_a_store_issue(store)]);
347 }
348
349 let mut issues = nested_store_issues(store)?;
350
351 check_db_md(store, &mut issues);
355
356 let files = store.walk()?;
357
358 let basenames = build_basename_index(&files);
363
364 let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
366 for rel in &files {
367 if let Some(p) = check_content_file(store, rel, Some(&basenames), &mut issues) {
368 parsed.push((rel.clone(), p));
369 }
370 }
371
372 check_duplicates(store, &parsed, &mut issues);
374
375 check_indexes(store, &files, &mut issues);
377
378 check_log(store, &mut issues);
380
381 check_assets(store, &parsed, &mut issues);
386
387 issues.sort_by(issue_order);
388 Ok(issues)
389}
390
391struct Parsed {
400 fm: Option<BTreeMap<String, Value>>,
403 fm_yaml: String,
406}
407
408fn check_content_file(
413 store: &Store,
414 rel: &Path,
415 basenames: Option<&BasenameIndex>,
416 issues: &mut Vec<Issue>,
417) -> Option<Parsed> {
418 let text = match store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) {
419 Ok(t) => t,
420 Err(e) => {
421 let detail = if e.kind() == std::io::ErrorKind::InvalidData {
429 "file is not valid UTF-8 text".to_string()
430 } else {
431 format!("file could not be read: {e}")
432 };
433 push(
434 issues,
435 Severity::Error,
436 codes::FM_UNREADABLE,
437 rel,
438 None,
439 None,
440 format!("content file is unreadable: {detail}"),
441 Some(
442 "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
443 .into(),
444 ),
445 vec![],
446 );
447 return None;
448 }
449 };
450
451 let is_content = is_content_file(rel);
452
453 let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
454 Some(split) => split,
455 None => {
456 if is_content {
460 push(
461 issues,
462 Severity::Error,
463 codes::FM_MISSING_TYPE,
464 rel,
465 None,
466 Some("type".into()),
467 "content file has no frontmatter `type:`".into(),
468 Some("add a YAML frontmatter block with `type:`".into()),
469 vec![],
470 );
471 push(
472 issues,
473 Severity::Error,
474 codes::SUMMARY_MISSING,
475 rel,
476 None,
477 Some("summary".into()),
478 "content file has no `summary`".into(),
479 Some("run `dbmd fm init`".into()),
480 vec![],
481 );
482 }
483 return None;
484 }
485 };
486
487 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
489 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
490 Ok(Value::Null) => Some(BTreeMap::new()),
492 Ok(_) => {
493 push(
497 issues,
498 Severity::Error,
499 codes::FM_MALFORMED_YAML,
500 rel,
501 Some(1),
502 None,
503 "frontmatter is not a YAML mapping".into(),
504 Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
505 vec![],
506 );
507 None
508 }
509 Err(e) => {
510 push(
513 issues,
514 Severity::Error,
515 codes::FM_MALFORMED_YAML,
516 rel,
517 Some(1),
518 None,
519 format!("frontmatter block isn't valid YAML: {e}"),
520 Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
521 vec![],
522 );
523 None
524 }
525 };
526
527 if let Some(map) = &fm {
528 check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
530 }
531
532 if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
554 check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
555 }
556
557 if is_content && body_opens_with_frontmatter(&body) {
564 push(
565 issues,
566 Severity::Warning,
567 codes::FM_IN_BODY,
568 rel,
569 Some(fm_end_line + 1),
570 None,
571 "the body opens with a second `---` frontmatter block; the record's \
572 frontmatter is the block at the top of the file, so this one is body \
573 text (usually an imported file's own frontmatter left in place)"
574 .into(),
575 Some(
576 "delete the leftover `---…---` block from the body, or move its \
577 fields into the record's frontmatter"
578 .into(),
579 ),
580 vec![],
581 );
582 }
583
584 Some(Parsed { fm, fm_yaml })
585}
586
587fn check_frontmatter(
589 store: &Store,
590 rel: &Path,
591 fm: &BTreeMap<String, Value>,
592 fm_yaml: &str,
593 basenames: Option<&BasenameIndex>,
594 issues: &mut Vec<Issue>,
595 is_content: bool,
596) {
597 let type_ = fm.get("type").and_then(scalar_string);
598
599 if is_content && type_.is_none() {
601 push(
602 issues,
603 Severity::Error,
604 codes::FM_MISSING_TYPE,
605 rel,
606 fm_key_line_or_top(fm_yaml, "type"),
607 Some("type".into()),
608 "content file has no `type:`".into(),
609 Some("add a `type:` field (e.g. `type: contact`)".into()),
610 vec![],
611 );
612 }
613
614 if is_content {
619 if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
628 match scalar_string(v) {
629 Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
630 Some(mt) => push(
631 issues,
632 Severity::Error,
633 codes::FM_BAD_META_TYPE,
634 rel,
635 fm_key_line_or_top(fm_yaml, "meta-type"),
636 Some("meta-type".into()),
637 format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
638 Some(
639 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
640 .into(),
641 ),
642 vec![],
643 ),
644 None => push(
645 issues,
646 Severity::Error,
647 codes::FM_BAD_META_TYPE,
648 rel,
649 fm_key_line_or_top(fm_yaml, "meta-type"),
650 Some("meta-type".into()),
651 "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
652 string, found a list or mapping"
653 .to_string(),
654 Some(
655 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
656 .into(),
657 ),
658 vec![],
659 ),
660 }
661 }
662 }
663
664 if is_content {
675 if let Some(v) = fm.get("id").filter(|v| !v.is_null()) {
676 let problem = match scalar_string(v) {
677 Some(id) if id.trim().is_empty() => Some("`id` is empty".to_string()),
678 Some(id) if id.chars().any(char::is_whitespace) => {
679 Some(format!("`id` {id:?} contains whitespace"))
680 }
681 Some(_) => None,
682 None => Some(
683 "`id` is not a scalar (found a list or mapping), so duplicate detection \
684 (DUP_ID) cannot see it"
685 .to_string(),
686 ),
687 };
688 if let Some(message) = problem {
689 push(
690 issues,
691 Severity::Warning,
692 codes::FM_BAD_ID,
693 rel,
694 fm_key_line_or_top(fm_yaml, "id"),
695 Some("id".into()),
696 message,
697 Some(
698 "use one opaque token with no whitespace — the recommended form is a \
699 lowercase ULID (`dbmd write` mints one) — or drop `id` to fall back to \
700 filename identity"
701 .into(),
702 ),
703 vec![],
704 );
705 }
706 }
707 }
708
709 if is_content {
711 check_summary(rel, fm, fm_yaml, issues);
712 }
713
714 if is_content {
718 for (key, missing_code) in [
719 ("created", codes::FM_MISSING_CREATED),
720 ("updated", codes::FM_MISSING_UPDATED),
721 ] {
722 let value = fm.get(key);
727 let missing = value.is_none() || value.is_some_and(Value::is_null);
728 if missing {
729 push(
730 issues,
731 Severity::Error,
732 missing_code,
733 rel,
734 fm_key_line_or_top(fm_yaml, key),
735 Some(key.into()),
736 format!("content file has no `{key}:` timestamp"),
737 Some(format!(
738 "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
739 )),
740 vec![],
741 );
742 } else if let Some(v) = value {
743 match scalar_string(v) {
749 Some(s) if is_iso8601(&s) => {}
750 Some(s) => push(
751 issues,
752 Severity::Error,
753 codes::FM_BAD_TIMESTAMP,
754 rel,
755 fm_key_line(fm_yaml, key),
756 Some(key.into()),
757 format!("`{key}` is not ISO-8601: {s:?}"),
758 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
759 vec![],
760 ),
761 None => push(
762 issues,
763 Severity::Error,
764 codes::FM_BAD_TIMESTAMP,
765 rel,
766 fm_key_line(fm_yaml, key),
767 Some(key.into()),
768 format!(
769 "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
770 ),
771 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
772 vec![],
773 ),
774 }
775 }
776 }
777 }
778 if let Some(tags) = fm.get("tags") {
780 if !is_flat_scalar_list(tags) {
781 push(
782 issues,
783 Severity::Warning,
784 codes::TAGS_MALFORMED,
785 rel,
786 fm_key_line(fm_yaml, "tags"),
787 Some("tags".into()),
788 "`tags` must be a flat YAML list of short scalar labels".into(),
789 Some("use block form: one `- <tag>` per line".into()),
790 vec![],
791 );
792 }
793 }
794
795 for key in detect_flow_form_link_lists(fm_yaml) {
797 push(
798 issues,
799 Severity::Error,
800 codes::WIKI_LINK_FLOW_FORM_LIST,
801 rel,
802 fm_key_line(fm_yaml, &key),
803 Some(key.clone()),
804 format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
805 Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
806 vec![],
807 );
808 }
809
810 let schema_link_keys: BTreeSet<String> =
815 effective_schema(store, type_.as_deref().unwrap_or(""))
816 .map(|s| {
817 s.fields
818 .iter()
819 .filter(|f| f.link_prefix.is_some())
820 .map(|f| f.name.clone())
821 .collect()
822 })
823 .unwrap_or_default();
824 for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
825 if schema_link_keys.contains(&key) {
826 continue;
827 }
828 check_wiki_link(
829 store,
830 rel,
831 &link,
832 Some(link.line),
833 Some(&key),
834 basenames,
835 issues,
836 );
837 }
838
839 if let Some(t) = &type_ {
841 if store.config.ignored_types.iter().any(|it| it == t) {
842 push(
843 issues,
844 Severity::Info,
845 codes::POLICY_IGNORED_TYPE_PRESENT,
846 rel,
847 fm_key_line(fm_yaml, "type"),
848 Some("type".into()),
849 format!("file has ignored type `{t}` (per DB.md ## Policies)"),
850 Some(
851 "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
852 .into(),
853 ),
854 vec![PathBuf::from("DB.md")],
856 );
857 }
858 let meta_type = fm
864 .get("meta-type")
865 .and_then(scalar_string)
866 .unwrap_or_else(|| "fact".to_string());
867 for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
868 if let Some(hit) =
869 derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
870 {
871 push(
872 issues,
873 Severity::Warning,
874 codes::POLICY_IGNORED_TYPE_DERIVED,
875 rel,
876 Some(link.line),
877 Some("derived_from".into()),
878 format!(
879 "conclusion record derives from ignored-type record `{}` (type `{}`)",
880 hit.target, hit.target_type
881 ),
882 Some(
883 "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
884 .into(),
885 ),
886 vec![
889 PathBuf::from(format!("{}.md", hit.target)),
890 PathBuf::from("DB.md"),
891 ],
892 );
893 }
894 }
895 }
896
897 if let Some(t) = &type_ {
899 if let Some(schema) = effective_schema(store, t) {
900 check_schema(store, rel, fm, fm_yaml, &schema, issues);
901 }
902 }
903}
904
905fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
907 let line = fm_key_line(fm_yaml, "summary");
908 match fm.get("summary") {
909 None => push(
910 issues,
911 Severity::Error,
912 codes::SUMMARY_MISSING,
913 rel,
914 fm_key_line_or_top(fm_yaml, "summary"),
917 Some("summary".into()),
918 "content file has no `summary`".into(),
919 Some("run `dbmd fm init`".into()),
920 vec![],
921 ),
922 Some(v) => {
923 let s = scalar_string(v).unwrap_or_default();
924 if s.trim().is_empty() {
925 push(
926 issues,
927 Severity::Error,
928 codes::SUMMARY_EMPTY,
929 rel,
930 line,
931 Some("summary".into()),
932 "`summary` is present but empty".into(),
933 Some("write a one-line summary, or run `dbmd fm init`".into()),
934 vec![],
935 );
936 } else if s.contains('\n') {
937 push(
938 issues,
939 Severity::Error,
940 codes::SUMMARY_MULTILINE,
941 rel,
942 line,
943 Some("summary".into()),
944 "`summary` must be one line (contains a newline)".into(),
945 Some("collapse the summary to a single line".into()),
946 vec![],
947 );
948 } else if s.chars().count() > MAX_SUMMARY_LEN {
949 push(
950 issues,
951 Severity::Warning,
952 codes::SUMMARY_TOO_LONG,
953 rel,
954 line,
955 Some("summary".into()),
956 format!(
957 "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
958 s.chars().count()
959 ),
960 Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
961 vec![],
962 );
963 }
964 }
965 }
966}
967
968fn check_body_wiki_links(
970 store: &Store,
971 rel: &Path,
972 body: &str,
973 fm_end_line: u32,
974 basenames: Option<&BasenameIndex>,
975 issues: &mut Vec<Issue>,
976) {
977 for link in extract_wiki_links(body) {
978 let abs_line = fm_end_line + link.line;
981 check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
982 }
983}
984
985type BasenameIndex = HashMap<String, Vec<PathBuf>>;
993
994fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
997 let mut idx: BasenameIndex = HashMap::new();
998 for rel in files {
999 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
1000 idx.entry(stem.to_string()).or_default().push(rel.clone());
1001 }
1002 }
1003 idx
1004}
1005
1006fn check_wiki_link(
1011 store: &Store,
1012 rel: &Path,
1013 link: &Link,
1014 line: Option<u32>,
1015 key: Option<&str>,
1016 basenames: Option<&BasenameIndex>,
1017 issues: &mut Vec<Issue>,
1018) {
1019 let bare = link.target.trim_end_matches(".md");
1020
1021 if !is_full_store_path(bare) {
1024 if !bare.contains('/') {
1029 if let Some(idx) = basenames {
1030 if let Some(matches) = idx.get(bare) {
1031 if matches.len() >= 2 {
1032 let mut related = matches.clone();
1033 related.sort();
1034 push(
1035 issues,
1036 Severity::Error,
1037 codes::WIKI_LINK_AMBIGUOUS,
1038 rel,
1039 line,
1040 key.map(str::to_string),
1041 format!(
1042 "short-form wiki-link `[[{}]]` matches multiple files",
1043 link.target
1044 ),
1045 Some("use the full store-relative path to disambiguate".into()),
1046 related,
1047 );
1048 return;
1049 }
1050 }
1051 }
1052 }
1053 push(
1054 issues,
1055 Severity::Error,
1056 codes::WIKI_LINK_SHORT_FORM,
1057 rel,
1058 line,
1059 key.map(str::to_string),
1060 format!(
1061 "wiki-link `[[{}]]` is not a full store-relative path",
1062 link.target
1063 ),
1064 short_form_suggestion(bare),
1065 vec![],
1066 );
1067 return;
1069 }
1070
1071 if link.target.ends_with(".md") {
1073 push(
1074 issues,
1075 Severity::Warning,
1076 codes::WIKI_LINK_HAS_EXTENSION,
1077 rel,
1078 line,
1079 key.map(str::to_string),
1080 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1081 Some(format!("drop the extension: [[{bare}]]")),
1082 vec![],
1083 );
1084 }
1085
1086 match resolve_wiki_target(store, bare) {
1091 TargetResolution::Exists => {}
1092 TargetResolution::Missing => push(
1093 issues,
1094 Severity::Error,
1095 codes::WIKI_LINK_BROKEN,
1096 rel,
1097 line,
1098 key.map(str::to_string),
1099 format!("wiki-link target `{bare}` doesn't exist"),
1100 Some(format!(
1101 "create `{bare}.md`, or point the link at an existing file"
1102 )),
1103 vec![],
1104 ),
1105 TargetResolution::Unsafe => push(
1106 issues,
1107 Severity::Error,
1108 codes::WIKI_LINK_BROKEN,
1109 rel,
1110 line,
1111 key.map(str::to_string),
1112 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1113 Some("use a full store-relative path under sources/ or records/".into()),
1114 vec![],
1115 ),
1116 }
1117}
1118
1119fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1130 store.config.schemas.get(type_).cloned()
1131}
1132
1133fn check_schema(
1135 store: &Store,
1136 rel: &Path,
1137 fm: &BTreeMap<String, Value>,
1138 fm_yaml: &str,
1139 schema: &Schema,
1140 issues: &mut Vec<Issue>,
1141) {
1142 for spec in &schema.fields {
1143 let present = fm.get(&spec.name);
1144 let line = fm_key_line(fm_yaml, &spec.name);
1145
1146 let is_empty = match present {
1154 None => true,
1155 Some(v) => is_empty_value(v),
1156 };
1157 if spec.required && is_empty {
1158 push(
1159 issues,
1160 Severity::Error,
1161 codes::SCHEMA_MISSING_REQUIRED,
1162 rel,
1163 fm_key_line_or_top(fm_yaml, &spec.name),
1166 Some(spec.name.clone()),
1167 format!("required field `{}` is absent or empty", spec.name),
1168 Some(format!("set `{}` to a non-empty value", spec.name)),
1169 vec![],
1170 );
1171 continue;
1172 }
1173 let Some(value) = present else { continue };
1174
1175 let value_empty = value.is_null()
1181 || scalar_string(value)
1182 .map(|s| s.trim().is_empty())
1183 .unwrap_or(false);
1184 if !spec.required && value_empty {
1185 continue;
1186 }
1187
1188 if let Some(prefix) = &spec.link_prefix {
1191 check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1192 continue; }
1194
1195 if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1202 push(
1203 issues,
1204 Severity::Error,
1205 codes::SCHEMA_SHAPE_MISMATCH,
1206 rel,
1207 line,
1208 Some(spec.name.clone()),
1209 format!(
1210 "`{}` must be a scalar value, found a list or mapping",
1211 spec.name
1212 ),
1213 Some(format!("set `{}` to a single scalar value", spec.name)),
1214 vec![],
1215 );
1216 continue;
1217 }
1218
1219 if let Some(allowed) = &spec.enum_values {
1221 if let Some(s) = scalar_string(value) {
1222 if !allowed.iter().any(|a| a == &s) {
1223 push(
1224 issues,
1225 Severity::Error,
1226 codes::SCHEMA_ENUM_VIOLATION,
1227 rel,
1228 line,
1229 Some(spec.name.clone()),
1230 format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1231 Some(format!("use one of: {}", allowed.join(", "))),
1232 vec![],
1233 );
1234 }
1235 }
1236 continue;
1237 }
1238
1239 if let Some(shape) = spec.shape {
1241 check_schema_shape(rel, &spec.name, value, shape, line, issues);
1242 }
1243 }
1244}
1245
1246fn check_schema_link(
1251 store: &Store,
1252 rel: &Path,
1253 field: &str,
1254 fm_yaml: &str,
1255 prefix: &Path,
1256 line: Option<u32>,
1257 issues: &mut Vec<Issue>,
1258) {
1259 let prefix_str = prefix.to_string_lossy();
1260 let prefix_str = prefix_str.trim_end_matches('/');
1261 let suggestion = |target_leaf: &str| {
1262 Some(format!(
1263 "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1264 ))
1265 };
1266
1267 let links = frontmatter_links_for_key(fm_yaml, field, 2);
1268 if links.is_empty() {
1269 let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1271 let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1272 let leaf = slugish(raw);
1273 push(
1274 issues,
1275 Severity::Error,
1276 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1277 rel,
1278 line,
1279 Some(field.to_string()),
1280 format!(
1281 "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1282 ),
1283 suggestion(&leaf),
1284 vec![],
1285 );
1286 return;
1287 }
1288
1289 for link in links {
1290 if link.target.ends_with(".md") {
1291 let bare = link.target.trim_end_matches(".md");
1292 push(
1293 issues,
1294 Severity::Warning,
1295 codes::WIKI_LINK_HAS_EXTENSION,
1296 rel,
1297 Some(link.line),
1298 Some(field.to_string()),
1299 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1300 Some(format!("drop the extension: [[{bare}]]")),
1301 vec![],
1302 );
1303 }
1304 let bare = link.target.trim_end_matches(".md");
1305 if !path_under_prefix(bare, prefix_str) {
1306 let leaf = bare.rsplit('/').next().unwrap_or(bare);
1307 push(
1308 issues,
1309 Severity::Error,
1310 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1311 rel,
1312 line,
1313 Some(field.to_string()),
1314 format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1315 suggestion(leaf),
1316 vec![],
1317 );
1318 } else {
1319 match resolve_wiki_target(store, bare) {
1324 TargetResolution::Exists => {}
1325 TargetResolution::Missing => push(
1326 issues,
1327 Severity::Error,
1328 codes::WIKI_LINK_BROKEN,
1329 rel,
1330 line,
1331 Some(field.to_string()),
1332 format!("wiki-link target `{bare}` doesn't exist"),
1333 Some(format!(
1334 "create `{bare}.md`, or point the link at an existing file"
1335 )),
1336 vec![],
1337 ),
1338 TargetResolution::Unsafe => push(
1339 issues,
1340 Severity::Error,
1341 codes::WIKI_LINK_BROKEN,
1342 rel,
1343 line,
1344 Some(field.to_string()),
1345 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1346 Some("use a full store-relative path under sources/ or records/".into()),
1347 vec![],
1348 ),
1349 }
1350 }
1351 }
1352}
1353
1354fn check_schema_shape(
1356 rel: &Path,
1357 field: &str,
1358 value: &Value,
1359 shape: Shape,
1360 line: Option<u32>,
1361 issues: &mut Vec<Issue>,
1362) {
1363 let s = scalar_string(value).unwrap_or_default();
1364 let ok = match shape {
1365 Shape::String => true, Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1367 Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1368 Shape::Date => is_iso8601_date_or_datetime(&s),
1369 Shape::Email => is_email(&s),
1370 Shape::Currency => is_currency(&s),
1371 Shape::Url => is_url(&s),
1372 };
1373 if !ok {
1374 push(
1375 issues,
1376 Severity::Error,
1377 codes::SCHEMA_SHAPE_MISMATCH,
1378 rel,
1379 line,
1380 Some(field.to_string()),
1381 format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1382 Some(shape_suggestion(shape)),
1383 vec![],
1384 );
1385 }
1386}
1387
1388fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1407 let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1410 .iter()
1411 .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1412 .collect();
1413
1414 let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1416 for (rel, p) in parsed {
1417 if let Some(map) = &p.fm {
1418 if let Some(id) = map.get("id").and_then(scalar_string) {
1419 if !id.trim().is_empty() {
1420 by_id.entry(id).or_default().push(rel.clone());
1421 }
1422 }
1423 }
1424 }
1425 for (id, files) in &by_id {
1426 if files.len() > 1 {
1427 let (reported, related) = canonical_and_related(files);
1428 let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1429 push(
1430 issues,
1431 Severity::Error,
1432 codes::DUP_ID,
1433 &reported,
1434 line,
1435 Some("id".into()),
1436 format!("id {id:?} is declared by more than one file"),
1437 Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1438 related,
1439 );
1440 }
1441 }
1442
1443 for (type_name, schema) in &store.config.schemas {
1448 for key_fields in &schema.unique_keys {
1449 soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1450 }
1451 }
1452}
1453
1454fn soft_dup(
1463 parsed: &[(PathBuf, Parsed)],
1464 issues: &mut Vec<Issue>,
1465 type_: &str,
1466 key_fields: &[String],
1467 fm_yaml_of: &HashMap<&PathBuf, &str>,
1468) {
1469 if key_fields.is_empty() {
1470 return;
1471 }
1472 let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1473 for (rel, p) in parsed {
1474 let is_type =
1475 p.fm.as_ref()
1476 .and_then(|m| m.get("type"))
1477 .and_then(scalar_string)
1478 .map(|t| t == type_)
1479 .unwrap_or(false);
1480 if !is_type {
1481 continue;
1482 }
1483 if let Some(key) = dedup_key(p, key_fields) {
1484 groups.entry(key).or_default().push(rel.clone());
1485 }
1486 }
1487 let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1490 .values()
1491 .filter(|files| files.len() > 1)
1492 .map(|files| canonical_and_related(files))
1493 .collect();
1494 collisions.sort_by(|a, b| a.0.cmp(&b.0));
1495
1496 let fields_disp = key_fields.join(", ");
1497 for (reported, related) in collisions {
1498 let (line, key) = if key_fields.len() == 1 {
1501 (
1502 fm_yaml_of
1503 .get(&reported)
1504 .and_then(|y| fm_key_line(y, &key_fields[0])),
1505 Some(key_fields[0].clone()),
1506 )
1507 } else {
1508 (Some(1), None)
1509 };
1510 let n = related.len();
1511 push(
1512 issues,
1513 Severity::Warning,
1514 codes::DUP_UNIQUE_KEY,
1515 &reported,
1516 line,
1517 key,
1518 format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1519 Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1520 related,
1521 );
1522 }
1523}
1524
1525fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1529 let mut out = Vec::with_capacity(key_fields.len());
1530 for f in key_fields {
1531 out.push(dedup_token(p, f)?);
1532 }
1533 Some(out)
1534}
1535
1536fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1541 let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1544 if !links.is_empty() {
1545 let set: BTreeSet<String> = links
1546 .into_iter()
1547 .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1548 .filter(|t| !t.is_empty())
1549 .collect();
1550 return if set.is_empty() {
1551 None
1552 } else {
1553 Some(set.into_iter().collect::<Vec<_>>().join(","))
1554 };
1555 }
1556 match p.fm.as_ref()?.get(field) {
1557 Some(Value::Sequence(items)) => {
1558 let set: BTreeSet<String> = items
1559 .iter()
1560 .filter_map(scalar_string)
1561 .map(|s| s.trim().to_lowercase())
1562 .filter(|t| !t.is_empty())
1563 .collect();
1564 if set.is_empty() {
1565 None
1566 } else {
1567 Some(set.into_iter().collect::<Vec<_>>().join(","))
1568 }
1569 }
1570 Some(v) => {
1571 let s = scalar_string(v)?.trim().to_lowercase();
1572 if s.is_empty() {
1573 None
1574 } else {
1575 Some(s)
1576 }
1577 }
1578 None => None,
1579 }
1580}
1581
1582fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1587 let mut sorted = files.to_vec();
1588 sorted.sort();
1589 let reported = sorted[0].clone();
1590 let related = sorted[1..].to_vec();
1591 (reported, related)
1592}
1593
1594fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1600 let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1604 for rel in files {
1605 if let Some(tf) = type_folder_of(rel) {
1606 type_folders.entry(tf).or_default().push(rel.clone());
1607 }
1608 }
1609
1610 let mut layers_with_type_folders: BTreeSet<&'static str> = BTreeSet::new();
1622 for tf in type_folders.keys() {
1623 match tf.iter().next().and_then(|s| s.to_str()) {
1624 Some("sources") => {
1625 layers_with_type_folders.insert("sources");
1626 }
1627 Some("records") => {
1628 layers_with_type_folders.insert("records");
1629 }
1630 _ => {}
1631 }
1632 }
1633
1634 if !type_folders.is_empty() {
1636 if !store
1637 .regular_file_exists(Path::new("index.md"))
1638 .unwrap_or(false)
1639 {
1640 push(
1641 issues,
1642 Severity::Error,
1643 codes::INDEX_MISSING,
1644 Path::new("index.md"),
1645 None,
1646 None,
1647 "store has files but no root `index.md`".into(),
1648 Some("run `dbmd index rebuild`".into()),
1649 vec![],
1650 );
1651 } else {
1652 check_index_scope(store, Path::new("index.md"), "root", None, issues);
1653 }
1654 }
1655
1656 for layer in &layers_with_type_folders {
1658 let layer_index_rel = PathBuf::from(layer).join("index.md");
1659 if !store.regular_file_exists(&layer_index_rel).unwrap_or(false) {
1660 push(
1661 issues,
1662 Severity::Error,
1663 codes::INDEX_MISSING,
1664 &layer_index_rel,
1665 None,
1666 None,
1667 format!("layer `{layer}/` has files but no `index.md`"),
1668 Some("run `dbmd index rebuild`".into()),
1669 vec![],
1670 );
1671 } else {
1672 check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1673 }
1674 }
1675
1676 for (tf, members) in &type_folders {
1678 let index_md_rel = tf.join("index.md");
1679 let index_md_present = store.regular_file_exists(&index_md_rel).unwrap_or(false);
1680 if !index_md_present {
1681 push(
1687 issues,
1688 Severity::Error,
1689 codes::INDEX_MISSING,
1690 tf,
1691 None,
1692 None,
1693 format!("non-empty folder `{}` has no index.md", tf.display()),
1694 Some(format!(
1695 "run `dbmd index rebuild --folder {}`",
1696 tf.display()
1697 )),
1698 vec![],
1699 );
1700 continue;
1701 }
1702
1703 check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1704 check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1705
1706 let jsonl_rel = tf.join("index.jsonl");
1710 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1711 push(
1712 issues,
1713 Severity::Error,
1714 codes::INDEX_JSONL_MISSING,
1715 &jsonl_rel,
1716 None,
1717 None,
1718 format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1719 Some("run `dbmd index rebuild`".into()),
1720 vec![],
1721 );
1722 } else {
1723 check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1724 }
1725 }
1726
1727 let mut loose_by_layer: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1735 for rel in files {
1736 if !is_content_file(rel) || type_folder_of(rel).is_some() {
1737 continue;
1738 }
1739 if let Some(layer_dir) = loose_layer_dir(rel) {
1740 loose_by_layer
1741 .entry(layer_dir)
1742 .or_default()
1743 .push(rel.clone());
1744 }
1745 }
1746 for (layer_dir, members) in &loose_by_layer {
1747 let jsonl_rel = layer_dir.join("index.jsonl");
1748 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1749 push(
1750 issues,
1751 Severity::Error,
1752 codes::INDEX_JSONL_MISSING,
1753 &jsonl_rel,
1754 None,
1755 None,
1756 format!(
1757 "loose files at `{}/` are not catalogued — the layer has no `index.jsonl`",
1758 layer_dir.display()
1759 ),
1760 Some("run `dbmd index rebuild`".into()),
1761 members.clone(),
1762 );
1763 } else {
1764 check_type_folder_index_jsonl(store, layer_dir, &jsonl_rel, members, issues);
1768 }
1769 }
1770
1771 for rel in walk_index_files(store) {
1773 let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1774 let parent_str = parent.to_string_lossy().to_string();
1775 let is_canonical = parent_str.is_empty() || matches!(parent_str.as_str(), "sources" | "records")
1777 || type_folders.contains_key(&parent);
1778 if !is_canonical {
1779 push(
1780 issues,
1781 Severity::Warning,
1782 codes::INDEX_ORPHAN,
1783 &rel,
1784 None,
1785 None,
1786 format!(
1787 "`{}` sits in an empty or non-canonical folder",
1788 rel.display()
1789 ),
1790 Some("remove it, or run `dbmd index rebuild`".into()),
1791 vec![],
1792 );
1793 }
1794 }
1795}
1796
1797fn check_type_folder_index_md(
1801 store: &Store,
1802 tf: &Path,
1803 index_rel: &Path,
1804 members: &[PathBuf],
1805 issues: &mut Vec<Issue>,
1806) {
1807 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1808 return;
1809 };
1810 let entries = parse_index_entries(&text);
1811
1812 let listed: BTreeSet<PathBuf> = entries
1813 .iter()
1814 .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1815 .collect();
1816
1817 for entry in &entries {
1819 let bare = entry.target.trim_end_matches(".md");
1820 let target_abs = match resolved_target_abs(store, bare) {
1823 Some(abs) => abs,
1824 None => {
1825 if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1826 push(
1827 issues,
1828 Severity::Error,
1829 codes::INDEX_STALE_ENTRY,
1830 index_rel,
1831 Some(entry.line),
1832 None,
1833 format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1834 Some("run `dbmd index rebuild`".into()),
1835 vec![],
1836 );
1837 } else {
1838 push(
1839 issues,
1840 Severity::Error,
1841 codes::INDEX_STALE_ENTRY,
1842 index_rel,
1843 Some(entry.line),
1844 None,
1845 format!("index entry `[[{bare}]]` points at a missing file"),
1846 Some("run `dbmd index rebuild`".into()),
1847 vec![PathBuf::from(format!("{bare}.md"))],
1851 );
1852 }
1853 continue;
1854 }
1855 };
1856 if let Some(expected) = read_summary(store, &target_abs) {
1863 match &entry.summary_text {
1864 Some(text_part)
1875 if crate::summary::collapse_whitespace(text_part)
1876 != crate::summary::collapse_whitespace(&expected) =>
1877 {
1878 push(
1879 issues,
1880 Severity::Error,
1881 codes::INDEX_SUMMARY_MISMATCH,
1882 index_rel,
1883 Some(entry.line),
1884 None,
1885 format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1886 Some("run `dbmd index rebuild`".into()),
1887 vec![PathBuf::from(format!("{bare}.md"))],
1888 );
1889 }
1890 None if !expected.trim().is_empty() => {
1891 push(
1892 issues,
1893 Severity::Error,
1894 codes::INDEX_SUMMARY_MISMATCH,
1895 index_rel,
1896 Some(entry.line),
1897 None,
1898 format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1899 Some("run `dbmd index rebuild`".into()),
1900 vec![PathBuf::from(format!("{bare}.md"))],
1901 );
1902 }
1903 _ => {}
1904 }
1905 }
1906 }
1907
1908 let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1912 if content_members.len() <= 500 {
1913 for m in content_members {
1914 let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1915 if !listed.contains(&bare) {
1916 push(
1917 issues,
1918 Severity::Error,
1919 codes::INDEX_MISSING_ENTRY,
1920 index_rel,
1921 None,
1922 None,
1923 format!(
1924 "file `{}` is not listed in its folder's `index.md`",
1925 m.display()
1926 ),
1927 Some("run `dbmd index rebuild`".into()),
1928 vec![(*m).clone()],
1929 );
1930 }
1931 }
1932 }
1933 let _ = tf;
1934}
1935
1936fn check_type_folder_index_jsonl(
1940 store: &Store,
1941 tf: &Path,
1942 jsonl_rel: &Path,
1943 members: &[PathBuf],
1944 issues: &mut Vec<Issue>,
1945) {
1946 let Ok(text) = store.read_text_bounded(jsonl_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1947 return;
1948 };
1949
1950 let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1952 for (i, line) in text.lines().enumerate() {
1953 let line = line.trim();
1954 if line.is_empty() {
1955 continue;
1956 }
1957 let rec: serde_json::Value = match serde_json::from_str(line) {
1958 Ok(v) => v,
1959 Err(e) => {
1960 push(
1961 issues,
1962 Severity::Error,
1963 codes::INDEX_JSONL_DESYNC,
1964 jsonl_rel,
1965 Some((i + 1) as u32),
1966 None,
1967 format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
1968 Some("run `dbmd index rebuild`".into()),
1969 vec![],
1970 );
1971 continue;
1972 }
1973 };
1974 if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
1975 if !is_safe_store_relative_path(Path::new(path)) {
1976 push(
1977 issues,
1978 Severity::Error,
1979 codes::INDEX_JSONL_DESYNC,
1980 jsonl_rel,
1981 Some((i + 1) as u32),
1982 None,
1983 format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
1984 Some("run `dbmd index rebuild`".into()),
1985 vec![],
1986 );
1987 continue;
1988 }
1989 records.insert(PathBuf::from(path), rec);
1990 }
1991 }
1992
1993 let member_set: BTreeSet<PathBuf> = members
1994 .iter()
1995 .filter(|m| is_content_file(m))
1996 .cloned()
1997 .collect();
1998
1999 for path in records.keys() {
2001 if !store.regular_file_exists(path).unwrap_or(false) {
2002 push(
2003 issues,
2004 Severity::Error,
2005 codes::INDEX_JSONL_DESYNC,
2006 jsonl_rel,
2007 None,
2008 None,
2009 format!(
2010 "`index.jsonl` record points at missing file `{}`",
2011 path.display()
2012 ),
2013 Some("run `dbmd index rebuild`".into()),
2014 vec![],
2015 );
2016 }
2017 }
2018
2019 for m in &member_set {
2021 if !records.contains_key(m) {
2022 push(
2023 issues,
2024 Severity::Error,
2025 codes::INDEX_JSONL_DESYNC,
2026 jsonl_rel,
2027 None,
2028 None,
2029 format!(
2030 "file `{}` is missing from the complete `index.jsonl`",
2031 m.display()
2032 ),
2033 Some("run `dbmd index rebuild`".into()),
2034 vec![m.clone()],
2035 );
2036 }
2037 }
2038
2039 for (path, rec) in &records {
2053 if !store.regular_file_exists(path).unwrap_or(false) {
2054 continue;
2055 }
2056 let Ok(expected) =
2057 crate::index::IndexRecord::expected_from_store(store, path, path.clone())
2058 else {
2059 continue; };
2061 let Ok(expected_json) = serde_json::to_value(&expected) else {
2062 continue;
2063 };
2064 let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
2065 continue;
2066 };
2067
2068 let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
2071 for key in have.keys().chain(want.keys()) {
2072 if key == "path" {
2073 continue;
2074 }
2075 if have.get(key) != want.get(key) {
2076 mismatched_keys.insert(key);
2077 }
2078 }
2079
2080 if !mismatched_keys.is_empty() {
2081 let keys: Vec<&str> = mismatched_keys.into_iter().collect();
2082 push(
2083 issues,
2084 Severity::Error,
2085 codes::INDEX_JSONL_STALE,
2086 jsonl_rel,
2087 None,
2088 Some(keys.join(",")),
2089 format!(
2090 "`index.jsonl` record for `{}` is stale ({})",
2091 path.display(),
2092 keys.join(", ")
2093 ),
2094 Some("run `dbmd index rebuild`".into()),
2095 vec![path.clone()],
2096 );
2097 }
2098 }
2099 let _ = tf;
2100}
2101
2102fn check_index_scope(
2104 store: &Store,
2105 index_rel: &Path,
2106 expected_scope: &str,
2107 expected_folder: Option<&str>,
2108 issues: &mut Vec<Issue>,
2109) {
2110 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2111 return;
2112 };
2113 let Some((yaml, _, _)) = split_frontmatter(&text) else {
2114 return;
2115 };
2116 let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
2117 return;
2118 };
2119 let fm = yaml_map_to_btree(&map);
2120
2121 if let Some(scope) = fm.get("scope").and_then(scalar_string) {
2122 let scope_ok =
2124 scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
2125 if !scope_ok {
2126 push(
2127 issues,
2128 Severity::Warning,
2129 codes::INDEX_WRONG_SCOPE,
2130 index_rel,
2131 fm_key_line(&yaml, "scope"),
2132 Some("scope".into()),
2133 format!(
2134 "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
2135 ),
2136 Some(format!("set `scope: {expected_scope}`")),
2137 vec![],
2138 );
2139 }
2140 }
2141 if let Some(expected) = expected_folder {
2143 if let Some(folder) = fm.get("folder").and_then(scalar_string) {
2144 if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
2145 push(
2146 issues,
2147 Severity::Warning,
2148 codes::INDEX_WRONG_SCOPE,
2149 index_rel,
2150 fm_key_line(&yaml, "folder"),
2151 Some("folder".into()),
2152 format!("index `folder: {folder}` doesn't match location `{expected}`"),
2153 Some(format!("set `folder: {expected}`")),
2154 vec![],
2155 );
2156 }
2157 }
2158 }
2159}
2160
2161fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2180 let mut prev: Option<DateTime<FixedOffset>> = None;
2181 for rel in log_files_chronological(store) {
2182 check_log_file(store, &rel, &mut prev, issues);
2183 }
2184}
2185
2186fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2190 let mut files: Vec<PathBuf> = Vec::new();
2191 let archive_dir = Path::new("log");
2192 if let Ok(entries) = store.regular_file_names(archive_dir) {
2193 let mut archives: Vec<PathBuf> = entries
2194 .into_iter()
2195 .filter(|name| {
2196 name.to_str()
2197 .and_then(|n| n.strip_suffix(".md"))
2198 .is_some_and(is_year_month_archive)
2199 })
2200 .map(|name| archive_dir.join(name))
2201 .collect();
2202 archives.sort();
2204 files.extend(archives);
2205 }
2206 if store
2208 .regular_file_exists(Path::new("log.md"))
2209 .unwrap_or(false)
2210 {
2211 files.push(PathBuf::from("log.md"));
2212 }
2213 files
2214}
2215
2216fn check_log_file(
2220 store: &Store,
2221 log_rel: &Path,
2222 prev: &mut Option<DateTime<FixedOffset>>,
2223 issues: &mut Vec<Issue>,
2224) {
2225 let Ok(text) = store.read_text_bounded(log_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2226 return;
2227 };
2228
2229 for (i, line) in text.lines().enumerate() {
2230 if !line.starts_with("## [") {
2231 continue;
2232 }
2233 let line_no = (i + 1) as u32;
2234 match parse_log_header(line) {
2235 None => push(
2236 issues,
2237 Severity::Error,
2238 codes::LOG_BAD_TIMESTAMP,
2239 log_rel,
2240 Some(line_no),
2241 None,
2242 format!("log entry header has an unparseable timestamp: {line:?}"),
2243 Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2244 vec![],
2245 ),
2246 Some((ts, kind, _object)) => {
2247 if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2248 push(
2249 issues,
2250 Severity::Warning,
2251 codes::LOG_UNKNOWN_KIND,
2252 log_rel,
2253 Some(line_no),
2254 None,
2255 format!("log entry kind `{kind}` is not recognized"),
2256 Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2257 vec![],
2258 );
2259 }
2260 if let Some(p) = *prev {
2261 if ts < p {
2262 push(
2263 issues,
2264 Severity::Warning,
2265 codes::LOG_OUT_OF_ORDER,
2266 log_rel,
2267 Some(line_no),
2268 None,
2269 "log entry is older than the entry above it (possible rewrite)".into(),
2270 Some("append corrective entries; never reorder past ones".into()),
2271 vec![],
2272 );
2273 }
2274 }
2275 *prev = Some(ts);
2276 }
2277 }
2278 }
2279}
2280
2281#[derive(Debug)]
2287struct Link {
2288 target: String,
2289 line: u32,
2290}
2291
2292fn store_marker_present(store: &Store) -> bool {
2296 store
2297 .regular_file_exists(Path::new("DB.md"))
2298 .unwrap_or(false)
2299}
2300
2301fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2312 let rel = Path::new("DB.md");
2313 let Ok(text) = store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2314 return; };
2316
2317 let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2318 push(
2322 issues,
2323 Severity::Error,
2324 codes::DB_MD_BAD_TYPE,
2325 rel,
2326 Some(1),
2327 Some("type".into()),
2328 "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2329 Some("add a `---` frontmatter block with `type: db-md`".into()),
2330 vec![],
2331 );
2332 for field in ["scope", "owner"] {
2333 push(
2334 issues,
2335 Severity::Error,
2336 codes::DB_MD_MISSING_FIELD,
2337 rel,
2338 Some(1),
2339 Some(field.into()),
2340 format!("DB.md frontmatter is missing required field `{field}`"),
2341 Some(format!("add `{field}:` to the DB.md frontmatter")),
2342 vec![],
2343 );
2344 }
2345 return;
2346 };
2347
2348 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2351 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2352 Ok(Value::Null) => Some(BTreeMap::new()),
2353 _ => None,
2354 };
2355
2356 match &fm {
2357 Some(map) => {
2358 let type_ = map.get("type").and_then(scalar_string);
2360 if type_.as_deref() != Some("db-md") {
2361 let (line, msg) = match &type_ {
2362 Some(t) => (
2363 fm_key_line(&fm_yaml, "type"),
2364 format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2365 ),
2366 None => (
2367 Some(1),
2368 "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2369 ),
2370 };
2371 push(
2372 issues,
2373 Severity::Error,
2374 codes::DB_MD_BAD_TYPE,
2375 rel,
2376 line,
2377 Some("type".into()),
2378 msg,
2379 Some("set `type: db-md` in the DB.md frontmatter".into()),
2380 vec![],
2381 );
2382 }
2383
2384 for field in ["scope", "owner"] {
2386 let present = map
2387 .get(field)
2388 .and_then(scalar_string)
2389 .map(|s| !s.trim().is_empty())
2390 .unwrap_or(false);
2391 if !present {
2392 push(
2393 issues,
2394 Severity::Error,
2395 codes::DB_MD_MISSING_FIELD,
2396 rel,
2397 fm_key_line_or_top(&fm_yaml, field),
2400 Some(field.into()),
2401 format!("DB.md frontmatter is missing required field `{field}`"),
2402 Some(format!("add `{field}:` to the DB.md frontmatter")),
2403 vec![],
2404 );
2405 }
2406 }
2407 }
2408 None => {
2409 push(
2412 issues,
2413 Severity::Error,
2414 codes::DB_MD_BAD_TYPE,
2415 rel,
2416 Some(1),
2417 Some("type".into()),
2418 "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2419 Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2420 vec![],
2421 );
2422 for field in ["scope", "owner"] {
2423 push(
2424 issues,
2425 Severity::Error,
2426 codes::DB_MD_MISSING_FIELD,
2427 rel,
2428 Some(1),
2429 Some(field.into()),
2430 format!("DB.md frontmatter is missing required field `{field}`"),
2431 Some(format!("add `{field}:` to the DB.md frontmatter")),
2432 vec![],
2433 );
2434 }
2435 }
2436 }
2437
2438 for section in crate::parser::extract_sections(&body) {
2452 if section.level != 2 {
2453 continue;
2454 }
2455 let name = section.heading.trim().to_ascii_lowercase();
2456 if matches!(
2457 name.as_str(),
2458 "agent instructions" | "policies" | "schemas" | "folders"
2459 ) {
2460 continue;
2461 }
2462 let file_line = fm_end_line + section.line;
2465 push(
2466 issues,
2467 Severity::Warning,
2468 codes::DB_MD_UNKNOWN_SECTION,
2469 rel,
2470 Some(file_line),
2471 None,
2472 format!(
2473 "DB.md has an unrecognized `## {}` section",
2474 section.heading.trim()
2475 ),
2476 Some(
2477 "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2478 `## Folders` — remove or rename this heading"
2479 .into(),
2480 ),
2481 vec![],
2482 );
2483 }
2484
2485 check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2490}
2491
2492fn check_db_md_schemas(
2499 store: &Store,
2500 rel: &Path,
2501 body: &str,
2502 fm_end_line: u32,
2503 issues: &mut Vec<Issue>,
2504) {
2505 if store.config.schemas.is_empty() {
2506 return;
2507 }
2508
2509 let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2514 let mut current_h2: Option<String> = None;
2515 for section in crate::parser::extract_sections(body) {
2516 match section.level {
2517 2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2518 3 if current_h2.as_deref() == Some("schemas") => {
2519 type_line
2522 .entry(section.heading.trim().to_string())
2523 .or_insert(fm_end_line + section.line);
2524 }
2525 _ => {}
2526 }
2527 }
2528
2529 for (type_name, schema) in &store.config.schemas {
2530 let line = type_line.get(type_name).copied();
2531 let mut seen: BTreeSet<String> = BTreeSet::new();
2532 for field in &schema.fields {
2533 let name = field.name.trim();
2534
2535 if name.is_empty() {
2539 push(
2540 issues,
2541 Severity::Warning,
2542 codes::DB_MD_SCHEMA_FIELD,
2543 rel,
2544 line,
2545 None,
2546 format!("`### {type_name}` has a schema field bullet with no field name"),
2547 Some(
2548 "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2549 .into(),
2550 ),
2551 vec![],
2552 );
2553 continue;
2554 }
2555
2556 if !seen.insert(name.to_string()) {
2560 push(
2561 issues,
2562 Severity::Warning,
2563 codes::DB_MD_SCHEMA_FIELD,
2564 rel,
2565 line,
2566 Some(name.to_string()),
2567 format!("`### {type_name}` declares field `{name}` more than once"),
2568 Some(
2569 "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2570 ),
2571 vec![],
2572 );
2573 }
2574
2575 for modifier in &field.unknown_modifiers {
2580 let modifier = modifier.trim();
2581 if modifier.is_empty() {
2582 continue;
2583 }
2584 push(
2585 issues,
2586 Severity::Info,
2587 codes::DB_MD_SCHEMA_FIELD,
2588 rel,
2589 line,
2590 Some(name.to_string()),
2591 format!(
2592 "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2593 ),
2594 Some(
2595 "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2596 .into(),
2597 ),
2598 vec![],
2599 );
2600 }
2601 }
2602
2603 let mut declared: BTreeMap<&str, bool> = BTreeMap::new();
2612 for f in &schema.fields {
2613 let e = declared.entry(f.name.trim()).or_insert(false);
2614 *e = *e || f.required;
2615 }
2616 let mut flagged: BTreeSet<&str> = BTreeSet::new();
2617 for key_fields in &schema.unique_keys {
2618 for field in key_fields {
2619 let name = field.trim();
2620 if name.is_empty()
2621 || declared.get(name).copied() == Some(true)
2622 || !flagged.insert(name)
2623 {
2624 continue;
2625 }
2626 let message = if declared.contains_key(name) {
2627 format!(
2628 "`### {type_name}` `unique:` key field `{name}` is not `required` — a record missing or leaving it empty is silently skipped by the unique check"
2629 )
2630 } else {
2631 format!(
2632 "`### {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"
2633 )
2634 };
2635 push(
2636 issues,
2637 Severity::Warning,
2638 codes::DB_MD_SCHEMA_FIELD,
2639 rel,
2640 line,
2641 Some(name.to_string()),
2642 message,
2643 Some(format!(
2644 "mark `{name}` `required` in `### {type_name}`, or build the `unique:` key from required fields only"
2645 )),
2646 vec![],
2647 );
2648 }
2649 }
2650 }
2651}
2652
2653fn not_a_store_issue(store: &Store) -> Issue {
2655 Issue {
2656 severity: Severity::Error,
2657 code: codes::NOT_A_STORE,
2658 file: store.root.clone(),
2659 line: None,
2660 key: None,
2661 message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2662 suggestion: Some("create a `DB.md` at the store root".into()),
2663 related: vec![],
2664 }
2665}
2666
2667fn is_content_file(rel: &Path) -> bool {
2670 if !is_safe_store_relative_path(rel) {
2676 return false;
2677 }
2678 let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2679 return false;
2680 };
2681 if !matches!(first, "sources" | "records") {
2682 return false;
2683 }
2684 let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2685 if matches!(name, "index.md" | "index.jsonl") {
2689 return false;
2690 }
2691 name.ends_with(".md")
2692}
2693
2694fn is_root_meta_file(rel: &Path) -> bool {
2701 let mut comps = rel.components();
2702 let Some(Component::Normal(only)) = comps.next() else {
2703 return false;
2704 };
2705 if comps.next().is_some() {
2706 return false; }
2708 matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2709}
2710
2711fn is_index_catalog_file(rel: &Path) -> bool {
2719 matches!(
2720 rel.file_name().and_then(|n| n.to_str()),
2721 Some("index.md") | Some("index.jsonl")
2722 )
2723}
2724
2725fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2729 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2734 let mut lines = text.lines();
2735 let first = lines.next()?;
2736 if first.trim_end() != "---" {
2737 return None;
2738 }
2739 let mut yaml = String::new();
2740 let mut close_line: Option<u32> = None;
2741 let mut current = 1u32;
2743 for line in lines {
2744 current += 1;
2745 if line.trim_end() == "---" {
2746 close_line = Some(current);
2747 break;
2748 }
2749 yaml.push_str(line);
2750 yaml.push('\n');
2751 }
2752 let close_line = close_line?;
2753 let body: String = text
2755 .lines()
2756 .skip(close_line as usize)
2757 .collect::<Vec<_>>()
2758 .join("\n");
2759 Some((yaml, body, close_line))
2760}
2761
2762fn body_opens_with_frontmatter(body: &str) -> bool {
2770 let start: String = body
2771 .lines()
2772 .skip_while(|l| l.trim().is_empty())
2773 .collect::<Vec<_>>()
2774 .join("\n");
2775 match split_frontmatter(&start) {
2776 Some((yaml, _, _)) => matches!(
2777 serde_norway::from_str::<Value>(&yaml),
2778 Ok(Value::Mapping(m)) if !m.is_empty()
2779 ),
2780 None => false,
2781 }
2782}
2783
2784fn read_summary(store: &Store, abs: &Path) -> Option<String> {
2786 let text = store
2787 .read_text_bounded(abs, crate::parser::MAX_DBMD_FILE_BYTES)
2788 .ok()?;
2789 let (yaml, _, _) = split_frontmatter(&text)?;
2790 let value: Value = serde_norway::from_str(&yaml).ok()?;
2791 if let Value::Mapping(m) = value {
2792 m.get(Value::String("summary".into()))
2793 .and_then(scalar_string)
2794 } else {
2795 None
2796 }
2797}
2798
2799fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2802 let mut out = BTreeMap::new();
2803 for (k, v) in map {
2804 if let Value::String(s) = k {
2805 out.insert(s.clone(), v.clone());
2806 }
2807 }
2808 out
2809}
2810
2811fn scalar_string(v: &Value) -> Option<String> {
2814 match v {
2815 Value::String(s) => Some(s.clone()),
2816 Value::Number(n) => Some(n.to_string()),
2817 Value::Bool(b) => Some(b.to_string()),
2818 _ => None,
2819 }
2820}
2821
2822fn is_empty_value(v: &Value) -> bool {
2829 match v {
2830 Value::Null => true,
2831 Value::Sequence(items) => items.is_empty(),
2832 Value::Mapping(map) => map.is_empty(),
2833 other => scalar_string(other)
2834 .map(|s| s.trim().is_empty())
2835 .unwrap_or(true),
2836 }
2837}
2838
2839fn is_flat_scalar_list(v: &Value) -> bool {
2842 match v {
2843 Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2844 _ => false,
2845 }
2846}
2847
2848fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2858 let mut out = Vec::new();
2859 for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2860 for link in links {
2861 out.push((key.clone(), link));
2862 }
2863 }
2864 out
2865}
2866
2867fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2871 for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2872 if k == key {
2873 return links;
2874 }
2875 }
2876 Vec::new()
2877}
2878
2879fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2883 for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2884 if k == key {
2885 return Some(value_text);
2886 }
2887 }
2888 None
2889}
2890
2891fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2898 let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2899 let mut current: Option<(String, String, Vec<Link>)> = None;
2900
2901 for (idx, raw_line) in fm_yaml.lines().enumerate() {
2902 let file_line = fm_start_line + idx as u32;
2903 let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2904 let trimmed = raw_line.trim();
2905
2906 let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2909 top_level_key(raw_line)
2910 } else {
2911 None
2912 };
2913
2914 if let Some((key, after)) = new_key {
2915 if let Some(done) = current.take() {
2916 blocks.push(done);
2917 }
2918 let mut links = Vec::new();
2919 collect_line_links(after, file_line, &mut links);
2920 current = Some((key, after.trim().to_string(), links));
2921 } else if let Some((_k, value_text, links)) = current.as_mut() {
2922 if !value_text.is_empty() {
2924 value_text.push('\n');
2925 }
2926 value_text.push_str(trimmed);
2927 collect_line_links(raw_line, file_line, links);
2928 }
2929 }
2930 if let Some(done) = current.take() {
2931 blocks.push(done);
2932 }
2933 blocks
2934}
2935
2936fn top_level_key(line: &str) -> Option<(String, &str)> {
2939 let (key, rest) = line.split_once(':')?;
2940 let key = key.trim();
2941 if key.is_empty()
2942 || !key
2943 .chars()
2944 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2945 {
2946 return None;
2947 }
2948 Some((key.to_string(), rest))
2949}
2950
2951fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2954 let bytes = s.as_bytes();
2955 let mut i = 0;
2956 while i + 1 < bytes.len() {
2957 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2958 if let Some(close) = s[i + 2..].find("]]") {
2959 let inner = &s[i + 2..i + 2 + close];
2960 let target = inner
2963 .trim_start_matches('[')
2964 .split('|')
2965 .next()
2966 .unwrap_or(inner)
2967 .trim()
2968 .to_string();
2969 if !target.is_empty() {
2970 links.push(Link {
2971 target,
2972 line: file_line,
2973 });
2974 }
2975 i = i + 2 + close + 2;
2976 continue;
2977 }
2978 }
2979 i += 1;
2980 }
2981}
2982
2983fn extract_wiki_links(body: &str) -> Vec<Link> {
2995 let mut out = Vec::new();
2996 let mut fence: Option<(u8, usize)> = None;
2997 for (idx, line) in body.lines().enumerate() {
2998 let content = line.trim_end_matches('\r');
2999 if let Some(f) = fence {
3000 if fence_closes(content, f) {
3004 fence = None;
3005 }
3006 continue;
3007 }
3008 if let Some(opened) = fence_opens(content) {
3009 fence = Some(opened);
3010 continue;
3011 }
3012 let line_no = (idx + 1) as u32;
3013 let bytes = line.as_bytes();
3014 let mut i = 0;
3015 while i + 1 < bytes.len() {
3016 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
3017 if let Some(close) = line[i + 2..].find("]]") {
3018 let inner = &line[i + 2..i + 2 + close];
3019 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3020 if !target.is_empty() && !target.starts_with('[') {
3028 out.push(Link {
3029 target,
3030 line: line_no,
3031 });
3032 }
3033 i = i + 2 + close + 2;
3034 continue;
3035 }
3036 }
3037 i += 1;
3038 }
3039 }
3040 out
3041}
3042
3043fn fence_opens(line: &str) -> Option<(u8, usize)> {
3049 let indent = line.len() - line.trim_start_matches(' ').len();
3050 if indent > 3 {
3051 return None;
3052 }
3053 let rest = &line[indent..];
3054 let byte = rest.bytes().next()?;
3055 if byte != b'`' && byte != b'~' {
3056 return None;
3057 }
3058 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3059 if run < 3 {
3060 return None;
3061 }
3062 if byte == b'`' && rest[run..].contains('`') {
3064 return None;
3065 }
3066 Some((byte, run))
3067}
3068
3069fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
3074 let (byte, open_len) = fence;
3075 let indent = line.len() - line.trim_start_matches(' ').len();
3076 if indent > 3 {
3077 return false;
3078 }
3079 let rest = &line[indent..];
3080 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3081 if run < open_len {
3082 return false;
3083 }
3084 rest[run..].trim().is_empty()
3085}
3086
3087fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
3104 let mut out = Vec::new();
3105 for line in fm_yaml.lines() {
3106 if line.starts_with(' ') || line.starts_with('\t') {
3108 continue;
3109 }
3110 let Some((key, rest)) = line.split_once(':') else {
3111 continue;
3112 };
3113 let key = key.trim();
3114 if key.is_empty()
3115 || key.starts_with('#')
3116 || key.starts_with('-')
3117 || !key
3118 .chars()
3119 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
3120 {
3121 continue;
3122 }
3123 let rest = rest.trim();
3124 if !rest.starts_with('[') {
3127 continue;
3128 }
3129 if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
3134 let nested = items.iter().any(|item| match item {
3135 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
3136 _ => false,
3137 });
3138 if nested {
3139 out.push(key.to_string());
3140 }
3141 }
3142 }
3143 out
3144}
3145
3146fn is_full_store_path(bare: &str) -> bool {
3149 let mut parts = bare.splitn(2, '/');
3150 let first = parts.next().unwrap_or("");
3151 let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
3152 matches!(first, "sources" | "records") && has_rest
3153}
3154
3155fn is_safe_store_relative_path(path: &Path) -> bool {
3159 let mut saw_component = false;
3160 for component in path.components() {
3161 match component {
3162 Component::Normal(_) => saw_component = true,
3163 Component::CurDir => {}
3164 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
3165 }
3166 }
3167 saw_component
3168}
3169
3170fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
3171 let path = Path::new(bare);
3172 if !is_safe_store_relative_path(path) {
3173 return None;
3174 }
3175 Some(PathBuf::from(format!("{bare}.md")))
3176}
3177
3178enum TargetResolution {
3180 Exists,
3182 Missing,
3184 Unsafe,
3186}
3187
3188fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
3197 if !is_safe_store_relative_path(Path::new(bare)) {
3201 return TargetResolution::Unsafe;
3202 }
3203 match resolved_target_abs(store, bare) {
3204 Some(_) => TargetResolution::Exists,
3205 None => TargetResolution::Missing,
3206 }
3207}
3208
3209fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
3235 if !is_safe_store_relative_path(Path::new(bare)) {
3236 return None;
3237 }
3238 let literal = PathBuf::from(bare);
3241 if store.regular_file_exists(&literal).ok()? && disk_case_matches(store, &literal, bare) {
3242 return Some(literal);
3243 }
3244 let with_md_rel = format!("{bare}.md");
3246 let with_md = PathBuf::from(&with_md_rel);
3247 if store.regular_file_exists(&with_md).ok()? && disk_case_matches(store, &with_md, &with_md_rel)
3248 {
3249 return Some(with_md);
3250 }
3251 None
3252}
3253
3254fn disk_case_matches(store: &Store, abs: &Path, requested: &str) -> bool {
3269 abs == Path::new(requested) && store.path_case_matches(abs).unwrap_or(true)
3270}
3271
3272fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3274 let prefix = prefix.trim_end_matches('/');
3275 bare == prefix || bare.starts_with(&format!("{prefix}/"))
3276}
3277
3278fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3282 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3283 if comps.len() < 3 {
3284 return None; }
3286 if !matches!(comps[0], "sources" | "records") {
3287 return None;
3288 }
3289 Some(PathBuf::from(comps[0]).join(comps[1]))
3290}
3291
3292fn loose_layer_dir(rel: &Path) -> Option<PathBuf> {
3297 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3298 if comps.len() != 2 || !matches!(comps[0], "sources" | "records") {
3299 return None;
3300 }
3301 Some(PathBuf::from(comps[0]))
3302}
3303
3304fn walk_index_files(store: &Store) -> Vec<PathBuf> {
3309 let mut out = Vec::new();
3310 if store
3311 .regular_file_exists(Path::new("index.md"))
3312 .unwrap_or(false)
3313 {
3314 out.push(PathBuf::from("index.md"));
3315 }
3316 for layer in ["sources", "records"] {
3317 if let Ok(files) = store.walk_regular_files(Path::new(layer)) {
3318 for rel in files {
3319 if rel.file_name().and_then(|name| name.to_str()) == Some("index.md") {
3320 out.push(rel);
3321 }
3322 }
3323 }
3324 }
3325 out.sort();
3326 out
3327}
3328
3329struct IndexEntry {
3332 target: String,
3333 summary_text: Option<String>,
3334 line: u32,
3335}
3336
3337fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3342 let mut out = Vec::new();
3343 let mut in_more = false;
3344 for (idx, line) in text.lines().enumerate() {
3345 let trimmed = line.trim_start();
3346 if trimmed.starts_with("## More") {
3347 in_more = true;
3348 continue;
3349 }
3350 if in_more {
3351 continue;
3352 }
3353 if !trimmed.starts_with("- ") {
3354 continue;
3355 }
3356 let Some(open) = trimmed.find("[[") else {
3358 continue;
3359 };
3360 let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3361 continue;
3362 };
3363 let inner = &trimmed[open + 2..open + 2 + close_rel];
3364 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3365
3366 let after = &trimmed[open + 2 + close_rel + 2..];
3368 let summary_text = extract_index_entry_summary(after);
3369
3370 out.push(IndexEntry {
3371 target,
3372 summary_text,
3373 line: (idx + 1) as u32,
3374 });
3375 }
3376 out
3377}
3378
3379fn extract_index_entry_summary(after: &str) -> Option<String> {
3385 let mut s = after.trim();
3386 if s.starts_with('(') {
3388 if let Some(close) = s.find(')') {
3389 s = s[close + 1..].trim_start();
3390 }
3391 }
3392 let s = s.strip_prefix('—').or_else(|| s.strip_prefix('-'))?.trim();
3394 if s.is_empty() {
3395 return None;
3396 }
3397 let s = match s.rsplit_once(" · ") {
3412 Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3413 _ => s,
3414 };
3415 Some(s.to_string())
3416}
3417
3418fn is_tag_suffix(s: &str) -> bool {
3423 let mut any = false;
3424 for tok in s.split_whitespace() {
3425 if !tok.starts_with('#') || tok.len() < 2 {
3426 return false;
3427 }
3428 any = true;
3429 }
3430 any
3431}
3432
3433fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3437 let rest = line.strip_prefix("## [")?;
3438 let close = rest.find(']')?;
3439 let ts_str = &rest[..close];
3440 let tail = rest[close + 1..].trim();
3441
3442 let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3445 let offset = FixedOffset::east_opt(0)?;
3446 let ts = naive.and_local_timezone(offset).single()?;
3447
3448 let (kind, object) = match tail.split_once('|') {
3450 Some((k, o)) => {
3451 let o = o.trim();
3452 (
3453 k.trim().to_string(),
3454 if o.is_empty() {
3455 None
3456 } else {
3457 Some(o.to_string())
3458 },
3459 )
3460 }
3461 None => (tail.to_string(), None),
3462 };
3463 if kind.is_empty() {
3464 return None;
3465 }
3466 Some((ts, kind, object))
3467}
3468
3469fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3479 let mut files = vec![PathBuf::from("log.md")];
3480 let archive_dir = Path::new("log");
3481 if let Ok(entries) = store.regular_file_names(archive_dir) {
3482 let mut archives: Vec<PathBuf> = entries
3483 .into_iter()
3484 .filter(|name| {
3485 name.to_str()
3486 .and_then(|n| n.strip_suffix(".md"))
3487 .is_some_and(is_year_month_archive)
3488 })
3489 .map(|name| archive_dir.join(name))
3490 .collect();
3491 archives.sort();
3495 files.extend(archives);
3496 }
3497 files.retain(|path| store.regular_file_exists(path).unwrap_or(false));
3498 files
3499}
3500
3501fn is_year_month_archive(s: &str) -> bool {
3504 let b = s.as_bytes();
3505 b.len() == 7
3506 && b[..4].iter().all(u8::is_ascii_digit)
3507 && b[4] == b'-'
3508 && b[5..7].iter().all(u8::is_ascii_digit)
3509}
3510
3511fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3517 let mut latest: Option<DateTime<FixedOffset>> = None;
3518 for file in log_files_for_working_set(store) {
3519 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3520 continue;
3521 };
3522 for line in text.lines() {
3523 if !line.starts_with("## [") {
3524 continue;
3525 }
3526 if let Some((ts, kind, _)) = parse_log_header(line) {
3527 if kind == "validate" {
3528 latest = Some(match latest {
3529 Some(p) if p >= ts => p,
3530 _ => ts,
3531 });
3532 }
3533 }
3534 }
3535 }
3536 latest
3537}
3538
3539fn changed_objects_since(
3550 store: &Store,
3551 cutoff: Option<DateTime<FixedOffset>>,
3552) -> BTreeSet<PathBuf> {
3553 let mut out = BTreeSet::new();
3554 for file in log_files_for_working_set(store) {
3555 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3556 continue;
3557 };
3558 for line in text.lines() {
3559 if !line.starts_with("## [") {
3560 continue;
3561 }
3562 let Some((ts, kind, object)) = parse_log_header(line) else {
3563 continue;
3564 };
3565 if let Some(c) = cutoff {
3566 if ts < c {
3567 continue;
3568 }
3569 }
3570 if !matches!(
3571 kind.as_str(),
3572 "create" | "update" | "ingest" | "rename" | "delete" | "link"
3573 ) {
3574 continue;
3575 }
3576 if let Some(obj) = object {
3577 let bare = obj
3579 .trim()
3580 .trim_start_matches("[[")
3581 .trim_end_matches("]]")
3582 .split('|')
3583 .next()
3584 .unwrap_or("")
3585 .trim()
3586 .trim_end_matches(".md")
3587 .to_string();
3588 if bare.is_empty() {
3589 continue;
3590 }
3591 if let Some(rel) = safe_md_target_rel(&bare) {
3601 out.insert(rel);
3602 }
3603 }
3604 }
3605 }
3606 out
3607}
3608
3609#[derive(Debug, Clone, PartialEq, Eq)]
3614pub struct DerivedFromIgnored {
3615 pub target: String,
3618 pub target_type: String,
3621}
3622
3623pub fn derived_from_ignored_type<I, S>(
3637 store: &Store,
3638 meta_type: &str,
3639 derived_from_targets: I,
3640) -> Option<DerivedFromIgnored>
3641where
3642 I: IntoIterator<Item = S>,
3643 S: AsRef<str>,
3644{
3645 if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3646 return None;
3647 }
3648 for target in derived_from_targets {
3649 let target = target.as_ref();
3650 if let Some(target_type) = link_target_type(store, target) {
3651 if store.config.ignored_types.contains(&target_type) {
3652 return Some(DerivedFromIgnored {
3653 target: target.to_string(),
3654 target_type,
3655 });
3656 }
3657 }
3658 }
3659 None
3660}
3661
3662fn link_target_type(store: &Store, target: &str) -> Option<String> {
3664 let bare = target.trim_end_matches(".md");
3665 let rel = safe_md_target_rel(bare)?;
3666 let text = store
3667 .read_text_bounded(&rel, crate::parser::MAX_DBMD_FILE_BYTES)
3668 .ok()?;
3669 let (yaml, _, _) = split_frontmatter(&text)?;
3670 let value: Value = serde_norway::from_str(&yaml).ok()?;
3671 if let Value::Mapping(m) = value {
3672 m.get(Value::String("type".into())).and_then(scalar_string)
3673 } else {
3674 None
3675 }
3676}
3677
3678fn is_iso8601(s: &str) -> bool {
3683 DateTime::parse_from_rfc3339(s.trim()).is_ok()
3684}
3685
3686fn is_iso8601_date_or_datetime(s: &str) -> bool {
3690 let s = s.trim();
3691 if DateTime::parse_from_rfc3339(s).is_ok() {
3692 return true;
3693 }
3694 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3695}
3696
3697fn is_email(s: &str) -> bool {
3702 let s = s.trim();
3703 let Some((local, domain)) = s.split_once('@') else {
3704 return false;
3705 };
3706 !local.is_empty()
3707 && !domain.contains('@')
3708 && domain.contains('.')
3709 && !domain.starts_with('.')
3710 && !domain.ends_with('.')
3711 && !domain.contains(' ')
3712 && !local.contains(' ')
3713}
3714
3715fn is_currency(s: &str) -> bool {
3722 let mut t = s.trim();
3723 for sym in ["$", "€", "£", "¥"] {
3725 if let Some(rest) = t.strip_prefix(sym) {
3726 t = rest.trim_start();
3727 break;
3728 }
3729 }
3730 if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3734 if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3735 t = rest.trim_start();
3736 }
3737 }
3738
3739 let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3740 is_plain_amount(cleaned.trim())
3741}
3742
3743fn is_plain_amount(s: &str) -> bool {
3746 let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3747 let (int_part, frac_part) = match digits.split_once('.') {
3748 Some((i, f)) => (i, Some(f)),
3749 None => (digits, None),
3750 };
3751 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3752 return false;
3753 }
3754 match frac_part {
3755 None => true,
3756 Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3757 }
3758}
3759
3760fn is_url(s: &str) -> bool {
3766 let s = s.trim();
3767 for scheme in ["http://", "https://"] {
3768 if let Some(rest) = s.strip_prefix(scheme) {
3769 return !rest.is_empty();
3770 }
3771 }
3772 false
3773}
3774
3775fn shape_suggestion(shape: Shape) -> String {
3777 match shape {
3778 Shape::String => "use a scalar string".into(),
3779 Shape::Int => "use an integer".into(),
3780 Shape::Bool => "use `true` or `false`".into(),
3781 Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3782 Shape::Email => "use a `<local>@<domain>` address".into(),
3783 Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3784 Shape::Url => "use an http(s) URL".into(),
3785 }
3786}
3787
3788fn short_form_suggestion(bare: &str) -> Option<String> {
3791 Some(format!(
3792 "use a full store-relative path, e.g. [[records/contacts/{}]]",
3793 slugish(bare)
3794 ))
3795}
3796
3797fn slugish(s: &str) -> String {
3799 s.trim()
3800 .to_lowercase()
3801 .chars()
3802 .map(|c| if c.is_whitespace() { '-' } else { c })
3803 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3804 .collect()
3805}
3806
3807fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3813 use crate::assets;
3814
3815 let manifest_rel = Path::new(assets::MANIFEST_FILE);
3816 let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3818 if store.regular_file_exists(manifest_rel).unwrap_or(false) {
3819 if let Ok(text) = store.read_text_bounded(manifest_rel, crate::parser::MAX_DBMD_FILE_BYTES)
3820 {
3821 for (i, line) in text.lines().enumerate() {
3822 if line.trim().is_empty() {
3823 continue;
3824 }
3825 match serde_json::from_str::<assets::AssetRecord>(line) {
3826 Ok(rec) => {
3827 manifest.insert(rec.path.clone(), rec);
3828 }
3829 Err(e) => push(
3830 issues,
3831 Severity::Error,
3832 codes::ASSET_MANIFEST_MALFORMED,
3833 manifest_rel,
3834 Some((i as u32) + 1),
3835 None,
3836 format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3837 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3838 vec![],
3839 ),
3840 }
3841 }
3842 }
3843 }
3844
3845 let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3848 for (rel, p) in parsed {
3849 let Some(map) = &p.fm else {
3850 continue;
3851 };
3852 for decl in assets::declarations_from_yaml_map(map) {
3853 let norm = match assets::normalize_asset_path(&decl.path) {
3854 Ok(n) => n,
3855 Err(_) => continue, };
3857 declared.insert(norm.clone());
3858 let is_md = Path::new(&norm)
3859 .extension()
3860 .and_then(|e| e.to_str())
3861 .map(|e| e.eq_ignore_ascii_case("md"))
3862 .unwrap_or(false);
3863 if is_md {
3864 push(
3865 issues,
3866 Severity::Warning,
3867 codes::ASSET_PATH_IS_CONTENT,
3868 rel,
3869 None,
3870 Some("asset".to_string()),
3871 format!("asset path `{norm}` points at a markdown content file"),
3872 Some("assets are raw binaries; reference a non-markdown path".to_string()),
3873 vec![PathBuf::from(&norm)],
3874 );
3875 }
3876 if !manifest.contains_key(&norm) {
3877 push(
3878 issues,
3879 Severity::Error,
3880 codes::ASSET_UNDECLARED,
3881 rel,
3882 None,
3883 Some("asset".to_string()),
3884 format!(
3885 "references asset `{norm}` with no record in {}",
3886 assets::MANIFEST_FILE
3887 ),
3888 Some("run `dbmd assets scan` to catalog it".to_string()),
3889 vec![PathBuf::from(&norm)],
3890 );
3891 }
3892 }
3893 }
3894
3895 for (path, rec) in &manifest {
3897 for w in &rec.wrappers {
3898 if !store.regular_file_exists(Path::new(w)).unwrap_or(false) {
3899 push(
3900 issues,
3901 Severity::Error,
3902 codes::ASSET_WRAPPER_BROKEN,
3903 Path::new(path),
3904 None,
3905 None,
3906 format!("manifest record for `{path}` names a missing wrapper `{w}`"),
3907 Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
3908 vec![PathBuf::from(w)],
3909 );
3910 }
3911 }
3912 if !declared.contains(path) {
3913 push(
3914 issues,
3915 Severity::Warning,
3916 codes::ASSET_MANIFEST_ORPHAN,
3917 Path::new(path),
3918 None,
3919 None,
3920 format!(
3921 "`{path}` is in {} but no wrapper references it",
3922 assets::MANIFEST_FILE
3923 ),
3924 Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
3925 vec![],
3926 );
3927 }
3928 }
3929}
3930
3931#[allow(clippy::too_many_arguments)]
3933fn push(
3934 issues: &mut Vec<Issue>,
3935 severity: Severity,
3936 code: &'static str,
3937 file: &Path,
3938 line: Option<u32>,
3939 key: Option<String>,
3940 message: String,
3941 suggestion: Option<String>,
3942 related: Vec<PathBuf>,
3943) {
3944 issues.push(Issue {
3945 severity,
3946 code,
3947 file: file.to_path_buf(),
3948 line,
3949 key,
3950 message,
3951 suggestion,
3952 related,
3953 });
3954}
3955
3956fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
3959 for (i, line) in fm_yaml.lines().enumerate() {
3960 let trimmed = line.trim_start();
3961 if let Some(rest) = trimmed.strip_prefix(key) {
3963 if rest.starts_with(':') && line.starts_with(key) {
3964 return Some((i as u32) + 2);
3966 }
3967 }
3968 }
3969 None
3970}
3971
3972fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
3978 fm_key_line(fm_yaml, key).or(Some(1))
3979}
3980
3981fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
3984 a.file
3985 .cmp(&b.file)
3986 .then(a.line.cmp(&b.line))
3987 .then(a.code.cmp(b.code))
3988 .then(a.key.cmp(&b.key))
3989}
3990
3991#[cfg(test)]
3996mod tests {
3997 use super::*;
3998 use crate::parser::{Config, FieldSpec};
3999 use std::fs;
4000 use tempfile::TempDir;
4001
4002 #[test]
4003 fn split_frontmatter_tolerates_leading_bom() {
4004 let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
4009 let parsed = split_frontmatter(text);
4010 assert!(
4011 parsed.is_some(),
4012 "a leading BOM must not hide frontmatter from validate"
4013 );
4014 let (yaml, body, close_line) = parsed.unwrap();
4015 assert_eq!(yaml, "type: contact\nsummary: hi\n");
4016 assert_eq!(body, "body");
4017 assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
4018 }
4019
4020 struct Fixture {
4023 dir: TempDir,
4024 config: Config,
4025 }
4026
4027 impl Fixture {
4028 fn new() -> Self {
4033 let dir = TempDir::new().unwrap();
4034 fs::write(
4035 dir.path().join("DB.md"),
4036 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
4037 )
4038 .unwrap();
4039 for layer in ["sources", "records"] {
4040 fs::create_dir_all(dir.path().join(layer)).unwrap();
4041 }
4042 Fixture {
4043 dir,
4044 config: Config::default(),
4045 }
4046 }
4047
4048 fn bare() -> Self {
4050 let dir = TempDir::new().unwrap();
4051 Fixture {
4052 dir,
4053 config: Config::default(),
4054 }
4055 }
4056
4057 fn write(&self, rel: &str, contents: &str) {
4059 let abs = self.dir.path().join(rel);
4060 fs::create_dir_all(abs.parent().unwrap()).unwrap();
4061 fs::write(abs, contents).unwrap();
4062 }
4063
4064 fn store(&self) -> Store {
4065 Store::from_root_and_config(self.dir.path(), self.config.clone()).unwrap()
4066 }
4067
4068 fn store_all(&self) -> Vec<Issue> {
4069 validate_all(&self.store()).unwrap()
4070 }
4071
4072 fn rebuild_indexes(&self) {
4079 crate::index::Index::rebuild_all(&self.store()).unwrap();
4080 }
4081 }
4082
4083 fn has(issues: &[Issue], code: &str) -> bool {
4085 issues.iter().any(|i| i.code == code)
4086 }
4087
4088 fn count(issues: &[Issue], code: &str) -> usize {
4090 issues.iter().filter(|i| i.code == code).count()
4091 }
4092
4093 fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
4095 issues
4096 .iter()
4097 .find(|i| i.code == code)
4098 .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
4099 }
4100
4101 fn valid_contact(summary: &str) -> String {
4103 format!(
4104 "---\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"
4105 )
4106 }
4107
4108 #[test]
4111 fn not_a_store_when_db_md_absent() {
4112 let fx = Fixture::bare();
4113 let issues = fx.store_all();
4114 assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
4115 assert_eq!(issues[0].code, codes::NOT_A_STORE);
4116 assert!(issues[0].is_error());
4117 }
4118
4119 #[test]
4120 fn working_set_also_reports_not_a_store() {
4121 let fx = Fixture::bare();
4122 let issues = validate_working_set(&fx.store(), None).unwrap();
4123 assert!(has(&issues, codes::NOT_A_STORE));
4124 }
4125
4126 #[test]
4127 fn both_scopes_report_nested_store_without_validating_its_content() {
4128 let fx = Fixture::new();
4129 fx.write(
4130 "records/nested/DB.md",
4131 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
4132 );
4133 fx.write("records/nested/records/notes/bad.md", "not frontmatter");
4136
4137 for issues in [
4138 validate_working_set(&fx.store(), None).unwrap(),
4139 validate_all(&fx.store()).unwrap(),
4140 ] {
4141 assert_eq!(count(&issues, codes::NESTED_STORE), 1, "{issues:#?}");
4142 assert_eq!(
4143 find(&issues, codes::NESTED_STORE).file,
4144 PathBuf::from("records/nested/DB.md")
4145 );
4146 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4147 }
4148 }
4149
4150 #[test]
4151 fn clean_store_has_no_issues() {
4152 let fx = Fixture::new();
4153 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4154 fx.rebuild_indexes();
4158 let issues = fx.store_all();
4159 assert!(
4160 issues.is_empty(),
4161 "expected a clean store, got: {issues:#?}"
4162 );
4163 }
4164
4165 #[test]
4173 fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
4174 let fx = Fixture::new();
4175 let body = |mt: &str| {
4176 format!(
4177 "---\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"
4178 )
4179 };
4180
4181 for ok in ["fact", "operational", "conclusion"] {
4183 fx.write("records/profiles/ok.md", &body(ok));
4184 let issues = validate_working_set(&fx.store(), None).unwrap();
4185 assert!(
4186 !has(&issues, codes::FM_BAD_META_TYPE),
4187 "`meta-type: {ok}` must be accepted; got {issues:#?}"
4188 );
4189 }
4190 fx.write(
4191 "records/profiles/absent.md",
4192 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
4193 );
4194 assert!(
4195 !has(
4196 &validate_working_set(&fx.store(), None).unwrap(),
4197 codes::FM_BAD_META_TYPE
4198 ),
4199 "an absent meta-type is the default `fact` and must be accepted"
4200 );
4201
4202 for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
4204 let fx2 = Fixture::new();
4205 fx2.write("records/profiles/bad.md", &body(bad));
4206 let issues = validate_working_set(&fx2.store(), None).unwrap();
4207 assert!(
4208 has(&issues, codes::FM_BAD_META_TYPE),
4209 "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
4210 );
4211 }
4212 }
4213
4214 #[test]
4223 fn id_absent_slug_ulid_and_numeric_are_all_silent() {
4224 let body = |id_line: &str| {
4225 format!(
4226 "---\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"
4227 )
4228 };
4229 for (case, id_line) in [
4230 ("absent", ""),
4231 ("slug", "id: sarah-chen\n"),
4232 ("ulid", "id: 01j5qc3v9k4ym8rwbn2tqe6f7d\n"),
4233 ("numeric-scalar", "id: 100\n"),
4234 ] {
4235 let fx = Fixture::new();
4236 fx.write("records/contacts/a.md", &body(id_line));
4237 let issues = validate_working_set(&fx.store(), None).unwrap();
4238 assert!(
4239 !has(&issues, codes::FM_BAD_ID),
4240 "id case `{case}` must be silent; got {issues:#?}"
4241 );
4242 }
4243 }
4244
4245 #[test]
4250 fn id_unusable_as_identifier_warns_fm_bad_id() {
4251 let body = |id_line: &str| {
4252 format!(
4253 "---\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"
4254 )
4255 };
4256 for bad in [
4257 "id: \"\"",
4258 "id: \" \"",
4259 "id: two words",
4260 "id: [a, b]",
4261 "id: {k: v}",
4262 ] {
4263 let fx = Fixture::new();
4264 fx.write("records/contacts/a.md", &body(bad));
4265 let issues = validate_working_set(&fx.store(), None).unwrap();
4266 let issue = issues
4267 .iter()
4268 .find(|i| i.code == codes::FM_BAD_ID)
4269 .unwrap_or_else(|| panic!("`{bad}` must fire FM_BAD_ID; got {issues:#?}"));
4270 assert!(
4271 matches!(issue.severity, Severity::Warning),
4272 "FM_BAD_ID is a warning (additive v0.4 — it must never block a store): {issue:#?}"
4273 );
4274 assert_eq!(issue.key.as_deref(), Some("id"));
4275 assert!(
4276 !issue.is_error(),
4277 "FM_BAD_ID must not fail validation: {issue:#?}"
4278 );
4279 }
4280 }
4281
4282 #[test]
4286 fn dup_id_fires_on_shared_ulid_ids() {
4287 let fx = Fixture::new();
4288 let rec = |name: &str| {
4289 format!(
4290 "---\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"
4291 )
4292 };
4293 fx.write("records/contacts/a.md", &rec("A"));
4294 fx.write("records/contacts/b.md", &rec("B"));
4295 let issues = fx.store_all();
4296 assert_eq!(count(&issues, codes::DUP_ID), 1, "{issues:#?}");
4297 let issue = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4298 assert!(issue.is_error());
4299 assert!(!has(&issues, codes::FM_BAD_ID), "{issues:#?}");
4301 }
4302
4303 #[test]
4309 fn valid_db_md_emits_no_structure_issue() {
4310 let fx = Fixture::new();
4311 let issues = fx.store_all();
4312 assert!(
4313 !has(&issues, codes::DB_MD_BAD_TYPE)
4314 && !has(&issues, codes::DB_MD_MISSING_FIELD)
4315 && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4316 "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4317 );
4318 }
4319
4320 #[test]
4324 fn db_md_wrong_type_is_error() {
4325 let fx = Fixture::new();
4326 fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4327 let issues = fx.store_all();
4328 let i = find(&issues, codes::DB_MD_BAD_TYPE);
4329 assert!(i.is_error());
4330 assert_eq!(i.file, PathBuf::from("DB.md"));
4331 assert_eq!(i.key.as_deref(), Some("type"));
4332 assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4333 }
4334
4335 #[test]
4338 fn db_md_missing_scope_and_owner_each_report() {
4339 let fx = Fixture::new();
4340 fx.write("DB.md", "---\ntype: db-md\n---\n");
4341 let issues = fx.store_all();
4342 assert_eq!(
4343 count(&issues, codes::DB_MD_MISSING_FIELD),
4344 2,
4345 "both scope and owner absent → two issues: {issues:#?}"
4346 );
4347 let keys: BTreeSet<Option<String>> = issues
4348 .iter()
4349 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4350 .map(|i| i.key.clone())
4351 .collect();
4352 assert_eq!(
4353 keys,
4354 BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4355 "one issue keyed on each missing field"
4356 );
4357 for i in issues
4358 .iter()
4359 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4360 {
4361 assert!(i.is_error());
4362 assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4363 }
4364 }
4365
4366 #[test]
4370 fn db_md_blank_required_field_is_missing() {
4371 let fx = Fixture::new();
4372 fx.write(
4373 "DB.md",
4374 "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4375 );
4376 let issues = fx.store_all();
4377 let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4378 assert_eq!(i.key.as_deref(), Some("owner"));
4379 assert_eq!(
4380 i.line,
4381 Some(4),
4382 "a present-but-empty field anchors to its line"
4383 );
4384 assert!(
4385 count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4386 "scope is present and non-empty → only owner reported"
4387 );
4388 }
4389
4390 #[test]
4393 fn db_md_unknown_section_is_warning() {
4394 let fx = Fixture::new();
4395 fx.write(
4396 "DB.md",
4397 "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4401 );
4402 let issues = fx.store_all();
4403 let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4404 assert!(!i.is_error(), "unknown section is a warning, not an error");
4405 assert_eq!(i.severity, Severity::Warning);
4406 assert_eq!(
4407 i.line,
4408 Some(11),
4409 "anchors to the `## Glossary` heading line"
4410 );
4411 assert!(
4412 i.message.contains("Glossary"),
4413 "the message names the offending section: {}",
4414 i.message
4415 );
4416 assert_eq!(
4418 count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4419 1,
4420 "only the unrecognized section is flagged: {issues:#?}"
4421 );
4422 }
4423
4424 #[test]
4427 fn db_md_no_frontmatter_reports_type_and_both_fields() {
4428 let fx = Fixture::new();
4429 fx.write("DB.md", "# just a heading, no frontmatter\n");
4430 let issues = fx.store_all();
4431 assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4432 assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4433 }
4434
4435 #[test]
4438 fn missing_type_is_error() {
4439 let fx = Fixture::new();
4440 fx.write(
4441 "records/contacts/a.md",
4442 "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4443 );
4444 let issues = fx.store_all();
4445 assert!(has(&issues, codes::FM_MISSING_TYPE));
4446 assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4447 }
4448
4449 #[test]
4450 fn missing_universal_timestamps_are_errors_on_content_files() {
4451 let fx = Fixture::new();
4452 fx.write(
4453 "records/contacts/a.md",
4454 "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4455 );
4456 let issues = fx.store_all();
4457
4458 let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4459 assert_eq!(missing_created.key.as_deref(), Some("created"));
4460 assert!(missing_created.is_error());
4461
4462 let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4463 assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4464 assert!(missing_updated.is_error());
4465 }
4466
4467 #[test]
4468 fn meta_files_do_not_require_universal_timestamps() {
4469 let fx = Fixture::new();
4470 let issues = fx.store_all();
4471
4472 assert!(
4473 !has(&issues, codes::FM_MISSING_CREATED),
4474 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4475 );
4476 assert!(
4477 !has(&issues, codes::FM_MISSING_UPDATED),
4478 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4479 );
4480 }
4481
4482 #[test]
4483 fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4484 let fx = Fixture::new();
4485 fx.write(
4486 "records/profiles/a.md",
4487 "# Just a heading\n\nNo frontmatter here.\n",
4488 );
4489 let issues = fx.store_all();
4490 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4491 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4492 }
4493
4494 #[test]
4495 fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4496 let fx = Fixture::new();
4497 fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4498 let issues = fx.store_all();
4499 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4500 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4501 }
4502
4503 #[test]
4504 fn malformed_yaml_is_error_and_suppresses_field_checks() {
4505 let fx = Fixture::new();
4506 fx.write(
4508 "records/contacts/a.md",
4509 "---\ntype: contact\n bad: : : :\n: : nope\n---\n\nbody\n",
4510 );
4511 let issues = fx.store_all();
4512 let issue = find(&issues, codes::FM_MALFORMED_YAML);
4513 assert!(issue.is_error());
4514 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4515 assert!(
4518 !has(&issues, codes::SUMMARY_MISSING),
4519 "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4520 );
4521 }
4522
4523 #[test]
4524 fn bad_created_timestamp_is_error() {
4525 let fx = Fixture::new();
4526 fx.write(
4527 "records/contacts/a.md",
4528 "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4529 );
4530 let issues = fx.store_all();
4531 let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4532 assert_eq!(issue.key.as_deref(), Some("created"));
4533 assert!(issue.is_error());
4534 }
4535
4536 #[test]
4537 fn date_only_created_is_rejected_but_type_date_field_accepted() {
4538 let fx = Fixture::new();
4539 fx.write(
4542 "records/contacts/a.md",
4543 "---\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",
4544 );
4545 let issues = fx.store_all();
4546 let created_issues: Vec<_> = issues
4547 .iter()
4548 .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4549 .collect();
4550 assert_eq!(
4551 created_issues.len(),
4552 1,
4553 "date-only `created` must fail: {issues:#?}"
4554 );
4555 assert!(
4556 !issues.iter().any(
4557 |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4558 ),
4559 "date-only `last_touch` is valid: {issues:#?}"
4560 );
4561 }
4562
4563 #[test]
4566 fn summary_missing_empty_multiline_toolong() {
4567 let fx = Fixture::new();
4568 fx.write(
4569 "records/profiles/missing.md",
4570 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4571 );
4572 fx.write(
4573 "records/profiles/empty.md",
4574 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \" \"\n---\n\nbody\n",
4575 );
4576 let long = "x".repeat(201);
4577 fx.write(
4578 "records/profiles/long.md",
4579 &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"),
4580 );
4581 let issues = fx.store_all();
4582 assert!(has(&issues, codes::SUMMARY_MISSING));
4583 assert_eq!(
4584 find(&issues, codes::SUMMARY_MISSING).file,
4585 PathBuf::from("records/profiles/missing.md")
4586 );
4587 assert!(has(&issues, codes::SUMMARY_EMPTY));
4588 assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4589 assert_eq!(
4590 find(&issues, codes::SUMMARY_TOO_LONG).severity,
4591 Severity::Warning
4592 );
4593 }
4594
4595 #[test]
4596 fn summary_multiline_via_yaml_block_scalar() {
4597 let fx = Fixture::new();
4598 fx.write(
4600 "records/profiles/a.md",
4601 "---\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",
4602 );
4603 let issues = fx.store_all();
4604 assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4605 }
4606
4607 #[test]
4608 fn summary_exactly_200_chars_is_ok() {
4609 let fx = Fixture::new();
4610 let s = "y".repeat(200);
4611 fx.write(
4612 "records/profiles/a.md",
4613 &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"),
4614 );
4615 let issues = fx.store_all();
4616 assert!(
4617 !has(&issues, codes::SUMMARY_TOO_LONG),
4618 "200 is the bound, inclusive: {issues:#?}"
4619 );
4620 }
4621
4622 #[test]
4623 fn meta_files_need_no_summary() {
4624 let fx = Fixture::new();
4625 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4628 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4629 fx.write(
4630 "records/index.md",
4631 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4632 );
4633 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4634 fx.write(
4635 "records/contacts/index.jsonl",
4636 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4637 );
4638 fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4639 let issues = fx.store_all();
4640 assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4641 }
4642
4643 #[test]
4646 fn nested_tags_warns_flat_tags_ok() {
4647 let fx = Fixture::new();
4648 fx.write(
4649 "records/contacts/nested.md",
4650 "---\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",
4651 );
4652 fx.write(
4653 "records/contacts/flat.md",
4654 "---\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",
4655 );
4656 let issues = fx.store_all();
4657 let tag_issues: Vec<_> = issues
4658 .iter()
4659 .filter(|i| i.code == codes::TAGS_MALFORMED)
4660 .collect();
4661 assert_eq!(
4662 tag_issues.len(),
4663 1,
4664 "only the nested-tags file should warn: {issues:#?}"
4665 );
4666 assert_eq!(
4667 tag_issues[0].file,
4668 PathBuf::from("records/contacts/nested.md")
4669 );
4670 assert_eq!(tag_issues[0].severity, Severity::Warning);
4671 }
4672
4673 #[test]
4676 fn short_form_wiki_link_is_error() {
4677 let fx = Fixture::new();
4678 let mut body = valid_contact("links to a short form");
4679 body.push_str("\nSee [[sarah-chen]] for details.\n");
4680 fx.write("records/contacts/a.md", &body);
4681 let issues = fx.store_all();
4682 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4683 assert!(issue.is_error());
4684 assert!(issue.message.contains("sarah-chen"));
4685 assert!(
4687 !issues
4688 .iter()
4689 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4690 "short-form should suppress broken: {issues:#?}"
4691 );
4692 }
4693
4694 #[test]
4695 fn broken_full_path_wiki_link_is_error() {
4696 let fx = Fixture::new();
4697 let mut body = valid_contact("links to a missing file");
4698 body.push_str("\nSee [[records/contacts/ghost]].\n");
4699 fx.write("records/contacts/a.md", &body);
4700 let issues = fx.store_all();
4701 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4702 assert!(issue.is_error());
4703 assert!(issue.message.contains("records/contacts/ghost"));
4704 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4705 }
4706
4707 #[test]
4708 fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4709 let fx = Fixture::new();
4710 let mut body = valid_contact("links with traversal");
4711 body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4712 fx.write("records/contacts/a.md", &body);
4713 let issues = fx.store_all();
4714 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4715 assert!(issue.message.contains("not a safe store-relative path"));
4716 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4717 }
4718
4719 #[test]
4720 fn valid_full_path_wiki_link_passes() {
4721 let fx = Fixture::new();
4722 fx.write("records/contacts/target.md", &valid_contact("target"));
4723 let mut body = valid_contact("links to target");
4724 body.push_str("\nSee [[records/contacts/target]].\n");
4725 fx.write("records/contacts/a.md", &body);
4726 let issues = fx.store_all();
4727 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4728 assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4729 }
4730
4731 #[test]
4732 fn md_extension_wiki_link_warns_and_resolves() {
4733 let fx = Fixture::new();
4734 fx.write("records/contacts/target.md", &valid_contact("target"));
4735 let mut body = valid_contact("links with extension");
4736 body.push_str("\nSee [[records/contacts/target.md]].\n");
4737 fx.write("records/contacts/a.md", &body);
4738 let issues = fx.store_all();
4739 let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4740 assert_eq!(issue.severity, Severity::Warning);
4741 assert_eq!(
4742 issue.suggestion.as_deref(),
4743 Some("drop the extension: [[records/contacts/target]]")
4744 );
4745 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4747 }
4748
4749 #[test]
4750 fn wiki_links_in_code_fences_are_ignored() {
4751 let fx = Fixture::new();
4752 let mut body = valid_contact("has a fenced example");
4753 body.push_str("\n```\n[[sarah-chen]]\n```\n");
4754 fx.write("records/contacts/a.md", &body);
4755 let issues = fx.store_all();
4756 assert!(
4757 !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4758 "fenced wiki-links must be ignored: {issues:#?}"
4759 );
4760 }
4761
4762 #[test]
4763 fn flow_form_link_list_in_frontmatter_is_error() {
4764 let fx = Fixture::new();
4765 fx.write(
4766 "records/meetings/m.md",
4767 "---\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",
4768 );
4769 let issues = fx.store_all();
4770 let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4771 assert!(issue.is_error());
4772 assert_eq!(issue.key.as_deref(), Some("attendees"));
4773 }
4774
4775 #[test]
4776 fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4777 let fx = Fixture::new();
4778 fx.write("records/contacts/a.md", &valid_contact("a"));
4779 fx.write("records/contacts/b.md", &valid_contact("b"));
4780 fx.write(
4781 "records/meetings/m.md",
4782 "---\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",
4783 );
4784 let issues = fx.store_all();
4785 assert!(
4786 !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4787 "{issues:#?}"
4788 );
4789 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4791 }
4792
4793 #[test]
4794 fn frontmatter_short_form_link_field_is_error() {
4795 let fx = Fixture::new();
4796 fx.write(
4799 "records/synthesis/a.md",
4800 "---\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",
4801 );
4802 let issues = fx.store_all();
4803 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4804 assert!(issue.is_error());
4805 assert_eq!(issue.key.as_deref(), Some("related"));
4806 }
4807
4808 #[test]
4809 fn unquoted_frontmatter_link_is_recognized() {
4810 let fx = Fixture::new();
4815 fx.write(
4816 "records/synthesis/short.md",
4817 "---\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",
4818 );
4819 fx.write(
4820 "records/synthesis/broken.md",
4821 "---\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",
4822 );
4823 let issues = fx.store_all();
4824 assert!(
4825 issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4826 && i.file == Path::new("records/synthesis/short.md")
4827 && i.key.as_deref() == Some("related")),
4828 "unquoted short-form frontmatter link must be caught: {issues:#?}"
4829 );
4830 assert!(
4831 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4832 && i.file == Path::new("records/synthesis/broken.md")),
4833 "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4834 );
4835 }
4836
4837 #[test]
4838 fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4839 let mut fx = Fixture::new();
4844 fx.config.schemas.insert(
4845 "contact".into(),
4846 Schema {
4847 fields: vec![FieldSpec {
4848 name: "company".into(),
4849 link_prefix: Some(PathBuf::from("records/companies")),
4850 ..Default::default()
4851 }],
4852 ..Default::default()
4853 },
4854 );
4855 fx.write(
4856 "records/contacts/a.md",
4857 "---\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",
4858 );
4859 let issues = fx.store_all();
4860 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4861 assert_eq!(issue.key.as_deref(), Some("company"));
4862 assert!(
4864 !issues
4865 .iter()
4866 .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4867 && i.key.as_deref() == Some("company")),
4868 "schema link fields are checked once, by the schema path: {issues:#?}"
4869 );
4870 }
4871
4872 #[test]
4873 fn schema_link_field_with_md_extension_still_warns() {
4874 let mut fx = Fixture::new();
4875 fx.config.schemas.insert(
4876 "contact".into(),
4877 Schema {
4878 fields: vec![FieldSpec {
4879 name: "company".into(),
4880 link_prefix: Some(PathBuf::from("records/companies")),
4881 ..Default::default()
4882 }],
4883 ..Default::default()
4884 },
4885 );
4886 fx.write(
4887 "records/companies/acme.md",
4888 "---\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",
4889 );
4890 fx.write(
4891 "records/contacts/a.md",
4892 "---\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",
4893 );
4894 let issues = fx.store_all();
4895 let issue = issues
4896 .iter()
4897 .find(|i| {
4898 i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
4899 })
4900 .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
4901 assert_eq!(issue.severity, Severity::Warning);
4902 assert!(
4903 !issues
4904 .iter()
4905 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
4906 "extensionless existence check should still find acme.md: {issues:#?}"
4907 );
4908 }
4909
4910 #[test]
4913 fn explicit_schema_required_shape_enum() {
4914 let fx = {
4915 let mut fx = Fixture::new();
4916 let schema = Schema {
4919 fields: vec![
4920 FieldSpec {
4921 name: "name".into(),
4922 required: true,
4923 ..Default::default()
4924 },
4925 FieldSpec {
4926 name: "email".into(),
4927 required: true,
4928 shape: Some(Shape::Email),
4929 ..Default::default()
4930 },
4931 FieldSpec {
4932 name: "status".into(),
4933 enum_values: Some(vec!["active".into(), "inactive".into()]),
4934 ..Default::default()
4935 },
4936 ],
4937 ..Default::default()
4938 };
4939 fx.config.schemas.insert("contact".into(), schema);
4940 fx
4941 };
4942 fx.write(
4943 "records/contacts/a.md",
4944 "---\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",
4945 );
4946 let issues = fx.store_all();
4947 assert!(
4949 issues
4950 .iter()
4951 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
4952 && i.key.as_deref() == Some("name")),
4953 "{issues:#?}"
4954 );
4955 assert!(
4957 issues.iter().any(
4958 |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
4959 ),
4960 "{issues:#?}"
4961 );
4962 assert!(
4964 issues
4965 .iter()
4966 .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
4967 && i.key.as_deref() == Some("status")),
4968 "{issues:#?}"
4969 );
4970 }
4971
4972 #[test]
4973 fn schema_without_link_field_allows_plain_value() {
4974 let mut fx = Fixture::new();
4978 fx.config.schemas.insert(
4979 "contact".into(),
4980 Schema {
4981 fields: vec![FieldSpec {
4982 name: "name".into(),
4983 required: true,
4984 ..Default::default()
4985 }],
4986 ..Default::default()
4987 },
4988 );
4989 fx.write(
4990 "records/contacts/a.md",
4991 "---\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",
4992 );
4993 let issues = fx.store_all();
4994 assert!(
4995 !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
4996 "no declared link field for `company` → a plain value is fine: {issues:#?}"
4997 );
4998 }
4999
5000 #[test]
5001 fn schema_link_field_plain_value_is_prefix_mismatch() {
5002 let mut fx = Fixture::new();
5005 fx.config.schemas.insert(
5006 "contact".into(),
5007 Schema {
5008 fields: vec![FieldSpec {
5009 name: "company".into(),
5010 link_prefix: Some(PathBuf::from("records/companies")),
5011 ..Default::default()
5012 }],
5013 ..Default::default()
5014 },
5015 );
5016 fx.write(
5017 "records/contacts/a.md",
5018 "---\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",
5019 );
5020 let issues = fx.store_all();
5021 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5022 assert_eq!(issue.key.as_deref(), Some("company"));
5023 assert!(issue
5024 .suggestion
5025 .as_deref()
5026 .unwrap()
5027 .contains("records/companies/"));
5028 }
5029
5030 #[test]
5031 fn schema_shape_int_and_url_and_currency() {
5032 let mut fx = Fixture::new();
5033 fx.config.schemas.insert(
5034 "widget".into(),
5035 Schema {
5036 fields: vec![
5037 FieldSpec {
5038 name: "qty".into(),
5039 shape: Some(Shape::Int),
5040 ..Default::default()
5041 },
5042 FieldSpec {
5043 name: "site".into(),
5044 shape: Some(Shape::Url),
5045 ..Default::default()
5046 },
5047 FieldSpec {
5048 name: "price".into(),
5049 shape: Some(Shape::Currency),
5050 ..Default::default()
5051 },
5052 ],
5053 ..Default::default()
5054 },
5055 );
5056 fx.write(
5059 "records/widgets/ok.md",
5060 "---\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",
5061 );
5062 fx.write(
5066 "records/widgets/bad.md",
5067 "---\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",
5068 );
5069 let issues = fx.store_all();
5070 let bad_shape: Vec<_> = issues
5071 .iter()
5072 .filter(|i| {
5073 i.code == codes::SCHEMA_SHAPE_MISMATCH
5074 && i.file == Path::new("records/widgets/bad.md")
5075 })
5076 .map(|i| i.key.clone().unwrap_or_default())
5077 .collect();
5078 assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
5079 assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
5080 assert!(
5081 bad_shape.contains(&"price".to_string()),
5082 "inf must be rejected as currency: {issues:#?}"
5083 );
5084 assert!(
5085 !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
5086 && i.file == Path::new("records/widgets/ok.md")),
5087 "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
5088 );
5089 }
5090
5091 #[test]
5092 fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
5093 let mut fx = Fixture::new();
5094 fx.config.schemas.insert(
5095 "contact".into(),
5096 Schema {
5097 fields: vec![
5098 FieldSpec {
5099 name: "email".into(),
5100 required: true,
5101 shape: Some(Shape::Email),
5102 ..Default::default()
5103 },
5104 FieldSpec {
5105 name: "status".into(),
5106 enum_values: Some(vec!["active".into(), "inactive".into()]),
5107 ..Default::default()
5108 },
5109 ],
5110 ..Default::default()
5111 },
5112 );
5113 fx.write(
5117 "records/contacts/bad.md",
5118 "---\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",
5119 );
5120 let issues = fx.store_all();
5121 let mismatched: Vec<_> = issues
5122 .iter()
5123 .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
5124 .map(|i| i.key.clone().unwrap_or_default())
5125 .collect();
5126 assert!(
5127 mismatched.contains(&"email".to_string()),
5128 "list-valued required email must flag: {issues:#?}"
5129 );
5130 assert!(
5131 mismatched.contains(&"status".to_string()),
5132 "list-valued enum must flag: {issues:#?}"
5133 );
5134 }
5135
5136 #[test]
5137 fn is_currency_accepts_codes_and_rejects_non_numeric() {
5138 for ok in [
5140 "100",
5141 "1234.56",
5142 "$1,234.50",
5143 "USD 100", "usd 100", "EUR 9.50",
5146 "£12",
5147 "¥1000",
5148 "-5.00", "+5",
5150 "1,000,000",
5151 ] {
5152 assert!(is_currency(ok), "expected currency: {ok:?}");
5153 }
5154 for bad in [
5157 "inf", "-inf", "infinity", "NaN", "nan", "12.999", "1.2345", "USD", "$", "free", "", " ", "1e3", "1.", ".5", "1 000", "USDD 100", ] {
5168 assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
5169 }
5170 }
5171
5172 #[test]
5175 fn ignored_type_present_is_info() {
5176 let mut fx = Fixture::new();
5177 fx.config.ignored_types.push("temp".into());
5178 fx.write(
5179 "records/temps/x.md",
5180 "---\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",
5181 );
5182 let issues = fx.store_all();
5183 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
5184 assert_eq!(issue.severity, Severity::Info);
5185 assert!(!issue.is_error());
5186 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5187 }
5188
5189 #[test]
5190 fn conclusion_record_derived_from_ignored_type_warns() {
5191 let mut fx = Fixture::new();
5192 fx.config.ignored_types.push("temp".into());
5193 fx.write(
5194 "records/temps/x.md",
5195 "---\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",
5196 );
5197 fx.write(
5201 "records/synthesis/t.md",
5202 "---\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",
5203 );
5204 let issues = fx.store_all();
5205 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
5206 assert_eq!(issue.severity, Severity::Warning);
5207 assert_eq!(issue.key.as_deref(), Some("derived_from"));
5208 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5209 }
5210
5211 #[test]
5219 fn derived_from_ignored_type_is_the_shared_policy_decision() {
5220 let mut fx = Fixture::new();
5221 fx.config.ignored_types.push("secret".into());
5222 fx.write(
5224 "records/secrets/s.md",
5225 "---\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",
5226 );
5227 fx.write(
5229 "records/contacts/c.md",
5230 "---\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",
5231 );
5232 let store = fx.store();
5233
5234 let hit =
5238 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
5239 .expect("conclusion → ignored-type record must match");
5240 assert_eq!(hit.target, "records/secrets/s");
5241 assert_eq!(hit.target_type, "secret");
5242
5243 assert_eq!(
5246 derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
5247 None,
5248 "only conclusion derivation is policed"
5249 );
5250
5251 assert_eq!(
5253 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
5254 None,
5255 "deriving from a non-ignored type is allowed"
5256 );
5257
5258 let hit = derived_from_ignored_type(
5260 &store,
5261 "conclusion",
5262 ["records/contacts/c", "records/secrets/s"],
5263 )
5264 .expect("a later ignored-type target must still be found");
5265 assert_eq!(hit.target, "records/secrets/s");
5266
5267 fx.config.ignored_types.clear();
5269 let store = fx.store();
5270 assert_eq!(
5271 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
5272 None,
5273 "an empty ignored-types policy short-circuits"
5274 );
5275 }
5276
5277 #[test]
5280 fn dup_id_is_hard_error_with_related() {
5281 let fx = Fixture::new();
5282 fx.write(
5283 "records/contacts/a.md",
5284 "---\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",
5285 );
5286 fx.write(
5287 "records/contacts/b.md",
5288 "---\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",
5289 );
5290 let issues = fx.store_all();
5291 assert_eq!(
5294 count(&issues, codes::DUP_ID),
5295 1,
5296 "one issue per group: {issues:#?}"
5297 );
5298 let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
5299 assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
5300 assert!(a.is_error());
5301 assert_eq!(a.key.as_deref(), Some("id"));
5302 assert_eq!(
5303 a.line,
5304 Some(3),
5305 "anchors to the `id` line on the reported file"
5306 );
5307 assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5308 }
5309
5310 #[test]
5311 fn dup_id_not_fired_in_working_set() {
5312 let fx = Fixture::new();
5314 fx.write(
5315 "records/contacts/a.md",
5316 "---\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",
5317 );
5318 fx.write(
5319 "records/contacts/b.md",
5320 "---\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",
5321 );
5322 fx.write(
5324 "log.md",
5325 "---\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",
5326 );
5327 let issues = validate_working_set(&fx.store(), None).unwrap();
5328 assert!(
5329 !has(&issues, codes::DUP_ID),
5330 "DUP_ID is --all only: {issues:#?}"
5331 );
5332 }
5333
5334 #[test]
5335 fn dup_unique_key_single_field_is_warning() {
5336 let mut fx = Fixture::new();
5337 fx.config.schemas.insert(
5339 "contact".into(),
5340 Schema {
5341 unique_keys: vec![vec!["email".into()]],
5342 ..Default::default()
5343 },
5344 );
5345 for (f, name) in [("a", "A"), ("b", "B")] {
5346 fx.write(
5347 &format!("records/contacts/{f}.md"),
5348 &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"),
5349 );
5350 }
5351 let issues = fx.store_all();
5352 assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5355 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5356 assert_eq!(dup.severity, Severity::Warning);
5357 assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5358 assert_eq!(dup.key.as_deref(), Some("email"));
5359 assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5360 }
5361
5362 #[test]
5363 fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5364 let mut fx = Fixture::new();
5365 fx.config.schemas.insert(
5367 "expense".into(),
5368 Schema {
5369 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5370 ..Default::default()
5371 },
5372 );
5373 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");
5374 let exp = |f: &str, amount: &str| {
5375 format!(
5376 "---\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"
5377 )
5378 };
5379 fx.write("records/expenses/e1.md", &exp("e1", "100"));
5380 fx.write("records/expenses/e2.md", &exp("e2", "100"));
5381 fx.write("records/expenses/e3.md", &exp("e3", "200")); let issues = fx.store_all();
5383 assert_eq!(
5386 count(&issues, codes::DUP_UNIQUE_KEY),
5387 1,
5388 "only e1+e2 collide, one issue: {issues:#?}"
5389 );
5390 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5391 assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5392 assert_eq!(
5393 dup.line,
5394 Some(1),
5395 "compound-key collision anchors to line 1"
5396 );
5397 assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5398 assert!(
5399 !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5400 && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5401 "e3 differs on amount and must not collide: {issues:#?}"
5402 );
5403 }
5404
5405 #[test]
5406 fn dup_unique_key_list_field_is_order_independent() {
5407 let mut fx = Fixture::new();
5408 fx.config.schemas.insert(
5410 "meeting".into(),
5411 Schema {
5412 unique_keys: vec![vec!["date".into(), "attendees".into()]],
5413 ..Default::default()
5414 },
5415 );
5416 fx.write("records/contacts/a.md", &valid_contact("a"));
5417 fx.write("records/contacts/b.md", &valid_contact("b"));
5418 let m = |f: &str, order: &str| {
5419 let attendees = if order == "ab" {
5420 " - [[records/contacts/a]]\n - [[records/contacts/b]]"
5421 } else {
5422 " - [[records/contacts/b]]\n - [[records/contacts/a]]"
5423 };
5424 format!(
5425 "---\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"
5426 )
5427 };
5428 fx.write("records/meetings/m1.md", &m("m1", "ab"));
5429 fx.write("records/meetings/m2.md", &m("m2", "ba"));
5430 let issues = fx.store_all();
5431 assert_eq!(
5434 count(&issues, codes::DUP_UNIQUE_KEY),
5435 1,
5436 "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5437 );
5438 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5439 assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5440 assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5441 }
5442
5443 #[test]
5446 fn missing_indexes_at_all_three_levels() {
5447 let fx = Fixture::new();
5448 fx.write("records/contacts/a.md", &valid_contact("a"));
5449 let issues = fx.store_all();
5450 let missing_files: BTreeSet<PathBuf> = issues
5454 .iter()
5455 .filter(|i| i.code == codes::INDEX_MISSING)
5456 .map(|i| i.file.clone())
5457 .collect();
5458 assert!(
5459 missing_files.contains(&PathBuf::from("index.md")),
5460 "{issues:#?}"
5461 );
5462 assert!(
5463 missing_files.contains(&PathBuf::from("records/index.md")),
5464 "{issues:#?}"
5465 );
5466 assert!(
5467 missing_files.contains(&PathBuf::from("records/contacts")),
5468 "{issues:#?}"
5469 );
5470 assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5473 }
5474
5475 #[test]
5476 fn index_stale_entry_and_missing_entry() {
5477 let fx = Fixture::new();
5478 fx.write(
5479 "records/contacts/present.md",
5480 &valid_contact("present contact"),
5481 );
5482 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5484 fx.write(
5485 "records/index.md",
5486 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5487 );
5488 fx.write(
5490 "records/contacts/index.md",
5491 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5492 );
5493 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5494 let issues = fx.store_all();
5495 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5496 assert!(stale.message.contains("ghost"));
5497 assert!(stale.is_error());
5498 let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5499 assert!(
5500 missing.message.contains("present.md"),
5501 "{}",
5502 missing.message
5503 );
5504 }
5505
5506 #[test]
5507 fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5508 let fx = Fixture::new();
5509 fx.write("records/contacts/a.md", &valid_contact("a"));
5510 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5511 fx.write(
5512 "records/index.md",
5513 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5514 );
5515 fx.write(
5516 "records/contacts/index.md",
5517 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5518 );
5519 fx.write(
5520 "records/contacts/index.jsonl",
5521 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5522 );
5523 let issues = fx.store_all();
5524 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5525 assert!(stale.message.contains("not a safe store-relative path"));
5526 }
5527
5528 #[test]
5529 fn index_summary_mismatch() {
5530 let fx = Fixture::new();
5531 fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5532 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5533 fx.write(
5534 "records/index.md",
5535 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5536 );
5537 fx.write(
5538 "records/contacts/index.md",
5539 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5540 );
5541 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5542 let issues = fx.store_all();
5543 let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5544 assert!(issue.is_error());
5545 assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5546 }
5547
5548 #[test]
5549 fn index_summary_match_passes() {
5550 let fx = Fixture::new();
5551 fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5552 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5553 fx.write(
5554 "records/index.md",
5555 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5556 );
5557 fx.write(
5558 "records/contacts/index.md",
5559 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5560 );
5561 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5562 let issues = fx.store_all();
5563 assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5564 }
5565
5566 #[test]
5567 fn index_entry_with_tag_suffix_matches_summary() {
5568 let fx = Fixture::new();
5569 fx.write("records/contacts/a.md", &valid_contact("clean summary"));
5570 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5571 fx.write(
5572 "records/index.md",
5573 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5574 );
5575 fx.write(
5579 "records/contacts/index.md",
5580 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary · #customer\n",
5581 );
5582 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5583 let issues = fx.store_all();
5584 assert!(
5585 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5586 "tag suffix should be stripped: {issues:#?}"
5587 );
5588 }
5589
5590 #[test]
5591 fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5592 let fx = Fixture::new();
5599 fx.write(
5600 "records/contacts/a.md",
5601 &valid_contact("Standup notes · #standup"),
5602 );
5603 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5604 fx.write(
5605 "records/index.md",
5606 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5607 );
5608 fx.write(
5609 "records/contacts/index.md",
5610 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5611 );
5612 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5613 let issues = fx.store_all();
5614 assert!(
5615 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5616 "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5617 );
5618 }
5619
5620 #[test]
5621 fn index_jsonl_desync_missing_file_in_jsonl() {
5622 let fx = Fixture::new();
5623 fx.write("records/contacts/a.md", &valid_contact("a"));
5624 fx.write("records/contacts/b.md", &valid_contact("b"));
5625 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5626 fx.write(
5627 "records/index.md",
5628 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5629 );
5630 fx.write(
5631 "records/contacts/index.md",
5632 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5633 );
5634 fx.write(
5636 "records/contacts/index.jsonl",
5637 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5638 );
5639 let issues = fx.store_all();
5640 let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5641 assert!(desync.message.contains("b.md"), "{}", desync.message);
5642 }
5643
5644 #[test]
5645 fn index_jsonl_desync_record_points_at_missing_file() {
5646 let fx = Fixture::new();
5647 fx.write("records/contacts/a.md", &valid_contact("a"));
5648 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5649 fx.write(
5650 "records/index.md",
5651 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5652 );
5653 fx.write(
5654 "records/contacts/index.md",
5655 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5656 );
5657 fx.write(
5658 "records/contacts/index.jsonl",
5659 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5660 );
5661 let issues = fx.store_all();
5662 assert!(
5663 issues
5664 .iter()
5665 .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5666 "{issues:#?}"
5667 );
5668 }
5669
5670 #[test]
5671 fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5672 let fx = Fixture::new();
5673 fx.write("records/contacts/a.md", &valid_contact("a"));
5674 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5675 fx.write(
5676 "records/index.md",
5677 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5678 );
5679 fx.write(
5680 "records/contacts/index.md",
5681 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5682 );
5683 fx.write(
5684 "records/contacts/index.jsonl",
5685 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5686 );
5687 let issues = fx.store_all();
5688 assert!(
5689 issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5690 && i.message.contains("not a safe store-relative path")),
5691 "{issues:#?}"
5692 );
5693 }
5694
5695 #[test]
5696 fn index_jsonl_stale_summary() {
5697 let fx = Fixture::new();
5698 fx.write("records/contacts/a.md", &valid_contact("real summary"));
5699 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5700 fx.write(
5701 "records/index.md",
5702 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5703 );
5704 fx.write(
5705 "records/contacts/index.md",
5706 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5707 );
5708 fx.write(
5710 "records/contacts/index.jsonl",
5711 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5712 );
5713 let issues = fx.store_all();
5714 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5715 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5716 assert!(stale.key.as_deref().unwrap().contains("summary"));
5717 }
5718
5719 #[test]
5727 fn index_jsonl_stale_queryable_field_email() {
5728 let fx = Fixture::new();
5729 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";
5730 fx.write("records/contacts/a.md", contact);
5731 fx.rebuild_indexes();
5733 let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5734 let good = fs::read_to_string(&jsonl_path).unwrap();
5735 assert!(
5737 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5738 "freshly-rebuilt sidecar must not be stale"
5739 );
5740 assert!(
5742 good.contains("real@correct.com"),
5743 "sidecar projects email: {good}"
5744 );
5745 fx.write(
5746 "records/contacts/index.jsonl",
5747 &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5748 );
5749
5750 let issues = fx.store_all();
5751 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5752 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5753 let key = stale.key.as_deref().unwrap();
5756 assert!(
5757 key.contains("email"),
5758 "expected `email` in stale key, got {key:?}"
5759 );
5760 assert!(!key.contains("summary"), "summary still matches: {key:?}");
5761 assert!(!key.contains("type"), "type still matches: {key:?}");
5762 }
5763
5764 #[test]
5768 fn index_jsonl_stale_typed_and_list_fields() {
5769 let fx = Fixture::new();
5770 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";
5771 fx.write("records/expenses/e.md", expense);
5772 fx.rebuild_indexes();
5773 let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5774 let good = fs::read_to_string(&jsonl_path).unwrap();
5775 assert!(
5776 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5777 "freshly-rebuilt sidecar must not be stale"
5778 );
5779 let stale_line = good
5781 .replace("\"q2\"", "\"WRONG-TAG\"")
5782 .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5783 .replace("1299", "9999");
5784 fx.write("records/expenses/index.jsonl", &stale_line);
5785
5786 let issues = fx.store_all();
5787 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5788 let key = stale.key.as_deref().unwrap();
5789 for expected in ["amount", "tags", "updated"] {
5790 assert!(
5791 key.contains(expected),
5792 "expected `{expected}` in stale key, got {key:?}"
5793 );
5794 }
5795 }
5796
5797 #[test]
5798 fn index_orphan_in_noncanonical_folder() {
5799 let fx = Fixture::new();
5800 fx.write("records/contacts/a.md", &valid_contact("a"));
5801 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5803 fx.write(
5804 "records/index.md",
5805 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5806 );
5807 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5808 fx.write(
5809 "records/contacts/index.jsonl",
5810 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5811 );
5812 fx.write(
5814 "records/contacts/subfolder/index.md",
5815 "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5816 );
5817 let issues = fx.store_all();
5818 let orphan = find(&issues, codes::INDEX_ORPHAN);
5819 assert_eq!(orphan.severity, Severity::Warning);
5820 assert_eq!(
5821 orphan.file,
5822 PathBuf::from("records/contacts/subfolder/index.md")
5823 );
5824 }
5825
5826 #[test]
5827 fn index_wrong_scope() {
5828 let fx = Fixture::new();
5829 fx.write("records/contacts/a.md", &valid_contact("a"));
5830 fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5832 fx.write(
5833 "records/index.md",
5834 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5835 );
5836 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5837 fx.write(
5838 "records/contacts/index.jsonl",
5839 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5840 );
5841 let issues = fx.store_all();
5842 let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5843 assert_eq!(issue.severity, Severity::Warning);
5844 assert_eq!(issue.file, PathBuf::from("index.md"));
5845 }
5846
5847 #[test]
5848 fn capped_type_folder_index_does_not_flag_missing_entries() {
5849 let fx = Fixture::new();
5851 for i in 0..501 {
5852 fx.write(
5853 &format!("records/contacts/c{i:04}.md"),
5854 &valid_contact(&format!("contact {i}")),
5855 );
5856 }
5857 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
5858 fx.write(
5859 "records/index.md",
5860 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5861 );
5862 fx.write(
5864 "records/contacts/index.md",
5865 "---\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",
5866 );
5867 let mut jsonl = String::new();
5869 for i in 0..501 {
5870 jsonl.push_str(&format!(
5871 "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
5872 ));
5873 }
5874 fx.write("records/contacts/index.jsonl", &jsonl);
5875 let issues = fx.store_all();
5876 assert!(
5877 !has(&issues, codes::INDEX_MISSING_ENTRY),
5878 "over the cap, missing browse entries are expected: {issues:#?}"
5879 );
5880 assert!(
5882 !has(&issues, codes::INDEX_JSONL_DESYNC),
5883 "{:#?}",
5884 issues
5885 .iter()
5886 .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
5887 .collect::<Vec<_>>()
5888 );
5889 }
5890
5891 #[test]
5894 fn log_bad_timestamp_unknown_kind_out_of_order() {
5895 let fx = Fixture::new();
5896 fx.write(
5897 "log.md",
5898 concat!(
5899 "---\ntype: log\n---\n\n# Log\n\n",
5900 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5901 "## [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", ),
5905 );
5906 let issues = fx.store_all();
5907 assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5908 assert_eq!(
5909 find(&issues, codes::LOG_OUT_OF_ORDER).severity,
5910 Severity::Warning
5911 );
5912 let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
5913 assert_eq!(unknown.severity, Severity::Warning);
5914 assert!(unknown.message.contains("frobnicate"));
5915 assert!(unknown
5916 .suggestion
5917 .as_deref()
5918 .is_some_and(|s| s.contains("create")));
5919 let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
5920 assert!(bad.is_error());
5921 }
5922
5923 #[test]
5924 fn log_validate_entry_without_object_is_well_formed() {
5925 let fx = Fixture::new();
5926 fx.write(
5927 "log.md",
5928 "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
5929 );
5930 let issues = fx.store_all();
5931 assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
5932 assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
5933 }
5934
5935 #[test]
5936 fn log_in_order_is_clean() {
5937 let fx = Fixture::new();
5938 fx.write(
5939 "log.md",
5940 concat!(
5941 "---\ntype: log\n---\n\n",
5942 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5943 "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
5944 ),
5945 );
5946 let issues = fx.store_all();
5947 assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5948 }
5949
5950 #[test]
5951 fn log_not_checked_in_working_set() {
5952 let fx = Fixture::new();
5954 fx.write(
5955 "log.md",
5956 concat!(
5957 "---\ntype: log\n---\n\n",
5958 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5959 "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
5960 ),
5961 );
5962 let issues = validate_working_set(&fx.store(), None).unwrap();
5963 assert!(
5964 !has(&issues, codes::LOG_OUT_OF_ORDER),
5965 "log ordering is --all only: {issues:#?}"
5966 );
5967 }
5968
5969 #[test]
5972 fn working_set_validates_only_changed_files() {
5973 let fx = Fixture::new();
5974 fx.write(
5977 "records/contacts/dirty.md",
5978 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
5979 );
5980 fx.write(
5981 "records/contacts/unlogged.md",
5982 "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
5983 );
5984 fx.write(
5985 "log.md",
5986 "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
5987 );
5988 let issues = validate_working_set(&fx.store(), None).unwrap();
5989 assert!(
5990 issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
5991 && i.file == Path::new("records/contacts/dirty.md")),
5992 "{issues:#?}"
5993 );
5994 assert!(
5995 !issues
5996 .iter()
5997 .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
5998 "unlogged file must not be in the working set: {issues:#?}"
5999 );
6000 }
6001
6002 #[test]
6003 fn working_set_includes_incoming_linkers_to_changed_path() {
6004 let fx = Fixture::new();
6005 fx.write(
6008 "records/profiles/linker.md",
6009 "---\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",
6010 );
6011 fx.write(
6013 "log.md",
6014 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
6015 );
6016 let issues = validate_working_set(&fx.store(), None).unwrap();
6017 assert!(
6018 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
6019 && i.file == Path::new("records/profiles/linker.md")),
6020 "incoming linker to a removed path must be validated: {issues:#?}"
6021 );
6022 }
6023
6024 #[test]
6025 fn working_set_respects_explicit_since_cutoff() {
6026 let fx = Fixture::new();
6027 fx.write(
6028 "records/contacts/old.md",
6029 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6030 );
6031 fx.write(
6032 "records/contacts/new.md",
6033 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6034 );
6035 fx.write(
6036 "log.md",
6037 concat!(
6038 "---\ntype: log\n---\n\n",
6039 "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
6040 "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
6041 ),
6042 );
6043 let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
6045 let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
6046 assert!(
6047 issues
6048 .iter()
6049 .any(|i| i.file == Path::new("records/contacts/new.md")),
6050 "{issues:#?}"
6051 );
6052 assert!(
6053 !issues
6054 .iter()
6055 .any(|i| i.file == Path::new("records/contacts/old.md")),
6056 "old change is before the cutoff: {issues:#?}"
6057 );
6058 }
6059
6060 #[test]
6061 fn working_set_default_since_is_last_validate_entry() {
6062 let fx = Fixture::new();
6063 fx.write(
6065 "records/contacts/before.md",
6066 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6067 );
6068 fx.write(
6069 "records/contacts/after.md",
6070 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6071 );
6072 fx.write(
6073 "log.md",
6074 concat!(
6075 "---\ntype: log\n---\n\n",
6076 "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
6077 "## [2026-05-21 10:00] validate\nPASS\n\n",
6078 "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
6079 ),
6080 );
6081 let issues = validate_working_set(&fx.store(), None).unwrap();
6082 assert!(
6083 issues
6084 .iter()
6085 .any(|i| i.file == Path::new("records/contacts/after.md")),
6086 "{issues:#?}"
6087 );
6088 assert!(
6089 !issues
6090 .iter()
6091 .any(|i| i.file == Path::new("records/contacts/before.md")),
6092 "change before the last validate entry is outside the default window: {issues:#?}"
6093 );
6094 }
6095
6096 #[test]
6099 fn issues_are_sorted_by_file_then_line() {
6100 let fx = Fixture::new();
6101 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");
6102 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");
6103 let issues = fx.store_all();
6104 let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
6105 let mut sorted = files.clone();
6106 sorted.sort();
6107 assert_eq!(
6108 files, sorted,
6109 "issues must be emitted in a stable file order"
6110 );
6111 }
6112
6113 #[test]
6116 fn frozen_page_is_not_a_validate_error() {
6117 let mut fx = Fixture::new();
6120 fx.config
6121 .frozen_pages
6122 .push(PathBuf::from("records/decisions/d.md"));
6123 fx.write(
6124 "records/decisions/d.md",
6125 "---\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",
6126 );
6127 let issues = fx.store_all();
6128 assert!(
6129 !has(&issues, codes::POLICY_FROZEN_PAGE),
6130 "frozen pages are enforced at write-time, not by validate: {issues:#?}"
6131 );
6132 }
6133
6134 #[test]
6135 fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
6136 let fx = Fixture::new();
6139 fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
6140 let mut body = valid_contact("links to sarah");
6141 body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
6142 fx.write("records/contacts/p.md", &body);
6143 let issues = fx.store_all();
6144 assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
6145 }
6146
6147 #[test]
6150 fn unknown_type_passes_through() {
6151 let fx = Fixture::new();
6155 fx.write(
6156 "records/proposals/x.md",
6157 "---\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",
6158 );
6159 let issues = fx.store_all();
6160 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
6161 assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
6162 assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
6163 assert!(
6165 !issues
6166 .iter()
6167 .any(|i| i.key.as_deref() == Some("custom_field")
6168 || i.key.as_deref() == Some("budget")),
6169 "unknown fields are ambient context: {issues:#?}"
6170 );
6171 }
6172
6173 #[test]
6176 fn incoming_linker_scan_does_not_prefix_match() {
6177 let fx = Fixture::new();
6180 fx.write(
6181 "records/profiles/only-sarah-chen.md",
6182 "---\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",
6183 );
6184 fx.write(
6186 "log.md",
6187 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
6188 );
6189 let issues = validate_working_set(&fx.store(), None).unwrap();
6190 assert!(
6191 !issues
6192 .iter()
6193 .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
6194 "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
6195 );
6196 }
6197
6198 #[test]
6199 fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
6200 let fx = Fixture::new();
6214 fx.write(
6217 "records/contacts/index.md",
6218 "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
6219 );
6220 fx.write(
6222 "log.md",
6223 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
6224 );
6225 let issues = validate_working_set(&fx.store(), None).unwrap();
6226 assert!(
6227 !issues
6228 .iter()
6229 .any(|i| i.file == Path::new("records/contacts/index.md")
6230 && i.code == codes::WIKI_LINK_BROKEN),
6231 "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
6232 working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
6233 );
6234 }
6235
6236 #[test]
6237 fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
6238 let fx = Fixture::new();
6247 fx.write(
6249 "records/profiles/refers-sarah.md",
6250 "---\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",
6251 );
6252 fx.write(
6256 "records/meetings/2026/05/kickoff.md",
6257 "---\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",
6258 );
6259 fx.write(
6261 "log.md",
6262 "---\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",
6263 );
6264
6265 let issues = validate_working_set(&fx.store(), None).unwrap();
6266 assert!(
6267 issues
6268 .iter()
6269 .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
6270 && i.code == codes::WIKI_LINK_BROKEN),
6271 "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
6272 );
6273 assert!(
6274 issues.iter().any(
6275 |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
6276 && i.code == codes::WIKI_LINK_BROKEN
6277 ),
6278 "linker to the SECOND deleted target (typed-field edge) must also be \
6279 pulled in and flagged — proves the scan covers the whole changed set, \
6280 not just one object: {issues:#?}"
6281 );
6282 }
6283
6284 #[test]
6285 fn frontmatter_block_sequence_links_each_get_their_own_line() {
6286 let fx = Fixture::new();
6288 fx.write(
6290 "records/meetings/m.md",
6291 "---\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",
6292 );
6293 let issues = fx.store_all();
6294 let broken_lines: BTreeSet<Option<u32>> = issues
6295 .iter()
6296 .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
6297 .map(|i| i.line)
6298 .collect();
6299 assert_eq!(
6300 broken_lines.len(),
6301 2,
6302 "two distinct broken-link lines: {issues:#?}"
6303 );
6304 }
6305
6306 #[test]
6309 fn null_created_is_missing_not_silently_passed() {
6310 let fx = Fixture::new();
6314 fx.write(
6315 "records/contacts/a.md",
6316 "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6317 );
6318 let issues = fx.store_all();
6319 assert!(
6320 has(&issues, codes::FM_MISSING_CREATED),
6321 "null `created:` must read as missing: {issues:#?}"
6322 );
6323 }
6324
6325 #[test]
6326 fn sequence_created_is_bad_timestamp() {
6327 let fx = Fixture::new();
6329 fx.write(
6330 "records/contacts/a.md",
6331 "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6332 );
6333 let issues = fx.store_all();
6334 assert!(
6335 issues
6336 .iter()
6337 .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6338 "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6339 );
6340 }
6341
6342 #[test]
6345 fn required_field_null_or_empty_collection_is_missing() {
6346 for value in ["", " []", " {}"] {
6351 let mut fx = Fixture::new();
6352 fx.config.schemas.insert(
6353 "contact".into(),
6354 Schema {
6355 fields: vec![FieldSpec {
6356 name: "name".into(),
6357 required: true,
6358 ..Default::default()
6359 }],
6360 ..Default::default()
6361 },
6362 );
6363 fx.write(
6364 "records/contacts/a.md",
6365 &format!(
6366 "---\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"
6367 ),
6368 );
6369 let issues = fx.store_all();
6370 assert!(
6371 issues
6372 .iter()
6373 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6374 && i.key.as_deref() == Some("name")),
6375 "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6376 );
6377 }
6378 }
6379
6380 #[test]
6383 fn wiki_link_to_raw_source_file_resolves() {
6384 let fx = Fixture::new();
6388 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6389 fx.write(
6390 "records/contacts/a.md",
6391 "---\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",
6392 );
6393 let issues = fx.store_all();
6394 assert!(
6395 !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6396 "a link to an existing raw source file must not be broken: {issues:#?}"
6397 );
6398 }
6399
6400 #[test]
6403 fn wrong_case_wiki_link_is_broken_exact_case() {
6404 let fx = Fixture::new();
6410 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6411 let mut body = valid_contact("links with the wrong case");
6412 body.push_str("\nKnows [[records/contacts/BOB]].\n");
6413 fx.write("records/contacts/alice.md", &body);
6414 let issues = fx.store_all();
6415 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6416 assert!(issue.is_error());
6417 assert!(
6418 issue.message.contains("records/contacts/BOB"),
6419 "the wrong-case target must be named in the issue: {issues:#?}"
6420 );
6421 }
6422
6423 #[test]
6424 fn correct_case_wiki_link_still_resolves() {
6425 let fx = Fixture::new();
6429 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6430 let mut body = valid_contact("links with the right case");
6431 body.push_str("\nKnows [[records/contacts/bob]].\n");
6432 fx.write("records/contacts/alice.md", &body);
6433 let issues = fx.store_all();
6434 assert!(
6435 !issues
6436 .iter()
6437 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("contacts/bob")),
6438 "a correct-case link must resolve clean: {issues:#?}"
6439 );
6440 }
6441
6442 #[test]
6443 fn wrong_case_raw_source_wiki_link_is_broken() {
6444 let fx = Fixture::new();
6449 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6450 fx.write(
6451 "records/contacts/a.md",
6452 "---\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",
6453 );
6454 let issues = fx.store_all();
6455 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6456 assert!(issue.is_error());
6457 assert!(
6458 issue.message.contains("2026-05-22-ELENA.eml"),
6459 "the wrong-case raw-source target must be flagged: {issues:#?}"
6460 );
6461 }
6462
6463 #[test]
6466 fn non_utf8_content_file_is_reported() {
6467 let fx = Fixture::new();
6471 let abs = fx.dir.path().join("records/notes/corrupt.md");
6472 fs::create_dir_all(abs.parent().unwrap()).unwrap();
6473 fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6474 let issues = validate_working_set(&fx.store(), None).unwrap();
6475 assert!(
6476 has(&issues, codes::FM_UNREADABLE),
6477 "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6478 );
6479 }
6480
6481 #[test]
6484 fn tilde_fence_containing_backtick_fence_does_not_invert() {
6485 let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6490 let links = extract_wiki_links(body);
6491 assert!(
6492 links.is_empty(),
6493 "wiki-link inside a nested code fence must be skipped: {links:?}"
6494 );
6495 }
6496
6497 #[test]
6500 fn all_sweep_visits_in_layer_log_folder() {
6501 let fx = Fixture::new();
6506 fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6507 let issues = fx.store_all();
6508 assert!(
6509 has(&issues, codes::FM_MISSING_TYPE),
6510 "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6511 );
6512 }
6513
6514 #[test]
6517 fn flow_form_link_list_with_spaces_is_flagged() {
6518 let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6522 assert!(
6523 keys.iter().any(|k| k == "attendees"),
6524 "spaced flow-form list must be detected: {keys:?}"
6525 );
6526 }
6527
6528 #[test]
6531 fn middot_hashtag_summary_tail_round_trips() {
6532 assert_eq!(
6538 extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6539 Some("Standup notes · #standup"),
6540 "a single-spaced middot tail is part of the summary, not a tag block"
6541 );
6542 assert_eq!(
6544 extract_index_entry_summary("— Renewal champion · #renewal #acme").as_deref(),
6545 Some("Renewal champion"),
6546 "the renderer's double-spaced ` · #tag` suffix is stripped"
6547 );
6548 }
6549
6550 #[test]
6553 fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6554 assert!(is_url("http://x"), "an 8-char http URL is valid");
6555 assert!(is_url("https://x"), "a 9-char https URL is valid");
6556 assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6557 assert!(!is_url("https://"), "a bare https scheme is rejected");
6558 }
6559
6560 #[test]
6561 fn email_shape_rejects_double_at() {
6562 assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6563 assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6564 assert!(is_email("sarah@acme.com"), "a normal address still passes");
6565 }
6566
6567 #[test]
6570 fn working_set_does_not_flag_log_md_body_links() {
6571 let fx = Fixture::new();
6577 fx.write("records/contacts/a.md", &valid_contact("A"));
6578 fx.write(
6579 "log.md",
6580 "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6581 );
6582 let issues = validate_working_set(&fx.store(), None).unwrap();
6583 assert!(
6584 !issues
6585 .iter()
6586 .any(|i| i.code == codes::WIKI_LINK_BROKEN
6587 && i.file == std::path::Path::new("log.md")),
6588 "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6589 );
6590 }
6591
6592 #[test]
6595 fn schema_duplicate_field_name_is_flagged() {
6596 let mut fx = Fixture::new();
6597 fx.config.schemas.insert(
6598 "contact".into(),
6599 Schema {
6600 fields: vec![
6601 FieldSpec {
6602 name: "name".into(),
6603 required: true,
6604 ..Default::default()
6605 },
6606 FieldSpec {
6607 name: "name".into(),
6608 ..Default::default()
6609 },
6610 ],
6611 ..Default::default()
6612 },
6613 );
6614 let issues = fx.store_all();
6615 assert!(
6616 issues
6617 .iter()
6618 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6619 "a duplicate schema field name must be flagged: {issues:#?}"
6620 );
6621 }
6622
6623 #[test]
6624 fn schema_unknown_modifier_is_info() {
6625 let mut fx = Fixture::new();
6626 fx.config.schemas.insert(
6627 "contact".into(),
6628 Schema {
6629 fields: vec![FieldSpec {
6630 name: "name".into(),
6631 unknown_modifiers: vec!["requierd".into()],
6632 ..Default::default()
6633 }],
6634 ..Default::default()
6635 },
6636 );
6637 let issues = fx.store_all();
6638 assert!(
6639 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6640 && i.severity == Severity::Info
6641 && i.key.as_deref() == Some("name")),
6642 "an unrecognized schema modifier must surface as Info: {issues:#?}"
6643 );
6644 }
6645
6646 #[test]
6652 fn schema_unique_key_optional_field_is_warning() {
6653 let mut fx = Fixture::new();
6654 fx.config.schemas.insert(
6655 "expense".into(),
6656 Schema {
6657 fields: vec![
6658 FieldSpec {
6659 name: "date".into(),
6660 required: true,
6661 ..Default::default()
6662 },
6663 FieldSpec {
6664 name: "amount".into(),
6665 required: true,
6666 ..Default::default()
6667 },
6668 FieldSpec {
6669 name: "vendor".into(),
6670 ..Default::default()
6671 },
6672 ],
6673 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
6674 ..Default::default()
6675 },
6676 );
6677 let issues = fx.store_all();
6678 assert!(
6679 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6680 && i.severity == Severity::Warning
6681 && i.key.as_deref() == Some("vendor")
6682 && i.message.contains("unique")),
6683 "a `unique:` key field not marked required must warn: {issues:#?}"
6684 );
6685 assert!(
6687 !issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6688 && matches!(i.key.as_deref(), Some("date") | Some("amount"))),
6689 "required key fields must not warn: {issues:#?}"
6690 );
6691 }
6692
6693 #[test]
6698 fn body_leading_frontmatter_block_is_warning() {
6699 let fx = Fixture::new();
6700 fx.write(
6701 "records/notes/imported.md",
6702 "---\ntype: note\nsummary: an imported daily note\ncreated: 2026-06-02T09:00:00-07:00\nupdated: 2026-06-02T09:00:00-07:00\n---\n---\ntags: [daily]\n---\n# 2026-06-02\n\nSigned the SOW.\n",
6703 );
6704 let issues = fx.store_all();
6705 assert!(
6706 issues
6707 .iter()
6708 .any(|i| i.code == codes::FM_IN_BODY && i.severity == Severity::Warning),
6709 "a body opening with a second frontmatter block must warn: {issues:#?}"
6710 );
6711 }
6712
6713 #[test]
6716 fn body_thematic_break_rules_do_not_warn() {
6717 let fx = Fixture::new();
6718 fx.write(
6719 "records/notes/rules.md",
6720 "---\ntype: note\nsummary: a note using horizontal rules\ncreated: 2026-06-02T09:00:00-07:00\nupdated: 2026-06-02T09:00:00-07:00\n---\n---\nJust some prose between two rules.\n---\nMore text.\n",
6721 );
6722 let issues = fx.store_all();
6723 assert!(
6724 !has(&issues, codes::FM_IN_BODY),
6725 "a `---` thematic rule around prose (not a YAML mapping) must NOT warn: {issues:#?}"
6726 );
6727 }
6728
6729 #[test]
6733 fn body_fenced_frontmatter_example_does_not_warn() {
6734 let fx = Fixture::new();
6735 fx.write(
6736 "records/notes/doc.md",
6737 "---\ntype: note\nsummary: a note showing an example record\ncreated: 2026-06-02T09:00:00-07:00\nupdated: 2026-06-02T09:00:00-07:00\n---\n```markdown\n---\ntype: contact\nname: Sam\n---\n```\n",
6738 );
6739 let issues = fx.store_all();
6740 assert!(
6741 !has(&issues, codes::FM_IN_BODY),
6742 "a fenced example block (body opens with a code fence, not `---`) must NOT warn: {issues:#?}"
6743 );
6744 }
6745
6746 #[test]
6749 fn schema_unique_key_undeclared_field_is_warning() {
6750 let mut fx = Fixture::new();
6751 fx.config.schemas.insert(
6752 "expense".into(),
6753 Schema {
6754 fields: vec![FieldSpec {
6755 name: "date".into(),
6756 required: true,
6757 ..Default::default()
6758 }],
6759 unique_keys: vec![vec!["date".into(), "vendor".into()]],
6760 ..Default::default()
6761 },
6762 );
6763 let issues = fx.store_all();
6764 assert!(
6765 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6766 && i.severity == Severity::Warning
6767 && i.key.as_deref() == Some("vendor")
6768 && i.message.contains("not declared")),
6769 "a `unique:` key field absent from the schema must warn: {issues:#?}"
6770 );
6771 }
6772
6773 #[test]
6775 fn schema_unique_key_all_required_is_clean() {
6776 let mut fx = Fixture::new();
6777 fx.config.schemas.insert(
6778 "expense".into(),
6779 Schema {
6780 fields: vec![
6781 FieldSpec {
6782 name: "date".into(),
6783 required: true,
6784 ..Default::default()
6785 },
6786 FieldSpec {
6787 name: "amount".into(),
6788 required: true,
6789 ..Default::default()
6790 },
6791 ],
6792 unique_keys: vec![vec!["date".into(), "amount".into()]],
6793 ..Default::default()
6794 },
6795 );
6796 let issues = fx.store_all();
6797 assert!(
6798 !issues
6799 .iter()
6800 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.message.contains("unique")),
6801 "an all-required unique key must not warn: {issues:#?}"
6802 );
6803 }
6804
6805 #[test]
6811 fn every_code_constant_is_documented_in_spec() {
6812 let this_src = include_str!("validate.rs");
6816 let mut codes_in_module: Vec<String> = Vec::new();
6817 let mut in_codes_mod = false;
6818 for line in this_src.lines() {
6819 let t = line.trim();
6820 if t.starts_with("pub mod codes") {
6821 in_codes_mod = true;
6822 continue;
6823 }
6824 if in_codes_mod && line == "}" {
6826 break;
6827 }
6828 if in_codes_mod {
6829 if let Some(rest) = t.strip_prefix("pub const ") {
6830 let value = rest
6832 .split_once('=')
6833 .map(|(_, v)| v.trim())
6834 .and_then(|v| v.strip_prefix('"'))
6835 .and_then(|v| v.strip_suffix("\";"))
6836 .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6837 codes_in_module.push(value.to_string());
6838 }
6839 }
6840 }
6841 assert!(
6842 codes_in_module.len() >= 36,
6843 "parsed only {} code constants from `mod codes`; the parser likely \
6844 broke against a source-format change",
6845 codes_in_module.len()
6846 );
6847
6848 let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6850 let spec = fs::read_to_string(&spec_path)
6851 .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6852
6853 let missing: Vec<&String> = codes_in_module
6855 .iter()
6856 .filter(|code| !spec.contains(&format!("| `{code}` |")))
6857 .collect();
6858 assert!(
6859 missing.is_empty(),
6860 "validation codes emitted by the engine but absent from SPEC.md \
6861 § Validation (the declared complete vocabulary): {missing:?}"
6862 );
6863 }
6864
6865 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";
6868 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";
6869
6870 #[test]
6871 fn loose_file_catalogued_in_layer_jsonl_validates_clean() {
6872 let fx = Fixture::new();
6873 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6874 fx.write("records/bob.md", LOOSE_BOB); fx.rebuild_indexes();
6876 let issues = fx.store_all();
6877 assert!(
6878 issues.is_empty(),
6879 "a rebuilt store with a catalogued loose file must validate clean, got: {issues:?}"
6880 );
6881 }
6882
6883 #[test]
6884 fn loose_file_with_missing_layer_jsonl_is_index_jsonl_missing() {
6885 let fx = Fixture::new();
6886 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6887 fx.write("records/bob.md", LOOSE_BOB);
6888 fx.rebuild_indexes();
6889 fs::remove_file(fx.dir.path().join("records/index.jsonl")).unwrap();
6891 let issues = fx.store_all();
6892 assert!(
6893 has(&issues, codes::INDEX_JSONL_MISSING),
6894 "a loose file with no layer index.jsonl must raise INDEX_JSONL_MISSING, got: {issues:?}"
6895 );
6896 }
6897
6898 #[cfg(unix)]
6899 #[test]
6900 fn validation_reads_opened_root_after_path_replacement() {
6901 use std::os::unix::fs::symlink;
6902
6903 let sandbox = tempfile::tempdir().unwrap();
6904 let root = sandbox.path().join("store");
6905 fs::create_dir_all(root.join("records/notes")).unwrap();
6906 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
6907 fs::write(
6908 root.join("records/notes/owned.md"),
6909 "---\ntype: note\n---\nowned body\n",
6910 )
6911 .unwrap();
6912 let store = Store::open_strict(&root).unwrap();
6913 let detached = sandbox.path().join("detached");
6914 fs::rename(&root, &detached).unwrap();
6915
6916 let replacement = sandbox.path().join("replacement");
6917 fs::create_dir_all(replacement.join("records/notes")).unwrap();
6918 fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
6919 fs::write(
6920 replacement.join("records/notes/replacement-secret.md"),
6921 "not frontmatter\n",
6922 )
6923 .unwrap();
6924 symlink(&replacement, &root).unwrap();
6925
6926 let issues = validate_content_sweep(&store).unwrap();
6927 assert!(
6928 issues
6929 .iter()
6930 .any(|issue| issue.file == Path::new("records/notes/owned.md")),
6931 "the held original file must be validated: {issues:?}"
6932 );
6933 assert!(
6934 issues
6935 .iter()
6936 .all(|issue| !issue.file.to_string_lossy().contains("replacement-secret")),
6937 "replacement-root files must be invisible: {issues:?}"
6938 );
6939 }
6940}