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::projection::ProjectionPolicy;
49use crate::store::Store;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Severity {
55 Error,
57 Warning,
59 Info,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Issue {
68 pub severity: Severity,
70 pub code: &'static str,
72 pub file: PathBuf,
74 pub line: Option<u32>,
76 pub key: Option<String>,
78 pub message: String,
80 pub suggestion: Option<String>,
82 pub related: Vec<PathBuf>,
84}
85
86impl Issue {
87 pub fn is_error(&self) -> bool {
90 matches!(self.severity, Severity::Error)
91 }
92}
93
94pub mod codes {
98 pub const NOT_A_STORE: &str = "NOT_A_STORE";
100 pub const NESTED_STORE: &str = "NESTED_STORE";
102 pub const DB_MD_BAD_TYPE: &str = "DB_MD_BAD_TYPE";
104 pub const DB_MD_MISSING_FIELD: &str = "DB_MD_MISSING_FIELD";
106 pub const DB_MD_UNKNOWN_SECTION: &str = "DB_MD_UNKNOWN_SECTION";
108 pub const DB_MD_SCHEMA_FIELD: &str = "DB_MD_SCHEMA_FIELD";
111 pub const FM_MISSING_TYPE: &str = "FM_MISSING_TYPE";
113 pub const FM_MISSING_CREATED: &str = "FM_MISSING_CREATED";
115 pub const FM_MISSING_UPDATED: &str = "FM_MISSING_UPDATED";
117 pub const FM_UNREADABLE: &str = "FM_UNREADABLE";
119 pub const FM_MALFORMED_YAML: &str = "FM_MALFORMED_YAML";
121 pub const FM_BAD_TIMESTAMP: &str = "FM_BAD_TIMESTAMP";
123 pub const FM_BAD_META_TYPE: &str = "FM_BAD_META_TYPE";
125 pub const FM_BAD_ID: &str = "FM_BAD_ID";
129 pub const FM_IN_BODY: &str = "FM_IN_BODY";
134 pub const SUMMARY_MISSING: &str = "SUMMARY_MISSING";
136 pub const SUMMARY_EMPTY: &str = "SUMMARY_EMPTY";
138 pub const SUMMARY_MULTILINE: &str = "SUMMARY_MULTILINE";
140 pub const SUMMARY_TOO_LONG: &str = "SUMMARY_TOO_LONG";
142 pub const WIKI_LINK_SHORT_FORM: &str = "WIKI_LINK_SHORT_FORM";
144 pub const WIKI_LINK_BROKEN: &str = "WIKI_LINK_BROKEN";
146 pub const WIKI_LINK_PROJECTION_UNRESOLVED: &str = "WIKI_LINK_PROJECTION_UNRESOLVED";
148 pub const WIKI_LINK_AMBIGUOUS: &str = "WIKI_LINK_AMBIGUOUS";
150 pub const WIKI_LINK_HAS_EXTENSION: &str = "WIKI_LINK_HAS_EXTENSION";
152 pub const WIKI_LINK_FLOW_FORM_LIST: &str = "WIKI_LINK_FLOW_FORM_LIST";
154 pub const DUP_ID: &str = "DUP_ID";
156 pub const DUP_UNIQUE_KEY: &str = "DUP_UNIQUE_KEY";
158 pub const SCHEMA_MISSING_REQUIRED: &str = "SCHEMA_MISSING_REQUIRED";
160 pub const SCHEMA_SHAPE_MISMATCH: &str = "SCHEMA_SHAPE_MISMATCH";
162 pub const SCHEMA_LINK_PREFIX_MISMATCH: &str = "SCHEMA_LINK_PREFIX_MISMATCH";
164 pub const SCHEMA_ENUM_VIOLATION: &str = "SCHEMA_ENUM_VIOLATION";
166 pub const POLICY_FROZEN_PAGE: &str = "POLICY_FROZEN_PAGE";
168 pub const POLICY_IGNORED_TYPE_PRESENT: &str = "POLICY_IGNORED_TYPE_PRESENT";
170 pub const POLICY_IGNORED_TYPE_DERIVED: &str = "POLICY_IGNORED_TYPE_DERIVED";
172 pub const LOG_BAD_TIMESTAMP: &str = "LOG_BAD_TIMESTAMP";
174 pub const LOG_UNKNOWN_KIND: &str = "LOG_UNKNOWN_KIND";
176 pub const LOG_OUT_OF_ORDER: &str = "LOG_OUT_OF_ORDER";
178 pub const INDEX_MISSING: &str = "INDEX_MISSING";
180 pub const INDEX_STALE_ENTRY: &str = "INDEX_STALE_ENTRY";
182 pub const INDEX_MISSING_ENTRY: &str = "INDEX_MISSING_ENTRY";
184 pub const INDEX_ORPHAN: &str = "INDEX_ORPHAN";
186 pub const INDEX_WRONG_SCOPE: &str = "INDEX_WRONG_SCOPE";
188 pub const INDEX_SUMMARY_MISMATCH: &str = "INDEX_SUMMARY_MISMATCH";
190 pub const INDEX_JSONL_MISSING: &str = "INDEX_JSONL_MISSING";
192 pub const INDEX_JSONL_DESYNC: &str = "INDEX_JSONL_DESYNC";
195 pub const INDEX_JSONL_STALE: &str = "INDEX_JSONL_STALE";
197 pub const TAGS_MALFORMED: &str = "TAGS_MALFORMED";
199 pub const ASSET_MANIFEST_MALFORMED: &str = "ASSET_MANIFEST_MALFORMED";
201 pub const ASSET_UNDECLARED: &str = "ASSET_UNDECLARED";
204 pub const ASSET_WRAPPER_BROKEN: &str = "ASSET_WRAPPER_BROKEN";
206 pub const ASSET_MANIFEST_ORPHAN: &str = "ASSET_MANIFEST_ORPHAN";
208 pub const ASSET_SUPERSESSION_INVALID: &str = "ASSET_SUPERSESSION_INVALID";
211}
212
213const MAX_SUMMARY_LEN: usize = 200;
215
216const RECOGNIZED_LOG_KINDS: &[&str] = &[
219 "ingest",
220 "create",
221 "update",
222 "delete",
223 "rename",
224 "link",
225 "validate",
226 "index-rebuild",
227 "contradiction",
228];
229
230pub fn validate_working_set(
256 store: &Store,
257 since: Option<DateTime<FixedOffset>>,
258) -> crate::Result<Vec<Issue>> {
259 #[cfg(any(unix, windows))]
264 let _listings = crate::fsx::DirListingScope::open();
265 if !store_marker_present(store) {
266 return Ok(vec![not_a_store_issue(store)]);
267 }
268
269 let cutoff = match since {
270 Some(ts) => Some(ts),
271 None => last_validate_at(store),
272 };
273
274 let changed = changed_objects_since(store, cutoff);
276 if changed.is_empty() && since.is_none() {
277 return validate_content_sweep(store);
278 }
279
280 let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
291 let mut working: BTreeSet<PathBuf> = changed;
292 for linker in store.find_links_to_any(&changed_targets)? {
293 working.insert(linker);
294 }
295
296 let mut issues = nested_store_issues(store)?;
297 for rel in &working {
298 if !store.regular_file_exists(rel).unwrap_or(false) {
301 continue;
302 }
303 check_content_file(store, rel, None, &mut issues);
308 }
309 issues.sort_by(issue_order);
310 Ok(issues)
311}
312
313pub fn apply_projection_policy(issues: &mut [Issue], policy: &ProjectionPolicy) {
322 for issue in issues {
323 if issue.code != codes::WIKI_LINK_BROKEN || issue.related.len() != 1 {
324 continue;
325 }
326 let target = issue.related[0].to_string_lossy();
327 if !policy.excludes_wiki_coordinate(target.as_ref()) {
328 continue;
329 }
330 issue.severity = Severity::Info;
331 issue.code = codes::WIKI_LINK_PROJECTION_UNRESOLVED;
332 issue.message =
333 format!("wiki-link target `{target}` is absent from this declared store projection");
334 issue.suggestion = Some(
335 "restore the excluded path to establish full-store semantic completeness".to_string(),
336 );
337 }
338}
339
340fn validate_content_sweep(store: &Store) -> crate::Result<Vec<Issue>> {
341 let mut issues = nested_store_issues(store)?;
342 for rel in store.walk()? {
343 check_content_file(store, &rel, None, &mut issues);
344 }
345 issues.sort_by(issue_order);
346 Ok(issues)
347}
348
349fn nested_store_issues(store: &Store) -> crate::Result<Vec<Issue>> {
353 let mut issues = Vec::new();
354 for nested in store.nested_store_roots()? {
355 let marker = nested.join("DB.md");
356 push(
357 &mut issues,
358 Severity::Error,
359 codes::NESTED_STORE,
360 &marker,
361 None,
362 None,
363 format!(
364 "`{}` is a db.md store nested inside this store",
365 nested.display()
366 ),
367 Some(
368 "move the nested store outside this store, or run dbmd from the nested root"
369 .to_string(),
370 ),
371 vec![],
372 );
373 }
374 Ok(issues)
375}
376
377pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
382 #[cfg(any(unix, windows))]
387 let _listings = crate::fsx::DirListingScope::open();
388 if !store_marker_present(store) {
389 return Ok(vec![not_a_store_issue(store)]);
390 }
391
392 let mut issues = nested_store_issues(store)?;
393
394 check_db_md(store, &mut issues);
398
399 let files = store.walk()?;
400
401 let basenames = build_basename_index(&files);
406
407 let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
409 for rel in &files {
410 if let Some(p) = check_content_file(store, rel, Some(&basenames), &mut issues) {
411 parsed.push((rel.clone(), p));
412 }
413 }
414
415 check_duplicates(store, &parsed, &mut issues);
417
418 check_indexes(store, &files, &mut issues);
420
421 check_log(store, &mut issues);
423
424 check_assets(store, &parsed, &mut issues);
429
430 issues.sort_by(issue_order);
431 Ok(issues)
432}
433
434struct Parsed {
443 fm: Option<BTreeMap<String, Value>>,
446 fm_yaml: String,
449}
450
451fn check_content_file(
456 store: &Store,
457 rel: &Path,
458 basenames: Option<&BasenameIndex>,
459 issues: &mut Vec<Issue>,
460) -> Option<Parsed> {
461 let text = match store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) {
462 Ok(t) => t,
463 Err(e) => {
464 let detail = if e.kind() == std::io::ErrorKind::InvalidData {
472 "file is not valid UTF-8 text".to_string()
473 } else {
474 format!("file could not be read: {e}")
475 };
476 push(
477 issues,
478 Severity::Error,
479 codes::FM_UNREADABLE,
480 rel,
481 None,
482 None,
483 format!("content file is unreadable: {detail}"),
484 Some(
485 "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
486 .into(),
487 ),
488 vec![],
489 );
490 return None;
491 }
492 };
493
494 let is_content = is_content_file(rel);
495
496 let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
497 Some(split) => split,
498 None => {
499 if is_content {
503 push(
504 issues,
505 Severity::Error,
506 codes::FM_MISSING_TYPE,
507 rel,
508 None,
509 Some("type".into()),
510 "content file has no frontmatter `type:`".into(),
511 Some("add a YAML frontmatter block with `type:`".into()),
512 vec![],
513 );
514 push(
515 issues,
516 Severity::Error,
517 codes::SUMMARY_MISSING,
518 rel,
519 None,
520 Some("summary".into()),
521 "content file has no `summary`".into(),
522 Some("run `dbmd fm init`".into()),
523 vec![],
524 );
525 }
526 return None;
527 }
528 };
529
530 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
532 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
533 Ok(Value::Null) => Some(BTreeMap::new()),
535 Ok(_) => {
536 push(
540 issues,
541 Severity::Error,
542 codes::FM_MALFORMED_YAML,
543 rel,
544 Some(1),
545 None,
546 "frontmatter is not a YAML mapping".into(),
547 Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
548 vec![],
549 );
550 None
551 }
552 Err(e) => {
553 push(
556 issues,
557 Severity::Error,
558 codes::FM_MALFORMED_YAML,
559 rel,
560 Some(1),
561 None,
562 format!("frontmatter block isn't valid YAML: {e}"),
563 Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
564 vec![],
565 );
566 None
567 }
568 };
569
570 if let Some(map) = &fm {
571 check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
573 }
574
575 if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
597 check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
598 }
599
600 if is_content && body_opens_with_frontmatter(&body) {
607 push(
608 issues,
609 Severity::Warning,
610 codes::FM_IN_BODY,
611 rel,
612 Some(fm_end_line + 1),
613 None,
614 "the body opens with a second `---` frontmatter block; the record's \
615 frontmatter is the block at the top of the file, so this one is body \
616 text (usually an imported file's own frontmatter left in place)"
617 .into(),
618 Some(
619 "delete the leftover `---…---` block from the body, or move its \
620 fields into the record's frontmatter"
621 .into(),
622 ),
623 vec![],
624 );
625 }
626
627 Some(Parsed { fm, fm_yaml })
628}
629
630fn check_frontmatter(
632 store: &Store,
633 rel: &Path,
634 fm: &BTreeMap<String, Value>,
635 fm_yaml: &str,
636 basenames: Option<&BasenameIndex>,
637 issues: &mut Vec<Issue>,
638 is_content: bool,
639) {
640 let type_ = fm.get("type").and_then(scalar_string);
641
642 if is_content && type_.is_none() {
644 push(
645 issues,
646 Severity::Error,
647 codes::FM_MISSING_TYPE,
648 rel,
649 fm_key_line_or_top(fm_yaml, "type"),
650 Some("type".into()),
651 "content file has no `type:`".into(),
652 Some("add a `type:` field (e.g. `type: contact`)".into()),
653 vec![],
654 );
655 }
656
657 if is_content {
662 if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
671 match scalar_string(v) {
672 Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
673 Some(mt) => push(
674 issues,
675 Severity::Error,
676 codes::FM_BAD_META_TYPE,
677 rel,
678 fm_key_line_or_top(fm_yaml, "meta-type"),
679 Some("meta-type".into()),
680 format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
681 Some(
682 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
683 .into(),
684 ),
685 vec![],
686 ),
687 None => push(
688 issues,
689 Severity::Error,
690 codes::FM_BAD_META_TYPE,
691 rel,
692 fm_key_line_or_top(fm_yaml, "meta-type"),
693 Some("meta-type".into()),
694 "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
695 string, found a list or mapping"
696 .to_string(),
697 Some(
698 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
699 .into(),
700 ),
701 vec![],
702 ),
703 }
704 }
705 }
706
707 if is_content {
718 if let Some(v) = fm.get("id").filter(|v| !v.is_null()) {
719 let problem = match scalar_string(v) {
720 Some(id) if id.trim().is_empty() => Some("`id` is empty".to_string()),
721 Some(id) if id.chars().any(char::is_whitespace) => {
722 Some(format!("`id` {id:?} contains whitespace"))
723 }
724 Some(_) => None,
725 None => Some(
726 "`id` is not a scalar (found a list or mapping), so duplicate detection \
727 (DUP_ID) cannot see it"
728 .to_string(),
729 ),
730 };
731 if let Some(message) = problem {
732 push(
733 issues,
734 Severity::Warning,
735 codes::FM_BAD_ID,
736 rel,
737 fm_key_line_or_top(fm_yaml, "id"),
738 Some("id".into()),
739 message,
740 Some(
741 "use one opaque token with no whitespace — the recommended form is a \
742 lowercase ULID (`dbmd write` mints one) — or drop `id` to fall back to \
743 filename identity"
744 .into(),
745 ),
746 vec![],
747 );
748 }
749 }
750 }
751
752 if is_content {
754 check_summary(rel, fm, fm_yaml, issues);
755 }
756
757 if is_content {
761 for (key, missing_code) in [
762 ("created", codes::FM_MISSING_CREATED),
763 ("updated", codes::FM_MISSING_UPDATED),
764 ] {
765 let value = fm.get(key);
770 let missing = value.is_none() || value.is_some_and(Value::is_null);
771 if missing {
772 push(
773 issues,
774 Severity::Error,
775 missing_code,
776 rel,
777 fm_key_line_or_top(fm_yaml, key),
778 Some(key.into()),
779 format!("content file has no `{key}:` timestamp"),
780 Some(format!(
781 "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
782 )),
783 vec![],
784 );
785 } else if let Some(v) = value {
786 match scalar_string(v) {
792 Some(s) if is_iso8601(&s) => {}
793 Some(s) => push(
794 issues,
795 Severity::Error,
796 codes::FM_BAD_TIMESTAMP,
797 rel,
798 fm_key_line(fm_yaml, key),
799 Some(key.into()),
800 format!("`{key}` is not ISO-8601: {s:?}"),
801 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
802 vec![],
803 ),
804 None => push(
805 issues,
806 Severity::Error,
807 codes::FM_BAD_TIMESTAMP,
808 rel,
809 fm_key_line(fm_yaml, key),
810 Some(key.into()),
811 format!(
812 "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
813 ),
814 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
815 vec![],
816 ),
817 }
818 }
819 }
820 }
821 if let Some(tags) = fm.get("tags") {
823 if !is_flat_scalar_list(tags) {
824 push(
825 issues,
826 Severity::Warning,
827 codes::TAGS_MALFORMED,
828 rel,
829 fm_key_line(fm_yaml, "tags"),
830 Some("tags".into()),
831 "`tags` must be a flat YAML list of short scalar labels".into(),
832 Some("use block form: one `- <tag>` per line".into()),
833 vec![],
834 );
835 }
836 }
837
838 for key in detect_flow_form_link_lists(fm_yaml) {
840 push(
841 issues,
842 Severity::Error,
843 codes::WIKI_LINK_FLOW_FORM_LIST,
844 rel,
845 fm_key_line(fm_yaml, &key),
846 Some(key.clone()),
847 format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
848 Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
849 vec![],
850 );
851 }
852
853 let schema_link_keys: BTreeSet<String> =
858 effective_schema(store, type_.as_deref().unwrap_or(""))
859 .map(|s| {
860 s.fields
861 .iter()
862 .filter(|f| f.link_prefix.is_some())
863 .map(|f| f.name.clone())
864 .collect()
865 })
866 .unwrap_or_default();
867 for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
868 if schema_link_keys.contains(&key) {
869 continue;
870 }
871 check_wiki_link(
872 store,
873 rel,
874 &link,
875 Some(link.line),
876 Some(&key),
877 basenames,
878 issues,
879 );
880 }
881
882 if let Some(t) = &type_ {
884 if store.config.ignored_types.iter().any(|it| it == t) {
885 push(
886 issues,
887 Severity::Info,
888 codes::POLICY_IGNORED_TYPE_PRESENT,
889 rel,
890 fm_key_line(fm_yaml, "type"),
891 Some("type".into()),
892 format!("file has ignored type `{t}` (per DB.md ## Policies)"),
893 Some(
894 "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
895 .into(),
896 ),
897 vec![PathBuf::from("DB.md")],
899 );
900 }
901 let meta_type = fm
907 .get("meta-type")
908 .and_then(scalar_string)
909 .unwrap_or_else(|| "fact".to_string());
910 for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
911 if let Some(hit) =
912 derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
913 {
914 push(
915 issues,
916 Severity::Warning,
917 codes::POLICY_IGNORED_TYPE_DERIVED,
918 rel,
919 Some(link.line),
920 Some("derived_from".into()),
921 format!(
922 "conclusion record derives from ignored-type record `{}` (type `{}`)",
923 hit.target, hit.target_type
924 ),
925 Some(
926 "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
927 .into(),
928 ),
929 vec![
932 PathBuf::from(format!("{}.md", hit.target)),
933 PathBuf::from("DB.md"),
934 ],
935 );
936 }
937 }
938 }
939
940 if let Some(t) = &type_ {
942 if let Some(schema) = effective_schema(store, t) {
943 check_schema(store, rel, fm, fm_yaml, &schema, issues);
944 }
945 }
946}
947
948fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
950 let line = fm_key_line(fm_yaml, "summary");
951 match fm.get("summary") {
952 None => push(
953 issues,
954 Severity::Error,
955 codes::SUMMARY_MISSING,
956 rel,
957 fm_key_line_or_top(fm_yaml, "summary"),
960 Some("summary".into()),
961 "content file has no `summary`".into(),
962 Some("run `dbmd fm init`".into()),
963 vec![],
964 ),
965 Some(v) => {
966 let s = scalar_string(v).unwrap_or_default();
967 if s.trim().is_empty() {
968 push(
969 issues,
970 Severity::Error,
971 codes::SUMMARY_EMPTY,
972 rel,
973 line,
974 Some("summary".into()),
975 "`summary` is present but empty".into(),
976 Some("write a one-line summary, or run `dbmd fm init`".into()),
977 vec![],
978 );
979 } else if s.contains('\n') {
980 push(
981 issues,
982 Severity::Error,
983 codes::SUMMARY_MULTILINE,
984 rel,
985 line,
986 Some("summary".into()),
987 "`summary` must be one line (contains a newline)".into(),
988 Some("collapse the summary to a single line".into()),
989 vec![],
990 );
991 } else if s.chars().count() > MAX_SUMMARY_LEN {
992 push(
993 issues,
994 Severity::Warning,
995 codes::SUMMARY_TOO_LONG,
996 rel,
997 line,
998 Some("summary".into()),
999 format!(
1000 "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
1001 s.chars().count()
1002 ),
1003 Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
1004 vec![],
1005 );
1006 }
1007 }
1008 }
1009}
1010
1011fn check_body_wiki_links(
1013 store: &Store,
1014 rel: &Path,
1015 body: &str,
1016 fm_end_line: u32,
1017 basenames: Option<&BasenameIndex>,
1018 issues: &mut Vec<Issue>,
1019) {
1020 for link in extract_wiki_links(body) {
1021 let abs_line = fm_end_line + link.line;
1024 check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
1025 }
1026}
1027
1028type BasenameIndex = HashMap<String, Vec<PathBuf>>;
1036
1037fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
1040 let mut idx: BasenameIndex = HashMap::new();
1041 for rel in files {
1042 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
1043 idx.entry(stem.to_string()).or_default().push(rel.clone());
1044 }
1045 }
1046 idx
1047}
1048
1049fn check_wiki_link(
1054 store: &Store,
1055 rel: &Path,
1056 link: &Link,
1057 line: Option<u32>,
1058 key: Option<&str>,
1059 basenames: Option<&BasenameIndex>,
1060 issues: &mut Vec<Issue>,
1061) {
1062 let bare = link.target.trim_end_matches(".md");
1063
1064 if !is_full_store_path(bare) {
1067 if !bare.contains('/') {
1072 if let Some(idx) = basenames {
1073 if let Some(matches) = idx.get(bare) {
1074 if matches.len() >= 2 {
1075 let mut related = matches.clone();
1076 related.sort();
1077 push(
1078 issues,
1079 Severity::Error,
1080 codes::WIKI_LINK_AMBIGUOUS,
1081 rel,
1082 line,
1083 key.map(str::to_string),
1084 format!(
1085 "short-form wiki-link `[[{}]]` matches multiple files",
1086 link.target
1087 ),
1088 Some("use the full store-relative path to disambiguate".into()),
1089 related,
1090 );
1091 return;
1092 }
1093 }
1094 }
1095 }
1096 push(
1097 issues,
1098 Severity::Error,
1099 codes::WIKI_LINK_SHORT_FORM,
1100 rel,
1101 line,
1102 key.map(str::to_string),
1103 format!(
1104 "wiki-link `[[{}]]` is not a full store-relative path",
1105 link.target
1106 ),
1107 short_form_suggestion(bare),
1108 vec![],
1109 );
1110 return;
1112 }
1113
1114 if link.target.ends_with(".md") {
1116 push(
1117 issues,
1118 Severity::Warning,
1119 codes::WIKI_LINK_HAS_EXTENSION,
1120 rel,
1121 line,
1122 key.map(str::to_string),
1123 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1124 Some(format!("drop the extension: [[{bare}]]")),
1125 vec![],
1126 );
1127 }
1128
1129 match resolve_wiki_target(store, bare) {
1134 TargetResolution::Exists => {}
1135 TargetResolution::Missing => push(
1136 issues,
1137 Severity::Error,
1138 codes::WIKI_LINK_BROKEN,
1139 rel,
1140 line,
1141 key.map(str::to_string),
1142 format!("wiki-link target `{bare}` doesn't exist"),
1143 Some(format!(
1144 "create `{bare}.md`, or point the link at an existing file"
1145 )),
1146 vec![PathBuf::from(bare)],
1147 ),
1148 TargetResolution::Unsafe => push(
1149 issues,
1150 Severity::Error,
1151 codes::WIKI_LINK_BROKEN,
1152 rel,
1153 line,
1154 key.map(str::to_string),
1155 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1156 Some("use a full store-relative path under sources/ or records/".into()),
1157 vec![],
1158 ),
1159 }
1160}
1161
1162fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1173 store.config.schemas.get(type_).cloned()
1174}
1175
1176fn check_schema(
1178 store: &Store,
1179 rel: &Path,
1180 fm: &BTreeMap<String, Value>,
1181 fm_yaml: &str,
1182 schema: &Schema,
1183 issues: &mut Vec<Issue>,
1184) {
1185 for spec in &schema.fields {
1186 let present = fm.get(&spec.name);
1187 let line = fm_key_line(fm_yaml, &spec.name);
1188
1189 let is_empty = match present {
1197 None => true,
1198 Some(v) => is_empty_value(v),
1199 };
1200 if spec.required && is_empty {
1201 push(
1202 issues,
1203 Severity::Error,
1204 codes::SCHEMA_MISSING_REQUIRED,
1205 rel,
1206 fm_key_line_or_top(fm_yaml, &spec.name),
1209 Some(spec.name.clone()),
1210 format!("required field `{}` is absent or empty", spec.name),
1211 Some(format!("set `{}` to a non-empty value", spec.name)),
1212 vec![],
1213 );
1214 continue;
1215 }
1216 let Some(value) = present else { continue };
1217
1218 let value_empty = value.is_null()
1224 || scalar_string(value)
1225 .map(|s| s.trim().is_empty())
1226 .unwrap_or(false);
1227 if !spec.required && value_empty {
1228 continue;
1229 }
1230
1231 if let Some(prefix) = &spec.link_prefix {
1234 check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1235 continue; }
1237
1238 if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1245 push(
1246 issues,
1247 Severity::Error,
1248 codes::SCHEMA_SHAPE_MISMATCH,
1249 rel,
1250 line,
1251 Some(spec.name.clone()),
1252 format!(
1253 "`{}` must be a scalar value, found a list or mapping",
1254 spec.name
1255 ),
1256 Some(format!("set `{}` to a single scalar value", spec.name)),
1257 vec![],
1258 );
1259 continue;
1260 }
1261
1262 if let Some(allowed) = &spec.enum_values {
1264 if let Some(s) = scalar_string(value) {
1265 if !allowed.iter().any(|a| a == &s) {
1266 push(
1267 issues,
1268 Severity::Error,
1269 codes::SCHEMA_ENUM_VIOLATION,
1270 rel,
1271 line,
1272 Some(spec.name.clone()),
1273 format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1274 Some(format!("use one of: {}", allowed.join(", "))),
1275 vec![],
1276 );
1277 }
1278 }
1279 continue;
1280 }
1281
1282 if let Some(shape) = spec.shape {
1284 check_schema_shape(rel, &spec.name, value, shape, line, issues);
1285 }
1286 }
1287}
1288
1289fn check_schema_link(
1294 store: &Store,
1295 rel: &Path,
1296 field: &str,
1297 fm_yaml: &str,
1298 prefix: &Path,
1299 line: Option<u32>,
1300 issues: &mut Vec<Issue>,
1301) {
1302 let prefix_str = prefix.to_string_lossy();
1303 let prefix_str = prefix_str.trim_end_matches('/');
1304 let suggestion = |target_leaf: &str| {
1305 Some(format!(
1306 "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1307 ))
1308 };
1309
1310 let links = frontmatter_links_for_key(fm_yaml, field, 2);
1311 if links.is_empty() {
1312 let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1314 let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1315 let leaf = slugish(raw);
1316 push(
1317 issues,
1318 Severity::Error,
1319 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1320 rel,
1321 line,
1322 Some(field.to_string()),
1323 format!(
1324 "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1325 ),
1326 suggestion(&leaf),
1327 vec![],
1328 );
1329 return;
1330 }
1331
1332 for link in links {
1333 if link.target.ends_with(".md") {
1334 let bare = link.target.trim_end_matches(".md");
1335 push(
1336 issues,
1337 Severity::Warning,
1338 codes::WIKI_LINK_HAS_EXTENSION,
1339 rel,
1340 Some(link.line),
1341 Some(field.to_string()),
1342 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1343 Some(format!("drop the extension: [[{bare}]]")),
1344 vec![],
1345 );
1346 }
1347 let bare = link.target.trim_end_matches(".md");
1348 if !path_under_prefix(bare, prefix_str) {
1349 let leaf = bare.rsplit('/').next().unwrap_or(bare);
1350 push(
1351 issues,
1352 Severity::Error,
1353 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1354 rel,
1355 line,
1356 Some(field.to_string()),
1357 format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1358 suggestion(leaf),
1359 vec![],
1360 );
1361 } else {
1362 match resolve_wiki_target(store, bare) {
1367 TargetResolution::Exists => {}
1368 TargetResolution::Missing => push(
1369 issues,
1370 Severity::Error,
1371 codes::WIKI_LINK_BROKEN,
1372 rel,
1373 line,
1374 Some(field.to_string()),
1375 format!("wiki-link target `{bare}` doesn't exist"),
1376 Some(format!(
1377 "create `{bare}.md`, or point the link at an existing file"
1378 )),
1379 vec![PathBuf::from(bare)],
1380 ),
1381 TargetResolution::Unsafe => push(
1382 issues,
1383 Severity::Error,
1384 codes::WIKI_LINK_BROKEN,
1385 rel,
1386 line,
1387 Some(field.to_string()),
1388 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1389 Some("use a full store-relative path under sources/ or records/".into()),
1390 vec![],
1391 ),
1392 }
1393 }
1394 }
1395}
1396
1397fn check_schema_shape(
1399 rel: &Path,
1400 field: &str,
1401 value: &Value,
1402 shape: Shape,
1403 line: Option<u32>,
1404 issues: &mut Vec<Issue>,
1405) {
1406 let s = scalar_string(value).unwrap_or_default();
1407 let ok = match shape {
1408 Shape::String => true, Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1410 Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1411 Shape::Date => is_iso8601_date_or_datetime(&s),
1412 Shape::Email => is_email(&s),
1413 Shape::Currency => is_currency(&s),
1414 Shape::Url => is_url(&s),
1415 };
1416 if !ok {
1417 push(
1418 issues,
1419 Severity::Error,
1420 codes::SCHEMA_SHAPE_MISMATCH,
1421 rel,
1422 line,
1423 Some(field.to_string()),
1424 format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1425 Some(shape_suggestion(shape)),
1426 vec![],
1427 );
1428 }
1429}
1430
1431fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1450 let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1453 .iter()
1454 .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1455 .collect();
1456
1457 let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1459 for (rel, p) in parsed {
1460 if let Some(map) = &p.fm {
1461 if let Some(id) = map.get("id").and_then(scalar_string) {
1462 if !id.trim().is_empty() {
1463 by_id.entry(id).or_default().push(rel.clone());
1464 }
1465 }
1466 }
1467 }
1468 for (id, files) in &by_id {
1469 if files.len() > 1 {
1470 let (reported, related) = canonical_and_related(files);
1471 let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1472 push(
1473 issues,
1474 Severity::Error,
1475 codes::DUP_ID,
1476 &reported,
1477 line,
1478 Some("id".into()),
1479 format!("id {id:?} is declared by more than one file"),
1480 Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1481 related,
1482 );
1483 }
1484 }
1485
1486 for (type_name, schema) in &store.config.schemas {
1491 for key_fields in &schema.unique_keys {
1492 soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1493 }
1494 }
1495}
1496
1497fn soft_dup(
1506 parsed: &[(PathBuf, Parsed)],
1507 issues: &mut Vec<Issue>,
1508 type_: &str,
1509 key_fields: &[String],
1510 fm_yaml_of: &HashMap<&PathBuf, &str>,
1511) {
1512 if key_fields.is_empty() {
1513 return;
1514 }
1515 let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1516 for (rel, p) in parsed {
1517 let is_type =
1518 p.fm.as_ref()
1519 .and_then(|m| m.get("type"))
1520 .and_then(scalar_string)
1521 .map(|t| t == type_)
1522 .unwrap_or(false);
1523 if !is_type {
1524 continue;
1525 }
1526 if let Some(key) = dedup_key(p, key_fields) {
1527 groups.entry(key).or_default().push(rel.clone());
1528 }
1529 }
1530 let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1533 .values()
1534 .filter(|files| files.len() > 1)
1535 .map(|files| canonical_and_related(files))
1536 .collect();
1537 collisions.sort_by(|a, b| a.0.cmp(&b.0));
1538
1539 let fields_disp = key_fields.join(", ");
1540 for (reported, related) in collisions {
1541 let (line, key) = if key_fields.len() == 1 {
1544 (
1545 fm_yaml_of
1546 .get(&reported)
1547 .and_then(|y| fm_key_line(y, &key_fields[0])),
1548 Some(key_fields[0].clone()),
1549 )
1550 } else {
1551 (Some(1), None)
1552 };
1553 let n = related.len();
1554 push(
1555 issues,
1556 Severity::Warning,
1557 codes::DUP_UNIQUE_KEY,
1558 &reported,
1559 line,
1560 key,
1561 format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1562 Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1563 related,
1564 );
1565 }
1566}
1567
1568fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1572 let mut out = Vec::with_capacity(key_fields.len());
1573 for f in key_fields {
1574 out.push(dedup_token(p, f)?);
1575 }
1576 Some(out)
1577}
1578
1579fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1584 let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1587 if !links.is_empty() {
1588 let set: BTreeSet<String> = links
1589 .into_iter()
1590 .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1591 .filter(|t| !t.is_empty())
1592 .collect();
1593 return if set.is_empty() {
1594 None
1595 } else {
1596 Some(set.into_iter().collect::<Vec<_>>().join(","))
1597 };
1598 }
1599 match p.fm.as_ref()?.get(field) {
1600 Some(Value::Sequence(items)) => {
1601 let set: BTreeSet<String> = items
1602 .iter()
1603 .filter_map(scalar_string)
1604 .map(|s| s.trim().to_lowercase())
1605 .filter(|t| !t.is_empty())
1606 .collect();
1607 if set.is_empty() {
1608 None
1609 } else {
1610 Some(set.into_iter().collect::<Vec<_>>().join(","))
1611 }
1612 }
1613 Some(v) => {
1614 let s = scalar_string(v)?.trim().to_lowercase();
1615 if s.is_empty() {
1616 None
1617 } else {
1618 Some(s)
1619 }
1620 }
1621 None => None,
1622 }
1623}
1624
1625fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1630 let mut sorted = files.to_vec();
1631 sorted.sort();
1632 let reported = sorted[0].clone();
1633 let related = sorted[1..].to_vec();
1634 (reported, related)
1635}
1636
1637fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1643 let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1647 for rel in files {
1648 if let Some(tf) = type_folder_of(rel) {
1649 type_folders.entry(tf).or_default().push(rel.clone());
1650 }
1651 }
1652
1653 let mut layers_with_type_folders: BTreeSet<&'static str> = BTreeSet::new();
1665 for tf in type_folders.keys() {
1666 match tf.iter().next().and_then(|s| s.to_str()) {
1667 Some("sources") => {
1668 layers_with_type_folders.insert("sources");
1669 }
1670 Some("records") => {
1671 layers_with_type_folders.insert("records");
1672 }
1673 _ => {}
1674 }
1675 }
1676
1677 if !type_folders.is_empty() {
1679 if !store
1680 .regular_file_exists(Path::new("index.md"))
1681 .unwrap_or(false)
1682 {
1683 push(
1684 issues,
1685 Severity::Error,
1686 codes::INDEX_MISSING,
1687 Path::new("index.md"),
1688 None,
1689 None,
1690 "store has files but no root `index.md`".into(),
1691 Some("run `dbmd index rebuild`".into()),
1692 vec![],
1693 );
1694 } else {
1695 check_index_scope(store, Path::new("index.md"), "root", None, issues);
1696 }
1697 }
1698
1699 for layer in &layers_with_type_folders {
1701 let layer_index_rel = PathBuf::from(layer).join("index.md");
1702 if !store.regular_file_exists(&layer_index_rel).unwrap_or(false) {
1703 push(
1704 issues,
1705 Severity::Error,
1706 codes::INDEX_MISSING,
1707 &layer_index_rel,
1708 None,
1709 None,
1710 format!("layer `{layer}/` has files but no `index.md`"),
1711 Some("run `dbmd index rebuild`".into()),
1712 vec![],
1713 );
1714 } else {
1715 check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1716 }
1717 }
1718
1719 for (tf, members) in &type_folders {
1721 let index_md_rel = tf.join("index.md");
1722 let index_md_present = store.regular_file_exists(&index_md_rel).unwrap_or(false);
1723 if !index_md_present {
1724 push(
1730 issues,
1731 Severity::Error,
1732 codes::INDEX_MISSING,
1733 tf,
1734 None,
1735 None,
1736 format!("non-empty folder `{}` has no index.md", tf.display()),
1737 Some(format!(
1738 "run `dbmd index rebuild --folder {}`",
1739 tf.display()
1740 )),
1741 vec![],
1742 );
1743 continue;
1744 }
1745
1746 check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1747 check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1748
1749 let jsonl_rel = tf.join("index.jsonl");
1753 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1754 push(
1755 issues,
1756 Severity::Error,
1757 codes::INDEX_JSONL_MISSING,
1758 &jsonl_rel,
1759 None,
1760 None,
1761 format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1762 Some("run `dbmd index rebuild`".into()),
1763 vec![],
1764 );
1765 } else {
1766 check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1767 }
1768 }
1769
1770 let mut loose_by_layer: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1778 for rel in files {
1779 if !is_content_file(rel) || type_folder_of(rel).is_some() {
1780 continue;
1781 }
1782 if let Some(layer_dir) = loose_layer_dir(rel) {
1783 loose_by_layer
1784 .entry(layer_dir)
1785 .or_default()
1786 .push(rel.clone());
1787 }
1788 }
1789 for (layer_dir, members) in &loose_by_layer {
1790 let jsonl_rel = layer_dir.join("index.jsonl");
1791 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1792 push(
1793 issues,
1794 Severity::Error,
1795 codes::INDEX_JSONL_MISSING,
1796 &jsonl_rel,
1797 None,
1798 None,
1799 format!(
1800 "loose files at `{}/` are not catalogued — the layer has no `index.jsonl`",
1801 layer_dir.display()
1802 ),
1803 Some("run `dbmd index rebuild`".into()),
1804 members.clone(),
1805 );
1806 } else {
1807 check_type_folder_index_jsonl(store, layer_dir, &jsonl_rel, members, issues);
1811 }
1812 }
1813
1814 for rel in walk_index_files(store) {
1816 let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1817 let parent_str = parent.to_string_lossy().to_string();
1818 let is_canonical = parent_str.is_empty() || matches!(parent_str.as_str(), "sources" | "records")
1820 || type_folders.contains_key(&parent);
1821 if !is_canonical {
1822 push(
1823 issues,
1824 Severity::Warning,
1825 codes::INDEX_ORPHAN,
1826 &rel,
1827 None,
1828 None,
1829 format!(
1830 "`{}` sits in an empty or non-canonical folder",
1831 rel.display()
1832 ),
1833 Some("remove it, or run `dbmd index rebuild`".into()),
1834 vec![],
1835 );
1836 }
1837 }
1838}
1839
1840fn check_type_folder_index_md(
1844 store: &Store,
1845 tf: &Path,
1846 index_rel: &Path,
1847 members: &[PathBuf],
1848 issues: &mut Vec<Issue>,
1849) {
1850 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1851 return;
1852 };
1853 let entries = parse_index_entries(&text);
1854
1855 let listed: BTreeSet<PathBuf> = entries
1856 .iter()
1857 .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1858 .collect();
1859
1860 for entry in &entries {
1862 let bare = entry.target.trim_end_matches(".md");
1863 let target_abs = match resolved_target_abs(store, bare) {
1866 Some(abs) => abs,
1867 None => {
1868 if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1869 push(
1870 issues,
1871 Severity::Error,
1872 codes::INDEX_STALE_ENTRY,
1873 index_rel,
1874 Some(entry.line),
1875 None,
1876 format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1877 Some("run `dbmd index rebuild`".into()),
1878 vec![],
1879 );
1880 } else {
1881 push(
1882 issues,
1883 Severity::Error,
1884 codes::INDEX_STALE_ENTRY,
1885 index_rel,
1886 Some(entry.line),
1887 None,
1888 format!("index entry `[[{bare}]]` points at a missing file"),
1889 Some("run `dbmd index rebuild`".into()),
1890 vec![PathBuf::from(format!("{bare}.md"))],
1894 );
1895 }
1896 continue;
1897 }
1898 };
1899 if let Some(expected) = read_summary(store, &target_abs) {
1906 match &entry.summary_text {
1907 Some(text_part)
1918 if crate::summary::collapse_whitespace(text_part)
1919 != crate::summary::collapse_whitespace(&expected) =>
1920 {
1921 push(
1922 issues,
1923 Severity::Error,
1924 codes::INDEX_SUMMARY_MISMATCH,
1925 index_rel,
1926 Some(entry.line),
1927 None,
1928 format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1929 Some("run `dbmd index rebuild`".into()),
1930 vec![PathBuf::from(format!("{bare}.md"))],
1931 );
1932 }
1933 None if !expected.trim().is_empty() => {
1934 push(
1935 issues,
1936 Severity::Error,
1937 codes::INDEX_SUMMARY_MISMATCH,
1938 index_rel,
1939 Some(entry.line),
1940 None,
1941 format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1942 Some("run `dbmd index rebuild`".into()),
1943 vec![PathBuf::from(format!("{bare}.md"))],
1944 );
1945 }
1946 _ => {}
1947 }
1948 }
1949 }
1950
1951 let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1955 if content_members.len() <= 500 {
1956 for m in content_members {
1957 let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1958 if !listed.contains(&bare) {
1959 push(
1960 issues,
1961 Severity::Error,
1962 codes::INDEX_MISSING_ENTRY,
1963 index_rel,
1964 None,
1965 None,
1966 format!(
1967 "file `{}` is not listed in its folder's `index.md`",
1968 m.display()
1969 ),
1970 Some("run `dbmd index rebuild`".into()),
1971 vec![(*m).clone()],
1972 );
1973 }
1974 }
1975 }
1976 let _ = tf;
1977}
1978
1979fn check_type_folder_index_jsonl(
1983 store: &Store,
1984 tf: &Path,
1985 jsonl_rel: &Path,
1986 members: &[PathBuf],
1987 issues: &mut Vec<Issue>,
1988) {
1989 let Ok(text) = store.read_text_bounded(jsonl_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1990 return;
1991 };
1992
1993 let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1995 for (i, line) in text.lines().enumerate() {
1996 let line = line.trim();
1997 if line.is_empty() {
1998 continue;
1999 }
2000 let rec: serde_json::Value = match serde_json::from_str(line) {
2001 Ok(v) => v,
2002 Err(e) => {
2003 push(
2004 issues,
2005 Severity::Error,
2006 codes::INDEX_JSONL_DESYNC,
2007 jsonl_rel,
2008 Some((i + 1) as u32),
2009 None,
2010 format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
2011 Some("run `dbmd index rebuild`".into()),
2012 vec![],
2013 );
2014 continue;
2015 }
2016 };
2017 if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
2018 if !is_safe_store_relative_path(Path::new(path)) {
2019 push(
2020 issues,
2021 Severity::Error,
2022 codes::INDEX_JSONL_DESYNC,
2023 jsonl_rel,
2024 Some((i + 1) as u32),
2025 None,
2026 format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
2027 Some("run `dbmd index rebuild`".into()),
2028 vec![],
2029 );
2030 continue;
2031 }
2032 records.insert(PathBuf::from(path), rec);
2033 }
2034 }
2035
2036 let member_set: BTreeSet<PathBuf> = members
2037 .iter()
2038 .filter(|m| is_content_file(m))
2039 .cloned()
2040 .collect();
2041
2042 for path in records.keys() {
2044 if !store.regular_file_exists(path).unwrap_or(false) {
2045 push(
2046 issues,
2047 Severity::Error,
2048 codes::INDEX_JSONL_DESYNC,
2049 jsonl_rel,
2050 None,
2051 None,
2052 format!(
2053 "`index.jsonl` record points at missing file `{}`",
2054 path.display()
2055 ),
2056 Some("run `dbmd index rebuild`".into()),
2057 vec![],
2058 );
2059 }
2060 }
2061
2062 for m in &member_set {
2064 if !records.contains_key(m) {
2065 push(
2066 issues,
2067 Severity::Error,
2068 codes::INDEX_JSONL_DESYNC,
2069 jsonl_rel,
2070 None,
2071 None,
2072 format!(
2073 "file `{}` is missing from the complete `index.jsonl`",
2074 m.display()
2075 ),
2076 Some("run `dbmd index rebuild`".into()),
2077 vec![m.clone()],
2078 );
2079 }
2080 }
2081
2082 for (path, rec) in &records {
2096 if !store.regular_file_exists(path).unwrap_or(false) {
2097 continue;
2098 }
2099 let Ok(expected) =
2100 crate::index::IndexRecord::expected_from_store(store, path, path.clone())
2101 else {
2102 continue; };
2104 let Ok(expected_json) = serde_json::to_value(&expected) else {
2105 continue;
2106 };
2107 let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
2108 continue;
2109 };
2110
2111 let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
2114 for key in have.keys().chain(want.keys()) {
2115 if key == "path" {
2116 continue;
2117 }
2118 if have.get(key) != want.get(key) {
2119 mismatched_keys.insert(key);
2120 }
2121 }
2122
2123 if !mismatched_keys.is_empty() {
2124 let keys: Vec<&str> = mismatched_keys.into_iter().collect();
2125 push(
2126 issues,
2127 Severity::Error,
2128 codes::INDEX_JSONL_STALE,
2129 jsonl_rel,
2130 None,
2131 Some(keys.join(",")),
2132 format!(
2133 "`index.jsonl` record for `{}` is stale ({})",
2134 path.display(),
2135 keys.join(", ")
2136 ),
2137 Some("run `dbmd index rebuild`".into()),
2138 vec![path.clone()],
2139 );
2140 }
2141 }
2142 let _ = tf;
2143}
2144
2145fn check_index_scope(
2147 store: &Store,
2148 index_rel: &Path,
2149 expected_scope: &str,
2150 expected_folder: Option<&str>,
2151 issues: &mut Vec<Issue>,
2152) {
2153 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2154 return;
2155 };
2156 let Some((yaml, _, _)) = split_frontmatter(&text) else {
2157 return;
2158 };
2159 let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
2160 return;
2161 };
2162 let fm = yaml_map_to_btree(&map);
2163
2164 if let Some(scope) = fm.get("scope").and_then(scalar_string) {
2165 let scope_ok =
2167 scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
2168 if !scope_ok {
2169 push(
2170 issues,
2171 Severity::Warning,
2172 codes::INDEX_WRONG_SCOPE,
2173 index_rel,
2174 fm_key_line(&yaml, "scope"),
2175 Some("scope".into()),
2176 format!(
2177 "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
2178 ),
2179 Some(format!("set `scope: {expected_scope}`")),
2180 vec![],
2181 );
2182 }
2183 }
2184 if let Some(expected) = expected_folder {
2186 if let Some(folder) = fm.get("folder").and_then(scalar_string) {
2187 if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
2188 push(
2189 issues,
2190 Severity::Warning,
2191 codes::INDEX_WRONG_SCOPE,
2192 index_rel,
2193 fm_key_line(&yaml, "folder"),
2194 Some("folder".into()),
2195 format!("index `folder: {folder}` doesn't match location `{expected}`"),
2196 Some(format!("set `folder: {expected}`")),
2197 vec![],
2198 );
2199 }
2200 }
2201 }
2202}
2203
2204fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2223 let mut prev: Option<DateTime<FixedOffset>> = None;
2224 for rel in log_files_chronological(store) {
2225 check_log_file(store, &rel, &mut prev, issues);
2226 }
2227}
2228
2229fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2233 let mut files: Vec<PathBuf> = Vec::new();
2234 let archive_dir = Path::new("log");
2235 if let Ok(entries) = store.regular_file_names(archive_dir) {
2236 let mut archives: Vec<PathBuf> = entries
2237 .into_iter()
2238 .filter(|name| {
2239 name.to_str()
2240 .and_then(|n| n.strip_suffix(".md"))
2241 .is_some_and(is_year_month_archive)
2242 })
2243 .map(|name| archive_dir.join(name))
2244 .collect();
2245 archives.sort();
2247 files.extend(archives);
2248 }
2249 if store
2251 .regular_file_exists(Path::new("log.md"))
2252 .unwrap_or(false)
2253 {
2254 files.push(PathBuf::from("log.md"));
2255 }
2256 files
2257}
2258
2259fn check_log_file(
2263 store: &Store,
2264 log_rel: &Path,
2265 prev: &mut Option<DateTime<FixedOffset>>,
2266 issues: &mut Vec<Issue>,
2267) {
2268 let Ok(text) = store.read_text_bounded(log_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2269 return;
2270 };
2271
2272 for (i, line) in text.lines().enumerate() {
2273 if !line.starts_with("## [") {
2274 continue;
2275 }
2276 let line_no = (i + 1) as u32;
2277 match parse_log_header(line) {
2278 None => push(
2279 issues,
2280 Severity::Error,
2281 codes::LOG_BAD_TIMESTAMP,
2282 log_rel,
2283 Some(line_no),
2284 None,
2285 format!("log entry header has an unparseable timestamp: {line:?}"),
2286 Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2287 vec![],
2288 ),
2289 Some((ts, kind, _object)) => {
2290 if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2291 push(
2292 issues,
2293 Severity::Warning,
2294 codes::LOG_UNKNOWN_KIND,
2295 log_rel,
2296 Some(line_no),
2297 None,
2298 format!("log entry kind `{kind}` is not recognized"),
2299 Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2300 vec![],
2301 );
2302 }
2303 if let Some(p) = *prev {
2304 if ts < p {
2305 push(
2306 issues,
2307 Severity::Warning,
2308 codes::LOG_OUT_OF_ORDER,
2309 log_rel,
2310 Some(line_no),
2311 None,
2312 "log entry is older than the entry above it (possible rewrite)".into(),
2313 Some("append corrective entries; never reorder past ones".into()),
2314 vec![],
2315 );
2316 }
2317 }
2318 *prev = Some(ts);
2319 }
2320 }
2321 }
2322}
2323
2324#[derive(Debug)]
2330struct Link {
2331 target: String,
2332 line: u32,
2333}
2334
2335fn store_marker_present(store: &Store) -> bool {
2339 store
2340 .regular_file_exists(Path::new("DB.md"))
2341 .unwrap_or(false)
2342}
2343
2344fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2355 let rel = Path::new("DB.md");
2356 let Ok(text) = store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2357 return; };
2359
2360 let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2361 push(
2365 issues,
2366 Severity::Error,
2367 codes::DB_MD_BAD_TYPE,
2368 rel,
2369 Some(1),
2370 Some("type".into()),
2371 "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2372 Some("add a `---` frontmatter block with `type: db-md`".into()),
2373 vec![],
2374 );
2375 for field in ["scope", "owner"] {
2376 push(
2377 issues,
2378 Severity::Error,
2379 codes::DB_MD_MISSING_FIELD,
2380 rel,
2381 Some(1),
2382 Some(field.into()),
2383 format!("DB.md frontmatter is missing required field `{field}`"),
2384 Some(format!("add `{field}:` to the DB.md frontmatter")),
2385 vec![],
2386 );
2387 }
2388 return;
2389 };
2390
2391 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2394 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2395 Ok(Value::Null) => Some(BTreeMap::new()),
2396 _ => None,
2397 };
2398
2399 match &fm {
2400 Some(map) => {
2401 let type_ = map.get("type").and_then(scalar_string);
2403 if type_.as_deref() != Some("db-md") {
2404 let (line, msg) = match &type_ {
2405 Some(t) => (
2406 fm_key_line(&fm_yaml, "type"),
2407 format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2408 ),
2409 None => (
2410 Some(1),
2411 "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2412 ),
2413 };
2414 push(
2415 issues,
2416 Severity::Error,
2417 codes::DB_MD_BAD_TYPE,
2418 rel,
2419 line,
2420 Some("type".into()),
2421 msg,
2422 Some("set `type: db-md` in the DB.md frontmatter".into()),
2423 vec![],
2424 );
2425 }
2426
2427 for field in ["scope", "owner"] {
2429 let present = map
2430 .get(field)
2431 .and_then(scalar_string)
2432 .map(|s| !s.trim().is_empty())
2433 .unwrap_or(false);
2434 if !present {
2435 push(
2436 issues,
2437 Severity::Error,
2438 codes::DB_MD_MISSING_FIELD,
2439 rel,
2440 fm_key_line_or_top(&fm_yaml, field),
2443 Some(field.into()),
2444 format!("DB.md frontmatter is missing required field `{field}`"),
2445 Some(format!("add `{field}:` to the DB.md frontmatter")),
2446 vec![],
2447 );
2448 }
2449 }
2450 }
2451 None => {
2452 push(
2455 issues,
2456 Severity::Error,
2457 codes::DB_MD_BAD_TYPE,
2458 rel,
2459 Some(1),
2460 Some("type".into()),
2461 "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2462 Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2463 vec![],
2464 );
2465 for field in ["scope", "owner"] {
2466 push(
2467 issues,
2468 Severity::Error,
2469 codes::DB_MD_MISSING_FIELD,
2470 rel,
2471 Some(1),
2472 Some(field.into()),
2473 format!("DB.md frontmatter is missing required field `{field}`"),
2474 Some(format!("add `{field}:` to the DB.md frontmatter")),
2475 vec![],
2476 );
2477 }
2478 }
2479 }
2480
2481 for section in crate::parser::extract_sections(&body) {
2495 if section.level != 2 {
2496 continue;
2497 }
2498 let name = section.heading.trim().to_ascii_lowercase();
2499 if matches!(
2500 name.as_str(),
2501 "agent instructions" | "policies" | "schemas" | "folders"
2502 ) {
2503 continue;
2504 }
2505 let file_line = fm_end_line + section.line;
2508 push(
2509 issues,
2510 Severity::Warning,
2511 codes::DB_MD_UNKNOWN_SECTION,
2512 rel,
2513 Some(file_line),
2514 None,
2515 format!(
2516 "DB.md has an unrecognized `## {}` section",
2517 section.heading.trim()
2518 ),
2519 Some(
2520 "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2521 `## Folders` — remove or rename this heading"
2522 .into(),
2523 ),
2524 vec![],
2525 );
2526 }
2527
2528 check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2533}
2534
2535fn check_db_md_schemas(
2542 store: &Store,
2543 rel: &Path,
2544 body: &str,
2545 fm_end_line: u32,
2546 issues: &mut Vec<Issue>,
2547) {
2548 if store.config.schemas.is_empty() {
2549 return;
2550 }
2551
2552 let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2557 let mut current_h2: Option<String> = None;
2558 for section in crate::parser::extract_sections(body) {
2559 match section.level {
2560 2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2561 3 if current_h2.as_deref() == Some("schemas") => {
2562 type_line
2565 .entry(section.heading.trim().to_string())
2566 .or_insert(fm_end_line + section.line);
2567 }
2568 _ => {}
2569 }
2570 }
2571
2572 for (type_name, schema) in &store.config.schemas {
2573 let line = type_line.get(type_name).copied();
2574 let mut seen: BTreeSet<String> = BTreeSet::new();
2575 for field in &schema.fields {
2576 let name = field.name.trim();
2577
2578 if name.is_empty() {
2582 push(
2583 issues,
2584 Severity::Warning,
2585 codes::DB_MD_SCHEMA_FIELD,
2586 rel,
2587 line,
2588 None,
2589 format!("`### {type_name}` has a schema field bullet with no field name"),
2590 Some(
2591 "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2592 .into(),
2593 ),
2594 vec![],
2595 );
2596 continue;
2597 }
2598
2599 if !seen.insert(name.to_string()) {
2603 push(
2604 issues,
2605 Severity::Warning,
2606 codes::DB_MD_SCHEMA_FIELD,
2607 rel,
2608 line,
2609 Some(name.to_string()),
2610 format!("`### {type_name}` declares field `{name}` more than once"),
2611 Some(
2612 "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2613 ),
2614 vec![],
2615 );
2616 }
2617
2618 for modifier in &field.unknown_modifiers {
2623 let modifier = modifier.trim();
2624 if modifier.is_empty() {
2625 continue;
2626 }
2627 push(
2628 issues,
2629 Severity::Info,
2630 codes::DB_MD_SCHEMA_FIELD,
2631 rel,
2632 line,
2633 Some(name.to_string()),
2634 format!(
2635 "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2636 ),
2637 Some(
2638 "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2639 .into(),
2640 ),
2641 vec![],
2642 );
2643 }
2644 }
2645
2646 let mut declared: BTreeMap<&str, bool> = BTreeMap::new();
2655 for f in &schema.fields {
2656 let e = declared.entry(f.name.trim()).or_insert(false);
2657 *e = *e || f.required;
2658 }
2659 let mut flagged: BTreeSet<&str> = BTreeSet::new();
2660 for key_fields in &schema.unique_keys {
2661 for field in key_fields {
2662 let name = field.trim();
2663 if name.is_empty()
2664 || declared.get(name).copied() == Some(true)
2665 || !flagged.insert(name)
2666 {
2667 continue;
2668 }
2669 let message = if declared.contains_key(name) {
2670 format!(
2671 "`### {type_name}` `unique:` key field `{name}` is not `required` — a record missing or leaving it empty is silently skipped by the unique check"
2672 )
2673 } else {
2674 format!(
2675 "`### {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"
2676 )
2677 };
2678 push(
2679 issues,
2680 Severity::Warning,
2681 codes::DB_MD_SCHEMA_FIELD,
2682 rel,
2683 line,
2684 Some(name.to_string()),
2685 message,
2686 Some(format!(
2687 "mark `{name}` `required` in `### {type_name}`, or build the `unique:` key from required fields only"
2688 )),
2689 vec![],
2690 );
2691 }
2692 }
2693 }
2694}
2695
2696fn not_a_store_issue(store: &Store) -> Issue {
2698 Issue {
2699 severity: Severity::Error,
2700 code: codes::NOT_A_STORE,
2701 file: store.root.clone(),
2702 line: None,
2703 key: None,
2704 message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2705 suggestion: Some("create a `DB.md` at the store root".into()),
2706 related: vec![],
2707 }
2708}
2709
2710fn is_content_file(rel: &Path) -> bool {
2713 if !is_safe_store_relative_path(rel) {
2719 return false;
2720 }
2721 let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2722 return false;
2723 };
2724 if !matches!(first, "sources" | "records") {
2725 return false;
2726 }
2727 let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2728 if matches!(name, "index.md" | "index.jsonl") {
2732 return false;
2733 }
2734 name.ends_with(".md")
2735}
2736
2737fn is_root_meta_file(rel: &Path) -> bool {
2744 let mut comps = rel.components();
2745 let Some(Component::Normal(only)) = comps.next() else {
2746 return false;
2747 };
2748 if comps.next().is_some() {
2749 return false; }
2751 matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2752}
2753
2754fn is_index_catalog_file(rel: &Path) -> bool {
2762 matches!(
2763 rel.file_name().and_then(|n| n.to_str()),
2764 Some("index.md") | Some("index.jsonl")
2765 )
2766}
2767
2768fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2772 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2777 let mut lines = text.lines();
2778 let first = lines.next()?;
2779 if first.trim_end() != "---" {
2780 return None;
2781 }
2782 let mut yaml = String::new();
2783 let mut close_line: Option<u32> = None;
2784 let mut current = 1u32;
2786 for line in lines {
2787 current += 1;
2788 if line.trim_end() == "---" {
2789 close_line = Some(current);
2790 break;
2791 }
2792 yaml.push_str(line);
2793 yaml.push('\n');
2794 }
2795 let close_line = close_line?;
2796 let body: String = text
2798 .lines()
2799 .skip(close_line as usize)
2800 .collect::<Vec<_>>()
2801 .join("\n");
2802 Some((yaml, body, close_line))
2803}
2804
2805fn body_opens_with_frontmatter(body: &str) -> bool {
2813 let start: String = body
2814 .lines()
2815 .skip_while(|l| l.trim().is_empty())
2816 .collect::<Vec<_>>()
2817 .join("\n");
2818 match split_frontmatter(&start) {
2819 Some((yaml, _, _)) => matches!(
2820 serde_norway::from_str::<Value>(&yaml),
2821 Ok(Value::Mapping(m)) if !m.is_empty()
2822 ),
2823 None => false,
2824 }
2825}
2826
2827fn read_summary(store: &Store, abs: &Path) -> Option<String> {
2829 let text = store
2830 .read_text_bounded(abs, crate::parser::MAX_DBMD_FILE_BYTES)
2831 .ok()?;
2832 let (yaml, _, _) = split_frontmatter(&text)?;
2833 let value: Value = serde_norway::from_str(&yaml).ok()?;
2834 if let Value::Mapping(m) = value {
2835 m.get(Value::String("summary".into()))
2836 .and_then(scalar_string)
2837 } else {
2838 None
2839 }
2840}
2841
2842fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2845 let mut out = BTreeMap::new();
2846 for (k, v) in map {
2847 if let Value::String(s) = k {
2848 out.insert(s.clone(), v.clone());
2849 }
2850 }
2851 out
2852}
2853
2854fn scalar_string(v: &Value) -> Option<String> {
2857 match v {
2858 Value::String(s) => Some(s.clone()),
2859 Value::Number(n) => Some(n.to_string()),
2860 Value::Bool(b) => Some(b.to_string()),
2861 _ => None,
2862 }
2863}
2864
2865fn is_empty_value(v: &Value) -> bool {
2872 match v {
2873 Value::Null => true,
2874 Value::Sequence(items) => items.is_empty(),
2875 Value::Mapping(map) => map.is_empty(),
2876 other => scalar_string(other)
2877 .map(|s| s.trim().is_empty())
2878 .unwrap_or(true),
2879 }
2880}
2881
2882fn is_flat_scalar_list(v: &Value) -> bool {
2885 match v {
2886 Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2887 _ => false,
2888 }
2889}
2890
2891fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2901 let mut out = Vec::new();
2902 for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2903 for link in links {
2904 out.push((key.clone(), link));
2905 }
2906 }
2907 out
2908}
2909
2910fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2914 for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2915 if k == key {
2916 return links;
2917 }
2918 }
2919 Vec::new()
2920}
2921
2922fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2926 for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2927 if k == key {
2928 return Some(value_text);
2929 }
2930 }
2931 None
2932}
2933
2934fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2941 let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2942 let mut current: Option<(String, String, Vec<Link>)> = None;
2943
2944 for (idx, raw_line) in fm_yaml.lines().enumerate() {
2945 let file_line = fm_start_line + idx as u32;
2946 let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2947 let trimmed = raw_line.trim();
2948
2949 let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2952 top_level_key(raw_line)
2953 } else {
2954 None
2955 };
2956
2957 if let Some((key, after)) = new_key {
2958 if let Some(done) = current.take() {
2959 blocks.push(done);
2960 }
2961 let mut links = Vec::new();
2962 collect_line_links(after, file_line, &mut links);
2963 current = Some((key, after.trim().to_string(), links));
2964 } else if let Some((_k, value_text, links)) = current.as_mut() {
2965 if !value_text.is_empty() {
2967 value_text.push('\n');
2968 }
2969 value_text.push_str(trimmed);
2970 collect_line_links(raw_line, file_line, links);
2971 }
2972 }
2973 if let Some(done) = current.take() {
2974 blocks.push(done);
2975 }
2976 blocks
2977}
2978
2979fn top_level_key(line: &str) -> Option<(String, &str)> {
2982 let (key, rest) = line.split_once(':')?;
2983 let key = key.trim();
2984 if key.is_empty()
2985 || !key
2986 .chars()
2987 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2988 {
2989 return None;
2990 }
2991 Some((key.to_string(), rest))
2992}
2993
2994fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2997 let bytes = s.as_bytes();
2998 let mut i = 0;
2999 while i + 1 < bytes.len() {
3000 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
3001 if let Some(close) = s[i + 2..].find("]]") {
3002 let inner = &s[i + 2..i + 2 + close];
3003 let target = inner
3006 .trim_start_matches('[')
3007 .split('|')
3008 .next()
3009 .unwrap_or(inner)
3010 .trim()
3011 .to_string();
3012 if !target.is_empty() {
3013 links.push(Link {
3014 target,
3015 line: file_line,
3016 });
3017 }
3018 i = i + 2 + close + 2;
3019 continue;
3020 }
3021 }
3022 i += 1;
3023 }
3024}
3025
3026fn extract_wiki_links(body: &str) -> Vec<Link> {
3038 let mut out = Vec::new();
3039 let mut fence: Option<(u8, usize)> = None;
3040 for (idx, line) in body.lines().enumerate() {
3041 let content = line.trim_end_matches('\r');
3042 if let Some(f) = fence {
3043 if fence_closes(content, f) {
3047 fence = None;
3048 }
3049 continue;
3050 }
3051 if let Some(opened) = fence_opens(content) {
3052 fence = Some(opened);
3053 continue;
3054 }
3055 let line_no = (idx + 1) as u32;
3056 let bytes = line.as_bytes();
3057 let mut i = 0;
3058 while i + 1 < bytes.len() {
3059 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
3060 if let Some(close) = line[i + 2..].find("]]") {
3061 let inner = &line[i + 2..i + 2 + close];
3062 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3063 if !target.is_empty() && !target.starts_with('[') {
3071 out.push(Link {
3072 target,
3073 line: line_no,
3074 });
3075 }
3076 i = i + 2 + close + 2;
3077 continue;
3078 }
3079 }
3080 i += 1;
3081 }
3082 }
3083 out
3084}
3085
3086fn fence_opens(line: &str) -> Option<(u8, usize)> {
3092 let indent = line.len() - line.trim_start_matches(' ').len();
3093 if indent > 3 {
3094 return None;
3095 }
3096 let rest = &line[indent..];
3097 let byte = rest.bytes().next()?;
3098 if byte != b'`' && byte != b'~' {
3099 return None;
3100 }
3101 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3102 if run < 3 {
3103 return None;
3104 }
3105 if byte == b'`' && rest[run..].contains('`') {
3107 return None;
3108 }
3109 Some((byte, run))
3110}
3111
3112fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
3117 let (byte, open_len) = fence;
3118 let indent = line.len() - line.trim_start_matches(' ').len();
3119 if indent > 3 {
3120 return false;
3121 }
3122 let rest = &line[indent..];
3123 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3124 if run < open_len {
3125 return false;
3126 }
3127 rest[run..].trim().is_empty()
3128}
3129
3130fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
3147 let mut out = Vec::new();
3148 for line in fm_yaml.lines() {
3149 if line.starts_with(' ') || line.starts_with('\t') {
3151 continue;
3152 }
3153 let Some((key, rest)) = line.split_once(':') else {
3154 continue;
3155 };
3156 let key = key.trim();
3157 if key.is_empty()
3158 || key.starts_with('#')
3159 || key.starts_with('-')
3160 || !key
3161 .chars()
3162 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
3163 {
3164 continue;
3165 }
3166 let rest = rest.trim();
3167 if !rest.starts_with('[') {
3170 continue;
3171 }
3172 if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
3177 let nested = items.iter().any(|item| match item {
3178 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
3179 _ => false,
3180 });
3181 if nested {
3182 out.push(key.to_string());
3183 }
3184 }
3185 }
3186 out
3187}
3188
3189fn is_full_store_path(bare: &str) -> bool {
3192 let mut parts = bare.splitn(2, '/');
3193 let first = parts.next().unwrap_or("");
3194 let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
3195 matches!(first, "sources" | "records") && has_rest
3196}
3197
3198fn is_safe_store_relative_path(path: &Path) -> bool {
3202 let mut saw_component = false;
3203 for component in path.components() {
3204 match component {
3205 Component::Normal(_) => saw_component = true,
3206 Component::CurDir => {}
3207 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
3208 }
3209 }
3210 saw_component
3211}
3212
3213fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
3214 let path = Path::new(bare);
3215 if !is_safe_store_relative_path(path) {
3216 return None;
3217 }
3218 Some(PathBuf::from(format!("{bare}.md")))
3219}
3220
3221enum TargetResolution {
3223 Exists,
3225 Missing,
3227 Unsafe,
3229}
3230
3231fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
3240 if !is_safe_store_relative_path(Path::new(bare)) {
3244 return TargetResolution::Unsafe;
3245 }
3246 match resolved_target_abs(store, bare) {
3247 Some(_) => TargetResolution::Exists,
3248 None => TargetResolution::Missing,
3249 }
3250}
3251
3252fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
3278 if !is_safe_store_relative_path(Path::new(bare)) {
3279 return None;
3280 }
3281 let literal = PathBuf::from(bare);
3284 if store.regular_file_exists(&literal).ok()? && disk_case_matches(store, &literal, bare) {
3285 return Some(literal);
3286 }
3287 let with_md_rel = format!("{bare}.md");
3289 let with_md = PathBuf::from(&with_md_rel);
3290 if store.regular_file_exists(&with_md).ok()? && disk_case_matches(store, &with_md, &with_md_rel)
3291 {
3292 return Some(with_md);
3293 }
3294 None
3295}
3296
3297fn disk_case_matches(store: &Store, abs: &Path, requested: &str) -> bool {
3312 abs == Path::new(requested) && store.path_case_matches(abs).unwrap_or(true)
3313}
3314
3315fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3317 let prefix = prefix.trim_end_matches('/');
3318 bare == prefix || bare.starts_with(&format!("{prefix}/"))
3319}
3320
3321fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3325 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3326 if comps.len() < 3 {
3327 return None; }
3329 if !matches!(comps[0], "sources" | "records") {
3330 return None;
3331 }
3332 Some(PathBuf::from(comps[0]).join(comps[1]))
3333}
3334
3335fn loose_layer_dir(rel: &Path) -> Option<PathBuf> {
3340 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3341 if comps.len() != 2 || !matches!(comps[0], "sources" | "records") {
3342 return None;
3343 }
3344 Some(PathBuf::from(comps[0]))
3345}
3346
3347fn walk_index_files(store: &Store) -> Vec<PathBuf> {
3352 let mut out = Vec::new();
3353 if store
3354 .regular_file_exists(Path::new("index.md"))
3355 .unwrap_or(false)
3356 {
3357 out.push(PathBuf::from("index.md"));
3358 }
3359 for layer in ["sources", "records"] {
3360 if let Ok(files) = store.walk_regular_files(Path::new(layer)) {
3361 for rel in files {
3362 if rel.file_name().and_then(|name| name.to_str()) == Some("index.md") {
3363 out.push(rel);
3364 }
3365 }
3366 }
3367 }
3368 out.sort();
3369 out
3370}
3371
3372struct IndexEntry {
3375 target: String,
3376 summary_text: Option<String>,
3377 line: u32,
3378}
3379
3380fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3385 let mut out = Vec::new();
3386 let mut in_more = false;
3387 for (idx, line) in text.lines().enumerate() {
3388 let trimmed = line.trim_start();
3389 if trimmed.starts_with("## More") {
3390 in_more = true;
3391 continue;
3392 }
3393 if in_more {
3394 continue;
3395 }
3396 if !trimmed.starts_with("- ") {
3397 continue;
3398 }
3399 let Some(open) = trimmed.find("[[") else {
3401 continue;
3402 };
3403 let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3404 continue;
3405 };
3406 let inner = &trimmed[open + 2..open + 2 + close_rel];
3407 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3408
3409 let after = &trimmed[open + 2 + close_rel + 2..];
3411 let summary_text = extract_index_entry_summary(after);
3412
3413 out.push(IndexEntry {
3414 target,
3415 summary_text,
3416 line: (idx + 1) as u32,
3417 });
3418 }
3419 out
3420}
3421
3422fn extract_index_entry_summary(after: &str) -> Option<String> {
3428 let mut s = after.trim();
3429 if s.starts_with('(') {
3431 if let Some(close) = s.find(')') {
3432 s = s[close + 1..].trim_start();
3433 }
3434 }
3435 let s = s.strip_prefix('—').or_else(|| s.strip_prefix('-'))?.trim();
3437 if s.is_empty() {
3438 return None;
3439 }
3440 let s = match s.rsplit_once(" · ") {
3455 Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3456 _ => s,
3457 };
3458 Some(s.to_string())
3459}
3460
3461fn is_tag_suffix(s: &str) -> bool {
3466 let mut any = false;
3467 for tok in s.split_whitespace() {
3468 if !tok.starts_with('#') || tok.len() < 2 {
3469 return false;
3470 }
3471 any = true;
3472 }
3473 any
3474}
3475
3476fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3480 let rest = line.strip_prefix("## [")?;
3481 let close = rest.find(']')?;
3482 let ts_str = &rest[..close];
3483 let tail = rest[close + 1..].trim();
3484
3485 let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3488 let offset = FixedOffset::east_opt(0)?;
3489 let ts = naive.and_local_timezone(offset).single()?;
3490
3491 let (kind, object) = match tail.split_once('|') {
3493 Some((k, o)) => {
3494 let o = o.trim();
3495 (
3496 k.trim().to_string(),
3497 if o.is_empty() {
3498 None
3499 } else {
3500 Some(o.to_string())
3501 },
3502 )
3503 }
3504 None => (tail.to_string(), None),
3505 };
3506 if kind.is_empty() {
3507 return None;
3508 }
3509 Some((ts, kind, object))
3510}
3511
3512fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3522 let mut files = vec![PathBuf::from("log.md")];
3523 let archive_dir = Path::new("log");
3524 if let Ok(entries) = store.regular_file_names(archive_dir) {
3525 let mut archives: Vec<PathBuf> = entries
3526 .into_iter()
3527 .filter(|name| {
3528 name.to_str()
3529 .and_then(|n| n.strip_suffix(".md"))
3530 .is_some_and(is_year_month_archive)
3531 })
3532 .map(|name| archive_dir.join(name))
3533 .collect();
3534 archives.sort();
3538 files.extend(archives);
3539 }
3540 files.retain(|path| store.regular_file_exists(path).unwrap_or(false));
3541 files
3542}
3543
3544fn is_year_month_archive(s: &str) -> bool {
3547 let b = s.as_bytes();
3548 b.len() == 7
3549 && b[..4].iter().all(u8::is_ascii_digit)
3550 && b[4] == b'-'
3551 && b[5..7].iter().all(u8::is_ascii_digit)
3552}
3553
3554fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3560 let mut latest: Option<DateTime<FixedOffset>> = None;
3561 for file in log_files_for_working_set(store) {
3562 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3563 continue;
3564 };
3565 for line in text.lines() {
3566 if !line.starts_with("## [") {
3567 continue;
3568 }
3569 if let Some((ts, kind, _)) = parse_log_header(line) {
3570 if kind == "validate" {
3571 latest = Some(match latest {
3572 Some(p) if p >= ts => p,
3573 _ => ts,
3574 });
3575 }
3576 }
3577 }
3578 }
3579 latest
3580}
3581
3582fn changed_objects_since(
3593 store: &Store,
3594 cutoff: Option<DateTime<FixedOffset>>,
3595) -> BTreeSet<PathBuf> {
3596 let mut out = BTreeSet::new();
3597 for file in log_files_for_working_set(store) {
3598 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3599 continue;
3600 };
3601 for line in text.lines() {
3602 if !line.starts_with("## [") {
3603 continue;
3604 }
3605 let Some((ts, kind, object)) = parse_log_header(line) else {
3606 continue;
3607 };
3608 if let Some(c) = cutoff {
3609 if ts < c {
3610 continue;
3611 }
3612 }
3613 if !matches!(
3614 kind.as_str(),
3615 "create" | "update" | "ingest" | "rename" | "delete" | "link"
3616 ) {
3617 continue;
3618 }
3619 if let Some(obj) = object {
3620 let bare = obj
3622 .trim()
3623 .trim_start_matches("[[")
3624 .trim_end_matches("]]")
3625 .split('|')
3626 .next()
3627 .unwrap_or("")
3628 .trim()
3629 .trim_end_matches(".md")
3630 .to_string();
3631 if bare.is_empty() {
3632 continue;
3633 }
3634 if let Some(rel) = safe_md_target_rel(&bare) {
3644 out.insert(rel);
3645 }
3646 }
3647 }
3648 }
3649 out
3650}
3651
3652#[derive(Debug, Clone, PartialEq, Eq)]
3657pub struct DerivedFromIgnored {
3658 pub target: String,
3661 pub target_type: String,
3664}
3665
3666pub fn derived_from_ignored_type<I, S>(
3680 store: &Store,
3681 meta_type: &str,
3682 derived_from_targets: I,
3683) -> Option<DerivedFromIgnored>
3684where
3685 I: IntoIterator<Item = S>,
3686 S: AsRef<str>,
3687{
3688 if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3689 return None;
3690 }
3691 for target in derived_from_targets {
3692 let target = target.as_ref();
3693 if let Some(target_type) = link_target_type(store, target) {
3694 if store.config.ignored_types.contains(&target_type) {
3695 return Some(DerivedFromIgnored {
3696 target: target.to_string(),
3697 target_type,
3698 });
3699 }
3700 }
3701 }
3702 None
3703}
3704
3705fn link_target_type(store: &Store, target: &str) -> Option<String> {
3707 let bare = target.trim_end_matches(".md");
3708 let rel = safe_md_target_rel(bare)?;
3709 let text = store
3710 .read_text_bounded(&rel, crate::parser::MAX_DBMD_FILE_BYTES)
3711 .ok()?;
3712 let (yaml, _, _) = split_frontmatter(&text)?;
3713 let value: Value = serde_norway::from_str(&yaml).ok()?;
3714 if let Value::Mapping(m) = value {
3715 m.get(Value::String("type".into())).and_then(scalar_string)
3716 } else {
3717 None
3718 }
3719}
3720
3721fn is_iso8601(s: &str) -> bool {
3726 DateTime::parse_from_rfc3339(s.trim()).is_ok()
3727}
3728
3729fn is_iso8601_date_or_datetime(s: &str) -> bool {
3733 let s = s.trim();
3734 if DateTime::parse_from_rfc3339(s).is_ok() {
3735 return true;
3736 }
3737 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3738}
3739
3740fn is_email(s: &str) -> bool {
3745 let s = s.trim();
3746 let Some((local, domain)) = s.split_once('@') else {
3747 return false;
3748 };
3749 !local.is_empty()
3750 && !domain.contains('@')
3751 && domain.contains('.')
3752 && !domain.starts_with('.')
3753 && !domain.ends_with('.')
3754 && !domain.contains(' ')
3755 && !local.contains(' ')
3756}
3757
3758fn is_currency(s: &str) -> bool {
3765 let mut t = s.trim();
3766 for sym in ["$", "€", "£", "¥"] {
3768 if let Some(rest) = t.strip_prefix(sym) {
3769 t = rest.trim_start();
3770 break;
3771 }
3772 }
3773 if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3777 if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3778 t = rest.trim_start();
3779 }
3780 }
3781
3782 let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3783 is_plain_amount(cleaned.trim())
3784}
3785
3786fn is_plain_amount(s: &str) -> bool {
3789 let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3790 let (int_part, frac_part) = match digits.split_once('.') {
3791 Some((i, f)) => (i, Some(f)),
3792 None => (digits, None),
3793 };
3794 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3795 return false;
3796 }
3797 match frac_part {
3798 None => true,
3799 Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3800 }
3801}
3802
3803fn is_url(s: &str) -> bool {
3809 let s = s.trim();
3810 for scheme in ["http://", "https://"] {
3811 if let Some(rest) = s.strip_prefix(scheme) {
3812 return !rest.is_empty();
3813 }
3814 }
3815 false
3816}
3817
3818fn shape_suggestion(shape: Shape) -> String {
3820 match shape {
3821 Shape::String => "use a scalar string".into(),
3822 Shape::Int => "use an integer".into(),
3823 Shape::Bool => "use `true` or `false`".into(),
3824 Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3825 Shape::Email => "use a `<local>@<domain>` address".into(),
3826 Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3827 Shape::Url => "use an http(s) URL".into(),
3828 }
3829}
3830
3831fn short_form_suggestion(bare: &str) -> Option<String> {
3834 Some(format!(
3835 "use a full store-relative path, e.g. [[records/contacts/{}]]",
3836 slugish(bare)
3837 ))
3838}
3839
3840fn slugish(s: &str) -> String {
3842 s.trim()
3843 .to_lowercase()
3844 .chars()
3845 .map(|c| if c.is_whitespace() { '-' } else { c })
3846 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3847 .collect()
3848}
3849
3850fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3856 use crate::assets;
3857
3858 let manifest_rel = Path::new(assets::MANIFEST_FILE);
3859 let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3861 if store.regular_file_exists(manifest_rel).unwrap_or(false) {
3862 if let Ok(text) = store.read_text_bounded(manifest_rel, crate::parser::MAX_DBMD_FILE_BYTES)
3863 {
3864 for (i, line) in text.lines().enumerate() {
3865 if line.trim().is_empty() {
3866 continue;
3867 }
3868 match serde_json::from_str::<assets::AssetRecord>(line) {
3869 Ok(rec) => {
3870 manifest.insert(rec.path.clone(), rec);
3871 }
3872 Err(e) => push(
3873 issues,
3874 Severity::Error,
3875 codes::ASSET_MANIFEST_MALFORMED,
3876 manifest_rel,
3877 Some((i as u32) + 1),
3878 None,
3879 format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3880 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3881 vec![],
3882 ),
3883 }
3884 }
3885 }
3886 }
3887
3888 let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3892 let mut supersessions: BTreeMap<String, (String, PathBuf)> = BTreeMap::new();
3893 for (rel, p) in parsed {
3894 let Some(map) = &p.fm else {
3895 continue;
3896 };
3897 for decl in assets::declarations_from_yaml_map(map) {
3898 let norm = match assets::normalize_asset_path(&decl.path) {
3899 Ok(n) => n,
3900 Err(_) => continue, };
3902 declared.insert(norm.clone());
3903 if !manifest.contains_key(&norm) {
3904 push(
3905 issues,
3906 Severity::Error,
3907 codes::ASSET_UNDECLARED,
3908 rel,
3909 None,
3910 Some("asset".to_string()),
3911 format!(
3912 "references asset `{norm}` with no record in {}",
3913 assets::MANIFEST_FILE
3914 ),
3915 Some("run `dbmd assets scan` to catalog it".to_string()),
3916 vec![PathBuf::from(&norm)],
3917 );
3918 }
3919 }
3920 match assets::asset_supersession_from_yaml_map(map) {
3921 Ok(Some(supersession)) => {
3922 declared.insert(supersession.original.clone());
3923 let wrapper = rel.to_string_lossy().replace('\\', "/");
3924 if let Some((prior_replacement, prior_wrapper)) =
3925 supersessions.get(&supersession.original)
3926 {
3927 if prior_replacement != &supersession.replacement {
3928 push(
3929 issues,
3930 Severity::Error,
3931 codes::ASSET_SUPERSESSION_INVALID,
3932 rel,
3933 None,
3934 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3935 format!(
3936 "asset `{}` is superseded by both `{}` and `{}` ({})",
3937 supersession.original,
3938 prior_replacement,
3939 supersession.replacement,
3940 prior_wrapper.display()
3941 ),
3942 Some(
3943 "keep exactly one replacement for an asset coordinate".to_string(),
3944 ),
3945 vec![prior_wrapper.clone()],
3946 );
3947 }
3948 } else {
3949 supersessions.insert(
3950 supersession.original.clone(),
3951 (supersession.replacement.clone(), rel.clone()),
3952 );
3953 }
3954 match manifest.get(&supersession.original) {
3955 Some(record)
3956 if !record.required && record.wrappers.contains(&wrapper) => {}
3957 Some(_) => push(
3958 issues,
3959 Severity::Error,
3960 codes::ASSET_SUPERSESSION_INVALID,
3961 rel,
3962 None,
3963 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3964 format!(
3965 "superseded asset `{}` must remain cataloged as optional evidence under this wrapper",
3966 supersession.original
3967 ),
3968 Some(format!(
3969 "run `dbmd assets refresh {}` --wrapper {wrapper}",
3970 supersession.replacement
3971 )),
3972 vec![PathBuf::from(&supersession.original)],
3973 ),
3974 None => push(
3975 issues,
3976 Severity::Error,
3977 codes::ASSET_SUPERSESSION_INVALID,
3978 rel,
3979 None,
3980 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3981 format!(
3982 "superseded asset `{}` has no record in {}",
3983 supersession.original,
3984 assets::MANIFEST_FILE
3985 ),
3986 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3987 vec![PathBuf::from(&supersession.original)],
3988 ),
3989 }
3990 }
3991 Ok(None) => {}
3992 Err(error) => push(
3993 issues,
3994 Severity::Error,
3995 codes::ASSET_SUPERSESSION_INVALID,
3996 rel,
3997 None,
3998 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3999 error,
4000 Some(format!(
4001 "remove `{}` or declare exactly one required replacement asset",
4002 assets::SUPERSEDES_ASSET_KEY
4003 )),
4004 vec![],
4005 ),
4006 }
4007 }
4008
4009 let mut reported_cycle_members = BTreeSet::new();
4010 for origin in supersessions.keys() {
4011 let mut order: Vec<String> = Vec::new();
4012 let mut positions = BTreeMap::new();
4013 let mut current = origin.as_str();
4014 while let Some((next, _)) = supersessions.get(current) {
4015 if let Some(start) = positions.get(current).copied() {
4016 for member in &order[start..] {
4017 if reported_cycle_members.insert(member.clone()) {
4018 let (_, wrapper) = &supersessions[member];
4019 push(
4020 issues,
4021 Severity::Error,
4022 codes::ASSET_SUPERSESSION_INVALID,
4023 wrapper,
4024 None,
4025 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
4026 format!("asset replacement cycle includes `{member}`"),
4027 Some("replace the cycle with a one-way provenance chain".to_string()),
4028 vec![],
4029 );
4030 }
4031 }
4032 break;
4033 }
4034 positions.insert(current.to_string(), order.len());
4035 order.push(current.to_string());
4036 current = next;
4037 }
4038 }
4039
4040 for (path, rec) in &manifest {
4042 for w in &rec.wrappers {
4043 if !store.regular_file_exists(Path::new(w)).unwrap_or(false) {
4044 push(
4045 issues,
4046 Severity::Error,
4047 codes::ASSET_WRAPPER_BROKEN,
4048 Path::new(path),
4049 None,
4050 None,
4051 format!("manifest record for `{path}` names a missing wrapper `{w}`"),
4052 Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
4053 vec![PathBuf::from(w)],
4054 );
4055 }
4056 }
4057 if !declared.contains(path) {
4058 push(
4059 issues,
4060 Severity::Warning,
4061 codes::ASSET_MANIFEST_ORPHAN,
4062 Path::new(path),
4063 None,
4064 None,
4065 format!(
4066 "`{path}` is in {} but no wrapper references it",
4067 assets::MANIFEST_FILE
4068 ),
4069 Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
4070 vec![],
4071 );
4072 }
4073 }
4074}
4075
4076#[allow(clippy::too_many_arguments)]
4078fn push(
4079 issues: &mut Vec<Issue>,
4080 severity: Severity,
4081 code: &'static str,
4082 file: &Path,
4083 line: Option<u32>,
4084 key: Option<String>,
4085 message: String,
4086 suggestion: Option<String>,
4087 related: Vec<PathBuf>,
4088) {
4089 issues.push(Issue {
4090 severity,
4091 code,
4092 file: file.to_path_buf(),
4093 line,
4094 key,
4095 message,
4096 suggestion,
4097 related,
4098 });
4099}
4100
4101fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
4104 for (i, line) in fm_yaml.lines().enumerate() {
4105 let trimmed = line.trim_start();
4106 if let Some(rest) = trimmed.strip_prefix(key) {
4108 if rest.starts_with(':') && line.starts_with(key) {
4109 return Some((i as u32) + 2);
4111 }
4112 }
4113 }
4114 None
4115}
4116
4117fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
4123 fm_key_line(fm_yaml, key).or(Some(1))
4124}
4125
4126fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
4129 a.file
4130 .cmp(&b.file)
4131 .then(a.line.cmp(&b.line))
4132 .then(a.code.cmp(b.code))
4133 .then(a.key.cmp(&b.key))
4134}
4135
4136#[cfg(test)]
4141mod tests {
4142 use super::*;
4143 use crate::parser::{Config, FieldSpec};
4144 use std::fs;
4145 use tempfile::TempDir;
4146
4147 #[test]
4148 fn split_frontmatter_tolerates_leading_bom() {
4149 let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
4154 let parsed = split_frontmatter(text);
4155 assert!(
4156 parsed.is_some(),
4157 "a leading BOM must not hide frontmatter from validate"
4158 );
4159 let (yaml, body, close_line) = parsed.unwrap();
4160 assert_eq!(yaml, "type: contact\nsummary: hi\n");
4161 assert_eq!(body, "body");
4162 assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
4163 }
4164
4165 struct Fixture {
4168 dir: TempDir,
4169 config: Config,
4170 }
4171
4172 impl Fixture {
4173 fn new() -> Self {
4178 let dir = TempDir::new().unwrap();
4179 fs::write(
4180 dir.path().join("DB.md"),
4181 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
4182 )
4183 .unwrap();
4184 for layer in ["sources", "records"] {
4185 fs::create_dir_all(dir.path().join(layer)).unwrap();
4186 }
4187 Fixture {
4188 dir,
4189 config: Config::default(),
4190 }
4191 }
4192
4193 fn bare() -> Self {
4195 let dir = TempDir::new().unwrap();
4196 Fixture {
4197 dir,
4198 config: Config::default(),
4199 }
4200 }
4201
4202 fn write(&self, rel: &str, contents: &str) {
4204 let abs = self.dir.path().join(rel);
4205 fs::create_dir_all(abs.parent().unwrap()).unwrap();
4206 fs::write(abs, contents).unwrap();
4207 }
4208
4209 fn store(&self) -> Store {
4210 Store::from_root_and_config(self.dir.path(), self.config.clone()).unwrap()
4211 }
4212
4213 fn store_all(&self) -> Vec<Issue> {
4214 validate_all(&self.store()).unwrap()
4215 }
4216
4217 fn rebuild_indexes(&self) {
4224 crate::index::Index::rebuild_all(&self.store()).unwrap();
4225 }
4226 }
4227
4228 fn has(issues: &[Issue], code: &str) -> bool {
4230 issues.iter().any(|i| i.code == code)
4231 }
4232
4233 fn count(issues: &[Issue], code: &str) -> usize {
4235 issues.iter().filter(|i| i.code == code).count()
4236 }
4237
4238 fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
4240 issues
4241 .iter()
4242 .find(|i| i.code == code)
4243 .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
4244 }
4245
4246 fn valid_contact(summary: &str) -> String {
4248 format!(
4249 "---\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"
4250 )
4251 }
4252
4253 #[test]
4256 fn not_a_store_when_db_md_absent() {
4257 let fx = Fixture::bare();
4258 let issues = fx.store_all();
4259 assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
4260 assert_eq!(issues[0].code, codes::NOT_A_STORE);
4261 assert!(issues[0].is_error());
4262 }
4263
4264 #[test]
4265 fn working_set_also_reports_not_a_store() {
4266 let fx = Fixture::bare();
4267 let issues = validate_working_set(&fx.store(), None).unwrap();
4268 assert!(has(&issues, codes::NOT_A_STORE));
4269 }
4270
4271 #[test]
4272 fn both_scopes_report_nested_store_without_validating_its_content() {
4273 let fx = Fixture::new();
4274 fx.write(
4275 "records/nested/DB.md",
4276 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
4277 );
4278 fx.write("records/nested/records/notes/bad.md", "not frontmatter");
4281
4282 for issues in [
4283 validate_working_set(&fx.store(), None).unwrap(),
4284 validate_all(&fx.store()).unwrap(),
4285 ] {
4286 assert_eq!(count(&issues, codes::NESTED_STORE), 1, "{issues:#?}");
4287 assert_eq!(
4288 find(&issues, codes::NESTED_STORE).file,
4289 PathBuf::from("records/nested/DB.md")
4290 );
4291 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4292 }
4293 }
4294
4295 #[test]
4296 fn clean_store_has_no_issues() {
4297 let fx = Fixture::new();
4298 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4299 fx.rebuild_indexes();
4303 let issues = fx.store_all();
4304 assert!(
4305 issues.is_empty(),
4306 "expected a clean store, got: {issues:#?}"
4307 );
4308 }
4309
4310 #[test]
4318 fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
4319 let fx = Fixture::new();
4320 let body = |mt: &str| {
4321 format!(
4322 "---\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"
4323 )
4324 };
4325
4326 for ok in ["fact", "operational", "conclusion"] {
4328 fx.write("records/profiles/ok.md", &body(ok));
4329 let issues = validate_working_set(&fx.store(), None).unwrap();
4330 assert!(
4331 !has(&issues, codes::FM_BAD_META_TYPE),
4332 "`meta-type: {ok}` must be accepted; got {issues:#?}"
4333 );
4334 }
4335 fx.write(
4336 "records/profiles/absent.md",
4337 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
4338 );
4339 assert!(
4340 !has(
4341 &validate_working_set(&fx.store(), None).unwrap(),
4342 codes::FM_BAD_META_TYPE
4343 ),
4344 "an absent meta-type is the default `fact` and must be accepted"
4345 );
4346
4347 for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
4349 let fx2 = Fixture::new();
4350 fx2.write("records/profiles/bad.md", &body(bad));
4351 let issues = validate_working_set(&fx2.store(), None).unwrap();
4352 assert!(
4353 has(&issues, codes::FM_BAD_META_TYPE),
4354 "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
4355 );
4356 }
4357 }
4358
4359 #[test]
4368 fn id_absent_slug_ulid_and_numeric_are_all_silent() {
4369 let body = |id_line: &str| {
4370 format!(
4371 "---\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"
4372 )
4373 };
4374 for (case, id_line) in [
4375 ("absent", ""),
4376 ("slug", "id: sarah-chen\n"),
4377 ("ulid", "id: 01j5qc3v9k4ym8rwbn2tqe6f7d\n"),
4378 ("numeric-scalar", "id: 100\n"),
4379 ] {
4380 let fx = Fixture::new();
4381 fx.write("records/contacts/a.md", &body(id_line));
4382 let issues = validate_working_set(&fx.store(), None).unwrap();
4383 assert!(
4384 !has(&issues, codes::FM_BAD_ID),
4385 "id case `{case}` must be silent; got {issues:#?}"
4386 );
4387 }
4388 }
4389
4390 #[test]
4395 fn id_unusable_as_identifier_warns_fm_bad_id() {
4396 let body = |id_line: &str| {
4397 format!(
4398 "---\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"
4399 )
4400 };
4401 for bad in [
4402 "id: \"\"",
4403 "id: \" \"",
4404 "id: two words",
4405 "id: [a, b]",
4406 "id: {k: v}",
4407 ] {
4408 let fx = Fixture::new();
4409 fx.write("records/contacts/a.md", &body(bad));
4410 let issues = validate_working_set(&fx.store(), None).unwrap();
4411 let issue = issues
4412 .iter()
4413 .find(|i| i.code == codes::FM_BAD_ID)
4414 .unwrap_or_else(|| panic!("`{bad}` must fire FM_BAD_ID; got {issues:#?}"));
4415 assert!(
4416 matches!(issue.severity, Severity::Warning),
4417 "FM_BAD_ID is a warning (additive v0.4 — it must never block a store): {issue:#?}"
4418 );
4419 assert_eq!(issue.key.as_deref(), Some("id"));
4420 assert!(
4421 !issue.is_error(),
4422 "FM_BAD_ID must not fail validation: {issue:#?}"
4423 );
4424 }
4425 }
4426
4427 #[test]
4431 fn dup_id_fires_on_shared_ulid_ids() {
4432 let fx = Fixture::new();
4433 let rec = |name: &str| {
4434 format!(
4435 "---\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"
4436 )
4437 };
4438 fx.write("records/contacts/a.md", &rec("A"));
4439 fx.write("records/contacts/b.md", &rec("B"));
4440 let issues = fx.store_all();
4441 assert_eq!(count(&issues, codes::DUP_ID), 1, "{issues:#?}");
4442 let issue = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4443 assert!(issue.is_error());
4444 assert!(!has(&issues, codes::FM_BAD_ID), "{issues:#?}");
4446 }
4447
4448 #[test]
4454 fn valid_db_md_emits_no_structure_issue() {
4455 let fx = Fixture::new();
4456 let issues = fx.store_all();
4457 assert!(
4458 !has(&issues, codes::DB_MD_BAD_TYPE)
4459 && !has(&issues, codes::DB_MD_MISSING_FIELD)
4460 && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4461 "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4462 );
4463 }
4464
4465 #[test]
4469 fn db_md_wrong_type_is_error() {
4470 let fx = Fixture::new();
4471 fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4472 let issues = fx.store_all();
4473 let i = find(&issues, codes::DB_MD_BAD_TYPE);
4474 assert!(i.is_error());
4475 assert_eq!(i.file, PathBuf::from("DB.md"));
4476 assert_eq!(i.key.as_deref(), Some("type"));
4477 assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4478 }
4479
4480 #[test]
4483 fn db_md_missing_scope_and_owner_each_report() {
4484 let fx = Fixture::new();
4485 fx.write("DB.md", "---\ntype: db-md\n---\n");
4486 let issues = fx.store_all();
4487 assert_eq!(
4488 count(&issues, codes::DB_MD_MISSING_FIELD),
4489 2,
4490 "both scope and owner absent → two issues: {issues:#?}"
4491 );
4492 let keys: BTreeSet<Option<String>> = issues
4493 .iter()
4494 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4495 .map(|i| i.key.clone())
4496 .collect();
4497 assert_eq!(
4498 keys,
4499 BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4500 "one issue keyed on each missing field"
4501 );
4502 for i in issues
4503 .iter()
4504 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4505 {
4506 assert!(i.is_error());
4507 assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4508 }
4509 }
4510
4511 #[test]
4515 fn db_md_blank_required_field_is_missing() {
4516 let fx = Fixture::new();
4517 fx.write(
4518 "DB.md",
4519 "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4520 );
4521 let issues = fx.store_all();
4522 let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4523 assert_eq!(i.key.as_deref(), Some("owner"));
4524 assert_eq!(
4525 i.line,
4526 Some(4),
4527 "a present-but-empty field anchors to its line"
4528 );
4529 assert!(
4530 count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4531 "scope is present and non-empty → only owner reported"
4532 );
4533 }
4534
4535 #[test]
4538 fn db_md_unknown_section_is_warning() {
4539 let fx = Fixture::new();
4540 fx.write(
4541 "DB.md",
4542 "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4546 );
4547 let issues = fx.store_all();
4548 let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4549 assert!(!i.is_error(), "unknown section is a warning, not an error");
4550 assert_eq!(i.severity, Severity::Warning);
4551 assert_eq!(
4552 i.line,
4553 Some(11),
4554 "anchors to the `## Glossary` heading line"
4555 );
4556 assert!(
4557 i.message.contains("Glossary"),
4558 "the message names the offending section: {}",
4559 i.message
4560 );
4561 assert_eq!(
4563 count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4564 1,
4565 "only the unrecognized section is flagged: {issues:#?}"
4566 );
4567 }
4568
4569 #[test]
4572 fn db_md_no_frontmatter_reports_type_and_both_fields() {
4573 let fx = Fixture::new();
4574 fx.write("DB.md", "# just a heading, no frontmatter\n");
4575 let issues = fx.store_all();
4576 assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4577 assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4578 }
4579
4580 #[test]
4583 fn missing_type_is_error() {
4584 let fx = Fixture::new();
4585 fx.write(
4586 "records/contacts/a.md",
4587 "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4588 );
4589 let issues = fx.store_all();
4590 assert!(has(&issues, codes::FM_MISSING_TYPE));
4591 assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4592 }
4593
4594 #[test]
4595 fn missing_universal_timestamps_are_errors_on_content_files() {
4596 let fx = Fixture::new();
4597 fx.write(
4598 "records/contacts/a.md",
4599 "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4600 );
4601 let issues = fx.store_all();
4602
4603 let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4604 assert_eq!(missing_created.key.as_deref(), Some("created"));
4605 assert!(missing_created.is_error());
4606
4607 let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4608 assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4609 assert!(missing_updated.is_error());
4610 }
4611
4612 #[test]
4613 fn meta_files_do_not_require_universal_timestamps() {
4614 let fx = Fixture::new();
4615 let issues = fx.store_all();
4616
4617 assert!(
4618 !has(&issues, codes::FM_MISSING_CREATED),
4619 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4620 );
4621 assert!(
4622 !has(&issues, codes::FM_MISSING_UPDATED),
4623 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4624 );
4625 }
4626
4627 #[test]
4628 fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4629 let fx = Fixture::new();
4630 fx.write(
4631 "records/profiles/a.md",
4632 "# Just a heading\n\nNo frontmatter here.\n",
4633 );
4634 let issues = fx.store_all();
4635 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4636 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4637 }
4638
4639 #[test]
4640 fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4641 let fx = Fixture::new();
4642 fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4643 let issues = fx.store_all();
4644 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4645 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4646 }
4647
4648 #[test]
4649 fn malformed_yaml_is_error_and_suppresses_field_checks() {
4650 let fx = Fixture::new();
4651 fx.write(
4653 "records/contacts/a.md",
4654 "---\ntype: contact\n bad: : : :\n: : nope\n---\n\nbody\n",
4655 );
4656 let issues = fx.store_all();
4657 let issue = find(&issues, codes::FM_MALFORMED_YAML);
4658 assert!(issue.is_error());
4659 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4660 assert!(
4663 !has(&issues, codes::SUMMARY_MISSING),
4664 "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4665 );
4666 }
4667
4668 #[test]
4669 fn bad_created_timestamp_is_error() {
4670 let fx = Fixture::new();
4671 fx.write(
4672 "records/contacts/a.md",
4673 "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4674 );
4675 let issues = fx.store_all();
4676 let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4677 assert_eq!(issue.key.as_deref(), Some("created"));
4678 assert!(issue.is_error());
4679 }
4680
4681 #[test]
4682 fn date_only_created_is_rejected_but_type_date_field_accepted() {
4683 let fx = Fixture::new();
4684 fx.write(
4687 "records/contacts/a.md",
4688 "---\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",
4689 );
4690 let issues = fx.store_all();
4691 let created_issues: Vec<_> = issues
4692 .iter()
4693 .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4694 .collect();
4695 assert_eq!(
4696 created_issues.len(),
4697 1,
4698 "date-only `created` must fail: {issues:#?}"
4699 );
4700 assert!(
4701 !issues.iter().any(
4702 |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4703 ),
4704 "date-only `last_touch` is valid: {issues:#?}"
4705 );
4706 }
4707
4708 #[test]
4711 fn summary_missing_empty_multiline_toolong() {
4712 let fx = Fixture::new();
4713 fx.write(
4714 "records/profiles/missing.md",
4715 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4716 );
4717 fx.write(
4718 "records/profiles/empty.md",
4719 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \" \"\n---\n\nbody\n",
4720 );
4721 let long = "x".repeat(201);
4722 fx.write(
4723 "records/profiles/long.md",
4724 &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"),
4725 );
4726 let issues = fx.store_all();
4727 assert!(has(&issues, codes::SUMMARY_MISSING));
4728 assert_eq!(
4729 find(&issues, codes::SUMMARY_MISSING).file,
4730 PathBuf::from("records/profiles/missing.md")
4731 );
4732 assert!(has(&issues, codes::SUMMARY_EMPTY));
4733 assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4734 assert_eq!(
4735 find(&issues, codes::SUMMARY_TOO_LONG).severity,
4736 Severity::Warning
4737 );
4738 }
4739
4740 #[test]
4741 fn summary_multiline_via_yaml_block_scalar() {
4742 let fx = Fixture::new();
4743 fx.write(
4745 "records/profiles/a.md",
4746 "---\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",
4747 );
4748 let issues = fx.store_all();
4749 assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4750 }
4751
4752 #[test]
4753 fn summary_exactly_200_chars_is_ok() {
4754 let fx = Fixture::new();
4755 let s = "y".repeat(200);
4756 fx.write(
4757 "records/profiles/a.md",
4758 &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"),
4759 );
4760 let issues = fx.store_all();
4761 assert!(
4762 !has(&issues, codes::SUMMARY_TOO_LONG),
4763 "200 is the bound, inclusive: {issues:#?}"
4764 );
4765 }
4766
4767 #[test]
4768 fn meta_files_need_no_summary() {
4769 let fx = Fixture::new();
4770 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4773 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4774 fx.write(
4775 "records/index.md",
4776 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4777 );
4778 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4779 fx.write(
4780 "records/contacts/index.jsonl",
4781 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4782 );
4783 fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4784 let issues = fx.store_all();
4785 assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4786 }
4787
4788 #[test]
4791 fn nested_tags_warns_flat_tags_ok() {
4792 let fx = Fixture::new();
4793 fx.write(
4794 "records/contacts/nested.md",
4795 "---\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",
4796 );
4797 fx.write(
4798 "records/contacts/flat.md",
4799 "---\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",
4800 );
4801 let issues = fx.store_all();
4802 let tag_issues: Vec<_> = issues
4803 .iter()
4804 .filter(|i| i.code == codes::TAGS_MALFORMED)
4805 .collect();
4806 assert_eq!(
4807 tag_issues.len(),
4808 1,
4809 "only the nested-tags file should warn: {issues:#?}"
4810 );
4811 assert_eq!(
4812 tag_issues[0].file,
4813 PathBuf::from("records/contacts/nested.md")
4814 );
4815 assert_eq!(tag_issues[0].severity, Severity::Warning);
4816 }
4817
4818 #[test]
4821 fn short_form_wiki_link_is_error() {
4822 let fx = Fixture::new();
4823 let mut body = valid_contact("links to a short form");
4824 body.push_str("\nSee [[sarah-chen]] for details.\n");
4825 fx.write("records/contacts/a.md", &body);
4826 let issues = fx.store_all();
4827 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4828 assert!(issue.is_error());
4829 assert!(issue.message.contains("sarah-chen"));
4830 assert!(
4832 !issues
4833 .iter()
4834 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4835 "short-form should suppress broken: {issues:#?}"
4836 );
4837 }
4838
4839 #[test]
4840 fn broken_full_path_wiki_link_is_error() {
4841 let fx = Fixture::new();
4842 let mut body = valid_contact("links to a missing file");
4843 body.push_str("\nSee [[records/contacts/ghost]].\n");
4844 fx.write("records/contacts/a.md", &body);
4845 let issues = fx.store_all();
4846 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4847 assert!(issue.is_error());
4848 assert!(issue.message.contains("records/contacts/ghost"));
4849 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4850 }
4851
4852 #[test]
4853 fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4854 let fx = Fixture::new();
4855 let mut body = valid_contact("links with traversal");
4856 body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4857 fx.write("records/contacts/a.md", &body);
4858 let issues = fx.store_all();
4859 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4860 assert!(issue.message.contains("not a safe store-relative path"));
4861 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4862 }
4863
4864 #[test]
4865 fn valid_full_path_wiki_link_passes() {
4866 let fx = Fixture::new();
4867 fx.write("records/contacts/target.md", &valid_contact("target"));
4868 let mut body = valid_contact("links to target");
4869 body.push_str("\nSee [[records/contacts/target]].\n");
4870 fx.write("records/contacts/a.md", &body);
4871 let issues = fx.store_all();
4872 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4873 assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4874 }
4875
4876 #[test]
4877 fn md_extension_wiki_link_warns_and_resolves() {
4878 let fx = Fixture::new();
4879 fx.write("records/contacts/target.md", &valid_contact("target"));
4880 let mut body = valid_contact("links with extension");
4881 body.push_str("\nSee [[records/contacts/target.md]].\n");
4882 fx.write("records/contacts/a.md", &body);
4883 let issues = fx.store_all();
4884 let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4885 assert_eq!(issue.severity, Severity::Warning);
4886 assert_eq!(
4887 issue.suggestion.as_deref(),
4888 Some("drop the extension: [[records/contacts/target]]")
4889 );
4890 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4892 }
4893
4894 #[test]
4895 fn wiki_links_in_code_fences_are_ignored() {
4896 let fx = Fixture::new();
4897 let mut body = valid_contact("has a fenced example");
4898 body.push_str("\n```\n[[sarah-chen]]\n```\n");
4899 fx.write("records/contacts/a.md", &body);
4900 let issues = fx.store_all();
4901 assert!(
4902 !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4903 "fenced wiki-links must be ignored: {issues:#?}"
4904 );
4905 }
4906
4907 #[test]
4908 fn flow_form_link_list_in_frontmatter_is_error() {
4909 let fx = Fixture::new();
4910 fx.write(
4911 "records/meetings/m.md",
4912 "---\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",
4913 );
4914 let issues = fx.store_all();
4915 let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4916 assert!(issue.is_error());
4917 assert_eq!(issue.key.as_deref(), Some("attendees"));
4918 }
4919
4920 #[test]
4921 fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4922 let fx = Fixture::new();
4923 fx.write("records/contacts/a.md", &valid_contact("a"));
4924 fx.write("records/contacts/b.md", &valid_contact("b"));
4925 fx.write(
4926 "records/meetings/m.md",
4927 "---\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",
4928 );
4929 let issues = fx.store_all();
4930 assert!(
4931 !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4932 "{issues:#?}"
4933 );
4934 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4936 }
4937
4938 #[test]
4939 fn frontmatter_short_form_link_field_is_error() {
4940 let fx = Fixture::new();
4941 fx.write(
4944 "records/synthesis/a.md",
4945 "---\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",
4946 );
4947 let issues = fx.store_all();
4948 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4949 assert!(issue.is_error());
4950 assert_eq!(issue.key.as_deref(), Some("related"));
4951 }
4952
4953 #[test]
4954 fn unquoted_frontmatter_link_is_recognized() {
4955 let fx = Fixture::new();
4960 fx.write(
4961 "records/synthesis/short.md",
4962 "---\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",
4963 );
4964 fx.write(
4965 "records/synthesis/broken.md",
4966 "---\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",
4967 );
4968 let issues = fx.store_all();
4969 assert!(
4970 issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4971 && i.file == Path::new("records/synthesis/short.md")
4972 && i.key.as_deref() == Some("related")),
4973 "unquoted short-form frontmatter link must be caught: {issues:#?}"
4974 );
4975 assert!(
4976 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4977 && i.file == Path::new("records/synthesis/broken.md")),
4978 "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4979 );
4980 }
4981
4982 #[test]
4983 fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4984 let mut fx = Fixture::new();
4989 fx.config.schemas.insert(
4990 "contact".into(),
4991 Schema {
4992 fields: vec![FieldSpec {
4993 name: "company".into(),
4994 link_prefix: Some(PathBuf::from("records/companies")),
4995 ..Default::default()
4996 }],
4997 ..Default::default()
4998 },
4999 );
5000 fx.write(
5001 "records/contacts/a.md",
5002 "---\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",
5003 );
5004 let issues = fx.store_all();
5005 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5006 assert_eq!(issue.key.as_deref(), Some("company"));
5007 assert!(
5009 !issues
5010 .iter()
5011 .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
5012 && i.key.as_deref() == Some("company")),
5013 "schema link fields are checked once, by the schema path: {issues:#?}"
5014 );
5015 }
5016
5017 #[test]
5018 fn schema_link_field_with_md_extension_still_warns() {
5019 let mut fx = Fixture::new();
5020 fx.config.schemas.insert(
5021 "contact".into(),
5022 Schema {
5023 fields: vec![FieldSpec {
5024 name: "company".into(),
5025 link_prefix: Some(PathBuf::from("records/companies")),
5026 ..Default::default()
5027 }],
5028 ..Default::default()
5029 },
5030 );
5031 fx.write(
5032 "records/companies/acme.md",
5033 "---\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",
5034 );
5035 fx.write(
5036 "records/contacts/a.md",
5037 "---\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",
5038 );
5039 let issues = fx.store_all();
5040 let issue = issues
5041 .iter()
5042 .find(|i| {
5043 i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
5044 })
5045 .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
5046 assert_eq!(issue.severity, Severity::Warning);
5047 assert!(
5048 !issues
5049 .iter()
5050 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
5051 "extensionless existence check should still find acme.md: {issues:#?}"
5052 );
5053 }
5054
5055 #[test]
5058 fn explicit_schema_required_shape_enum() {
5059 let fx = {
5060 let mut fx = Fixture::new();
5061 let schema = Schema {
5064 fields: vec![
5065 FieldSpec {
5066 name: "name".into(),
5067 required: true,
5068 ..Default::default()
5069 },
5070 FieldSpec {
5071 name: "email".into(),
5072 required: true,
5073 shape: Some(Shape::Email),
5074 ..Default::default()
5075 },
5076 FieldSpec {
5077 name: "status".into(),
5078 enum_values: Some(vec!["active".into(), "inactive".into()]),
5079 ..Default::default()
5080 },
5081 ],
5082 ..Default::default()
5083 };
5084 fx.config.schemas.insert("contact".into(), schema);
5085 fx
5086 };
5087 fx.write(
5088 "records/contacts/a.md",
5089 "---\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",
5090 );
5091 let issues = fx.store_all();
5092 assert!(
5094 issues
5095 .iter()
5096 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
5097 && i.key.as_deref() == Some("name")),
5098 "{issues:#?}"
5099 );
5100 assert!(
5102 issues.iter().any(
5103 |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
5104 ),
5105 "{issues:#?}"
5106 );
5107 assert!(
5109 issues
5110 .iter()
5111 .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
5112 && i.key.as_deref() == Some("status")),
5113 "{issues:#?}"
5114 );
5115 }
5116
5117 #[test]
5118 fn schema_without_link_field_allows_plain_value() {
5119 let mut fx = Fixture::new();
5123 fx.config.schemas.insert(
5124 "contact".into(),
5125 Schema {
5126 fields: vec![FieldSpec {
5127 name: "name".into(),
5128 required: true,
5129 ..Default::default()
5130 }],
5131 ..Default::default()
5132 },
5133 );
5134 fx.write(
5135 "records/contacts/a.md",
5136 "---\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",
5137 );
5138 let issues = fx.store_all();
5139 assert!(
5140 !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
5141 "no declared link field for `company` → a plain value is fine: {issues:#?}"
5142 );
5143 }
5144
5145 #[test]
5146 fn schema_link_field_plain_value_is_prefix_mismatch() {
5147 let mut fx = Fixture::new();
5150 fx.config.schemas.insert(
5151 "contact".into(),
5152 Schema {
5153 fields: vec![FieldSpec {
5154 name: "company".into(),
5155 link_prefix: Some(PathBuf::from("records/companies")),
5156 ..Default::default()
5157 }],
5158 ..Default::default()
5159 },
5160 );
5161 fx.write(
5162 "records/contacts/a.md",
5163 "---\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",
5164 );
5165 let issues = fx.store_all();
5166 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5167 assert_eq!(issue.key.as_deref(), Some("company"));
5168 assert!(issue
5169 .suggestion
5170 .as_deref()
5171 .unwrap()
5172 .contains("records/companies/"));
5173 }
5174
5175 #[test]
5176 fn schema_shape_int_and_url_and_currency() {
5177 let mut fx = Fixture::new();
5178 fx.config.schemas.insert(
5179 "widget".into(),
5180 Schema {
5181 fields: vec![
5182 FieldSpec {
5183 name: "qty".into(),
5184 shape: Some(Shape::Int),
5185 ..Default::default()
5186 },
5187 FieldSpec {
5188 name: "site".into(),
5189 shape: Some(Shape::Url),
5190 ..Default::default()
5191 },
5192 FieldSpec {
5193 name: "price".into(),
5194 shape: Some(Shape::Currency),
5195 ..Default::default()
5196 },
5197 ],
5198 ..Default::default()
5199 },
5200 );
5201 fx.write(
5204 "records/widgets/ok.md",
5205 "---\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",
5206 );
5207 fx.write(
5211 "records/widgets/bad.md",
5212 "---\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",
5213 );
5214 let issues = fx.store_all();
5215 let bad_shape: Vec<_> = issues
5216 .iter()
5217 .filter(|i| {
5218 i.code == codes::SCHEMA_SHAPE_MISMATCH
5219 && i.file == Path::new("records/widgets/bad.md")
5220 })
5221 .map(|i| i.key.clone().unwrap_or_default())
5222 .collect();
5223 assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
5224 assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
5225 assert!(
5226 bad_shape.contains(&"price".to_string()),
5227 "inf must be rejected as currency: {issues:#?}"
5228 );
5229 assert!(
5230 !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
5231 && i.file == Path::new("records/widgets/ok.md")),
5232 "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
5233 );
5234 }
5235
5236 #[test]
5237 fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
5238 let mut fx = Fixture::new();
5239 fx.config.schemas.insert(
5240 "contact".into(),
5241 Schema {
5242 fields: vec![
5243 FieldSpec {
5244 name: "email".into(),
5245 required: true,
5246 shape: Some(Shape::Email),
5247 ..Default::default()
5248 },
5249 FieldSpec {
5250 name: "status".into(),
5251 enum_values: Some(vec!["active".into(), "inactive".into()]),
5252 ..Default::default()
5253 },
5254 ],
5255 ..Default::default()
5256 },
5257 );
5258 fx.write(
5262 "records/contacts/bad.md",
5263 "---\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",
5264 );
5265 let issues = fx.store_all();
5266 let mismatched: Vec<_> = issues
5267 .iter()
5268 .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
5269 .map(|i| i.key.clone().unwrap_or_default())
5270 .collect();
5271 assert!(
5272 mismatched.contains(&"email".to_string()),
5273 "list-valued required email must flag: {issues:#?}"
5274 );
5275 assert!(
5276 mismatched.contains(&"status".to_string()),
5277 "list-valued enum must flag: {issues:#?}"
5278 );
5279 }
5280
5281 #[test]
5282 fn is_currency_accepts_codes_and_rejects_non_numeric() {
5283 for ok in [
5285 "100",
5286 "1234.56",
5287 "$1,234.50",
5288 "USD 100", "usd 100", "EUR 9.50",
5291 "£12",
5292 "¥1000",
5293 "-5.00", "+5",
5295 "1,000,000",
5296 ] {
5297 assert!(is_currency(ok), "expected currency: {ok:?}");
5298 }
5299 for bad in [
5302 "inf", "-inf", "infinity", "NaN", "nan", "12.999", "1.2345", "USD", "$", "free", "", " ", "1e3", "1.", ".5", "1 000", "USDD 100", ] {
5313 assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
5314 }
5315 }
5316
5317 #[test]
5320 fn ignored_type_present_is_info() {
5321 let mut fx = Fixture::new();
5322 fx.config.ignored_types.push("temp".into());
5323 fx.write(
5324 "records/temps/x.md",
5325 "---\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",
5326 );
5327 let issues = fx.store_all();
5328 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
5329 assert_eq!(issue.severity, Severity::Info);
5330 assert!(!issue.is_error());
5331 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5332 }
5333
5334 #[test]
5335 fn conclusion_record_derived_from_ignored_type_warns() {
5336 let mut fx = Fixture::new();
5337 fx.config.ignored_types.push("temp".into());
5338 fx.write(
5339 "records/temps/x.md",
5340 "---\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",
5341 );
5342 fx.write(
5346 "records/synthesis/t.md",
5347 "---\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",
5348 );
5349 let issues = fx.store_all();
5350 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
5351 assert_eq!(issue.severity, Severity::Warning);
5352 assert_eq!(issue.key.as_deref(), Some("derived_from"));
5353 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5354 }
5355
5356 #[test]
5364 fn derived_from_ignored_type_is_the_shared_policy_decision() {
5365 let mut fx = Fixture::new();
5366 fx.config.ignored_types.push("secret".into());
5367 fx.write(
5369 "records/secrets/s.md",
5370 "---\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",
5371 );
5372 fx.write(
5374 "records/contacts/c.md",
5375 "---\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",
5376 );
5377 let store = fx.store();
5378
5379 let hit =
5383 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
5384 .expect("conclusion → ignored-type record must match");
5385 assert_eq!(hit.target, "records/secrets/s");
5386 assert_eq!(hit.target_type, "secret");
5387
5388 assert_eq!(
5391 derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
5392 None,
5393 "only conclusion derivation is policed"
5394 );
5395
5396 assert_eq!(
5398 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
5399 None,
5400 "deriving from a non-ignored type is allowed"
5401 );
5402
5403 let hit = derived_from_ignored_type(
5405 &store,
5406 "conclusion",
5407 ["records/contacts/c", "records/secrets/s"],
5408 )
5409 .expect("a later ignored-type target must still be found");
5410 assert_eq!(hit.target, "records/secrets/s");
5411
5412 fx.config.ignored_types.clear();
5414 let store = fx.store();
5415 assert_eq!(
5416 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
5417 None,
5418 "an empty ignored-types policy short-circuits"
5419 );
5420 }
5421
5422 #[test]
5425 fn dup_id_is_hard_error_with_related() {
5426 let fx = Fixture::new();
5427 fx.write(
5428 "records/contacts/a.md",
5429 "---\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",
5430 );
5431 fx.write(
5432 "records/contacts/b.md",
5433 "---\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",
5434 );
5435 let issues = fx.store_all();
5436 assert_eq!(
5439 count(&issues, codes::DUP_ID),
5440 1,
5441 "one issue per group: {issues:#?}"
5442 );
5443 let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
5444 assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
5445 assert!(a.is_error());
5446 assert_eq!(a.key.as_deref(), Some("id"));
5447 assert_eq!(
5448 a.line,
5449 Some(3),
5450 "anchors to the `id` line on the reported file"
5451 );
5452 assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5453 }
5454
5455 #[test]
5456 fn dup_id_not_fired_in_working_set() {
5457 let fx = Fixture::new();
5459 fx.write(
5460 "records/contacts/a.md",
5461 "---\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",
5462 );
5463 fx.write(
5464 "records/contacts/b.md",
5465 "---\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",
5466 );
5467 fx.write(
5469 "log.md",
5470 "---\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",
5471 );
5472 let issues = validate_working_set(&fx.store(), None).unwrap();
5473 assert!(
5474 !has(&issues, codes::DUP_ID),
5475 "DUP_ID is --all only: {issues:#?}"
5476 );
5477 }
5478
5479 #[test]
5480 fn dup_unique_key_single_field_is_warning() {
5481 let mut fx = Fixture::new();
5482 fx.config.schemas.insert(
5484 "contact".into(),
5485 Schema {
5486 unique_keys: vec![vec!["email".into()]],
5487 ..Default::default()
5488 },
5489 );
5490 for (f, name) in [("a", "A"), ("b", "B")] {
5491 fx.write(
5492 &format!("records/contacts/{f}.md"),
5493 &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"),
5494 );
5495 }
5496 let issues = fx.store_all();
5497 assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5500 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5501 assert_eq!(dup.severity, Severity::Warning);
5502 assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5503 assert_eq!(dup.key.as_deref(), Some("email"));
5504 assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5505 }
5506
5507 #[test]
5508 fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5509 let mut fx = Fixture::new();
5510 fx.config.schemas.insert(
5512 "expense".into(),
5513 Schema {
5514 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5515 ..Default::default()
5516 },
5517 );
5518 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");
5519 let exp = |f: &str, amount: &str| {
5520 format!(
5521 "---\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"
5522 )
5523 };
5524 fx.write("records/expenses/e1.md", &exp("e1", "100"));
5525 fx.write("records/expenses/e2.md", &exp("e2", "100"));
5526 fx.write("records/expenses/e3.md", &exp("e3", "200")); let issues = fx.store_all();
5528 assert_eq!(
5531 count(&issues, codes::DUP_UNIQUE_KEY),
5532 1,
5533 "only e1+e2 collide, one issue: {issues:#?}"
5534 );
5535 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5536 assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5537 assert_eq!(
5538 dup.line,
5539 Some(1),
5540 "compound-key collision anchors to line 1"
5541 );
5542 assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5543 assert!(
5544 !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5545 && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5546 "e3 differs on amount and must not collide: {issues:#?}"
5547 );
5548 }
5549
5550 #[test]
5551 fn dup_unique_key_list_field_is_order_independent() {
5552 let mut fx = Fixture::new();
5553 fx.config.schemas.insert(
5555 "meeting".into(),
5556 Schema {
5557 unique_keys: vec![vec!["date".into(), "attendees".into()]],
5558 ..Default::default()
5559 },
5560 );
5561 fx.write("records/contacts/a.md", &valid_contact("a"));
5562 fx.write("records/contacts/b.md", &valid_contact("b"));
5563 let m = |f: &str, order: &str| {
5564 let attendees = if order == "ab" {
5565 " - [[records/contacts/a]]\n - [[records/contacts/b]]"
5566 } else {
5567 " - [[records/contacts/b]]\n - [[records/contacts/a]]"
5568 };
5569 format!(
5570 "---\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"
5571 )
5572 };
5573 fx.write("records/meetings/m1.md", &m("m1", "ab"));
5574 fx.write("records/meetings/m2.md", &m("m2", "ba"));
5575 let issues = fx.store_all();
5576 assert_eq!(
5579 count(&issues, codes::DUP_UNIQUE_KEY),
5580 1,
5581 "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5582 );
5583 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5584 assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5585 assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5586 }
5587
5588 #[test]
5591 fn missing_indexes_at_all_three_levels() {
5592 let fx = Fixture::new();
5593 fx.write("records/contacts/a.md", &valid_contact("a"));
5594 let issues = fx.store_all();
5595 let missing_files: BTreeSet<PathBuf> = issues
5599 .iter()
5600 .filter(|i| i.code == codes::INDEX_MISSING)
5601 .map(|i| i.file.clone())
5602 .collect();
5603 assert!(
5604 missing_files.contains(&PathBuf::from("index.md")),
5605 "{issues:#?}"
5606 );
5607 assert!(
5608 missing_files.contains(&PathBuf::from("records/index.md")),
5609 "{issues:#?}"
5610 );
5611 assert!(
5612 missing_files.contains(&PathBuf::from("records/contacts")),
5613 "{issues:#?}"
5614 );
5615 assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5618 }
5619
5620 #[test]
5621 fn index_stale_entry_and_missing_entry() {
5622 let fx = Fixture::new();
5623 fx.write(
5624 "records/contacts/present.md",
5625 &valid_contact("present contact"),
5626 );
5627 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5629 fx.write(
5630 "records/index.md",
5631 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5632 );
5633 fx.write(
5635 "records/contacts/index.md",
5636 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5637 );
5638 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5639 let issues = fx.store_all();
5640 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5641 assert!(stale.message.contains("ghost"));
5642 assert!(stale.is_error());
5643 let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5644 assert!(
5645 missing.message.contains("present.md"),
5646 "{}",
5647 missing.message
5648 );
5649 }
5650
5651 #[test]
5652 fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5653 let fx = Fixture::new();
5654 fx.write("records/contacts/a.md", &valid_contact("a"));
5655 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5656 fx.write(
5657 "records/index.md",
5658 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5659 );
5660 fx.write(
5661 "records/contacts/index.md",
5662 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5663 );
5664 fx.write(
5665 "records/contacts/index.jsonl",
5666 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5667 );
5668 let issues = fx.store_all();
5669 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5670 assert!(stale.message.contains("not a safe store-relative path"));
5671 }
5672
5673 #[test]
5674 fn index_summary_mismatch() {
5675 let fx = Fixture::new();
5676 fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5677 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5678 fx.write(
5679 "records/index.md",
5680 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5681 );
5682 fx.write(
5683 "records/contacts/index.md",
5684 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5685 );
5686 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5687 let issues = fx.store_all();
5688 let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5689 assert!(issue.is_error());
5690 assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5691 }
5692
5693 #[test]
5694 fn index_summary_match_passes() {
5695 let fx = Fixture::new();
5696 fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5697 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5698 fx.write(
5699 "records/index.md",
5700 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5701 );
5702 fx.write(
5703 "records/contacts/index.md",
5704 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5705 );
5706 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5707 let issues = fx.store_all();
5708 assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5709 }
5710
5711 #[test]
5712 fn index_entry_with_tag_suffix_matches_summary() {
5713 let fx = Fixture::new();
5714 fx.write("records/contacts/a.md", &valid_contact("clean summary"));
5715 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5716 fx.write(
5717 "records/index.md",
5718 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5719 );
5720 fx.write(
5724 "records/contacts/index.md",
5725 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary · #customer\n",
5726 );
5727 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5728 let issues = fx.store_all();
5729 assert!(
5730 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5731 "tag suffix should be stripped: {issues:#?}"
5732 );
5733 }
5734
5735 #[test]
5736 fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5737 let fx = Fixture::new();
5744 fx.write(
5745 "records/contacts/a.md",
5746 &valid_contact("Standup notes · #standup"),
5747 );
5748 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5749 fx.write(
5750 "records/index.md",
5751 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5752 );
5753 fx.write(
5754 "records/contacts/index.md",
5755 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5756 );
5757 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5758 let issues = fx.store_all();
5759 assert!(
5760 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5761 "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5762 );
5763 }
5764
5765 #[test]
5766 fn index_jsonl_desync_missing_file_in_jsonl() {
5767 let fx = Fixture::new();
5768 fx.write("records/contacts/a.md", &valid_contact("a"));
5769 fx.write("records/contacts/b.md", &valid_contact("b"));
5770 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5771 fx.write(
5772 "records/index.md",
5773 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5774 );
5775 fx.write(
5776 "records/contacts/index.md",
5777 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5778 );
5779 fx.write(
5781 "records/contacts/index.jsonl",
5782 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5783 );
5784 let issues = fx.store_all();
5785 let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5786 assert!(desync.message.contains("b.md"), "{}", desync.message);
5787 }
5788
5789 #[test]
5790 fn index_jsonl_desync_record_points_at_missing_file() {
5791 let fx = Fixture::new();
5792 fx.write("records/contacts/a.md", &valid_contact("a"));
5793 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5794 fx.write(
5795 "records/index.md",
5796 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5797 );
5798 fx.write(
5799 "records/contacts/index.md",
5800 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5801 );
5802 fx.write(
5803 "records/contacts/index.jsonl",
5804 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5805 );
5806 let issues = fx.store_all();
5807 assert!(
5808 issues
5809 .iter()
5810 .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5811 "{issues:#?}"
5812 );
5813 }
5814
5815 #[test]
5816 fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5817 let fx = Fixture::new();
5818 fx.write("records/contacts/a.md", &valid_contact("a"));
5819 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5820 fx.write(
5821 "records/index.md",
5822 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5823 );
5824 fx.write(
5825 "records/contacts/index.md",
5826 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5827 );
5828 fx.write(
5829 "records/contacts/index.jsonl",
5830 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5831 );
5832 let issues = fx.store_all();
5833 assert!(
5834 issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5835 && i.message.contains("not a safe store-relative path")),
5836 "{issues:#?}"
5837 );
5838 }
5839
5840 #[test]
5841 fn index_jsonl_stale_summary() {
5842 let fx = Fixture::new();
5843 fx.write("records/contacts/a.md", &valid_contact("real summary"));
5844 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5845 fx.write(
5846 "records/index.md",
5847 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5848 );
5849 fx.write(
5850 "records/contacts/index.md",
5851 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5852 );
5853 fx.write(
5855 "records/contacts/index.jsonl",
5856 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5857 );
5858 let issues = fx.store_all();
5859 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5860 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5861 assert!(stale.key.as_deref().unwrap().contains("summary"));
5862 }
5863
5864 #[test]
5872 fn index_jsonl_stale_queryable_field_email() {
5873 let fx = Fixture::new();
5874 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";
5875 fx.write("records/contacts/a.md", contact);
5876 fx.rebuild_indexes();
5878 let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5879 let good = fs::read_to_string(&jsonl_path).unwrap();
5880 assert!(
5882 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5883 "freshly-rebuilt sidecar must not be stale"
5884 );
5885 assert!(
5887 good.contains("real@correct.com"),
5888 "sidecar projects email: {good}"
5889 );
5890 fx.write(
5891 "records/contacts/index.jsonl",
5892 &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5893 );
5894
5895 let issues = fx.store_all();
5896 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5897 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5898 let key = stale.key.as_deref().unwrap();
5901 assert!(
5902 key.contains("email"),
5903 "expected `email` in stale key, got {key:?}"
5904 );
5905 assert!(!key.contains("summary"), "summary still matches: {key:?}");
5906 assert!(!key.contains("type"), "type still matches: {key:?}");
5907 }
5908
5909 #[test]
5913 fn index_jsonl_stale_typed_and_list_fields() {
5914 let fx = Fixture::new();
5915 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";
5916 fx.write("records/expenses/e.md", expense);
5917 fx.rebuild_indexes();
5918 let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5919 let good = fs::read_to_string(&jsonl_path).unwrap();
5920 assert!(
5921 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5922 "freshly-rebuilt sidecar must not be stale"
5923 );
5924 let stale_line = good
5926 .replace("\"q2\"", "\"WRONG-TAG\"")
5927 .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5928 .replace("1299", "9999");
5929 fx.write("records/expenses/index.jsonl", &stale_line);
5930
5931 let issues = fx.store_all();
5932 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5933 let key = stale.key.as_deref().unwrap();
5934 for expected in ["amount", "tags", "updated"] {
5935 assert!(
5936 key.contains(expected),
5937 "expected `{expected}` in stale key, got {key:?}"
5938 );
5939 }
5940 }
5941
5942 #[test]
5943 fn index_orphan_in_noncanonical_folder() {
5944 let fx = Fixture::new();
5945 fx.write("records/contacts/a.md", &valid_contact("a"));
5946 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5948 fx.write(
5949 "records/index.md",
5950 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5951 );
5952 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5953 fx.write(
5954 "records/contacts/index.jsonl",
5955 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5956 );
5957 fx.write(
5959 "records/contacts/subfolder/index.md",
5960 "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5961 );
5962 let issues = fx.store_all();
5963 let orphan = find(&issues, codes::INDEX_ORPHAN);
5964 assert_eq!(orphan.severity, Severity::Warning);
5965 assert_eq!(
5966 orphan.file,
5967 PathBuf::from("records/contacts/subfolder/index.md")
5968 );
5969 }
5970
5971 #[test]
5972 fn index_wrong_scope() {
5973 let fx = Fixture::new();
5974 fx.write("records/contacts/a.md", &valid_contact("a"));
5975 fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5977 fx.write(
5978 "records/index.md",
5979 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5980 );
5981 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5982 fx.write(
5983 "records/contacts/index.jsonl",
5984 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5985 );
5986 let issues = fx.store_all();
5987 let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5988 assert_eq!(issue.severity, Severity::Warning);
5989 assert_eq!(issue.file, PathBuf::from("index.md"));
5990 }
5991
5992 #[test]
5993 fn capped_type_folder_index_does_not_flag_missing_entries() {
5994 let fx = Fixture::new();
5996 for i in 0..501 {
5997 fx.write(
5998 &format!("records/contacts/c{i:04}.md"),
5999 &valid_contact(&format!("contact {i}")),
6000 );
6001 }
6002 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
6003 fx.write(
6004 "records/index.md",
6005 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
6006 );
6007 fx.write(
6009 "records/contacts/index.md",
6010 "---\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",
6011 );
6012 let mut jsonl = String::new();
6014 for i in 0..501 {
6015 jsonl.push_str(&format!(
6016 "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
6017 ));
6018 }
6019 fx.write("records/contacts/index.jsonl", &jsonl);
6020 let issues = fx.store_all();
6021 assert!(
6022 !has(&issues, codes::INDEX_MISSING_ENTRY),
6023 "over the cap, missing browse entries are expected: {issues:#?}"
6024 );
6025 assert!(
6027 !has(&issues, codes::INDEX_JSONL_DESYNC),
6028 "{:#?}",
6029 issues
6030 .iter()
6031 .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
6032 .collect::<Vec<_>>()
6033 );
6034 }
6035
6036 #[test]
6039 fn log_bad_timestamp_unknown_kind_out_of_order() {
6040 let fx = Fixture::new();
6041 fx.write(
6042 "log.md",
6043 concat!(
6044 "---\ntype: log\n---\n\n# Log\n\n",
6045 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6046 "## [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", ),
6050 );
6051 let issues = fx.store_all();
6052 assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6053 assert_eq!(
6054 find(&issues, codes::LOG_OUT_OF_ORDER).severity,
6055 Severity::Warning
6056 );
6057 let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
6058 assert_eq!(unknown.severity, Severity::Warning);
6059 assert!(unknown.message.contains("frobnicate"));
6060 assert!(unknown
6061 .suggestion
6062 .as_deref()
6063 .is_some_and(|s| s.contains("create")));
6064 let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
6065 assert!(bad.is_error());
6066 }
6067
6068 #[test]
6069 fn log_validate_entry_without_object_is_well_formed() {
6070 let fx = Fixture::new();
6071 fx.write(
6072 "log.md",
6073 "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
6074 );
6075 let issues = fx.store_all();
6076 assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
6077 assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
6078 }
6079
6080 #[test]
6081 fn log_in_order_is_clean() {
6082 let fx = Fixture::new();
6083 fx.write(
6084 "log.md",
6085 concat!(
6086 "---\ntype: log\n---\n\n",
6087 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6088 "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
6089 ),
6090 );
6091 let issues = fx.store_all();
6092 assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6093 }
6094
6095 #[test]
6096 fn log_not_checked_in_working_set() {
6097 let fx = Fixture::new();
6099 fx.write(
6100 "log.md",
6101 concat!(
6102 "---\ntype: log\n---\n\n",
6103 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6104 "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
6105 ),
6106 );
6107 let issues = validate_working_set(&fx.store(), None).unwrap();
6108 assert!(
6109 !has(&issues, codes::LOG_OUT_OF_ORDER),
6110 "log ordering is --all only: {issues:#?}"
6111 );
6112 }
6113
6114 #[test]
6117 fn working_set_validates_only_changed_files() {
6118 let fx = Fixture::new();
6119 fx.write(
6122 "records/contacts/dirty.md",
6123 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6124 );
6125 fx.write(
6126 "records/contacts/unlogged.md",
6127 "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6128 );
6129 fx.write(
6130 "log.md",
6131 "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
6132 );
6133 let issues = validate_working_set(&fx.store(), None).unwrap();
6134 assert!(
6135 issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
6136 && i.file == Path::new("records/contacts/dirty.md")),
6137 "{issues:#?}"
6138 );
6139 assert!(
6140 !issues
6141 .iter()
6142 .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
6143 "unlogged file must not be in the working set: {issues:#?}"
6144 );
6145 }
6146
6147 #[test]
6148 fn working_set_includes_incoming_linkers_to_changed_path() {
6149 let fx = Fixture::new();
6150 fx.write(
6153 "records/profiles/linker.md",
6154 "---\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",
6155 );
6156 fx.write(
6158 "log.md",
6159 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
6160 );
6161 let issues = validate_working_set(&fx.store(), None).unwrap();
6162 assert!(
6163 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
6164 && i.file == Path::new("records/profiles/linker.md")),
6165 "incoming linker to a removed path must be validated: {issues:#?}"
6166 );
6167 }
6168
6169 #[test]
6170 fn working_set_respects_explicit_since_cutoff() {
6171 let fx = Fixture::new();
6172 fx.write(
6173 "records/contacts/old.md",
6174 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6175 );
6176 fx.write(
6177 "records/contacts/new.md",
6178 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6179 );
6180 fx.write(
6181 "log.md",
6182 concat!(
6183 "---\ntype: log\n---\n\n",
6184 "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
6185 "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
6186 ),
6187 );
6188 let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
6190 let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
6191 assert!(
6192 issues
6193 .iter()
6194 .any(|i| i.file == Path::new("records/contacts/new.md")),
6195 "{issues:#?}"
6196 );
6197 assert!(
6198 !issues
6199 .iter()
6200 .any(|i| i.file == Path::new("records/contacts/old.md")),
6201 "old change is before the cutoff: {issues:#?}"
6202 );
6203 }
6204
6205 #[test]
6206 fn working_set_default_since_is_last_validate_entry() {
6207 let fx = Fixture::new();
6208 fx.write(
6210 "records/contacts/before.md",
6211 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6212 );
6213 fx.write(
6214 "records/contacts/after.md",
6215 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6216 );
6217 fx.write(
6218 "log.md",
6219 concat!(
6220 "---\ntype: log\n---\n\n",
6221 "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
6222 "## [2026-05-21 10:00] validate\nPASS\n\n",
6223 "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
6224 ),
6225 );
6226 let issues = validate_working_set(&fx.store(), None).unwrap();
6227 assert!(
6228 issues
6229 .iter()
6230 .any(|i| i.file == Path::new("records/contacts/after.md")),
6231 "{issues:#?}"
6232 );
6233 assert!(
6234 !issues
6235 .iter()
6236 .any(|i| i.file == Path::new("records/contacts/before.md")),
6237 "change before the last validate entry is outside the default window: {issues:#?}"
6238 );
6239 }
6240
6241 #[test]
6244 fn issues_are_sorted_by_file_then_line() {
6245 let fx = Fixture::new();
6246 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");
6247 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");
6248 let issues = fx.store_all();
6249 let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
6250 let mut sorted = files.clone();
6251 sorted.sort();
6252 assert_eq!(
6253 files, sorted,
6254 "issues must be emitted in a stable file order"
6255 );
6256 }
6257
6258 #[test]
6261 fn frozen_page_is_not_a_validate_error() {
6262 let mut fx = Fixture::new();
6265 fx.config
6266 .frozen_pages
6267 .push(PathBuf::from("records/decisions/d.md"));
6268 fx.write(
6269 "records/decisions/d.md",
6270 "---\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",
6271 );
6272 let issues = fx.store_all();
6273 assert!(
6274 !has(&issues, codes::POLICY_FROZEN_PAGE),
6275 "frozen pages are enforced at write-time, not by validate: {issues:#?}"
6276 );
6277 }
6278
6279 #[test]
6280 fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
6281 let fx = Fixture::new();
6284 fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
6285 let mut body = valid_contact("links to sarah");
6286 body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
6287 fx.write("records/contacts/p.md", &body);
6288 let issues = fx.store_all();
6289 assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
6290 }
6291
6292 #[test]
6295 fn unknown_type_passes_through() {
6296 let fx = Fixture::new();
6300 fx.write(
6301 "records/proposals/x.md",
6302 "---\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",
6303 );
6304 let issues = fx.store_all();
6305 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
6306 assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
6307 assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
6308 assert!(
6310 !issues
6311 .iter()
6312 .any(|i| i.key.as_deref() == Some("custom_field")
6313 || i.key.as_deref() == Some("budget")),
6314 "unknown fields are ambient context: {issues:#?}"
6315 );
6316 }
6317
6318 #[test]
6321 fn incoming_linker_scan_does_not_prefix_match() {
6322 let fx = Fixture::new();
6325 fx.write(
6326 "records/profiles/only-sarah-chen.md",
6327 "---\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",
6328 );
6329 fx.write(
6331 "log.md",
6332 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
6333 );
6334 let issues = validate_working_set(&fx.store(), None).unwrap();
6335 assert!(
6336 !issues
6337 .iter()
6338 .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
6339 "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
6340 );
6341 }
6342
6343 #[test]
6344 fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
6345 let fx = Fixture::new();
6359 fx.write(
6362 "records/contacts/index.md",
6363 "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
6364 );
6365 fx.write(
6367 "log.md",
6368 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
6369 );
6370 let issues = validate_working_set(&fx.store(), None).unwrap();
6371 assert!(
6372 !issues
6373 .iter()
6374 .any(|i| i.file == Path::new("records/contacts/index.md")
6375 && i.code == codes::WIKI_LINK_BROKEN),
6376 "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
6377 working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
6378 );
6379 }
6380
6381 #[test]
6382 fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
6383 let fx = Fixture::new();
6392 fx.write(
6394 "records/profiles/refers-sarah.md",
6395 "---\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",
6396 );
6397 fx.write(
6401 "records/meetings/2026/05/kickoff.md",
6402 "---\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",
6403 );
6404 fx.write(
6406 "log.md",
6407 "---\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",
6408 );
6409
6410 let issues = validate_working_set(&fx.store(), None).unwrap();
6411 assert!(
6412 issues
6413 .iter()
6414 .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
6415 && i.code == codes::WIKI_LINK_BROKEN),
6416 "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
6417 );
6418 assert!(
6419 issues.iter().any(
6420 |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
6421 && i.code == codes::WIKI_LINK_BROKEN
6422 ),
6423 "linker to the SECOND deleted target (typed-field edge) must also be \
6424 pulled in and flagged — proves the scan covers the whole changed set, \
6425 not just one object: {issues:#?}"
6426 );
6427 }
6428
6429 #[test]
6430 fn frontmatter_block_sequence_links_each_get_their_own_line() {
6431 let fx = Fixture::new();
6433 fx.write(
6435 "records/meetings/m.md",
6436 "---\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",
6437 );
6438 let issues = fx.store_all();
6439 let broken_lines: BTreeSet<Option<u32>> = issues
6440 .iter()
6441 .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
6442 .map(|i| i.line)
6443 .collect();
6444 assert_eq!(
6445 broken_lines.len(),
6446 2,
6447 "two distinct broken-link lines: {issues:#?}"
6448 );
6449 }
6450
6451 #[test]
6454 fn null_created_is_missing_not_silently_passed() {
6455 let fx = Fixture::new();
6459 fx.write(
6460 "records/contacts/a.md",
6461 "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6462 );
6463 let issues = fx.store_all();
6464 assert!(
6465 has(&issues, codes::FM_MISSING_CREATED),
6466 "null `created:` must read as missing: {issues:#?}"
6467 );
6468 }
6469
6470 #[test]
6471 fn sequence_created_is_bad_timestamp() {
6472 let fx = Fixture::new();
6474 fx.write(
6475 "records/contacts/a.md",
6476 "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6477 );
6478 let issues = fx.store_all();
6479 assert!(
6480 issues
6481 .iter()
6482 .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6483 "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6484 );
6485 }
6486
6487 #[test]
6490 fn required_field_null_or_empty_collection_is_missing() {
6491 for value in ["", " []", " {}"] {
6496 let mut fx = Fixture::new();
6497 fx.config.schemas.insert(
6498 "contact".into(),
6499 Schema {
6500 fields: vec![FieldSpec {
6501 name: "name".into(),
6502 required: true,
6503 ..Default::default()
6504 }],
6505 ..Default::default()
6506 },
6507 );
6508 fx.write(
6509 "records/contacts/a.md",
6510 &format!(
6511 "---\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"
6512 ),
6513 );
6514 let issues = fx.store_all();
6515 assert!(
6516 issues
6517 .iter()
6518 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6519 && i.key.as_deref() == Some("name")),
6520 "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6521 );
6522 }
6523 }
6524
6525 #[test]
6528 fn wiki_link_to_raw_source_file_resolves() {
6529 let fx = Fixture::new();
6533 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6534 fx.write(
6535 "records/contacts/a.md",
6536 "---\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",
6537 );
6538 let issues = fx.store_all();
6539 assert!(
6540 !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6541 "a link to an existing raw source file must not be broken: {issues:#?}"
6542 );
6543 }
6544
6545 #[test]
6548 fn wrong_case_wiki_link_is_broken_exact_case() {
6549 let fx = Fixture::new();
6555 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6556 let mut body = valid_contact("links with the wrong case");
6557 body.push_str("\nKnows [[records/contacts/BOB]].\n");
6558 fx.write("records/contacts/alice.md", &body);
6559 let issues = fx.store_all();
6560 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6561 assert!(issue.is_error());
6562 assert!(
6563 issue.message.contains("records/contacts/BOB"),
6564 "the wrong-case target must be named in the issue: {issues:#?}"
6565 );
6566 }
6567
6568 #[test]
6569 fn correct_case_wiki_link_still_resolves() {
6570 let fx = Fixture::new();
6574 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6575 let mut body = valid_contact("links with the right case");
6576 body.push_str("\nKnows [[records/contacts/bob]].\n");
6577 fx.write("records/contacts/alice.md", &body);
6578 let issues = fx.store_all();
6579 assert!(
6580 !issues
6581 .iter()
6582 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("contacts/bob")),
6583 "a correct-case link must resolve clean: {issues:#?}"
6584 );
6585 }
6586
6587 #[test]
6588 fn wrong_case_raw_source_wiki_link_is_broken() {
6589 let fx = Fixture::new();
6594 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6595 fx.write(
6596 "records/contacts/a.md",
6597 "---\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",
6598 );
6599 let issues = fx.store_all();
6600 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6601 assert!(issue.is_error());
6602 assert!(
6603 issue.message.contains("2026-05-22-ELENA.eml"),
6604 "the wrong-case raw-source target must be flagged: {issues:#?}"
6605 );
6606 }
6607
6608 #[test]
6611 fn non_utf8_content_file_is_reported() {
6612 let fx = Fixture::new();
6616 let abs = fx.dir.path().join("records/notes/corrupt.md");
6617 fs::create_dir_all(abs.parent().unwrap()).unwrap();
6618 fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6619 let issues = validate_working_set(&fx.store(), None).unwrap();
6620 assert!(
6621 has(&issues, codes::FM_UNREADABLE),
6622 "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6623 );
6624 }
6625
6626 #[test]
6629 fn tilde_fence_containing_backtick_fence_does_not_invert() {
6630 let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6635 let links = extract_wiki_links(body);
6636 assert!(
6637 links.is_empty(),
6638 "wiki-link inside a nested code fence must be skipped: {links:?}"
6639 );
6640 }
6641
6642 #[test]
6645 fn all_sweep_visits_in_layer_log_folder() {
6646 let fx = Fixture::new();
6651 fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6652 let issues = fx.store_all();
6653 assert!(
6654 has(&issues, codes::FM_MISSING_TYPE),
6655 "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6656 );
6657 }
6658
6659 #[test]
6662 fn flow_form_link_list_with_spaces_is_flagged() {
6663 let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6667 assert!(
6668 keys.iter().any(|k| k == "attendees"),
6669 "spaced flow-form list must be detected: {keys:?}"
6670 );
6671 }
6672
6673 #[test]
6676 fn middot_hashtag_summary_tail_round_trips() {
6677 assert_eq!(
6683 extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6684 Some("Standup notes · #standup"),
6685 "a single-spaced middot tail is part of the summary, not a tag block"
6686 );
6687 assert_eq!(
6689 extract_index_entry_summary("— Renewal champion · #renewal #acme").as_deref(),
6690 Some("Renewal champion"),
6691 "the renderer's double-spaced ` · #tag` suffix is stripped"
6692 );
6693 }
6694
6695 #[test]
6698 fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6699 assert!(is_url("http://x"), "an 8-char http URL is valid");
6700 assert!(is_url("https://x"), "a 9-char https URL is valid");
6701 assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6702 assert!(!is_url("https://"), "a bare https scheme is rejected");
6703 }
6704
6705 #[test]
6706 fn email_shape_rejects_double_at() {
6707 assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6708 assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6709 assert!(is_email("sarah@acme.com"), "a normal address still passes");
6710 }
6711
6712 #[test]
6715 fn working_set_does_not_flag_log_md_body_links() {
6716 let fx = Fixture::new();
6722 fx.write("records/contacts/a.md", &valid_contact("A"));
6723 fx.write(
6724 "log.md",
6725 "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6726 );
6727 let issues = validate_working_set(&fx.store(), None).unwrap();
6728 assert!(
6729 !issues
6730 .iter()
6731 .any(|i| i.code == codes::WIKI_LINK_BROKEN
6732 && i.file == std::path::Path::new("log.md")),
6733 "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6734 );
6735 }
6736
6737 #[test]
6740 fn schema_duplicate_field_name_is_flagged() {
6741 let mut fx = Fixture::new();
6742 fx.config.schemas.insert(
6743 "contact".into(),
6744 Schema {
6745 fields: vec![
6746 FieldSpec {
6747 name: "name".into(),
6748 required: true,
6749 ..Default::default()
6750 },
6751 FieldSpec {
6752 name: "name".into(),
6753 ..Default::default()
6754 },
6755 ],
6756 ..Default::default()
6757 },
6758 );
6759 let issues = fx.store_all();
6760 assert!(
6761 issues
6762 .iter()
6763 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6764 "a duplicate schema field name must be flagged: {issues:#?}"
6765 );
6766 }
6767
6768 #[test]
6769 fn schema_unknown_modifier_is_info() {
6770 let mut fx = Fixture::new();
6771 fx.config.schemas.insert(
6772 "contact".into(),
6773 Schema {
6774 fields: vec![FieldSpec {
6775 name: "name".into(),
6776 unknown_modifiers: vec!["requierd".into()],
6777 ..Default::default()
6778 }],
6779 ..Default::default()
6780 },
6781 );
6782 let issues = fx.store_all();
6783 assert!(
6784 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6785 && i.severity == Severity::Info
6786 && i.key.as_deref() == Some("name")),
6787 "an unrecognized schema modifier must surface as Info: {issues:#?}"
6788 );
6789 }
6790
6791 #[test]
6797 fn schema_unique_key_optional_field_is_warning() {
6798 let mut fx = Fixture::new();
6799 fx.config.schemas.insert(
6800 "expense".into(),
6801 Schema {
6802 fields: vec![
6803 FieldSpec {
6804 name: "date".into(),
6805 required: true,
6806 ..Default::default()
6807 },
6808 FieldSpec {
6809 name: "amount".into(),
6810 required: true,
6811 ..Default::default()
6812 },
6813 FieldSpec {
6814 name: "vendor".into(),
6815 ..Default::default()
6816 },
6817 ],
6818 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
6819 ..Default::default()
6820 },
6821 );
6822 let issues = fx.store_all();
6823 assert!(
6824 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6825 && i.severity == Severity::Warning
6826 && i.key.as_deref() == Some("vendor")
6827 && i.message.contains("unique")),
6828 "a `unique:` key field not marked required must warn: {issues:#?}"
6829 );
6830 assert!(
6832 !issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6833 && matches!(i.key.as_deref(), Some("date") | Some("amount"))),
6834 "required key fields must not warn: {issues:#?}"
6835 );
6836 }
6837
6838 #[test]
6843 fn body_leading_frontmatter_block_is_warning() {
6844 let fx = Fixture::new();
6845 fx.write(
6846 "records/notes/imported.md",
6847 "---\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",
6848 );
6849 let issues = fx.store_all();
6850 assert!(
6851 issues
6852 .iter()
6853 .any(|i| i.code == codes::FM_IN_BODY && i.severity == Severity::Warning),
6854 "a body opening with a second frontmatter block must warn: {issues:#?}"
6855 );
6856 }
6857
6858 #[test]
6861 fn body_thematic_break_rules_do_not_warn() {
6862 let fx = Fixture::new();
6863 fx.write(
6864 "records/notes/rules.md",
6865 "---\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",
6866 );
6867 let issues = fx.store_all();
6868 assert!(
6869 !has(&issues, codes::FM_IN_BODY),
6870 "a `---` thematic rule around prose (not a YAML mapping) must NOT warn: {issues:#?}"
6871 );
6872 }
6873
6874 #[test]
6878 fn body_fenced_frontmatter_example_does_not_warn() {
6879 let fx = Fixture::new();
6880 fx.write(
6881 "records/notes/doc.md",
6882 "---\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",
6883 );
6884 let issues = fx.store_all();
6885 assert!(
6886 !has(&issues, codes::FM_IN_BODY),
6887 "a fenced example block (body opens with a code fence, not `---`) must NOT warn: {issues:#?}"
6888 );
6889 }
6890
6891 #[test]
6894 fn schema_unique_key_undeclared_field_is_warning() {
6895 let mut fx = Fixture::new();
6896 fx.config.schemas.insert(
6897 "expense".into(),
6898 Schema {
6899 fields: vec![FieldSpec {
6900 name: "date".into(),
6901 required: true,
6902 ..Default::default()
6903 }],
6904 unique_keys: vec![vec!["date".into(), "vendor".into()]],
6905 ..Default::default()
6906 },
6907 );
6908 let issues = fx.store_all();
6909 assert!(
6910 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6911 && i.severity == Severity::Warning
6912 && i.key.as_deref() == Some("vendor")
6913 && i.message.contains("not declared")),
6914 "a `unique:` key field absent from the schema must warn: {issues:#?}"
6915 );
6916 }
6917
6918 #[test]
6920 fn schema_unique_key_all_required_is_clean() {
6921 let mut fx = Fixture::new();
6922 fx.config.schemas.insert(
6923 "expense".into(),
6924 Schema {
6925 fields: vec![
6926 FieldSpec {
6927 name: "date".into(),
6928 required: true,
6929 ..Default::default()
6930 },
6931 FieldSpec {
6932 name: "amount".into(),
6933 required: true,
6934 ..Default::default()
6935 },
6936 ],
6937 unique_keys: vec![vec!["date".into(), "amount".into()]],
6938 ..Default::default()
6939 },
6940 );
6941 let issues = fx.store_all();
6942 assert!(
6943 !issues
6944 .iter()
6945 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.message.contains("unique")),
6946 "an all-required unique key must not warn: {issues:#?}"
6947 );
6948 }
6949
6950 #[test]
6956 fn every_code_constant_is_documented_in_spec() {
6957 let this_src = include_str!("validate.rs");
6961 let mut codes_in_module: Vec<String> = Vec::new();
6962 let mut in_codes_mod = false;
6963 for line in this_src.lines() {
6964 let t = line.trim();
6965 if t.starts_with("pub mod codes") {
6966 in_codes_mod = true;
6967 continue;
6968 }
6969 if in_codes_mod && line == "}" {
6971 break;
6972 }
6973 if in_codes_mod {
6974 if let Some(rest) = t.strip_prefix("pub const ") {
6975 let value = rest
6977 .split_once('=')
6978 .map(|(_, v)| v.trim())
6979 .and_then(|v| v.strip_prefix('"'))
6980 .and_then(|v| v.strip_suffix("\";"))
6981 .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6982 codes_in_module.push(value.to_string());
6983 }
6984 }
6985 }
6986 assert!(
6987 codes_in_module.len() >= 36,
6988 "parsed only {} code constants from `mod codes`; the parser likely \
6989 broke against a source-format change",
6990 codes_in_module.len()
6991 );
6992
6993 let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6995 let spec = fs::read_to_string(&spec_path)
6996 .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6997
6998 let missing: Vec<&String> = codes_in_module
7000 .iter()
7001 .filter(|code| !spec.contains(&format!("| `{code}` |")))
7002 .collect();
7003 assert!(
7004 missing.is_empty(),
7005 "validation codes emitted by the engine but absent from SPEC.md \
7006 § Validation (the declared complete vocabulary): {missing:?}"
7007 );
7008 }
7009
7010 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";
7013 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";
7014
7015 #[test]
7016 fn loose_file_catalogued_in_layer_jsonl_validates_clean() {
7017 let fx = Fixture::new();
7018 fx.write("records/contacts/alice.md", LOOSE_ALICE);
7019 fx.write("records/bob.md", LOOSE_BOB); fx.rebuild_indexes();
7021 let issues = fx.store_all();
7022 assert!(
7023 issues.is_empty(),
7024 "a rebuilt store with a catalogued loose file must validate clean, got: {issues:?}"
7025 );
7026 }
7027
7028 #[test]
7029 fn loose_file_with_missing_layer_jsonl_is_index_jsonl_missing() {
7030 let fx = Fixture::new();
7031 fx.write("records/contacts/alice.md", LOOSE_ALICE);
7032 fx.write("records/bob.md", LOOSE_BOB);
7033 fx.rebuild_indexes();
7034 fs::remove_file(fx.dir.path().join("records/index.jsonl")).unwrap();
7036 let issues = fx.store_all();
7037 assert!(
7038 has(&issues, codes::INDEX_JSONL_MISSING),
7039 "a loose file with no layer index.jsonl must raise INDEX_JSONL_MISSING, got: {issues:?}"
7040 );
7041 }
7042
7043 #[test]
7052 fn a_second_sweep_on_the_same_store_sees_files_written_since_the_first() {
7053 let sandbox = tempfile::tempdir().unwrap();
7054 let root = sandbox.path().join("store");
7055 fs::create_dir_all(root.join("records/notes")).unwrap();
7056 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7057 fs::write(
7058 root.join("records/notes/linker.md"),
7059 "---\ntype: note\n---\nsee [[records/notes/target.md]]\n",
7060 )
7061 .unwrap();
7062
7063 let store = Store::open_strict(&root).unwrap();
7064
7065 let before = validate_all(&store).unwrap();
7067 assert!(
7068 before.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
7069 "the target is absent, so the first sweep must report a broken link"
7070 );
7071
7072 fs::write(
7074 root.join("records/notes/target.md"),
7075 "---\ntype: note\n---\ntarget body\n",
7076 )
7077 .unwrap();
7078
7079 let after = validate_all(&store).unwrap();
7080 assert!(
7081 !after.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
7082 "the second sweep must see the file written since the first — a \
7083 directory listing cached beyond one sweep would still call it broken"
7084 );
7085 }
7086
7087 #[cfg(unix)]
7088 #[test]
7089 fn validation_reads_opened_root_after_path_replacement() {
7090 use std::os::unix::fs::symlink;
7091
7092 let sandbox = tempfile::tempdir().unwrap();
7093 let root = sandbox.path().join("store");
7094 fs::create_dir_all(root.join("records/notes")).unwrap();
7095 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7096 fs::write(
7097 root.join("records/notes/owned.md"),
7098 "---\ntype: note\n---\nowned body\n",
7099 )
7100 .unwrap();
7101 let store = Store::open_strict(&root).unwrap();
7102 let detached = sandbox.path().join("detached");
7103 fs::rename(&root, &detached).unwrap();
7104
7105 let replacement = sandbox.path().join("replacement");
7106 fs::create_dir_all(replacement.join("records/notes")).unwrap();
7107 fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7108 fs::write(
7109 replacement.join("records/notes/replacement-secret.md"),
7110 "not frontmatter\n",
7111 )
7112 .unwrap();
7113 symlink(&replacement, &root).unwrap();
7114
7115 let issues = validate_content_sweep(&store).unwrap();
7116 assert!(
7117 issues
7118 .iter()
7119 .any(|issue| issue.file == Path::new("records/notes/owned.md")),
7120 "the held original file must be validated: {issues:?}"
7121 );
7122 assert!(
7123 issues
7124 .iter()
7125 .all(|issue| !issue.file.to_string_lossy().contains("replacement-secret")),
7126 "replacement-root files must be invisible: {issues:?}"
7127 );
7128 }
7129}