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 if !store_marker_present(store) {
260 return Ok(vec![not_a_store_issue(store)]);
261 }
262
263 let cutoff = match since {
264 Some(ts) => Some(ts),
265 None => last_validate_at(store),
266 };
267
268 let changed = changed_objects_since(store, cutoff);
270 if changed.is_empty() && since.is_none() {
271 return validate_content_sweep(store);
272 }
273
274 let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
285 let mut working: BTreeSet<PathBuf> = changed;
286 for linker in store.find_links_to_any(&changed_targets)? {
287 working.insert(linker);
288 }
289
290 let mut issues = nested_store_issues(store)?;
291 for rel in &working {
292 if !store.regular_file_exists(rel).unwrap_or(false) {
295 continue;
296 }
297 check_content_file(store, rel, None, &mut issues);
302 }
303 issues.sort_by(issue_order);
304 Ok(issues)
305}
306
307pub fn apply_projection_policy(issues: &mut [Issue], policy: &ProjectionPolicy) {
316 for issue in issues {
317 if issue.code != codes::WIKI_LINK_BROKEN || issue.related.len() != 1 {
318 continue;
319 }
320 let target = issue.related[0].to_string_lossy();
321 if !policy.excludes_wiki_coordinate(target.as_ref()) {
322 continue;
323 }
324 issue.severity = Severity::Info;
325 issue.code = codes::WIKI_LINK_PROJECTION_UNRESOLVED;
326 issue.message =
327 format!("wiki-link target `{target}` is absent from this declared store projection");
328 issue.suggestion = Some(
329 "restore the excluded path to establish full-store semantic completeness".to_string(),
330 );
331 }
332}
333
334fn validate_content_sweep(store: &Store) -> crate::Result<Vec<Issue>> {
335 let mut issues = nested_store_issues(store)?;
336 for rel in store.walk()? {
337 check_content_file(store, &rel, None, &mut issues);
338 }
339 issues.sort_by(issue_order);
340 Ok(issues)
341}
342
343fn nested_store_issues(store: &Store) -> crate::Result<Vec<Issue>> {
347 let mut issues = Vec::new();
348 for nested in store.nested_store_roots()? {
349 let marker = nested.join("DB.md");
350 push(
351 &mut issues,
352 Severity::Error,
353 codes::NESTED_STORE,
354 &marker,
355 None,
356 None,
357 format!(
358 "`{}` is a db.md store nested inside this store",
359 nested.display()
360 ),
361 Some(
362 "move the nested store outside this store, or run dbmd from the nested root"
363 .to_string(),
364 ),
365 vec![],
366 );
367 }
368 Ok(issues)
369}
370
371pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
376 if !store_marker_present(store) {
377 return Ok(vec![not_a_store_issue(store)]);
378 }
379
380 let mut issues = nested_store_issues(store)?;
381
382 check_db_md(store, &mut issues);
386
387 let files = store.walk()?;
388
389 let basenames = build_basename_index(&files);
394
395 let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
397 for rel in &files {
398 if let Some(p) = check_content_file(store, rel, Some(&basenames), &mut issues) {
399 parsed.push((rel.clone(), p));
400 }
401 }
402
403 check_duplicates(store, &parsed, &mut issues);
405
406 check_indexes(store, &files, &mut issues);
408
409 check_log(store, &mut issues);
411
412 check_assets(store, &parsed, &mut issues);
417
418 issues.sort_by(issue_order);
419 Ok(issues)
420}
421
422struct Parsed {
431 fm: Option<BTreeMap<String, Value>>,
434 fm_yaml: String,
437}
438
439fn check_content_file(
444 store: &Store,
445 rel: &Path,
446 basenames: Option<&BasenameIndex>,
447 issues: &mut Vec<Issue>,
448) -> Option<Parsed> {
449 let text = match store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) {
450 Ok(t) => t,
451 Err(e) => {
452 let detail = if e.kind() == std::io::ErrorKind::InvalidData {
460 "file is not valid UTF-8 text".to_string()
461 } else {
462 format!("file could not be read: {e}")
463 };
464 push(
465 issues,
466 Severity::Error,
467 codes::FM_UNREADABLE,
468 rel,
469 None,
470 None,
471 format!("content file is unreadable: {detail}"),
472 Some(
473 "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
474 .into(),
475 ),
476 vec![],
477 );
478 return None;
479 }
480 };
481
482 let is_content = is_content_file(rel);
483
484 let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
485 Some(split) => split,
486 None => {
487 if is_content {
491 push(
492 issues,
493 Severity::Error,
494 codes::FM_MISSING_TYPE,
495 rel,
496 None,
497 Some("type".into()),
498 "content file has no frontmatter `type:`".into(),
499 Some("add a YAML frontmatter block with `type:`".into()),
500 vec![],
501 );
502 push(
503 issues,
504 Severity::Error,
505 codes::SUMMARY_MISSING,
506 rel,
507 None,
508 Some("summary".into()),
509 "content file has no `summary`".into(),
510 Some("run `dbmd fm init`".into()),
511 vec![],
512 );
513 }
514 return None;
515 }
516 };
517
518 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
520 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
521 Ok(Value::Null) => Some(BTreeMap::new()),
523 Ok(_) => {
524 push(
528 issues,
529 Severity::Error,
530 codes::FM_MALFORMED_YAML,
531 rel,
532 Some(1),
533 None,
534 "frontmatter is not a YAML mapping".into(),
535 Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
536 vec![],
537 );
538 None
539 }
540 Err(e) => {
541 push(
544 issues,
545 Severity::Error,
546 codes::FM_MALFORMED_YAML,
547 rel,
548 Some(1),
549 None,
550 format!("frontmatter block isn't valid YAML: {e}"),
551 Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
552 vec![],
553 );
554 None
555 }
556 };
557
558 if let Some(map) = &fm {
559 check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
561 }
562
563 if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
585 check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
586 }
587
588 if is_content && body_opens_with_frontmatter(&body) {
595 push(
596 issues,
597 Severity::Warning,
598 codes::FM_IN_BODY,
599 rel,
600 Some(fm_end_line + 1),
601 None,
602 "the body opens with a second `---` frontmatter block; the record's \
603 frontmatter is the block at the top of the file, so this one is body \
604 text (usually an imported file's own frontmatter left in place)"
605 .into(),
606 Some(
607 "delete the leftover `---…---` block from the body, or move its \
608 fields into the record's frontmatter"
609 .into(),
610 ),
611 vec![],
612 );
613 }
614
615 Some(Parsed { fm, fm_yaml })
616}
617
618fn check_frontmatter(
620 store: &Store,
621 rel: &Path,
622 fm: &BTreeMap<String, Value>,
623 fm_yaml: &str,
624 basenames: Option<&BasenameIndex>,
625 issues: &mut Vec<Issue>,
626 is_content: bool,
627) {
628 let type_ = fm.get("type").and_then(scalar_string);
629
630 if is_content && type_.is_none() {
632 push(
633 issues,
634 Severity::Error,
635 codes::FM_MISSING_TYPE,
636 rel,
637 fm_key_line_or_top(fm_yaml, "type"),
638 Some("type".into()),
639 "content file has no `type:`".into(),
640 Some("add a `type:` field (e.g. `type: contact`)".into()),
641 vec![],
642 );
643 }
644
645 if is_content {
650 if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
659 match scalar_string(v) {
660 Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
661 Some(mt) => push(
662 issues,
663 Severity::Error,
664 codes::FM_BAD_META_TYPE,
665 rel,
666 fm_key_line_or_top(fm_yaml, "meta-type"),
667 Some("meta-type".into()),
668 format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
669 Some(
670 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
671 .into(),
672 ),
673 vec![],
674 ),
675 None => push(
676 issues,
677 Severity::Error,
678 codes::FM_BAD_META_TYPE,
679 rel,
680 fm_key_line_or_top(fm_yaml, "meta-type"),
681 Some("meta-type".into()),
682 "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
683 string, found a list or mapping"
684 .to_string(),
685 Some(
686 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
687 .into(),
688 ),
689 vec![],
690 ),
691 }
692 }
693 }
694
695 if is_content {
706 if let Some(v) = fm.get("id").filter(|v| !v.is_null()) {
707 let problem = match scalar_string(v) {
708 Some(id) if id.trim().is_empty() => Some("`id` is empty".to_string()),
709 Some(id) if id.chars().any(char::is_whitespace) => {
710 Some(format!("`id` {id:?} contains whitespace"))
711 }
712 Some(_) => None,
713 None => Some(
714 "`id` is not a scalar (found a list or mapping), so duplicate detection \
715 (DUP_ID) cannot see it"
716 .to_string(),
717 ),
718 };
719 if let Some(message) = problem {
720 push(
721 issues,
722 Severity::Warning,
723 codes::FM_BAD_ID,
724 rel,
725 fm_key_line_or_top(fm_yaml, "id"),
726 Some("id".into()),
727 message,
728 Some(
729 "use one opaque token with no whitespace — the recommended form is a \
730 lowercase ULID (`dbmd write` mints one) — or drop `id` to fall back to \
731 filename identity"
732 .into(),
733 ),
734 vec![],
735 );
736 }
737 }
738 }
739
740 if is_content {
742 check_summary(rel, fm, fm_yaml, issues);
743 }
744
745 if is_content {
749 for (key, missing_code) in [
750 ("created", codes::FM_MISSING_CREATED),
751 ("updated", codes::FM_MISSING_UPDATED),
752 ] {
753 let value = fm.get(key);
758 let missing = value.is_none() || value.is_some_and(Value::is_null);
759 if missing {
760 push(
761 issues,
762 Severity::Error,
763 missing_code,
764 rel,
765 fm_key_line_or_top(fm_yaml, key),
766 Some(key.into()),
767 format!("content file has no `{key}:` timestamp"),
768 Some(format!(
769 "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
770 )),
771 vec![],
772 );
773 } else if let Some(v) = value {
774 match scalar_string(v) {
780 Some(s) if is_iso8601(&s) => {}
781 Some(s) => push(
782 issues,
783 Severity::Error,
784 codes::FM_BAD_TIMESTAMP,
785 rel,
786 fm_key_line(fm_yaml, key),
787 Some(key.into()),
788 format!("`{key}` is not ISO-8601: {s:?}"),
789 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
790 vec![],
791 ),
792 None => push(
793 issues,
794 Severity::Error,
795 codes::FM_BAD_TIMESTAMP,
796 rel,
797 fm_key_line(fm_yaml, key),
798 Some(key.into()),
799 format!(
800 "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
801 ),
802 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
803 vec![],
804 ),
805 }
806 }
807 }
808 }
809 if let Some(tags) = fm.get("tags") {
811 if !is_flat_scalar_list(tags) {
812 push(
813 issues,
814 Severity::Warning,
815 codes::TAGS_MALFORMED,
816 rel,
817 fm_key_line(fm_yaml, "tags"),
818 Some("tags".into()),
819 "`tags` must be a flat YAML list of short scalar labels".into(),
820 Some("use block form: one `- <tag>` per line".into()),
821 vec![],
822 );
823 }
824 }
825
826 for key in detect_flow_form_link_lists(fm_yaml) {
828 push(
829 issues,
830 Severity::Error,
831 codes::WIKI_LINK_FLOW_FORM_LIST,
832 rel,
833 fm_key_line(fm_yaml, &key),
834 Some(key.clone()),
835 format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
836 Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
837 vec![],
838 );
839 }
840
841 let schema_link_keys: BTreeSet<String> =
846 effective_schema(store, type_.as_deref().unwrap_or(""))
847 .map(|s| {
848 s.fields
849 .iter()
850 .filter(|f| f.link_prefix.is_some())
851 .map(|f| f.name.clone())
852 .collect()
853 })
854 .unwrap_or_default();
855 for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
856 if schema_link_keys.contains(&key) {
857 continue;
858 }
859 check_wiki_link(
860 store,
861 rel,
862 &link,
863 Some(link.line),
864 Some(&key),
865 basenames,
866 issues,
867 );
868 }
869
870 if let Some(t) = &type_ {
872 if store.config.ignored_types.iter().any(|it| it == t) {
873 push(
874 issues,
875 Severity::Info,
876 codes::POLICY_IGNORED_TYPE_PRESENT,
877 rel,
878 fm_key_line(fm_yaml, "type"),
879 Some("type".into()),
880 format!("file has ignored type `{t}` (per DB.md ## Policies)"),
881 Some(
882 "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
883 .into(),
884 ),
885 vec![PathBuf::from("DB.md")],
887 );
888 }
889 let meta_type = fm
895 .get("meta-type")
896 .and_then(scalar_string)
897 .unwrap_or_else(|| "fact".to_string());
898 for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
899 if let Some(hit) =
900 derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
901 {
902 push(
903 issues,
904 Severity::Warning,
905 codes::POLICY_IGNORED_TYPE_DERIVED,
906 rel,
907 Some(link.line),
908 Some("derived_from".into()),
909 format!(
910 "conclusion record derives from ignored-type record `{}` (type `{}`)",
911 hit.target, hit.target_type
912 ),
913 Some(
914 "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
915 .into(),
916 ),
917 vec![
920 PathBuf::from(format!("{}.md", hit.target)),
921 PathBuf::from("DB.md"),
922 ],
923 );
924 }
925 }
926 }
927
928 if let Some(t) = &type_ {
930 if let Some(schema) = effective_schema(store, t) {
931 check_schema(store, rel, fm, fm_yaml, &schema, issues);
932 }
933 }
934}
935
936fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
938 let line = fm_key_line(fm_yaml, "summary");
939 match fm.get("summary") {
940 None => push(
941 issues,
942 Severity::Error,
943 codes::SUMMARY_MISSING,
944 rel,
945 fm_key_line_or_top(fm_yaml, "summary"),
948 Some("summary".into()),
949 "content file has no `summary`".into(),
950 Some("run `dbmd fm init`".into()),
951 vec![],
952 ),
953 Some(v) => {
954 let s = scalar_string(v).unwrap_or_default();
955 if s.trim().is_empty() {
956 push(
957 issues,
958 Severity::Error,
959 codes::SUMMARY_EMPTY,
960 rel,
961 line,
962 Some("summary".into()),
963 "`summary` is present but empty".into(),
964 Some("write a one-line summary, or run `dbmd fm init`".into()),
965 vec![],
966 );
967 } else if s.contains('\n') {
968 push(
969 issues,
970 Severity::Error,
971 codes::SUMMARY_MULTILINE,
972 rel,
973 line,
974 Some("summary".into()),
975 "`summary` must be one line (contains a newline)".into(),
976 Some("collapse the summary to a single line".into()),
977 vec![],
978 );
979 } else if s.chars().count() > MAX_SUMMARY_LEN {
980 push(
981 issues,
982 Severity::Warning,
983 codes::SUMMARY_TOO_LONG,
984 rel,
985 line,
986 Some("summary".into()),
987 format!(
988 "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
989 s.chars().count()
990 ),
991 Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
992 vec![],
993 );
994 }
995 }
996 }
997}
998
999fn check_body_wiki_links(
1001 store: &Store,
1002 rel: &Path,
1003 body: &str,
1004 fm_end_line: u32,
1005 basenames: Option<&BasenameIndex>,
1006 issues: &mut Vec<Issue>,
1007) {
1008 for link in extract_wiki_links(body) {
1009 let abs_line = fm_end_line + link.line;
1012 check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
1013 }
1014}
1015
1016type BasenameIndex = HashMap<String, Vec<PathBuf>>;
1024
1025fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
1028 let mut idx: BasenameIndex = HashMap::new();
1029 for rel in files {
1030 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
1031 idx.entry(stem.to_string()).or_default().push(rel.clone());
1032 }
1033 }
1034 idx
1035}
1036
1037fn check_wiki_link(
1042 store: &Store,
1043 rel: &Path,
1044 link: &Link,
1045 line: Option<u32>,
1046 key: Option<&str>,
1047 basenames: Option<&BasenameIndex>,
1048 issues: &mut Vec<Issue>,
1049) {
1050 let bare = link.target.trim_end_matches(".md");
1051
1052 if !is_full_store_path(bare) {
1055 if !bare.contains('/') {
1060 if let Some(idx) = basenames {
1061 if let Some(matches) = idx.get(bare) {
1062 if matches.len() >= 2 {
1063 let mut related = matches.clone();
1064 related.sort();
1065 push(
1066 issues,
1067 Severity::Error,
1068 codes::WIKI_LINK_AMBIGUOUS,
1069 rel,
1070 line,
1071 key.map(str::to_string),
1072 format!(
1073 "short-form wiki-link `[[{}]]` matches multiple files",
1074 link.target
1075 ),
1076 Some("use the full store-relative path to disambiguate".into()),
1077 related,
1078 );
1079 return;
1080 }
1081 }
1082 }
1083 }
1084 push(
1085 issues,
1086 Severity::Error,
1087 codes::WIKI_LINK_SHORT_FORM,
1088 rel,
1089 line,
1090 key.map(str::to_string),
1091 format!(
1092 "wiki-link `[[{}]]` is not a full store-relative path",
1093 link.target
1094 ),
1095 short_form_suggestion(bare),
1096 vec![],
1097 );
1098 return;
1100 }
1101
1102 if link.target.ends_with(".md") {
1104 push(
1105 issues,
1106 Severity::Warning,
1107 codes::WIKI_LINK_HAS_EXTENSION,
1108 rel,
1109 line,
1110 key.map(str::to_string),
1111 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1112 Some(format!("drop the extension: [[{bare}]]")),
1113 vec![],
1114 );
1115 }
1116
1117 match resolve_wiki_target(store, bare) {
1122 TargetResolution::Exists => {}
1123 TargetResolution::Missing => push(
1124 issues,
1125 Severity::Error,
1126 codes::WIKI_LINK_BROKEN,
1127 rel,
1128 line,
1129 key.map(str::to_string),
1130 format!("wiki-link target `{bare}` doesn't exist"),
1131 Some(format!(
1132 "create `{bare}.md`, or point the link at an existing file"
1133 )),
1134 vec![PathBuf::from(bare)],
1135 ),
1136 TargetResolution::Unsafe => push(
1137 issues,
1138 Severity::Error,
1139 codes::WIKI_LINK_BROKEN,
1140 rel,
1141 line,
1142 key.map(str::to_string),
1143 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1144 Some("use a full store-relative path under sources/ or records/".into()),
1145 vec![],
1146 ),
1147 }
1148}
1149
1150fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1161 store.config.schemas.get(type_).cloned()
1162}
1163
1164fn check_schema(
1166 store: &Store,
1167 rel: &Path,
1168 fm: &BTreeMap<String, Value>,
1169 fm_yaml: &str,
1170 schema: &Schema,
1171 issues: &mut Vec<Issue>,
1172) {
1173 for spec in &schema.fields {
1174 let present = fm.get(&spec.name);
1175 let line = fm_key_line(fm_yaml, &spec.name);
1176
1177 let is_empty = match present {
1185 None => true,
1186 Some(v) => is_empty_value(v),
1187 };
1188 if spec.required && is_empty {
1189 push(
1190 issues,
1191 Severity::Error,
1192 codes::SCHEMA_MISSING_REQUIRED,
1193 rel,
1194 fm_key_line_or_top(fm_yaml, &spec.name),
1197 Some(spec.name.clone()),
1198 format!("required field `{}` is absent or empty", spec.name),
1199 Some(format!("set `{}` to a non-empty value", spec.name)),
1200 vec![],
1201 );
1202 continue;
1203 }
1204 let Some(value) = present else { continue };
1205
1206 let value_empty = value.is_null()
1212 || scalar_string(value)
1213 .map(|s| s.trim().is_empty())
1214 .unwrap_or(false);
1215 if !spec.required && value_empty {
1216 continue;
1217 }
1218
1219 if let Some(prefix) = &spec.link_prefix {
1222 check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1223 continue; }
1225
1226 if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1233 push(
1234 issues,
1235 Severity::Error,
1236 codes::SCHEMA_SHAPE_MISMATCH,
1237 rel,
1238 line,
1239 Some(spec.name.clone()),
1240 format!(
1241 "`{}` must be a scalar value, found a list or mapping",
1242 spec.name
1243 ),
1244 Some(format!("set `{}` to a single scalar value", spec.name)),
1245 vec![],
1246 );
1247 continue;
1248 }
1249
1250 if let Some(allowed) = &spec.enum_values {
1252 if let Some(s) = scalar_string(value) {
1253 if !allowed.iter().any(|a| a == &s) {
1254 push(
1255 issues,
1256 Severity::Error,
1257 codes::SCHEMA_ENUM_VIOLATION,
1258 rel,
1259 line,
1260 Some(spec.name.clone()),
1261 format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1262 Some(format!("use one of: {}", allowed.join(", "))),
1263 vec![],
1264 );
1265 }
1266 }
1267 continue;
1268 }
1269
1270 if let Some(shape) = spec.shape {
1272 check_schema_shape(rel, &spec.name, value, shape, line, issues);
1273 }
1274 }
1275}
1276
1277fn check_schema_link(
1282 store: &Store,
1283 rel: &Path,
1284 field: &str,
1285 fm_yaml: &str,
1286 prefix: &Path,
1287 line: Option<u32>,
1288 issues: &mut Vec<Issue>,
1289) {
1290 let prefix_str = prefix.to_string_lossy();
1291 let prefix_str = prefix_str.trim_end_matches('/');
1292 let suggestion = |target_leaf: &str| {
1293 Some(format!(
1294 "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1295 ))
1296 };
1297
1298 let links = frontmatter_links_for_key(fm_yaml, field, 2);
1299 if links.is_empty() {
1300 let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1302 let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1303 let leaf = slugish(raw);
1304 push(
1305 issues,
1306 Severity::Error,
1307 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1308 rel,
1309 line,
1310 Some(field.to_string()),
1311 format!(
1312 "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1313 ),
1314 suggestion(&leaf),
1315 vec![],
1316 );
1317 return;
1318 }
1319
1320 for link in links {
1321 if link.target.ends_with(".md") {
1322 let bare = link.target.trim_end_matches(".md");
1323 push(
1324 issues,
1325 Severity::Warning,
1326 codes::WIKI_LINK_HAS_EXTENSION,
1327 rel,
1328 Some(link.line),
1329 Some(field.to_string()),
1330 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1331 Some(format!("drop the extension: [[{bare}]]")),
1332 vec![],
1333 );
1334 }
1335 let bare = link.target.trim_end_matches(".md");
1336 if !path_under_prefix(bare, prefix_str) {
1337 let leaf = bare.rsplit('/').next().unwrap_or(bare);
1338 push(
1339 issues,
1340 Severity::Error,
1341 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1342 rel,
1343 line,
1344 Some(field.to_string()),
1345 format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1346 suggestion(leaf),
1347 vec![],
1348 );
1349 } else {
1350 match resolve_wiki_target(store, bare) {
1355 TargetResolution::Exists => {}
1356 TargetResolution::Missing => push(
1357 issues,
1358 Severity::Error,
1359 codes::WIKI_LINK_BROKEN,
1360 rel,
1361 line,
1362 Some(field.to_string()),
1363 format!("wiki-link target `{bare}` doesn't exist"),
1364 Some(format!(
1365 "create `{bare}.md`, or point the link at an existing file"
1366 )),
1367 vec![PathBuf::from(bare)],
1368 ),
1369 TargetResolution::Unsafe => push(
1370 issues,
1371 Severity::Error,
1372 codes::WIKI_LINK_BROKEN,
1373 rel,
1374 line,
1375 Some(field.to_string()),
1376 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1377 Some("use a full store-relative path under sources/ or records/".into()),
1378 vec![],
1379 ),
1380 }
1381 }
1382 }
1383}
1384
1385fn check_schema_shape(
1387 rel: &Path,
1388 field: &str,
1389 value: &Value,
1390 shape: Shape,
1391 line: Option<u32>,
1392 issues: &mut Vec<Issue>,
1393) {
1394 let s = scalar_string(value).unwrap_or_default();
1395 let ok = match shape {
1396 Shape::String => true, Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1398 Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1399 Shape::Date => is_iso8601_date_or_datetime(&s),
1400 Shape::Email => is_email(&s),
1401 Shape::Currency => is_currency(&s),
1402 Shape::Url => is_url(&s),
1403 };
1404 if !ok {
1405 push(
1406 issues,
1407 Severity::Error,
1408 codes::SCHEMA_SHAPE_MISMATCH,
1409 rel,
1410 line,
1411 Some(field.to_string()),
1412 format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1413 Some(shape_suggestion(shape)),
1414 vec![],
1415 );
1416 }
1417}
1418
1419fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1438 let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1441 .iter()
1442 .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1443 .collect();
1444
1445 let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1447 for (rel, p) in parsed {
1448 if let Some(map) = &p.fm {
1449 if let Some(id) = map.get("id").and_then(scalar_string) {
1450 if !id.trim().is_empty() {
1451 by_id.entry(id).or_default().push(rel.clone());
1452 }
1453 }
1454 }
1455 }
1456 for (id, files) in &by_id {
1457 if files.len() > 1 {
1458 let (reported, related) = canonical_and_related(files);
1459 let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1460 push(
1461 issues,
1462 Severity::Error,
1463 codes::DUP_ID,
1464 &reported,
1465 line,
1466 Some("id".into()),
1467 format!("id {id:?} is declared by more than one file"),
1468 Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1469 related,
1470 );
1471 }
1472 }
1473
1474 for (type_name, schema) in &store.config.schemas {
1479 for key_fields in &schema.unique_keys {
1480 soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1481 }
1482 }
1483}
1484
1485fn soft_dup(
1494 parsed: &[(PathBuf, Parsed)],
1495 issues: &mut Vec<Issue>,
1496 type_: &str,
1497 key_fields: &[String],
1498 fm_yaml_of: &HashMap<&PathBuf, &str>,
1499) {
1500 if key_fields.is_empty() {
1501 return;
1502 }
1503 let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1504 for (rel, p) in parsed {
1505 let is_type =
1506 p.fm.as_ref()
1507 .and_then(|m| m.get("type"))
1508 .and_then(scalar_string)
1509 .map(|t| t == type_)
1510 .unwrap_or(false);
1511 if !is_type {
1512 continue;
1513 }
1514 if let Some(key) = dedup_key(p, key_fields) {
1515 groups.entry(key).or_default().push(rel.clone());
1516 }
1517 }
1518 let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1521 .values()
1522 .filter(|files| files.len() > 1)
1523 .map(|files| canonical_and_related(files))
1524 .collect();
1525 collisions.sort_by(|a, b| a.0.cmp(&b.0));
1526
1527 let fields_disp = key_fields.join(", ");
1528 for (reported, related) in collisions {
1529 let (line, key) = if key_fields.len() == 1 {
1532 (
1533 fm_yaml_of
1534 .get(&reported)
1535 .and_then(|y| fm_key_line(y, &key_fields[0])),
1536 Some(key_fields[0].clone()),
1537 )
1538 } else {
1539 (Some(1), None)
1540 };
1541 let n = related.len();
1542 push(
1543 issues,
1544 Severity::Warning,
1545 codes::DUP_UNIQUE_KEY,
1546 &reported,
1547 line,
1548 key,
1549 format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1550 Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1551 related,
1552 );
1553 }
1554}
1555
1556fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1560 let mut out = Vec::with_capacity(key_fields.len());
1561 for f in key_fields {
1562 out.push(dedup_token(p, f)?);
1563 }
1564 Some(out)
1565}
1566
1567fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1572 let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1575 if !links.is_empty() {
1576 let set: BTreeSet<String> = links
1577 .into_iter()
1578 .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1579 .filter(|t| !t.is_empty())
1580 .collect();
1581 return if set.is_empty() {
1582 None
1583 } else {
1584 Some(set.into_iter().collect::<Vec<_>>().join(","))
1585 };
1586 }
1587 match p.fm.as_ref()?.get(field) {
1588 Some(Value::Sequence(items)) => {
1589 let set: BTreeSet<String> = items
1590 .iter()
1591 .filter_map(scalar_string)
1592 .map(|s| s.trim().to_lowercase())
1593 .filter(|t| !t.is_empty())
1594 .collect();
1595 if set.is_empty() {
1596 None
1597 } else {
1598 Some(set.into_iter().collect::<Vec<_>>().join(","))
1599 }
1600 }
1601 Some(v) => {
1602 let s = scalar_string(v)?.trim().to_lowercase();
1603 if s.is_empty() {
1604 None
1605 } else {
1606 Some(s)
1607 }
1608 }
1609 None => None,
1610 }
1611}
1612
1613fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1618 let mut sorted = files.to_vec();
1619 sorted.sort();
1620 let reported = sorted[0].clone();
1621 let related = sorted[1..].to_vec();
1622 (reported, related)
1623}
1624
1625fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1631 let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1635 for rel in files {
1636 if let Some(tf) = type_folder_of(rel) {
1637 type_folders.entry(tf).or_default().push(rel.clone());
1638 }
1639 }
1640
1641 let mut layers_with_type_folders: BTreeSet<&'static str> = BTreeSet::new();
1653 for tf in type_folders.keys() {
1654 match tf.iter().next().and_then(|s| s.to_str()) {
1655 Some("sources") => {
1656 layers_with_type_folders.insert("sources");
1657 }
1658 Some("records") => {
1659 layers_with_type_folders.insert("records");
1660 }
1661 _ => {}
1662 }
1663 }
1664
1665 if !type_folders.is_empty() {
1667 if !store
1668 .regular_file_exists(Path::new("index.md"))
1669 .unwrap_or(false)
1670 {
1671 push(
1672 issues,
1673 Severity::Error,
1674 codes::INDEX_MISSING,
1675 Path::new("index.md"),
1676 None,
1677 None,
1678 "store has files but no root `index.md`".into(),
1679 Some("run `dbmd index rebuild`".into()),
1680 vec![],
1681 );
1682 } else {
1683 check_index_scope(store, Path::new("index.md"), "root", None, issues);
1684 }
1685 }
1686
1687 for layer in &layers_with_type_folders {
1689 let layer_index_rel = PathBuf::from(layer).join("index.md");
1690 if !store.regular_file_exists(&layer_index_rel).unwrap_or(false) {
1691 push(
1692 issues,
1693 Severity::Error,
1694 codes::INDEX_MISSING,
1695 &layer_index_rel,
1696 None,
1697 None,
1698 format!("layer `{layer}/` has files but no `index.md`"),
1699 Some("run `dbmd index rebuild`".into()),
1700 vec![],
1701 );
1702 } else {
1703 check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1704 }
1705 }
1706
1707 for (tf, members) in &type_folders {
1709 let index_md_rel = tf.join("index.md");
1710 let index_md_present = store.regular_file_exists(&index_md_rel).unwrap_or(false);
1711 if !index_md_present {
1712 push(
1718 issues,
1719 Severity::Error,
1720 codes::INDEX_MISSING,
1721 tf,
1722 None,
1723 None,
1724 format!("non-empty folder `{}` has no index.md", tf.display()),
1725 Some(format!(
1726 "run `dbmd index rebuild --folder {}`",
1727 tf.display()
1728 )),
1729 vec![],
1730 );
1731 continue;
1732 }
1733
1734 check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1735 check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1736
1737 let jsonl_rel = tf.join("index.jsonl");
1741 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1742 push(
1743 issues,
1744 Severity::Error,
1745 codes::INDEX_JSONL_MISSING,
1746 &jsonl_rel,
1747 None,
1748 None,
1749 format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1750 Some("run `dbmd index rebuild`".into()),
1751 vec![],
1752 );
1753 } else {
1754 check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1755 }
1756 }
1757
1758 let mut loose_by_layer: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1766 for rel in files {
1767 if !is_content_file(rel) || type_folder_of(rel).is_some() {
1768 continue;
1769 }
1770 if let Some(layer_dir) = loose_layer_dir(rel) {
1771 loose_by_layer
1772 .entry(layer_dir)
1773 .or_default()
1774 .push(rel.clone());
1775 }
1776 }
1777 for (layer_dir, members) in &loose_by_layer {
1778 let jsonl_rel = layer_dir.join("index.jsonl");
1779 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1780 push(
1781 issues,
1782 Severity::Error,
1783 codes::INDEX_JSONL_MISSING,
1784 &jsonl_rel,
1785 None,
1786 None,
1787 format!(
1788 "loose files at `{}/` are not catalogued — the layer has no `index.jsonl`",
1789 layer_dir.display()
1790 ),
1791 Some("run `dbmd index rebuild`".into()),
1792 members.clone(),
1793 );
1794 } else {
1795 check_type_folder_index_jsonl(store, layer_dir, &jsonl_rel, members, issues);
1799 }
1800 }
1801
1802 for rel in walk_index_files(store) {
1804 let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1805 let parent_str = parent.to_string_lossy().to_string();
1806 let is_canonical = parent_str.is_empty() || matches!(parent_str.as_str(), "sources" | "records")
1808 || type_folders.contains_key(&parent);
1809 if !is_canonical {
1810 push(
1811 issues,
1812 Severity::Warning,
1813 codes::INDEX_ORPHAN,
1814 &rel,
1815 None,
1816 None,
1817 format!(
1818 "`{}` sits in an empty or non-canonical folder",
1819 rel.display()
1820 ),
1821 Some("remove it, or run `dbmd index rebuild`".into()),
1822 vec![],
1823 );
1824 }
1825 }
1826}
1827
1828fn check_type_folder_index_md(
1832 store: &Store,
1833 tf: &Path,
1834 index_rel: &Path,
1835 members: &[PathBuf],
1836 issues: &mut Vec<Issue>,
1837) {
1838 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1839 return;
1840 };
1841 let entries = parse_index_entries(&text);
1842
1843 let listed: BTreeSet<PathBuf> = entries
1844 .iter()
1845 .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1846 .collect();
1847
1848 for entry in &entries {
1850 let bare = entry.target.trim_end_matches(".md");
1851 let target_abs = match resolved_target_abs(store, bare) {
1854 Some(abs) => abs,
1855 None => {
1856 if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1857 push(
1858 issues,
1859 Severity::Error,
1860 codes::INDEX_STALE_ENTRY,
1861 index_rel,
1862 Some(entry.line),
1863 None,
1864 format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1865 Some("run `dbmd index rebuild`".into()),
1866 vec![],
1867 );
1868 } else {
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}]]` points at a missing file"),
1877 Some("run `dbmd index rebuild`".into()),
1878 vec![PathBuf::from(format!("{bare}.md"))],
1882 );
1883 }
1884 continue;
1885 }
1886 };
1887 if let Some(expected) = read_summary(store, &target_abs) {
1894 match &entry.summary_text {
1895 Some(text_part)
1906 if crate::summary::collapse_whitespace(text_part)
1907 != crate::summary::collapse_whitespace(&expected) =>
1908 {
1909 push(
1910 issues,
1911 Severity::Error,
1912 codes::INDEX_SUMMARY_MISMATCH,
1913 index_rel,
1914 Some(entry.line),
1915 None,
1916 format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1917 Some("run `dbmd index rebuild`".into()),
1918 vec![PathBuf::from(format!("{bare}.md"))],
1919 );
1920 }
1921 None if !expected.trim().is_empty() => {
1922 push(
1923 issues,
1924 Severity::Error,
1925 codes::INDEX_SUMMARY_MISMATCH,
1926 index_rel,
1927 Some(entry.line),
1928 None,
1929 format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1930 Some("run `dbmd index rebuild`".into()),
1931 vec![PathBuf::from(format!("{bare}.md"))],
1932 );
1933 }
1934 _ => {}
1935 }
1936 }
1937 }
1938
1939 let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1943 if content_members.len() <= 500 {
1944 for m in content_members {
1945 let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1946 if !listed.contains(&bare) {
1947 push(
1948 issues,
1949 Severity::Error,
1950 codes::INDEX_MISSING_ENTRY,
1951 index_rel,
1952 None,
1953 None,
1954 format!(
1955 "file `{}` is not listed in its folder's `index.md`",
1956 m.display()
1957 ),
1958 Some("run `dbmd index rebuild`".into()),
1959 vec![(*m).clone()],
1960 );
1961 }
1962 }
1963 }
1964 let _ = tf;
1965}
1966
1967fn check_type_folder_index_jsonl(
1971 store: &Store,
1972 tf: &Path,
1973 jsonl_rel: &Path,
1974 members: &[PathBuf],
1975 issues: &mut Vec<Issue>,
1976) {
1977 let Ok(text) = store.read_text_bounded(jsonl_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1978 return;
1979 };
1980
1981 let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1983 for (i, line) in text.lines().enumerate() {
1984 let line = line.trim();
1985 if line.is_empty() {
1986 continue;
1987 }
1988 let rec: serde_json::Value = match serde_json::from_str(line) {
1989 Ok(v) => v,
1990 Err(e) => {
1991 push(
1992 issues,
1993 Severity::Error,
1994 codes::INDEX_JSONL_DESYNC,
1995 jsonl_rel,
1996 Some((i + 1) as u32),
1997 None,
1998 format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
1999 Some("run `dbmd index rebuild`".into()),
2000 vec![],
2001 );
2002 continue;
2003 }
2004 };
2005 if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
2006 if !is_safe_store_relative_path(Path::new(path)) {
2007 push(
2008 issues,
2009 Severity::Error,
2010 codes::INDEX_JSONL_DESYNC,
2011 jsonl_rel,
2012 Some((i + 1) as u32),
2013 None,
2014 format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
2015 Some("run `dbmd index rebuild`".into()),
2016 vec![],
2017 );
2018 continue;
2019 }
2020 records.insert(PathBuf::from(path), rec);
2021 }
2022 }
2023
2024 let member_set: BTreeSet<PathBuf> = members
2025 .iter()
2026 .filter(|m| is_content_file(m))
2027 .cloned()
2028 .collect();
2029
2030 for path in records.keys() {
2032 if !store.regular_file_exists(path).unwrap_or(false) {
2033 push(
2034 issues,
2035 Severity::Error,
2036 codes::INDEX_JSONL_DESYNC,
2037 jsonl_rel,
2038 None,
2039 None,
2040 format!(
2041 "`index.jsonl` record points at missing file `{}`",
2042 path.display()
2043 ),
2044 Some("run `dbmd index rebuild`".into()),
2045 vec![],
2046 );
2047 }
2048 }
2049
2050 for m in &member_set {
2052 if !records.contains_key(m) {
2053 push(
2054 issues,
2055 Severity::Error,
2056 codes::INDEX_JSONL_DESYNC,
2057 jsonl_rel,
2058 None,
2059 None,
2060 format!(
2061 "file `{}` is missing from the complete `index.jsonl`",
2062 m.display()
2063 ),
2064 Some("run `dbmd index rebuild`".into()),
2065 vec![m.clone()],
2066 );
2067 }
2068 }
2069
2070 for (path, rec) in &records {
2084 if !store.regular_file_exists(path).unwrap_or(false) {
2085 continue;
2086 }
2087 let Ok(expected) =
2088 crate::index::IndexRecord::expected_from_store(store, path, path.clone())
2089 else {
2090 continue; };
2092 let Ok(expected_json) = serde_json::to_value(&expected) else {
2093 continue;
2094 };
2095 let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
2096 continue;
2097 };
2098
2099 let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
2102 for key in have.keys().chain(want.keys()) {
2103 if key == "path" {
2104 continue;
2105 }
2106 if have.get(key) != want.get(key) {
2107 mismatched_keys.insert(key);
2108 }
2109 }
2110
2111 if !mismatched_keys.is_empty() {
2112 let keys: Vec<&str> = mismatched_keys.into_iter().collect();
2113 push(
2114 issues,
2115 Severity::Error,
2116 codes::INDEX_JSONL_STALE,
2117 jsonl_rel,
2118 None,
2119 Some(keys.join(",")),
2120 format!(
2121 "`index.jsonl` record for `{}` is stale ({})",
2122 path.display(),
2123 keys.join(", ")
2124 ),
2125 Some("run `dbmd index rebuild`".into()),
2126 vec![path.clone()],
2127 );
2128 }
2129 }
2130 let _ = tf;
2131}
2132
2133fn check_index_scope(
2135 store: &Store,
2136 index_rel: &Path,
2137 expected_scope: &str,
2138 expected_folder: Option<&str>,
2139 issues: &mut Vec<Issue>,
2140) {
2141 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2142 return;
2143 };
2144 let Some((yaml, _, _)) = split_frontmatter(&text) else {
2145 return;
2146 };
2147 let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
2148 return;
2149 };
2150 let fm = yaml_map_to_btree(&map);
2151
2152 if let Some(scope) = fm.get("scope").and_then(scalar_string) {
2153 let scope_ok =
2155 scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
2156 if !scope_ok {
2157 push(
2158 issues,
2159 Severity::Warning,
2160 codes::INDEX_WRONG_SCOPE,
2161 index_rel,
2162 fm_key_line(&yaml, "scope"),
2163 Some("scope".into()),
2164 format!(
2165 "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
2166 ),
2167 Some(format!("set `scope: {expected_scope}`")),
2168 vec![],
2169 );
2170 }
2171 }
2172 if let Some(expected) = expected_folder {
2174 if let Some(folder) = fm.get("folder").and_then(scalar_string) {
2175 if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
2176 push(
2177 issues,
2178 Severity::Warning,
2179 codes::INDEX_WRONG_SCOPE,
2180 index_rel,
2181 fm_key_line(&yaml, "folder"),
2182 Some("folder".into()),
2183 format!("index `folder: {folder}` doesn't match location `{expected}`"),
2184 Some(format!("set `folder: {expected}`")),
2185 vec![],
2186 );
2187 }
2188 }
2189 }
2190}
2191
2192fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2211 let mut prev: Option<DateTime<FixedOffset>> = None;
2212 for rel in log_files_chronological(store) {
2213 check_log_file(store, &rel, &mut prev, issues);
2214 }
2215}
2216
2217fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2221 let mut files: Vec<PathBuf> = Vec::new();
2222 let archive_dir = Path::new("log");
2223 if let Ok(entries) = store.regular_file_names(archive_dir) {
2224 let mut archives: Vec<PathBuf> = entries
2225 .into_iter()
2226 .filter(|name| {
2227 name.to_str()
2228 .and_then(|n| n.strip_suffix(".md"))
2229 .is_some_and(is_year_month_archive)
2230 })
2231 .map(|name| archive_dir.join(name))
2232 .collect();
2233 archives.sort();
2235 files.extend(archives);
2236 }
2237 if store
2239 .regular_file_exists(Path::new("log.md"))
2240 .unwrap_or(false)
2241 {
2242 files.push(PathBuf::from("log.md"));
2243 }
2244 files
2245}
2246
2247fn check_log_file(
2251 store: &Store,
2252 log_rel: &Path,
2253 prev: &mut Option<DateTime<FixedOffset>>,
2254 issues: &mut Vec<Issue>,
2255) {
2256 let Ok(text) = store.read_text_bounded(log_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2257 return;
2258 };
2259
2260 for (i, line) in text.lines().enumerate() {
2261 if !line.starts_with("## [") {
2262 continue;
2263 }
2264 let line_no = (i + 1) as u32;
2265 match parse_log_header(line) {
2266 None => push(
2267 issues,
2268 Severity::Error,
2269 codes::LOG_BAD_TIMESTAMP,
2270 log_rel,
2271 Some(line_no),
2272 None,
2273 format!("log entry header has an unparseable timestamp: {line:?}"),
2274 Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2275 vec![],
2276 ),
2277 Some((ts, kind, _object)) => {
2278 if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2279 push(
2280 issues,
2281 Severity::Warning,
2282 codes::LOG_UNKNOWN_KIND,
2283 log_rel,
2284 Some(line_no),
2285 None,
2286 format!("log entry kind `{kind}` is not recognized"),
2287 Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2288 vec![],
2289 );
2290 }
2291 if let Some(p) = *prev {
2292 if ts < p {
2293 push(
2294 issues,
2295 Severity::Warning,
2296 codes::LOG_OUT_OF_ORDER,
2297 log_rel,
2298 Some(line_no),
2299 None,
2300 "log entry is older than the entry above it (possible rewrite)".into(),
2301 Some("append corrective entries; never reorder past ones".into()),
2302 vec![],
2303 );
2304 }
2305 }
2306 *prev = Some(ts);
2307 }
2308 }
2309 }
2310}
2311
2312#[derive(Debug)]
2318struct Link {
2319 target: String,
2320 line: u32,
2321}
2322
2323fn store_marker_present(store: &Store) -> bool {
2327 store
2328 .regular_file_exists(Path::new("DB.md"))
2329 .unwrap_or(false)
2330}
2331
2332fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2343 let rel = Path::new("DB.md");
2344 let Ok(text) = store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2345 return; };
2347
2348 let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2349 push(
2353 issues,
2354 Severity::Error,
2355 codes::DB_MD_BAD_TYPE,
2356 rel,
2357 Some(1),
2358 Some("type".into()),
2359 "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2360 Some("add a `---` frontmatter block with `type: db-md`".into()),
2361 vec![],
2362 );
2363 for field in ["scope", "owner"] {
2364 push(
2365 issues,
2366 Severity::Error,
2367 codes::DB_MD_MISSING_FIELD,
2368 rel,
2369 Some(1),
2370 Some(field.into()),
2371 format!("DB.md frontmatter is missing required field `{field}`"),
2372 Some(format!("add `{field}:` to the DB.md frontmatter")),
2373 vec![],
2374 );
2375 }
2376 return;
2377 };
2378
2379 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2382 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2383 Ok(Value::Null) => Some(BTreeMap::new()),
2384 _ => None,
2385 };
2386
2387 match &fm {
2388 Some(map) => {
2389 let type_ = map.get("type").and_then(scalar_string);
2391 if type_.as_deref() != Some("db-md") {
2392 let (line, msg) = match &type_ {
2393 Some(t) => (
2394 fm_key_line(&fm_yaml, "type"),
2395 format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2396 ),
2397 None => (
2398 Some(1),
2399 "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2400 ),
2401 };
2402 push(
2403 issues,
2404 Severity::Error,
2405 codes::DB_MD_BAD_TYPE,
2406 rel,
2407 line,
2408 Some("type".into()),
2409 msg,
2410 Some("set `type: db-md` in the DB.md frontmatter".into()),
2411 vec![],
2412 );
2413 }
2414
2415 for field in ["scope", "owner"] {
2417 let present = map
2418 .get(field)
2419 .and_then(scalar_string)
2420 .map(|s| !s.trim().is_empty())
2421 .unwrap_or(false);
2422 if !present {
2423 push(
2424 issues,
2425 Severity::Error,
2426 codes::DB_MD_MISSING_FIELD,
2427 rel,
2428 fm_key_line_or_top(&fm_yaml, field),
2431 Some(field.into()),
2432 format!("DB.md frontmatter is missing required field `{field}`"),
2433 Some(format!("add `{field}:` to the DB.md frontmatter")),
2434 vec![],
2435 );
2436 }
2437 }
2438 }
2439 None => {
2440 push(
2443 issues,
2444 Severity::Error,
2445 codes::DB_MD_BAD_TYPE,
2446 rel,
2447 Some(1),
2448 Some("type".into()),
2449 "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2450 Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2451 vec![],
2452 );
2453 for field in ["scope", "owner"] {
2454 push(
2455 issues,
2456 Severity::Error,
2457 codes::DB_MD_MISSING_FIELD,
2458 rel,
2459 Some(1),
2460 Some(field.into()),
2461 format!("DB.md frontmatter is missing required field `{field}`"),
2462 Some(format!("add `{field}:` to the DB.md frontmatter")),
2463 vec![],
2464 );
2465 }
2466 }
2467 }
2468
2469 for section in crate::parser::extract_sections(&body) {
2483 if section.level != 2 {
2484 continue;
2485 }
2486 let name = section.heading.trim().to_ascii_lowercase();
2487 if matches!(
2488 name.as_str(),
2489 "agent instructions" | "policies" | "schemas" | "folders"
2490 ) {
2491 continue;
2492 }
2493 let file_line = fm_end_line + section.line;
2496 push(
2497 issues,
2498 Severity::Warning,
2499 codes::DB_MD_UNKNOWN_SECTION,
2500 rel,
2501 Some(file_line),
2502 None,
2503 format!(
2504 "DB.md has an unrecognized `## {}` section",
2505 section.heading.trim()
2506 ),
2507 Some(
2508 "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2509 `## Folders` — remove or rename this heading"
2510 .into(),
2511 ),
2512 vec![],
2513 );
2514 }
2515
2516 check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2521}
2522
2523fn check_db_md_schemas(
2530 store: &Store,
2531 rel: &Path,
2532 body: &str,
2533 fm_end_line: u32,
2534 issues: &mut Vec<Issue>,
2535) {
2536 if store.config.schemas.is_empty() {
2537 return;
2538 }
2539
2540 let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2545 let mut current_h2: Option<String> = None;
2546 for section in crate::parser::extract_sections(body) {
2547 match section.level {
2548 2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2549 3 if current_h2.as_deref() == Some("schemas") => {
2550 type_line
2553 .entry(section.heading.trim().to_string())
2554 .or_insert(fm_end_line + section.line);
2555 }
2556 _ => {}
2557 }
2558 }
2559
2560 for (type_name, schema) in &store.config.schemas {
2561 let line = type_line.get(type_name).copied();
2562 let mut seen: BTreeSet<String> = BTreeSet::new();
2563 for field in &schema.fields {
2564 let name = field.name.trim();
2565
2566 if name.is_empty() {
2570 push(
2571 issues,
2572 Severity::Warning,
2573 codes::DB_MD_SCHEMA_FIELD,
2574 rel,
2575 line,
2576 None,
2577 format!("`### {type_name}` has a schema field bullet with no field name"),
2578 Some(
2579 "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2580 .into(),
2581 ),
2582 vec![],
2583 );
2584 continue;
2585 }
2586
2587 if !seen.insert(name.to_string()) {
2591 push(
2592 issues,
2593 Severity::Warning,
2594 codes::DB_MD_SCHEMA_FIELD,
2595 rel,
2596 line,
2597 Some(name.to_string()),
2598 format!("`### {type_name}` declares field `{name}` more than once"),
2599 Some(
2600 "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2601 ),
2602 vec![],
2603 );
2604 }
2605
2606 for modifier in &field.unknown_modifiers {
2611 let modifier = modifier.trim();
2612 if modifier.is_empty() {
2613 continue;
2614 }
2615 push(
2616 issues,
2617 Severity::Info,
2618 codes::DB_MD_SCHEMA_FIELD,
2619 rel,
2620 line,
2621 Some(name.to_string()),
2622 format!(
2623 "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2624 ),
2625 Some(
2626 "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2627 .into(),
2628 ),
2629 vec![],
2630 );
2631 }
2632 }
2633
2634 let mut declared: BTreeMap<&str, bool> = BTreeMap::new();
2643 for f in &schema.fields {
2644 let e = declared.entry(f.name.trim()).or_insert(false);
2645 *e = *e || f.required;
2646 }
2647 let mut flagged: BTreeSet<&str> = BTreeSet::new();
2648 for key_fields in &schema.unique_keys {
2649 for field in key_fields {
2650 let name = field.trim();
2651 if name.is_empty()
2652 || declared.get(name).copied() == Some(true)
2653 || !flagged.insert(name)
2654 {
2655 continue;
2656 }
2657 let message = if declared.contains_key(name) {
2658 format!(
2659 "`### {type_name}` `unique:` key field `{name}` is not `required` — a record missing or leaving it empty is silently skipped by the unique check"
2660 )
2661 } else {
2662 format!(
2663 "`### {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"
2664 )
2665 };
2666 push(
2667 issues,
2668 Severity::Warning,
2669 codes::DB_MD_SCHEMA_FIELD,
2670 rel,
2671 line,
2672 Some(name.to_string()),
2673 message,
2674 Some(format!(
2675 "mark `{name}` `required` in `### {type_name}`, or build the `unique:` key from required fields only"
2676 )),
2677 vec![],
2678 );
2679 }
2680 }
2681 }
2682}
2683
2684fn not_a_store_issue(store: &Store) -> Issue {
2686 Issue {
2687 severity: Severity::Error,
2688 code: codes::NOT_A_STORE,
2689 file: store.root.clone(),
2690 line: None,
2691 key: None,
2692 message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2693 suggestion: Some("create a `DB.md` at the store root".into()),
2694 related: vec![],
2695 }
2696}
2697
2698fn is_content_file(rel: &Path) -> bool {
2701 if !is_safe_store_relative_path(rel) {
2707 return false;
2708 }
2709 let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2710 return false;
2711 };
2712 if !matches!(first, "sources" | "records") {
2713 return false;
2714 }
2715 let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2716 if matches!(name, "index.md" | "index.jsonl") {
2720 return false;
2721 }
2722 name.ends_with(".md")
2723}
2724
2725fn is_root_meta_file(rel: &Path) -> bool {
2732 let mut comps = rel.components();
2733 let Some(Component::Normal(only)) = comps.next() else {
2734 return false;
2735 };
2736 if comps.next().is_some() {
2737 return false; }
2739 matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2740}
2741
2742fn is_index_catalog_file(rel: &Path) -> bool {
2750 matches!(
2751 rel.file_name().and_then(|n| n.to_str()),
2752 Some("index.md") | Some("index.jsonl")
2753 )
2754}
2755
2756fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2760 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2765 let mut lines = text.lines();
2766 let first = lines.next()?;
2767 if first.trim_end() != "---" {
2768 return None;
2769 }
2770 let mut yaml = String::new();
2771 let mut close_line: Option<u32> = None;
2772 let mut current = 1u32;
2774 for line in lines {
2775 current += 1;
2776 if line.trim_end() == "---" {
2777 close_line = Some(current);
2778 break;
2779 }
2780 yaml.push_str(line);
2781 yaml.push('\n');
2782 }
2783 let close_line = close_line?;
2784 let body: String = text
2786 .lines()
2787 .skip(close_line as usize)
2788 .collect::<Vec<_>>()
2789 .join("\n");
2790 Some((yaml, body, close_line))
2791}
2792
2793fn body_opens_with_frontmatter(body: &str) -> bool {
2801 let start: String = body
2802 .lines()
2803 .skip_while(|l| l.trim().is_empty())
2804 .collect::<Vec<_>>()
2805 .join("\n");
2806 match split_frontmatter(&start) {
2807 Some((yaml, _, _)) => matches!(
2808 serde_norway::from_str::<Value>(&yaml),
2809 Ok(Value::Mapping(m)) if !m.is_empty()
2810 ),
2811 None => false,
2812 }
2813}
2814
2815fn read_summary(store: &Store, abs: &Path) -> Option<String> {
2817 let text = store
2818 .read_text_bounded(abs, crate::parser::MAX_DBMD_FILE_BYTES)
2819 .ok()?;
2820 let (yaml, _, _) = split_frontmatter(&text)?;
2821 let value: Value = serde_norway::from_str(&yaml).ok()?;
2822 if let Value::Mapping(m) = value {
2823 m.get(Value::String("summary".into()))
2824 .and_then(scalar_string)
2825 } else {
2826 None
2827 }
2828}
2829
2830fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2833 let mut out = BTreeMap::new();
2834 for (k, v) in map {
2835 if let Value::String(s) = k {
2836 out.insert(s.clone(), v.clone());
2837 }
2838 }
2839 out
2840}
2841
2842fn scalar_string(v: &Value) -> Option<String> {
2845 match v {
2846 Value::String(s) => Some(s.clone()),
2847 Value::Number(n) => Some(n.to_string()),
2848 Value::Bool(b) => Some(b.to_string()),
2849 _ => None,
2850 }
2851}
2852
2853fn is_empty_value(v: &Value) -> bool {
2860 match v {
2861 Value::Null => true,
2862 Value::Sequence(items) => items.is_empty(),
2863 Value::Mapping(map) => map.is_empty(),
2864 other => scalar_string(other)
2865 .map(|s| s.trim().is_empty())
2866 .unwrap_or(true),
2867 }
2868}
2869
2870fn is_flat_scalar_list(v: &Value) -> bool {
2873 match v {
2874 Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2875 _ => false,
2876 }
2877}
2878
2879fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2889 let mut out = Vec::new();
2890 for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2891 for link in links {
2892 out.push((key.clone(), link));
2893 }
2894 }
2895 out
2896}
2897
2898fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2902 for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2903 if k == key {
2904 return links;
2905 }
2906 }
2907 Vec::new()
2908}
2909
2910fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2914 for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2915 if k == key {
2916 return Some(value_text);
2917 }
2918 }
2919 None
2920}
2921
2922fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2929 let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2930 let mut current: Option<(String, String, Vec<Link>)> = None;
2931
2932 for (idx, raw_line) in fm_yaml.lines().enumerate() {
2933 let file_line = fm_start_line + idx as u32;
2934 let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2935 let trimmed = raw_line.trim();
2936
2937 let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2940 top_level_key(raw_line)
2941 } else {
2942 None
2943 };
2944
2945 if let Some((key, after)) = new_key {
2946 if let Some(done) = current.take() {
2947 blocks.push(done);
2948 }
2949 let mut links = Vec::new();
2950 collect_line_links(after, file_line, &mut links);
2951 current = Some((key, after.trim().to_string(), links));
2952 } else if let Some((_k, value_text, links)) = current.as_mut() {
2953 if !value_text.is_empty() {
2955 value_text.push('\n');
2956 }
2957 value_text.push_str(trimmed);
2958 collect_line_links(raw_line, file_line, links);
2959 }
2960 }
2961 if let Some(done) = current.take() {
2962 blocks.push(done);
2963 }
2964 blocks
2965}
2966
2967fn top_level_key(line: &str) -> Option<(String, &str)> {
2970 let (key, rest) = line.split_once(':')?;
2971 let key = key.trim();
2972 if key.is_empty()
2973 || !key
2974 .chars()
2975 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2976 {
2977 return None;
2978 }
2979 Some((key.to_string(), rest))
2980}
2981
2982fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2985 let bytes = s.as_bytes();
2986 let mut i = 0;
2987 while i + 1 < bytes.len() {
2988 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2989 if let Some(close) = s[i + 2..].find("]]") {
2990 let inner = &s[i + 2..i + 2 + close];
2991 let target = inner
2994 .trim_start_matches('[')
2995 .split('|')
2996 .next()
2997 .unwrap_or(inner)
2998 .trim()
2999 .to_string();
3000 if !target.is_empty() {
3001 links.push(Link {
3002 target,
3003 line: file_line,
3004 });
3005 }
3006 i = i + 2 + close + 2;
3007 continue;
3008 }
3009 }
3010 i += 1;
3011 }
3012}
3013
3014fn extract_wiki_links(body: &str) -> Vec<Link> {
3026 let mut out = Vec::new();
3027 let mut fence: Option<(u8, usize)> = None;
3028 for (idx, line) in body.lines().enumerate() {
3029 let content = line.trim_end_matches('\r');
3030 if let Some(f) = fence {
3031 if fence_closes(content, f) {
3035 fence = None;
3036 }
3037 continue;
3038 }
3039 if let Some(opened) = fence_opens(content) {
3040 fence = Some(opened);
3041 continue;
3042 }
3043 let line_no = (idx + 1) as u32;
3044 let bytes = line.as_bytes();
3045 let mut i = 0;
3046 while i + 1 < bytes.len() {
3047 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
3048 if let Some(close) = line[i + 2..].find("]]") {
3049 let inner = &line[i + 2..i + 2 + close];
3050 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3051 if !target.is_empty() && !target.starts_with('[') {
3059 out.push(Link {
3060 target,
3061 line: line_no,
3062 });
3063 }
3064 i = i + 2 + close + 2;
3065 continue;
3066 }
3067 }
3068 i += 1;
3069 }
3070 }
3071 out
3072}
3073
3074fn fence_opens(line: &str) -> Option<(u8, usize)> {
3080 let indent = line.len() - line.trim_start_matches(' ').len();
3081 if indent > 3 {
3082 return None;
3083 }
3084 let rest = &line[indent..];
3085 let byte = rest.bytes().next()?;
3086 if byte != b'`' && byte != b'~' {
3087 return None;
3088 }
3089 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3090 if run < 3 {
3091 return None;
3092 }
3093 if byte == b'`' && rest[run..].contains('`') {
3095 return None;
3096 }
3097 Some((byte, run))
3098}
3099
3100fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
3105 let (byte, open_len) = fence;
3106 let indent = line.len() - line.trim_start_matches(' ').len();
3107 if indent > 3 {
3108 return false;
3109 }
3110 let rest = &line[indent..];
3111 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3112 if run < open_len {
3113 return false;
3114 }
3115 rest[run..].trim().is_empty()
3116}
3117
3118fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
3135 let mut out = Vec::new();
3136 for line in fm_yaml.lines() {
3137 if line.starts_with(' ') || line.starts_with('\t') {
3139 continue;
3140 }
3141 let Some((key, rest)) = line.split_once(':') else {
3142 continue;
3143 };
3144 let key = key.trim();
3145 if key.is_empty()
3146 || key.starts_with('#')
3147 || key.starts_with('-')
3148 || !key
3149 .chars()
3150 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
3151 {
3152 continue;
3153 }
3154 let rest = rest.trim();
3155 if !rest.starts_with('[') {
3158 continue;
3159 }
3160 if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
3165 let nested = items.iter().any(|item| match item {
3166 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
3167 _ => false,
3168 });
3169 if nested {
3170 out.push(key.to_string());
3171 }
3172 }
3173 }
3174 out
3175}
3176
3177fn is_full_store_path(bare: &str) -> bool {
3180 let mut parts = bare.splitn(2, '/');
3181 let first = parts.next().unwrap_or("");
3182 let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
3183 matches!(first, "sources" | "records") && has_rest
3184}
3185
3186fn is_safe_store_relative_path(path: &Path) -> bool {
3190 let mut saw_component = false;
3191 for component in path.components() {
3192 match component {
3193 Component::Normal(_) => saw_component = true,
3194 Component::CurDir => {}
3195 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
3196 }
3197 }
3198 saw_component
3199}
3200
3201fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
3202 let path = Path::new(bare);
3203 if !is_safe_store_relative_path(path) {
3204 return None;
3205 }
3206 Some(PathBuf::from(format!("{bare}.md")))
3207}
3208
3209enum TargetResolution {
3211 Exists,
3213 Missing,
3215 Unsafe,
3217}
3218
3219fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
3228 if !is_safe_store_relative_path(Path::new(bare)) {
3232 return TargetResolution::Unsafe;
3233 }
3234 match resolved_target_abs(store, bare) {
3235 Some(_) => TargetResolution::Exists,
3236 None => TargetResolution::Missing,
3237 }
3238}
3239
3240fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
3266 if !is_safe_store_relative_path(Path::new(bare)) {
3267 return None;
3268 }
3269 let literal = PathBuf::from(bare);
3272 if store.regular_file_exists(&literal).ok()? && disk_case_matches(store, &literal, bare) {
3273 return Some(literal);
3274 }
3275 let with_md_rel = format!("{bare}.md");
3277 let with_md = PathBuf::from(&with_md_rel);
3278 if store.regular_file_exists(&with_md).ok()? && disk_case_matches(store, &with_md, &with_md_rel)
3279 {
3280 return Some(with_md);
3281 }
3282 None
3283}
3284
3285fn disk_case_matches(store: &Store, abs: &Path, requested: &str) -> bool {
3300 abs == Path::new(requested) && store.path_case_matches(abs).unwrap_or(true)
3301}
3302
3303fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3305 let prefix = prefix.trim_end_matches('/');
3306 bare == prefix || bare.starts_with(&format!("{prefix}/"))
3307}
3308
3309fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3313 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3314 if comps.len() < 3 {
3315 return None; }
3317 if !matches!(comps[0], "sources" | "records") {
3318 return None;
3319 }
3320 Some(PathBuf::from(comps[0]).join(comps[1]))
3321}
3322
3323fn loose_layer_dir(rel: &Path) -> Option<PathBuf> {
3328 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3329 if comps.len() != 2 || !matches!(comps[0], "sources" | "records") {
3330 return None;
3331 }
3332 Some(PathBuf::from(comps[0]))
3333}
3334
3335fn walk_index_files(store: &Store) -> Vec<PathBuf> {
3340 let mut out = Vec::new();
3341 if store
3342 .regular_file_exists(Path::new("index.md"))
3343 .unwrap_or(false)
3344 {
3345 out.push(PathBuf::from("index.md"));
3346 }
3347 for layer in ["sources", "records"] {
3348 if let Ok(files) = store.walk_regular_files(Path::new(layer)) {
3349 for rel in files {
3350 if rel.file_name().and_then(|name| name.to_str()) == Some("index.md") {
3351 out.push(rel);
3352 }
3353 }
3354 }
3355 }
3356 out.sort();
3357 out
3358}
3359
3360struct IndexEntry {
3363 target: String,
3364 summary_text: Option<String>,
3365 line: u32,
3366}
3367
3368fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3373 let mut out = Vec::new();
3374 let mut in_more = false;
3375 for (idx, line) in text.lines().enumerate() {
3376 let trimmed = line.trim_start();
3377 if trimmed.starts_with("## More") {
3378 in_more = true;
3379 continue;
3380 }
3381 if in_more {
3382 continue;
3383 }
3384 if !trimmed.starts_with("- ") {
3385 continue;
3386 }
3387 let Some(open) = trimmed.find("[[") else {
3389 continue;
3390 };
3391 let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3392 continue;
3393 };
3394 let inner = &trimmed[open + 2..open + 2 + close_rel];
3395 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3396
3397 let after = &trimmed[open + 2 + close_rel + 2..];
3399 let summary_text = extract_index_entry_summary(after);
3400
3401 out.push(IndexEntry {
3402 target,
3403 summary_text,
3404 line: (idx + 1) as u32,
3405 });
3406 }
3407 out
3408}
3409
3410fn extract_index_entry_summary(after: &str) -> Option<String> {
3416 let mut s = after.trim();
3417 if s.starts_with('(') {
3419 if let Some(close) = s.find(')') {
3420 s = s[close + 1..].trim_start();
3421 }
3422 }
3423 let s = s.strip_prefix('—').or_else(|| s.strip_prefix('-'))?.trim();
3425 if s.is_empty() {
3426 return None;
3427 }
3428 let s = match s.rsplit_once(" · ") {
3443 Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3444 _ => s,
3445 };
3446 Some(s.to_string())
3447}
3448
3449fn is_tag_suffix(s: &str) -> bool {
3454 let mut any = false;
3455 for tok in s.split_whitespace() {
3456 if !tok.starts_with('#') || tok.len() < 2 {
3457 return false;
3458 }
3459 any = true;
3460 }
3461 any
3462}
3463
3464fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3468 let rest = line.strip_prefix("## [")?;
3469 let close = rest.find(']')?;
3470 let ts_str = &rest[..close];
3471 let tail = rest[close + 1..].trim();
3472
3473 let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3476 let offset = FixedOffset::east_opt(0)?;
3477 let ts = naive.and_local_timezone(offset).single()?;
3478
3479 let (kind, object) = match tail.split_once('|') {
3481 Some((k, o)) => {
3482 let o = o.trim();
3483 (
3484 k.trim().to_string(),
3485 if o.is_empty() {
3486 None
3487 } else {
3488 Some(o.to_string())
3489 },
3490 )
3491 }
3492 None => (tail.to_string(), None),
3493 };
3494 if kind.is_empty() {
3495 return None;
3496 }
3497 Some((ts, kind, object))
3498}
3499
3500fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3510 let mut files = vec![PathBuf::from("log.md")];
3511 let archive_dir = Path::new("log");
3512 if let Ok(entries) = store.regular_file_names(archive_dir) {
3513 let mut archives: Vec<PathBuf> = entries
3514 .into_iter()
3515 .filter(|name| {
3516 name.to_str()
3517 .and_then(|n| n.strip_suffix(".md"))
3518 .is_some_and(is_year_month_archive)
3519 })
3520 .map(|name| archive_dir.join(name))
3521 .collect();
3522 archives.sort();
3526 files.extend(archives);
3527 }
3528 files.retain(|path| store.regular_file_exists(path).unwrap_or(false));
3529 files
3530}
3531
3532fn is_year_month_archive(s: &str) -> bool {
3535 let b = s.as_bytes();
3536 b.len() == 7
3537 && b[..4].iter().all(u8::is_ascii_digit)
3538 && b[4] == b'-'
3539 && b[5..7].iter().all(u8::is_ascii_digit)
3540}
3541
3542fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3548 let mut latest: Option<DateTime<FixedOffset>> = None;
3549 for file in log_files_for_working_set(store) {
3550 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3551 continue;
3552 };
3553 for line in text.lines() {
3554 if !line.starts_with("## [") {
3555 continue;
3556 }
3557 if let Some((ts, kind, _)) = parse_log_header(line) {
3558 if kind == "validate" {
3559 latest = Some(match latest {
3560 Some(p) if p >= ts => p,
3561 _ => ts,
3562 });
3563 }
3564 }
3565 }
3566 }
3567 latest
3568}
3569
3570fn changed_objects_since(
3581 store: &Store,
3582 cutoff: Option<DateTime<FixedOffset>>,
3583) -> BTreeSet<PathBuf> {
3584 let mut out = BTreeSet::new();
3585 for file in log_files_for_working_set(store) {
3586 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3587 continue;
3588 };
3589 for line in text.lines() {
3590 if !line.starts_with("## [") {
3591 continue;
3592 }
3593 let Some((ts, kind, object)) = parse_log_header(line) else {
3594 continue;
3595 };
3596 if let Some(c) = cutoff {
3597 if ts < c {
3598 continue;
3599 }
3600 }
3601 if !matches!(
3602 kind.as_str(),
3603 "create" | "update" | "ingest" | "rename" | "delete" | "link"
3604 ) {
3605 continue;
3606 }
3607 if let Some(obj) = object {
3608 let bare = obj
3610 .trim()
3611 .trim_start_matches("[[")
3612 .trim_end_matches("]]")
3613 .split('|')
3614 .next()
3615 .unwrap_or("")
3616 .trim()
3617 .trim_end_matches(".md")
3618 .to_string();
3619 if bare.is_empty() {
3620 continue;
3621 }
3622 if let Some(rel) = safe_md_target_rel(&bare) {
3632 out.insert(rel);
3633 }
3634 }
3635 }
3636 }
3637 out
3638}
3639
3640#[derive(Debug, Clone, PartialEq, Eq)]
3645pub struct DerivedFromIgnored {
3646 pub target: String,
3649 pub target_type: String,
3652}
3653
3654pub fn derived_from_ignored_type<I, S>(
3668 store: &Store,
3669 meta_type: &str,
3670 derived_from_targets: I,
3671) -> Option<DerivedFromIgnored>
3672where
3673 I: IntoIterator<Item = S>,
3674 S: AsRef<str>,
3675{
3676 if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3677 return None;
3678 }
3679 for target in derived_from_targets {
3680 let target = target.as_ref();
3681 if let Some(target_type) = link_target_type(store, target) {
3682 if store.config.ignored_types.contains(&target_type) {
3683 return Some(DerivedFromIgnored {
3684 target: target.to_string(),
3685 target_type,
3686 });
3687 }
3688 }
3689 }
3690 None
3691}
3692
3693fn link_target_type(store: &Store, target: &str) -> Option<String> {
3695 let bare = target.trim_end_matches(".md");
3696 let rel = safe_md_target_rel(bare)?;
3697 let text = store
3698 .read_text_bounded(&rel, crate::parser::MAX_DBMD_FILE_BYTES)
3699 .ok()?;
3700 let (yaml, _, _) = split_frontmatter(&text)?;
3701 let value: Value = serde_norway::from_str(&yaml).ok()?;
3702 if let Value::Mapping(m) = value {
3703 m.get(Value::String("type".into())).and_then(scalar_string)
3704 } else {
3705 None
3706 }
3707}
3708
3709fn is_iso8601(s: &str) -> bool {
3714 DateTime::parse_from_rfc3339(s.trim()).is_ok()
3715}
3716
3717fn is_iso8601_date_or_datetime(s: &str) -> bool {
3721 let s = s.trim();
3722 if DateTime::parse_from_rfc3339(s).is_ok() {
3723 return true;
3724 }
3725 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3726}
3727
3728fn is_email(s: &str) -> bool {
3733 let s = s.trim();
3734 let Some((local, domain)) = s.split_once('@') else {
3735 return false;
3736 };
3737 !local.is_empty()
3738 && !domain.contains('@')
3739 && domain.contains('.')
3740 && !domain.starts_with('.')
3741 && !domain.ends_with('.')
3742 && !domain.contains(' ')
3743 && !local.contains(' ')
3744}
3745
3746fn is_currency(s: &str) -> bool {
3753 let mut t = s.trim();
3754 for sym in ["$", "€", "£", "¥"] {
3756 if let Some(rest) = t.strip_prefix(sym) {
3757 t = rest.trim_start();
3758 break;
3759 }
3760 }
3761 if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3765 if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3766 t = rest.trim_start();
3767 }
3768 }
3769
3770 let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3771 is_plain_amount(cleaned.trim())
3772}
3773
3774fn is_plain_amount(s: &str) -> bool {
3777 let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3778 let (int_part, frac_part) = match digits.split_once('.') {
3779 Some((i, f)) => (i, Some(f)),
3780 None => (digits, None),
3781 };
3782 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3783 return false;
3784 }
3785 match frac_part {
3786 None => true,
3787 Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3788 }
3789}
3790
3791fn is_url(s: &str) -> bool {
3797 let s = s.trim();
3798 for scheme in ["http://", "https://"] {
3799 if let Some(rest) = s.strip_prefix(scheme) {
3800 return !rest.is_empty();
3801 }
3802 }
3803 false
3804}
3805
3806fn shape_suggestion(shape: Shape) -> String {
3808 match shape {
3809 Shape::String => "use a scalar string".into(),
3810 Shape::Int => "use an integer".into(),
3811 Shape::Bool => "use `true` or `false`".into(),
3812 Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3813 Shape::Email => "use a `<local>@<domain>` address".into(),
3814 Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3815 Shape::Url => "use an http(s) URL".into(),
3816 }
3817}
3818
3819fn short_form_suggestion(bare: &str) -> Option<String> {
3822 Some(format!(
3823 "use a full store-relative path, e.g. [[records/contacts/{}]]",
3824 slugish(bare)
3825 ))
3826}
3827
3828fn slugish(s: &str) -> String {
3830 s.trim()
3831 .to_lowercase()
3832 .chars()
3833 .map(|c| if c.is_whitespace() { '-' } else { c })
3834 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3835 .collect()
3836}
3837
3838fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3844 use crate::assets;
3845
3846 let manifest_rel = Path::new(assets::MANIFEST_FILE);
3847 let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3849 if store.regular_file_exists(manifest_rel).unwrap_or(false) {
3850 if let Ok(text) = store.read_text_bounded(manifest_rel, crate::parser::MAX_DBMD_FILE_BYTES)
3851 {
3852 for (i, line) in text.lines().enumerate() {
3853 if line.trim().is_empty() {
3854 continue;
3855 }
3856 match serde_json::from_str::<assets::AssetRecord>(line) {
3857 Ok(rec) => {
3858 manifest.insert(rec.path.clone(), rec);
3859 }
3860 Err(e) => push(
3861 issues,
3862 Severity::Error,
3863 codes::ASSET_MANIFEST_MALFORMED,
3864 manifest_rel,
3865 Some((i as u32) + 1),
3866 None,
3867 format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3868 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3869 vec![],
3870 ),
3871 }
3872 }
3873 }
3874 }
3875
3876 let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3880 let mut supersessions: BTreeMap<String, (String, PathBuf)> = BTreeMap::new();
3881 for (rel, p) in parsed {
3882 let Some(map) = &p.fm else {
3883 continue;
3884 };
3885 for decl in assets::declarations_from_yaml_map(map) {
3886 let norm = match assets::normalize_asset_path(&decl.path) {
3887 Ok(n) => n,
3888 Err(_) => continue, };
3890 declared.insert(norm.clone());
3891 if !manifest.contains_key(&norm) {
3892 push(
3893 issues,
3894 Severity::Error,
3895 codes::ASSET_UNDECLARED,
3896 rel,
3897 None,
3898 Some("asset".to_string()),
3899 format!(
3900 "references asset `{norm}` with no record in {}",
3901 assets::MANIFEST_FILE
3902 ),
3903 Some("run `dbmd assets scan` to catalog it".to_string()),
3904 vec![PathBuf::from(&norm)],
3905 );
3906 }
3907 }
3908 match assets::asset_supersession_from_yaml_map(map) {
3909 Ok(Some(supersession)) => {
3910 declared.insert(supersession.original.clone());
3911 let wrapper = rel.to_string_lossy().replace('\\', "/");
3912 if let Some((prior_replacement, prior_wrapper)) =
3913 supersessions.get(&supersession.original)
3914 {
3915 if prior_replacement != &supersession.replacement {
3916 push(
3917 issues,
3918 Severity::Error,
3919 codes::ASSET_SUPERSESSION_INVALID,
3920 rel,
3921 None,
3922 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3923 format!(
3924 "asset `{}` is superseded by both `{}` and `{}` ({})",
3925 supersession.original,
3926 prior_replacement,
3927 supersession.replacement,
3928 prior_wrapper.display()
3929 ),
3930 Some(
3931 "keep exactly one replacement for an asset coordinate".to_string(),
3932 ),
3933 vec![prior_wrapper.clone()],
3934 );
3935 }
3936 } else {
3937 supersessions.insert(
3938 supersession.original.clone(),
3939 (supersession.replacement.clone(), rel.clone()),
3940 );
3941 }
3942 match manifest.get(&supersession.original) {
3943 Some(record)
3944 if !record.required && record.wrappers.contains(&wrapper) => {}
3945 Some(_) => push(
3946 issues,
3947 Severity::Error,
3948 codes::ASSET_SUPERSESSION_INVALID,
3949 rel,
3950 None,
3951 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3952 format!(
3953 "superseded asset `{}` must remain cataloged as optional evidence under this wrapper",
3954 supersession.original
3955 ),
3956 Some(format!(
3957 "run `dbmd assets refresh {}` --wrapper {wrapper}",
3958 supersession.replacement
3959 )),
3960 vec![PathBuf::from(&supersession.original)],
3961 ),
3962 None => push(
3963 issues,
3964 Severity::Error,
3965 codes::ASSET_SUPERSESSION_INVALID,
3966 rel,
3967 None,
3968 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3969 format!(
3970 "superseded asset `{}` has no record in {}",
3971 supersession.original,
3972 assets::MANIFEST_FILE
3973 ),
3974 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3975 vec![PathBuf::from(&supersession.original)],
3976 ),
3977 }
3978 }
3979 Ok(None) => {}
3980 Err(error) => push(
3981 issues,
3982 Severity::Error,
3983 codes::ASSET_SUPERSESSION_INVALID,
3984 rel,
3985 None,
3986 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3987 error,
3988 Some(format!(
3989 "remove `{}` or declare exactly one required replacement asset",
3990 assets::SUPERSEDES_ASSET_KEY
3991 )),
3992 vec![],
3993 ),
3994 }
3995 }
3996
3997 let mut reported_cycle_members = BTreeSet::new();
3998 for origin in supersessions.keys() {
3999 let mut order: Vec<String> = Vec::new();
4000 let mut positions = BTreeMap::new();
4001 let mut current = origin.as_str();
4002 while let Some((next, _)) = supersessions.get(current) {
4003 if let Some(start) = positions.get(current).copied() {
4004 for member in &order[start..] {
4005 if reported_cycle_members.insert(member.clone()) {
4006 let (_, wrapper) = &supersessions[member];
4007 push(
4008 issues,
4009 Severity::Error,
4010 codes::ASSET_SUPERSESSION_INVALID,
4011 wrapper,
4012 None,
4013 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
4014 format!("asset replacement cycle includes `{member}`"),
4015 Some("replace the cycle with a one-way provenance chain".to_string()),
4016 vec![],
4017 );
4018 }
4019 }
4020 break;
4021 }
4022 positions.insert(current.to_string(), order.len());
4023 order.push(current.to_string());
4024 current = next;
4025 }
4026 }
4027
4028 for (path, rec) in &manifest {
4030 for w in &rec.wrappers {
4031 if !store.regular_file_exists(Path::new(w)).unwrap_or(false) {
4032 push(
4033 issues,
4034 Severity::Error,
4035 codes::ASSET_WRAPPER_BROKEN,
4036 Path::new(path),
4037 None,
4038 None,
4039 format!("manifest record for `{path}` names a missing wrapper `{w}`"),
4040 Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
4041 vec![PathBuf::from(w)],
4042 );
4043 }
4044 }
4045 if !declared.contains(path) {
4046 push(
4047 issues,
4048 Severity::Warning,
4049 codes::ASSET_MANIFEST_ORPHAN,
4050 Path::new(path),
4051 None,
4052 None,
4053 format!(
4054 "`{path}` is in {} but no wrapper references it",
4055 assets::MANIFEST_FILE
4056 ),
4057 Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
4058 vec![],
4059 );
4060 }
4061 }
4062}
4063
4064#[allow(clippy::too_many_arguments)]
4066fn push(
4067 issues: &mut Vec<Issue>,
4068 severity: Severity,
4069 code: &'static str,
4070 file: &Path,
4071 line: Option<u32>,
4072 key: Option<String>,
4073 message: String,
4074 suggestion: Option<String>,
4075 related: Vec<PathBuf>,
4076) {
4077 issues.push(Issue {
4078 severity,
4079 code,
4080 file: file.to_path_buf(),
4081 line,
4082 key,
4083 message,
4084 suggestion,
4085 related,
4086 });
4087}
4088
4089fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
4092 for (i, line) in fm_yaml.lines().enumerate() {
4093 let trimmed = line.trim_start();
4094 if let Some(rest) = trimmed.strip_prefix(key) {
4096 if rest.starts_with(':') && line.starts_with(key) {
4097 return Some((i as u32) + 2);
4099 }
4100 }
4101 }
4102 None
4103}
4104
4105fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
4111 fm_key_line(fm_yaml, key).or(Some(1))
4112}
4113
4114fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
4117 a.file
4118 .cmp(&b.file)
4119 .then(a.line.cmp(&b.line))
4120 .then(a.code.cmp(b.code))
4121 .then(a.key.cmp(&b.key))
4122}
4123
4124#[cfg(test)]
4129mod tests {
4130 use super::*;
4131 use crate::parser::{Config, FieldSpec};
4132 use std::fs;
4133 use tempfile::TempDir;
4134
4135 #[test]
4136 fn split_frontmatter_tolerates_leading_bom() {
4137 let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
4142 let parsed = split_frontmatter(text);
4143 assert!(
4144 parsed.is_some(),
4145 "a leading BOM must not hide frontmatter from validate"
4146 );
4147 let (yaml, body, close_line) = parsed.unwrap();
4148 assert_eq!(yaml, "type: contact\nsummary: hi\n");
4149 assert_eq!(body, "body");
4150 assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
4151 }
4152
4153 struct Fixture {
4156 dir: TempDir,
4157 config: Config,
4158 }
4159
4160 impl Fixture {
4161 fn new() -> Self {
4166 let dir = TempDir::new().unwrap();
4167 fs::write(
4168 dir.path().join("DB.md"),
4169 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
4170 )
4171 .unwrap();
4172 for layer in ["sources", "records"] {
4173 fs::create_dir_all(dir.path().join(layer)).unwrap();
4174 }
4175 Fixture {
4176 dir,
4177 config: Config::default(),
4178 }
4179 }
4180
4181 fn bare() -> Self {
4183 let dir = TempDir::new().unwrap();
4184 Fixture {
4185 dir,
4186 config: Config::default(),
4187 }
4188 }
4189
4190 fn write(&self, rel: &str, contents: &str) {
4192 let abs = self.dir.path().join(rel);
4193 fs::create_dir_all(abs.parent().unwrap()).unwrap();
4194 fs::write(abs, contents).unwrap();
4195 }
4196
4197 fn store(&self) -> Store {
4198 Store::from_root_and_config(self.dir.path(), self.config.clone()).unwrap()
4199 }
4200
4201 fn store_all(&self) -> Vec<Issue> {
4202 validate_all(&self.store()).unwrap()
4203 }
4204
4205 fn rebuild_indexes(&self) {
4212 crate::index::Index::rebuild_all(&self.store()).unwrap();
4213 }
4214 }
4215
4216 fn has(issues: &[Issue], code: &str) -> bool {
4218 issues.iter().any(|i| i.code == code)
4219 }
4220
4221 fn count(issues: &[Issue], code: &str) -> usize {
4223 issues.iter().filter(|i| i.code == code).count()
4224 }
4225
4226 fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
4228 issues
4229 .iter()
4230 .find(|i| i.code == code)
4231 .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
4232 }
4233
4234 fn valid_contact(summary: &str) -> String {
4236 format!(
4237 "---\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"
4238 )
4239 }
4240
4241 #[test]
4244 fn not_a_store_when_db_md_absent() {
4245 let fx = Fixture::bare();
4246 let issues = fx.store_all();
4247 assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
4248 assert_eq!(issues[0].code, codes::NOT_A_STORE);
4249 assert!(issues[0].is_error());
4250 }
4251
4252 #[test]
4253 fn working_set_also_reports_not_a_store() {
4254 let fx = Fixture::bare();
4255 let issues = validate_working_set(&fx.store(), None).unwrap();
4256 assert!(has(&issues, codes::NOT_A_STORE));
4257 }
4258
4259 #[test]
4260 fn both_scopes_report_nested_store_without_validating_its_content() {
4261 let fx = Fixture::new();
4262 fx.write(
4263 "records/nested/DB.md",
4264 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
4265 );
4266 fx.write("records/nested/records/notes/bad.md", "not frontmatter");
4269
4270 for issues in [
4271 validate_working_set(&fx.store(), None).unwrap(),
4272 validate_all(&fx.store()).unwrap(),
4273 ] {
4274 assert_eq!(count(&issues, codes::NESTED_STORE), 1, "{issues:#?}");
4275 assert_eq!(
4276 find(&issues, codes::NESTED_STORE).file,
4277 PathBuf::from("records/nested/DB.md")
4278 );
4279 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4280 }
4281 }
4282
4283 #[test]
4284 fn clean_store_has_no_issues() {
4285 let fx = Fixture::new();
4286 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4287 fx.rebuild_indexes();
4291 let issues = fx.store_all();
4292 assert!(
4293 issues.is_empty(),
4294 "expected a clean store, got: {issues:#?}"
4295 );
4296 }
4297
4298 #[test]
4306 fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
4307 let fx = Fixture::new();
4308 let body = |mt: &str| {
4309 format!(
4310 "---\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"
4311 )
4312 };
4313
4314 for ok in ["fact", "operational", "conclusion"] {
4316 fx.write("records/profiles/ok.md", &body(ok));
4317 let issues = validate_working_set(&fx.store(), None).unwrap();
4318 assert!(
4319 !has(&issues, codes::FM_BAD_META_TYPE),
4320 "`meta-type: {ok}` must be accepted; got {issues:#?}"
4321 );
4322 }
4323 fx.write(
4324 "records/profiles/absent.md",
4325 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
4326 );
4327 assert!(
4328 !has(
4329 &validate_working_set(&fx.store(), None).unwrap(),
4330 codes::FM_BAD_META_TYPE
4331 ),
4332 "an absent meta-type is the default `fact` and must be accepted"
4333 );
4334
4335 for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
4337 let fx2 = Fixture::new();
4338 fx2.write("records/profiles/bad.md", &body(bad));
4339 let issues = validate_working_set(&fx2.store(), None).unwrap();
4340 assert!(
4341 has(&issues, codes::FM_BAD_META_TYPE),
4342 "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
4343 );
4344 }
4345 }
4346
4347 #[test]
4356 fn id_absent_slug_ulid_and_numeric_are_all_silent() {
4357 let body = |id_line: &str| {
4358 format!(
4359 "---\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"
4360 )
4361 };
4362 for (case, id_line) in [
4363 ("absent", ""),
4364 ("slug", "id: sarah-chen\n"),
4365 ("ulid", "id: 01j5qc3v9k4ym8rwbn2tqe6f7d\n"),
4366 ("numeric-scalar", "id: 100\n"),
4367 ] {
4368 let fx = Fixture::new();
4369 fx.write("records/contacts/a.md", &body(id_line));
4370 let issues = validate_working_set(&fx.store(), None).unwrap();
4371 assert!(
4372 !has(&issues, codes::FM_BAD_ID),
4373 "id case `{case}` must be silent; got {issues:#?}"
4374 );
4375 }
4376 }
4377
4378 #[test]
4383 fn id_unusable_as_identifier_warns_fm_bad_id() {
4384 let body = |id_line: &str| {
4385 format!(
4386 "---\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"
4387 )
4388 };
4389 for bad in [
4390 "id: \"\"",
4391 "id: \" \"",
4392 "id: two words",
4393 "id: [a, b]",
4394 "id: {k: v}",
4395 ] {
4396 let fx = Fixture::new();
4397 fx.write("records/contacts/a.md", &body(bad));
4398 let issues = validate_working_set(&fx.store(), None).unwrap();
4399 let issue = issues
4400 .iter()
4401 .find(|i| i.code == codes::FM_BAD_ID)
4402 .unwrap_or_else(|| panic!("`{bad}` must fire FM_BAD_ID; got {issues:#?}"));
4403 assert!(
4404 matches!(issue.severity, Severity::Warning),
4405 "FM_BAD_ID is a warning (additive v0.4 — it must never block a store): {issue:#?}"
4406 );
4407 assert_eq!(issue.key.as_deref(), Some("id"));
4408 assert!(
4409 !issue.is_error(),
4410 "FM_BAD_ID must not fail validation: {issue:#?}"
4411 );
4412 }
4413 }
4414
4415 #[test]
4419 fn dup_id_fires_on_shared_ulid_ids() {
4420 let fx = Fixture::new();
4421 let rec = |name: &str| {
4422 format!(
4423 "---\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"
4424 )
4425 };
4426 fx.write("records/contacts/a.md", &rec("A"));
4427 fx.write("records/contacts/b.md", &rec("B"));
4428 let issues = fx.store_all();
4429 assert_eq!(count(&issues, codes::DUP_ID), 1, "{issues:#?}");
4430 let issue = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4431 assert!(issue.is_error());
4432 assert!(!has(&issues, codes::FM_BAD_ID), "{issues:#?}");
4434 }
4435
4436 #[test]
4442 fn valid_db_md_emits_no_structure_issue() {
4443 let fx = Fixture::new();
4444 let issues = fx.store_all();
4445 assert!(
4446 !has(&issues, codes::DB_MD_BAD_TYPE)
4447 && !has(&issues, codes::DB_MD_MISSING_FIELD)
4448 && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4449 "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4450 );
4451 }
4452
4453 #[test]
4457 fn db_md_wrong_type_is_error() {
4458 let fx = Fixture::new();
4459 fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4460 let issues = fx.store_all();
4461 let i = find(&issues, codes::DB_MD_BAD_TYPE);
4462 assert!(i.is_error());
4463 assert_eq!(i.file, PathBuf::from("DB.md"));
4464 assert_eq!(i.key.as_deref(), Some("type"));
4465 assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4466 }
4467
4468 #[test]
4471 fn db_md_missing_scope_and_owner_each_report() {
4472 let fx = Fixture::new();
4473 fx.write("DB.md", "---\ntype: db-md\n---\n");
4474 let issues = fx.store_all();
4475 assert_eq!(
4476 count(&issues, codes::DB_MD_MISSING_FIELD),
4477 2,
4478 "both scope and owner absent → two issues: {issues:#?}"
4479 );
4480 let keys: BTreeSet<Option<String>> = issues
4481 .iter()
4482 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4483 .map(|i| i.key.clone())
4484 .collect();
4485 assert_eq!(
4486 keys,
4487 BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4488 "one issue keyed on each missing field"
4489 );
4490 for i in issues
4491 .iter()
4492 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4493 {
4494 assert!(i.is_error());
4495 assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4496 }
4497 }
4498
4499 #[test]
4503 fn db_md_blank_required_field_is_missing() {
4504 let fx = Fixture::new();
4505 fx.write(
4506 "DB.md",
4507 "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4508 );
4509 let issues = fx.store_all();
4510 let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4511 assert_eq!(i.key.as_deref(), Some("owner"));
4512 assert_eq!(
4513 i.line,
4514 Some(4),
4515 "a present-but-empty field anchors to its line"
4516 );
4517 assert!(
4518 count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4519 "scope is present and non-empty → only owner reported"
4520 );
4521 }
4522
4523 #[test]
4526 fn db_md_unknown_section_is_warning() {
4527 let fx = Fixture::new();
4528 fx.write(
4529 "DB.md",
4530 "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4534 );
4535 let issues = fx.store_all();
4536 let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4537 assert!(!i.is_error(), "unknown section is a warning, not an error");
4538 assert_eq!(i.severity, Severity::Warning);
4539 assert_eq!(
4540 i.line,
4541 Some(11),
4542 "anchors to the `## Glossary` heading line"
4543 );
4544 assert!(
4545 i.message.contains("Glossary"),
4546 "the message names the offending section: {}",
4547 i.message
4548 );
4549 assert_eq!(
4551 count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4552 1,
4553 "only the unrecognized section is flagged: {issues:#?}"
4554 );
4555 }
4556
4557 #[test]
4560 fn db_md_no_frontmatter_reports_type_and_both_fields() {
4561 let fx = Fixture::new();
4562 fx.write("DB.md", "# just a heading, no frontmatter\n");
4563 let issues = fx.store_all();
4564 assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4565 assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4566 }
4567
4568 #[test]
4571 fn missing_type_is_error() {
4572 let fx = Fixture::new();
4573 fx.write(
4574 "records/contacts/a.md",
4575 "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4576 );
4577 let issues = fx.store_all();
4578 assert!(has(&issues, codes::FM_MISSING_TYPE));
4579 assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4580 }
4581
4582 #[test]
4583 fn missing_universal_timestamps_are_errors_on_content_files() {
4584 let fx = Fixture::new();
4585 fx.write(
4586 "records/contacts/a.md",
4587 "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4588 );
4589 let issues = fx.store_all();
4590
4591 let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4592 assert_eq!(missing_created.key.as_deref(), Some("created"));
4593 assert!(missing_created.is_error());
4594
4595 let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4596 assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4597 assert!(missing_updated.is_error());
4598 }
4599
4600 #[test]
4601 fn meta_files_do_not_require_universal_timestamps() {
4602 let fx = Fixture::new();
4603 let issues = fx.store_all();
4604
4605 assert!(
4606 !has(&issues, codes::FM_MISSING_CREATED),
4607 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4608 );
4609 assert!(
4610 !has(&issues, codes::FM_MISSING_UPDATED),
4611 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4612 );
4613 }
4614
4615 #[test]
4616 fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4617 let fx = Fixture::new();
4618 fx.write(
4619 "records/profiles/a.md",
4620 "# Just a heading\n\nNo frontmatter here.\n",
4621 );
4622 let issues = fx.store_all();
4623 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4624 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4625 }
4626
4627 #[test]
4628 fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4629 let fx = Fixture::new();
4630 fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4631 let issues = fx.store_all();
4632 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4633 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4634 }
4635
4636 #[test]
4637 fn malformed_yaml_is_error_and_suppresses_field_checks() {
4638 let fx = Fixture::new();
4639 fx.write(
4641 "records/contacts/a.md",
4642 "---\ntype: contact\n bad: : : :\n: : nope\n---\n\nbody\n",
4643 );
4644 let issues = fx.store_all();
4645 let issue = find(&issues, codes::FM_MALFORMED_YAML);
4646 assert!(issue.is_error());
4647 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4648 assert!(
4651 !has(&issues, codes::SUMMARY_MISSING),
4652 "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4653 );
4654 }
4655
4656 #[test]
4657 fn bad_created_timestamp_is_error() {
4658 let fx = Fixture::new();
4659 fx.write(
4660 "records/contacts/a.md",
4661 "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4662 );
4663 let issues = fx.store_all();
4664 let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4665 assert_eq!(issue.key.as_deref(), Some("created"));
4666 assert!(issue.is_error());
4667 }
4668
4669 #[test]
4670 fn date_only_created_is_rejected_but_type_date_field_accepted() {
4671 let fx = Fixture::new();
4672 fx.write(
4675 "records/contacts/a.md",
4676 "---\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",
4677 );
4678 let issues = fx.store_all();
4679 let created_issues: Vec<_> = issues
4680 .iter()
4681 .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4682 .collect();
4683 assert_eq!(
4684 created_issues.len(),
4685 1,
4686 "date-only `created` must fail: {issues:#?}"
4687 );
4688 assert!(
4689 !issues.iter().any(
4690 |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4691 ),
4692 "date-only `last_touch` is valid: {issues:#?}"
4693 );
4694 }
4695
4696 #[test]
4699 fn summary_missing_empty_multiline_toolong() {
4700 let fx = Fixture::new();
4701 fx.write(
4702 "records/profiles/missing.md",
4703 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4704 );
4705 fx.write(
4706 "records/profiles/empty.md",
4707 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \" \"\n---\n\nbody\n",
4708 );
4709 let long = "x".repeat(201);
4710 fx.write(
4711 "records/profiles/long.md",
4712 &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"),
4713 );
4714 let issues = fx.store_all();
4715 assert!(has(&issues, codes::SUMMARY_MISSING));
4716 assert_eq!(
4717 find(&issues, codes::SUMMARY_MISSING).file,
4718 PathBuf::from("records/profiles/missing.md")
4719 );
4720 assert!(has(&issues, codes::SUMMARY_EMPTY));
4721 assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4722 assert_eq!(
4723 find(&issues, codes::SUMMARY_TOO_LONG).severity,
4724 Severity::Warning
4725 );
4726 }
4727
4728 #[test]
4729 fn summary_multiline_via_yaml_block_scalar() {
4730 let fx = Fixture::new();
4731 fx.write(
4733 "records/profiles/a.md",
4734 "---\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",
4735 );
4736 let issues = fx.store_all();
4737 assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4738 }
4739
4740 #[test]
4741 fn summary_exactly_200_chars_is_ok() {
4742 let fx = Fixture::new();
4743 let s = "y".repeat(200);
4744 fx.write(
4745 "records/profiles/a.md",
4746 &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"),
4747 );
4748 let issues = fx.store_all();
4749 assert!(
4750 !has(&issues, codes::SUMMARY_TOO_LONG),
4751 "200 is the bound, inclusive: {issues:#?}"
4752 );
4753 }
4754
4755 #[test]
4756 fn meta_files_need_no_summary() {
4757 let fx = Fixture::new();
4758 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4761 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4762 fx.write(
4763 "records/index.md",
4764 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4765 );
4766 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4767 fx.write(
4768 "records/contacts/index.jsonl",
4769 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4770 );
4771 fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4772 let issues = fx.store_all();
4773 assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4774 }
4775
4776 #[test]
4779 fn nested_tags_warns_flat_tags_ok() {
4780 let fx = Fixture::new();
4781 fx.write(
4782 "records/contacts/nested.md",
4783 "---\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",
4784 );
4785 fx.write(
4786 "records/contacts/flat.md",
4787 "---\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",
4788 );
4789 let issues = fx.store_all();
4790 let tag_issues: Vec<_> = issues
4791 .iter()
4792 .filter(|i| i.code == codes::TAGS_MALFORMED)
4793 .collect();
4794 assert_eq!(
4795 tag_issues.len(),
4796 1,
4797 "only the nested-tags file should warn: {issues:#?}"
4798 );
4799 assert_eq!(
4800 tag_issues[0].file,
4801 PathBuf::from("records/contacts/nested.md")
4802 );
4803 assert_eq!(tag_issues[0].severity, Severity::Warning);
4804 }
4805
4806 #[test]
4809 fn short_form_wiki_link_is_error() {
4810 let fx = Fixture::new();
4811 let mut body = valid_contact("links to a short form");
4812 body.push_str("\nSee [[sarah-chen]] for details.\n");
4813 fx.write("records/contacts/a.md", &body);
4814 let issues = fx.store_all();
4815 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4816 assert!(issue.is_error());
4817 assert!(issue.message.contains("sarah-chen"));
4818 assert!(
4820 !issues
4821 .iter()
4822 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4823 "short-form should suppress broken: {issues:#?}"
4824 );
4825 }
4826
4827 #[test]
4828 fn broken_full_path_wiki_link_is_error() {
4829 let fx = Fixture::new();
4830 let mut body = valid_contact("links to a missing file");
4831 body.push_str("\nSee [[records/contacts/ghost]].\n");
4832 fx.write("records/contacts/a.md", &body);
4833 let issues = fx.store_all();
4834 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4835 assert!(issue.is_error());
4836 assert!(issue.message.contains("records/contacts/ghost"));
4837 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4838 }
4839
4840 #[test]
4841 fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4842 let fx = Fixture::new();
4843 let mut body = valid_contact("links with traversal");
4844 body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4845 fx.write("records/contacts/a.md", &body);
4846 let issues = fx.store_all();
4847 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4848 assert!(issue.message.contains("not a safe store-relative path"));
4849 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4850 }
4851
4852 #[test]
4853 fn valid_full_path_wiki_link_passes() {
4854 let fx = Fixture::new();
4855 fx.write("records/contacts/target.md", &valid_contact("target"));
4856 let mut body = valid_contact("links to target");
4857 body.push_str("\nSee [[records/contacts/target]].\n");
4858 fx.write("records/contacts/a.md", &body);
4859 let issues = fx.store_all();
4860 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4861 assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4862 }
4863
4864 #[test]
4865 fn md_extension_wiki_link_warns_and_resolves() {
4866 let fx = Fixture::new();
4867 fx.write("records/contacts/target.md", &valid_contact("target"));
4868 let mut body = valid_contact("links with extension");
4869 body.push_str("\nSee [[records/contacts/target.md]].\n");
4870 fx.write("records/contacts/a.md", &body);
4871 let issues = fx.store_all();
4872 let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4873 assert_eq!(issue.severity, Severity::Warning);
4874 assert_eq!(
4875 issue.suggestion.as_deref(),
4876 Some("drop the extension: [[records/contacts/target]]")
4877 );
4878 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4880 }
4881
4882 #[test]
4883 fn wiki_links_in_code_fences_are_ignored() {
4884 let fx = Fixture::new();
4885 let mut body = valid_contact("has a fenced example");
4886 body.push_str("\n```\n[[sarah-chen]]\n```\n");
4887 fx.write("records/contacts/a.md", &body);
4888 let issues = fx.store_all();
4889 assert!(
4890 !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4891 "fenced wiki-links must be ignored: {issues:#?}"
4892 );
4893 }
4894
4895 #[test]
4896 fn flow_form_link_list_in_frontmatter_is_error() {
4897 let fx = Fixture::new();
4898 fx.write(
4899 "records/meetings/m.md",
4900 "---\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",
4901 );
4902 let issues = fx.store_all();
4903 let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4904 assert!(issue.is_error());
4905 assert_eq!(issue.key.as_deref(), Some("attendees"));
4906 }
4907
4908 #[test]
4909 fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4910 let fx = Fixture::new();
4911 fx.write("records/contacts/a.md", &valid_contact("a"));
4912 fx.write("records/contacts/b.md", &valid_contact("b"));
4913 fx.write(
4914 "records/meetings/m.md",
4915 "---\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",
4916 );
4917 let issues = fx.store_all();
4918 assert!(
4919 !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4920 "{issues:#?}"
4921 );
4922 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4924 }
4925
4926 #[test]
4927 fn frontmatter_short_form_link_field_is_error() {
4928 let fx = Fixture::new();
4929 fx.write(
4932 "records/synthesis/a.md",
4933 "---\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",
4934 );
4935 let issues = fx.store_all();
4936 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4937 assert!(issue.is_error());
4938 assert_eq!(issue.key.as_deref(), Some("related"));
4939 }
4940
4941 #[test]
4942 fn unquoted_frontmatter_link_is_recognized() {
4943 let fx = Fixture::new();
4948 fx.write(
4949 "records/synthesis/short.md",
4950 "---\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",
4951 );
4952 fx.write(
4953 "records/synthesis/broken.md",
4954 "---\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",
4955 );
4956 let issues = fx.store_all();
4957 assert!(
4958 issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4959 && i.file == Path::new("records/synthesis/short.md")
4960 && i.key.as_deref() == Some("related")),
4961 "unquoted short-form frontmatter link must be caught: {issues:#?}"
4962 );
4963 assert!(
4964 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4965 && i.file == Path::new("records/synthesis/broken.md")),
4966 "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4967 );
4968 }
4969
4970 #[test]
4971 fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4972 let mut fx = Fixture::new();
4977 fx.config.schemas.insert(
4978 "contact".into(),
4979 Schema {
4980 fields: vec![FieldSpec {
4981 name: "company".into(),
4982 link_prefix: Some(PathBuf::from("records/companies")),
4983 ..Default::default()
4984 }],
4985 ..Default::default()
4986 },
4987 );
4988 fx.write(
4989 "records/contacts/a.md",
4990 "---\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",
4991 );
4992 let issues = fx.store_all();
4993 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4994 assert_eq!(issue.key.as_deref(), Some("company"));
4995 assert!(
4997 !issues
4998 .iter()
4999 .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
5000 && i.key.as_deref() == Some("company")),
5001 "schema link fields are checked once, by the schema path: {issues:#?}"
5002 );
5003 }
5004
5005 #[test]
5006 fn schema_link_field_with_md_extension_still_warns() {
5007 let mut fx = Fixture::new();
5008 fx.config.schemas.insert(
5009 "contact".into(),
5010 Schema {
5011 fields: vec![FieldSpec {
5012 name: "company".into(),
5013 link_prefix: Some(PathBuf::from("records/companies")),
5014 ..Default::default()
5015 }],
5016 ..Default::default()
5017 },
5018 );
5019 fx.write(
5020 "records/companies/acme.md",
5021 "---\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",
5022 );
5023 fx.write(
5024 "records/contacts/a.md",
5025 "---\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",
5026 );
5027 let issues = fx.store_all();
5028 let issue = issues
5029 .iter()
5030 .find(|i| {
5031 i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
5032 })
5033 .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
5034 assert_eq!(issue.severity, Severity::Warning);
5035 assert!(
5036 !issues
5037 .iter()
5038 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
5039 "extensionless existence check should still find acme.md: {issues:#?}"
5040 );
5041 }
5042
5043 #[test]
5046 fn explicit_schema_required_shape_enum() {
5047 let fx = {
5048 let mut fx = Fixture::new();
5049 let schema = Schema {
5052 fields: vec![
5053 FieldSpec {
5054 name: "name".into(),
5055 required: true,
5056 ..Default::default()
5057 },
5058 FieldSpec {
5059 name: "email".into(),
5060 required: true,
5061 shape: Some(Shape::Email),
5062 ..Default::default()
5063 },
5064 FieldSpec {
5065 name: "status".into(),
5066 enum_values: Some(vec!["active".into(), "inactive".into()]),
5067 ..Default::default()
5068 },
5069 ],
5070 ..Default::default()
5071 };
5072 fx.config.schemas.insert("contact".into(), schema);
5073 fx
5074 };
5075 fx.write(
5076 "records/contacts/a.md",
5077 "---\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",
5078 );
5079 let issues = fx.store_all();
5080 assert!(
5082 issues
5083 .iter()
5084 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
5085 && i.key.as_deref() == Some("name")),
5086 "{issues:#?}"
5087 );
5088 assert!(
5090 issues.iter().any(
5091 |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
5092 ),
5093 "{issues:#?}"
5094 );
5095 assert!(
5097 issues
5098 .iter()
5099 .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
5100 && i.key.as_deref() == Some("status")),
5101 "{issues:#?}"
5102 );
5103 }
5104
5105 #[test]
5106 fn schema_without_link_field_allows_plain_value() {
5107 let mut fx = Fixture::new();
5111 fx.config.schemas.insert(
5112 "contact".into(),
5113 Schema {
5114 fields: vec![FieldSpec {
5115 name: "name".into(),
5116 required: true,
5117 ..Default::default()
5118 }],
5119 ..Default::default()
5120 },
5121 );
5122 fx.write(
5123 "records/contacts/a.md",
5124 "---\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",
5125 );
5126 let issues = fx.store_all();
5127 assert!(
5128 !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
5129 "no declared link field for `company` → a plain value is fine: {issues:#?}"
5130 );
5131 }
5132
5133 #[test]
5134 fn schema_link_field_plain_value_is_prefix_mismatch() {
5135 let mut fx = Fixture::new();
5138 fx.config.schemas.insert(
5139 "contact".into(),
5140 Schema {
5141 fields: vec![FieldSpec {
5142 name: "company".into(),
5143 link_prefix: Some(PathBuf::from("records/companies")),
5144 ..Default::default()
5145 }],
5146 ..Default::default()
5147 },
5148 );
5149 fx.write(
5150 "records/contacts/a.md",
5151 "---\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",
5152 );
5153 let issues = fx.store_all();
5154 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5155 assert_eq!(issue.key.as_deref(), Some("company"));
5156 assert!(issue
5157 .suggestion
5158 .as_deref()
5159 .unwrap()
5160 .contains("records/companies/"));
5161 }
5162
5163 #[test]
5164 fn schema_shape_int_and_url_and_currency() {
5165 let mut fx = Fixture::new();
5166 fx.config.schemas.insert(
5167 "widget".into(),
5168 Schema {
5169 fields: vec![
5170 FieldSpec {
5171 name: "qty".into(),
5172 shape: Some(Shape::Int),
5173 ..Default::default()
5174 },
5175 FieldSpec {
5176 name: "site".into(),
5177 shape: Some(Shape::Url),
5178 ..Default::default()
5179 },
5180 FieldSpec {
5181 name: "price".into(),
5182 shape: Some(Shape::Currency),
5183 ..Default::default()
5184 },
5185 ],
5186 ..Default::default()
5187 },
5188 );
5189 fx.write(
5192 "records/widgets/ok.md",
5193 "---\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",
5194 );
5195 fx.write(
5199 "records/widgets/bad.md",
5200 "---\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",
5201 );
5202 let issues = fx.store_all();
5203 let bad_shape: Vec<_> = issues
5204 .iter()
5205 .filter(|i| {
5206 i.code == codes::SCHEMA_SHAPE_MISMATCH
5207 && i.file == Path::new("records/widgets/bad.md")
5208 })
5209 .map(|i| i.key.clone().unwrap_or_default())
5210 .collect();
5211 assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
5212 assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
5213 assert!(
5214 bad_shape.contains(&"price".to_string()),
5215 "inf must be rejected as currency: {issues:#?}"
5216 );
5217 assert!(
5218 !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
5219 && i.file == Path::new("records/widgets/ok.md")),
5220 "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
5221 );
5222 }
5223
5224 #[test]
5225 fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
5226 let mut fx = Fixture::new();
5227 fx.config.schemas.insert(
5228 "contact".into(),
5229 Schema {
5230 fields: vec![
5231 FieldSpec {
5232 name: "email".into(),
5233 required: true,
5234 shape: Some(Shape::Email),
5235 ..Default::default()
5236 },
5237 FieldSpec {
5238 name: "status".into(),
5239 enum_values: Some(vec!["active".into(), "inactive".into()]),
5240 ..Default::default()
5241 },
5242 ],
5243 ..Default::default()
5244 },
5245 );
5246 fx.write(
5250 "records/contacts/bad.md",
5251 "---\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",
5252 );
5253 let issues = fx.store_all();
5254 let mismatched: Vec<_> = issues
5255 .iter()
5256 .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
5257 .map(|i| i.key.clone().unwrap_or_default())
5258 .collect();
5259 assert!(
5260 mismatched.contains(&"email".to_string()),
5261 "list-valued required email must flag: {issues:#?}"
5262 );
5263 assert!(
5264 mismatched.contains(&"status".to_string()),
5265 "list-valued enum must flag: {issues:#?}"
5266 );
5267 }
5268
5269 #[test]
5270 fn is_currency_accepts_codes_and_rejects_non_numeric() {
5271 for ok in [
5273 "100",
5274 "1234.56",
5275 "$1,234.50",
5276 "USD 100", "usd 100", "EUR 9.50",
5279 "£12",
5280 "¥1000",
5281 "-5.00", "+5",
5283 "1,000,000",
5284 ] {
5285 assert!(is_currency(ok), "expected currency: {ok:?}");
5286 }
5287 for bad in [
5290 "inf", "-inf", "infinity", "NaN", "nan", "12.999", "1.2345", "USD", "$", "free", "", " ", "1e3", "1.", ".5", "1 000", "USDD 100", ] {
5301 assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
5302 }
5303 }
5304
5305 #[test]
5308 fn ignored_type_present_is_info() {
5309 let mut fx = Fixture::new();
5310 fx.config.ignored_types.push("temp".into());
5311 fx.write(
5312 "records/temps/x.md",
5313 "---\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",
5314 );
5315 let issues = fx.store_all();
5316 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
5317 assert_eq!(issue.severity, Severity::Info);
5318 assert!(!issue.is_error());
5319 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5320 }
5321
5322 #[test]
5323 fn conclusion_record_derived_from_ignored_type_warns() {
5324 let mut fx = Fixture::new();
5325 fx.config.ignored_types.push("temp".into());
5326 fx.write(
5327 "records/temps/x.md",
5328 "---\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",
5329 );
5330 fx.write(
5334 "records/synthesis/t.md",
5335 "---\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",
5336 );
5337 let issues = fx.store_all();
5338 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
5339 assert_eq!(issue.severity, Severity::Warning);
5340 assert_eq!(issue.key.as_deref(), Some("derived_from"));
5341 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5342 }
5343
5344 #[test]
5352 fn derived_from_ignored_type_is_the_shared_policy_decision() {
5353 let mut fx = Fixture::new();
5354 fx.config.ignored_types.push("secret".into());
5355 fx.write(
5357 "records/secrets/s.md",
5358 "---\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",
5359 );
5360 fx.write(
5362 "records/contacts/c.md",
5363 "---\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",
5364 );
5365 let store = fx.store();
5366
5367 let hit =
5371 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
5372 .expect("conclusion → ignored-type record must match");
5373 assert_eq!(hit.target, "records/secrets/s");
5374 assert_eq!(hit.target_type, "secret");
5375
5376 assert_eq!(
5379 derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
5380 None,
5381 "only conclusion derivation is policed"
5382 );
5383
5384 assert_eq!(
5386 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
5387 None,
5388 "deriving from a non-ignored type is allowed"
5389 );
5390
5391 let hit = derived_from_ignored_type(
5393 &store,
5394 "conclusion",
5395 ["records/contacts/c", "records/secrets/s"],
5396 )
5397 .expect("a later ignored-type target must still be found");
5398 assert_eq!(hit.target, "records/secrets/s");
5399
5400 fx.config.ignored_types.clear();
5402 let store = fx.store();
5403 assert_eq!(
5404 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
5405 None,
5406 "an empty ignored-types policy short-circuits"
5407 );
5408 }
5409
5410 #[test]
5413 fn dup_id_is_hard_error_with_related() {
5414 let fx = Fixture::new();
5415 fx.write(
5416 "records/contacts/a.md",
5417 "---\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",
5418 );
5419 fx.write(
5420 "records/contacts/b.md",
5421 "---\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",
5422 );
5423 let issues = fx.store_all();
5424 assert_eq!(
5427 count(&issues, codes::DUP_ID),
5428 1,
5429 "one issue per group: {issues:#?}"
5430 );
5431 let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
5432 assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
5433 assert!(a.is_error());
5434 assert_eq!(a.key.as_deref(), Some("id"));
5435 assert_eq!(
5436 a.line,
5437 Some(3),
5438 "anchors to the `id` line on the reported file"
5439 );
5440 assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5441 }
5442
5443 #[test]
5444 fn dup_id_not_fired_in_working_set() {
5445 let fx = Fixture::new();
5447 fx.write(
5448 "records/contacts/a.md",
5449 "---\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",
5450 );
5451 fx.write(
5452 "records/contacts/b.md",
5453 "---\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",
5454 );
5455 fx.write(
5457 "log.md",
5458 "---\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",
5459 );
5460 let issues = validate_working_set(&fx.store(), None).unwrap();
5461 assert!(
5462 !has(&issues, codes::DUP_ID),
5463 "DUP_ID is --all only: {issues:#?}"
5464 );
5465 }
5466
5467 #[test]
5468 fn dup_unique_key_single_field_is_warning() {
5469 let mut fx = Fixture::new();
5470 fx.config.schemas.insert(
5472 "contact".into(),
5473 Schema {
5474 unique_keys: vec![vec!["email".into()]],
5475 ..Default::default()
5476 },
5477 );
5478 for (f, name) in [("a", "A"), ("b", "B")] {
5479 fx.write(
5480 &format!("records/contacts/{f}.md"),
5481 &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"),
5482 );
5483 }
5484 let issues = fx.store_all();
5485 assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5488 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5489 assert_eq!(dup.severity, Severity::Warning);
5490 assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5491 assert_eq!(dup.key.as_deref(), Some("email"));
5492 assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5493 }
5494
5495 #[test]
5496 fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5497 let mut fx = Fixture::new();
5498 fx.config.schemas.insert(
5500 "expense".into(),
5501 Schema {
5502 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5503 ..Default::default()
5504 },
5505 );
5506 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");
5507 let exp = |f: &str, amount: &str| {
5508 format!(
5509 "---\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"
5510 )
5511 };
5512 fx.write("records/expenses/e1.md", &exp("e1", "100"));
5513 fx.write("records/expenses/e2.md", &exp("e2", "100"));
5514 fx.write("records/expenses/e3.md", &exp("e3", "200")); let issues = fx.store_all();
5516 assert_eq!(
5519 count(&issues, codes::DUP_UNIQUE_KEY),
5520 1,
5521 "only e1+e2 collide, one issue: {issues:#?}"
5522 );
5523 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5524 assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5525 assert_eq!(
5526 dup.line,
5527 Some(1),
5528 "compound-key collision anchors to line 1"
5529 );
5530 assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5531 assert!(
5532 !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5533 && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5534 "e3 differs on amount and must not collide: {issues:#?}"
5535 );
5536 }
5537
5538 #[test]
5539 fn dup_unique_key_list_field_is_order_independent() {
5540 let mut fx = Fixture::new();
5541 fx.config.schemas.insert(
5543 "meeting".into(),
5544 Schema {
5545 unique_keys: vec![vec!["date".into(), "attendees".into()]],
5546 ..Default::default()
5547 },
5548 );
5549 fx.write("records/contacts/a.md", &valid_contact("a"));
5550 fx.write("records/contacts/b.md", &valid_contact("b"));
5551 let m = |f: &str, order: &str| {
5552 let attendees = if order == "ab" {
5553 " - [[records/contacts/a]]\n - [[records/contacts/b]]"
5554 } else {
5555 " - [[records/contacts/b]]\n - [[records/contacts/a]]"
5556 };
5557 format!(
5558 "---\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"
5559 )
5560 };
5561 fx.write("records/meetings/m1.md", &m("m1", "ab"));
5562 fx.write("records/meetings/m2.md", &m("m2", "ba"));
5563 let issues = fx.store_all();
5564 assert_eq!(
5567 count(&issues, codes::DUP_UNIQUE_KEY),
5568 1,
5569 "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5570 );
5571 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5572 assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5573 assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5574 }
5575
5576 #[test]
5579 fn missing_indexes_at_all_three_levels() {
5580 let fx = Fixture::new();
5581 fx.write("records/contacts/a.md", &valid_contact("a"));
5582 let issues = fx.store_all();
5583 let missing_files: BTreeSet<PathBuf> = issues
5587 .iter()
5588 .filter(|i| i.code == codes::INDEX_MISSING)
5589 .map(|i| i.file.clone())
5590 .collect();
5591 assert!(
5592 missing_files.contains(&PathBuf::from("index.md")),
5593 "{issues:#?}"
5594 );
5595 assert!(
5596 missing_files.contains(&PathBuf::from("records/index.md")),
5597 "{issues:#?}"
5598 );
5599 assert!(
5600 missing_files.contains(&PathBuf::from("records/contacts")),
5601 "{issues:#?}"
5602 );
5603 assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5606 }
5607
5608 #[test]
5609 fn index_stale_entry_and_missing_entry() {
5610 let fx = Fixture::new();
5611 fx.write(
5612 "records/contacts/present.md",
5613 &valid_contact("present contact"),
5614 );
5615 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5617 fx.write(
5618 "records/index.md",
5619 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5620 );
5621 fx.write(
5623 "records/contacts/index.md",
5624 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5625 );
5626 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5627 let issues = fx.store_all();
5628 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5629 assert!(stale.message.contains("ghost"));
5630 assert!(stale.is_error());
5631 let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5632 assert!(
5633 missing.message.contains("present.md"),
5634 "{}",
5635 missing.message
5636 );
5637 }
5638
5639 #[test]
5640 fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5641 let fx = Fixture::new();
5642 fx.write("records/contacts/a.md", &valid_contact("a"));
5643 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5644 fx.write(
5645 "records/index.md",
5646 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5647 );
5648 fx.write(
5649 "records/contacts/index.md",
5650 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5651 );
5652 fx.write(
5653 "records/contacts/index.jsonl",
5654 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5655 );
5656 let issues = fx.store_all();
5657 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5658 assert!(stale.message.contains("not a safe store-relative path"));
5659 }
5660
5661 #[test]
5662 fn index_summary_mismatch() {
5663 let fx = Fixture::new();
5664 fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5665 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5666 fx.write(
5667 "records/index.md",
5668 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5669 );
5670 fx.write(
5671 "records/contacts/index.md",
5672 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5673 );
5674 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5675 let issues = fx.store_all();
5676 let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5677 assert!(issue.is_error());
5678 assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5679 }
5680
5681 #[test]
5682 fn index_summary_match_passes() {
5683 let fx = Fixture::new();
5684 fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5685 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5686 fx.write(
5687 "records/index.md",
5688 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5689 );
5690 fx.write(
5691 "records/contacts/index.md",
5692 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5693 );
5694 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5695 let issues = fx.store_all();
5696 assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5697 }
5698
5699 #[test]
5700 fn index_entry_with_tag_suffix_matches_summary() {
5701 let fx = Fixture::new();
5702 fx.write("records/contacts/a.md", &valid_contact("clean summary"));
5703 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5704 fx.write(
5705 "records/index.md",
5706 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5707 );
5708 fx.write(
5712 "records/contacts/index.md",
5713 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary · #customer\n",
5714 );
5715 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5716 let issues = fx.store_all();
5717 assert!(
5718 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5719 "tag suffix should be stripped: {issues:#?}"
5720 );
5721 }
5722
5723 #[test]
5724 fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5725 let fx = Fixture::new();
5732 fx.write(
5733 "records/contacts/a.md",
5734 &valid_contact("Standup notes · #standup"),
5735 );
5736 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5737 fx.write(
5738 "records/index.md",
5739 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5740 );
5741 fx.write(
5742 "records/contacts/index.md",
5743 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5744 );
5745 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5746 let issues = fx.store_all();
5747 assert!(
5748 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5749 "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5750 );
5751 }
5752
5753 #[test]
5754 fn index_jsonl_desync_missing_file_in_jsonl() {
5755 let fx = Fixture::new();
5756 fx.write("records/contacts/a.md", &valid_contact("a"));
5757 fx.write("records/contacts/b.md", &valid_contact("b"));
5758 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5759 fx.write(
5760 "records/index.md",
5761 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5762 );
5763 fx.write(
5764 "records/contacts/index.md",
5765 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5766 );
5767 fx.write(
5769 "records/contacts/index.jsonl",
5770 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5771 );
5772 let issues = fx.store_all();
5773 let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5774 assert!(desync.message.contains("b.md"), "{}", desync.message);
5775 }
5776
5777 #[test]
5778 fn index_jsonl_desync_record_points_at_missing_file() {
5779 let fx = Fixture::new();
5780 fx.write("records/contacts/a.md", &valid_contact("a"));
5781 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5782 fx.write(
5783 "records/index.md",
5784 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5785 );
5786 fx.write(
5787 "records/contacts/index.md",
5788 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5789 );
5790 fx.write(
5791 "records/contacts/index.jsonl",
5792 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5793 );
5794 let issues = fx.store_all();
5795 assert!(
5796 issues
5797 .iter()
5798 .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5799 "{issues:#?}"
5800 );
5801 }
5802
5803 #[test]
5804 fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5805 let fx = Fixture::new();
5806 fx.write("records/contacts/a.md", &valid_contact("a"));
5807 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5808 fx.write(
5809 "records/index.md",
5810 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5811 );
5812 fx.write(
5813 "records/contacts/index.md",
5814 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5815 );
5816 fx.write(
5817 "records/contacts/index.jsonl",
5818 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5819 );
5820 let issues = fx.store_all();
5821 assert!(
5822 issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5823 && i.message.contains("not a safe store-relative path")),
5824 "{issues:#?}"
5825 );
5826 }
5827
5828 #[test]
5829 fn index_jsonl_stale_summary() {
5830 let fx = Fixture::new();
5831 fx.write("records/contacts/a.md", &valid_contact("real summary"));
5832 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5833 fx.write(
5834 "records/index.md",
5835 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5836 );
5837 fx.write(
5838 "records/contacts/index.md",
5839 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5840 );
5841 fx.write(
5843 "records/contacts/index.jsonl",
5844 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5845 );
5846 let issues = fx.store_all();
5847 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5848 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5849 assert!(stale.key.as_deref().unwrap().contains("summary"));
5850 }
5851
5852 #[test]
5860 fn index_jsonl_stale_queryable_field_email() {
5861 let fx = Fixture::new();
5862 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";
5863 fx.write("records/contacts/a.md", contact);
5864 fx.rebuild_indexes();
5866 let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5867 let good = fs::read_to_string(&jsonl_path).unwrap();
5868 assert!(
5870 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5871 "freshly-rebuilt sidecar must not be stale"
5872 );
5873 assert!(
5875 good.contains("real@correct.com"),
5876 "sidecar projects email: {good}"
5877 );
5878 fx.write(
5879 "records/contacts/index.jsonl",
5880 &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5881 );
5882
5883 let issues = fx.store_all();
5884 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5885 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5886 let key = stale.key.as_deref().unwrap();
5889 assert!(
5890 key.contains("email"),
5891 "expected `email` in stale key, got {key:?}"
5892 );
5893 assert!(!key.contains("summary"), "summary still matches: {key:?}");
5894 assert!(!key.contains("type"), "type still matches: {key:?}");
5895 }
5896
5897 #[test]
5901 fn index_jsonl_stale_typed_and_list_fields() {
5902 let fx = Fixture::new();
5903 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";
5904 fx.write("records/expenses/e.md", expense);
5905 fx.rebuild_indexes();
5906 let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5907 let good = fs::read_to_string(&jsonl_path).unwrap();
5908 assert!(
5909 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5910 "freshly-rebuilt sidecar must not be stale"
5911 );
5912 let stale_line = good
5914 .replace("\"q2\"", "\"WRONG-TAG\"")
5915 .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5916 .replace("1299", "9999");
5917 fx.write("records/expenses/index.jsonl", &stale_line);
5918
5919 let issues = fx.store_all();
5920 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5921 let key = stale.key.as_deref().unwrap();
5922 for expected in ["amount", "tags", "updated"] {
5923 assert!(
5924 key.contains(expected),
5925 "expected `{expected}` in stale key, got {key:?}"
5926 );
5927 }
5928 }
5929
5930 #[test]
5931 fn index_orphan_in_noncanonical_folder() {
5932 let fx = Fixture::new();
5933 fx.write("records/contacts/a.md", &valid_contact("a"));
5934 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5936 fx.write(
5937 "records/index.md",
5938 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5939 );
5940 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5941 fx.write(
5942 "records/contacts/index.jsonl",
5943 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5944 );
5945 fx.write(
5947 "records/contacts/subfolder/index.md",
5948 "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5949 );
5950 let issues = fx.store_all();
5951 let orphan = find(&issues, codes::INDEX_ORPHAN);
5952 assert_eq!(orphan.severity, Severity::Warning);
5953 assert_eq!(
5954 orphan.file,
5955 PathBuf::from("records/contacts/subfolder/index.md")
5956 );
5957 }
5958
5959 #[test]
5960 fn index_wrong_scope() {
5961 let fx = Fixture::new();
5962 fx.write("records/contacts/a.md", &valid_contact("a"));
5963 fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5965 fx.write(
5966 "records/index.md",
5967 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5968 );
5969 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5970 fx.write(
5971 "records/contacts/index.jsonl",
5972 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5973 );
5974 let issues = fx.store_all();
5975 let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5976 assert_eq!(issue.severity, Severity::Warning);
5977 assert_eq!(issue.file, PathBuf::from("index.md"));
5978 }
5979
5980 #[test]
5981 fn capped_type_folder_index_does_not_flag_missing_entries() {
5982 let fx = Fixture::new();
5984 for i in 0..501 {
5985 fx.write(
5986 &format!("records/contacts/c{i:04}.md"),
5987 &valid_contact(&format!("contact {i}")),
5988 );
5989 }
5990 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
5991 fx.write(
5992 "records/index.md",
5993 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5994 );
5995 fx.write(
5997 "records/contacts/index.md",
5998 "---\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",
5999 );
6000 let mut jsonl = String::new();
6002 for i in 0..501 {
6003 jsonl.push_str(&format!(
6004 "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
6005 ));
6006 }
6007 fx.write("records/contacts/index.jsonl", &jsonl);
6008 let issues = fx.store_all();
6009 assert!(
6010 !has(&issues, codes::INDEX_MISSING_ENTRY),
6011 "over the cap, missing browse entries are expected: {issues:#?}"
6012 );
6013 assert!(
6015 !has(&issues, codes::INDEX_JSONL_DESYNC),
6016 "{:#?}",
6017 issues
6018 .iter()
6019 .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
6020 .collect::<Vec<_>>()
6021 );
6022 }
6023
6024 #[test]
6027 fn log_bad_timestamp_unknown_kind_out_of_order() {
6028 let fx = Fixture::new();
6029 fx.write(
6030 "log.md",
6031 concat!(
6032 "---\ntype: log\n---\n\n# Log\n\n",
6033 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6034 "## [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", ),
6038 );
6039 let issues = fx.store_all();
6040 assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6041 assert_eq!(
6042 find(&issues, codes::LOG_OUT_OF_ORDER).severity,
6043 Severity::Warning
6044 );
6045 let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
6046 assert_eq!(unknown.severity, Severity::Warning);
6047 assert!(unknown.message.contains("frobnicate"));
6048 assert!(unknown
6049 .suggestion
6050 .as_deref()
6051 .is_some_and(|s| s.contains("create")));
6052 let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
6053 assert!(bad.is_error());
6054 }
6055
6056 #[test]
6057 fn log_validate_entry_without_object_is_well_formed() {
6058 let fx = Fixture::new();
6059 fx.write(
6060 "log.md",
6061 "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
6062 );
6063 let issues = fx.store_all();
6064 assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
6065 assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
6066 }
6067
6068 #[test]
6069 fn log_in_order_is_clean() {
6070 let fx = Fixture::new();
6071 fx.write(
6072 "log.md",
6073 concat!(
6074 "---\ntype: log\n---\n\n",
6075 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6076 "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
6077 ),
6078 );
6079 let issues = fx.store_all();
6080 assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6081 }
6082
6083 #[test]
6084 fn log_not_checked_in_working_set() {
6085 let fx = Fixture::new();
6087 fx.write(
6088 "log.md",
6089 concat!(
6090 "---\ntype: log\n---\n\n",
6091 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6092 "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
6093 ),
6094 );
6095 let issues = validate_working_set(&fx.store(), None).unwrap();
6096 assert!(
6097 !has(&issues, codes::LOG_OUT_OF_ORDER),
6098 "log ordering is --all only: {issues:#?}"
6099 );
6100 }
6101
6102 #[test]
6105 fn working_set_validates_only_changed_files() {
6106 let fx = Fixture::new();
6107 fx.write(
6110 "records/contacts/dirty.md",
6111 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6112 );
6113 fx.write(
6114 "records/contacts/unlogged.md",
6115 "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6116 );
6117 fx.write(
6118 "log.md",
6119 "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
6120 );
6121 let issues = validate_working_set(&fx.store(), None).unwrap();
6122 assert!(
6123 issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
6124 && i.file == Path::new("records/contacts/dirty.md")),
6125 "{issues:#?}"
6126 );
6127 assert!(
6128 !issues
6129 .iter()
6130 .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
6131 "unlogged file must not be in the working set: {issues:#?}"
6132 );
6133 }
6134
6135 #[test]
6136 fn working_set_includes_incoming_linkers_to_changed_path() {
6137 let fx = Fixture::new();
6138 fx.write(
6141 "records/profiles/linker.md",
6142 "---\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",
6143 );
6144 fx.write(
6146 "log.md",
6147 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
6148 );
6149 let issues = validate_working_set(&fx.store(), None).unwrap();
6150 assert!(
6151 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
6152 && i.file == Path::new("records/profiles/linker.md")),
6153 "incoming linker to a removed path must be validated: {issues:#?}"
6154 );
6155 }
6156
6157 #[test]
6158 fn working_set_respects_explicit_since_cutoff() {
6159 let fx = Fixture::new();
6160 fx.write(
6161 "records/contacts/old.md",
6162 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6163 );
6164 fx.write(
6165 "records/contacts/new.md",
6166 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6167 );
6168 fx.write(
6169 "log.md",
6170 concat!(
6171 "---\ntype: log\n---\n\n",
6172 "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
6173 "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
6174 ),
6175 );
6176 let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
6178 let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
6179 assert!(
6180 issues
6181 .iter()
6182 .any(|i| i.file == Path::new("records/contacts/new.md")),
6183 "{issues:#?}"
6184 );
6185 assert!(
6186 !issues
6187 .iter()
6188 .any(|i| i.file == Path::new("records/contacts/old.md")),
6189 "old change is before the cutoff: {issues:#?}"
6190 );
6191 }
6192
6193 #[test]
6194 fn working_set_default_since_is_last_validate_entry() {
6195 let fx = Fixture::new();
6196 fx.write(
6198 "records/contacts/before.md",
6199 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6200 );
6201 fx.write(
6202 "records/contacts/after.md",
6203 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6204 );
6205 fx.write(
6206 "log.md",
6207 concat!(
6208 "---\ntype: log\n---\n\n",
6209 "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
6210 "## [2026-05-21 10:00] validate\nPASS\n\n",
6211 "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
6212 ),
6213 );
6214 let issues = validate_working_set(&fx.store(), None).unwrap();
6215 assert!(
6216 issues
6217 .iter()
6218 .any(|i| i.file == Path::new("records/contacts/after.md")),
6219 "{issues:#?}"
6220 );
6221 assert!(
6222 !issues
6223 .iter()
6224 .any(|i| i.file == Path::new("records/contacts/before.md")),
6225 "change before the last validate entry is outside the default window: {issues:#?}"
6226 );
6227 }
6228
6229 #[test]
6232 fn issues_are_sorted_by_file_then_line() {
6233 let fx = Fixture::new();
6234 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");
6235 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");
6236 let issues = fx.store_all();
6237 let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
6238 let mut sorted = files.clone();
6239 sorted.sort();
6240 assert_eq!(
6241 files, sorted,
6242 "issues must be emitted in a stable file order"
6243 );
6244 }
6245
6246 #[test]
6249 fn frozen_page_is_not_a_validate_error() {
6250 let mut fx = Fixture::new();
6253 fx.config
6254 .frozen_pages
6255 .push(PathBuf::from("records/decisions/d.md"));
6256 fx.write(
6257 "records/decisions/d.md",
6258 "---\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",
6259 );
6260 let issues = fx.store_all();
6261 assert!(
6262 !has(&issues, codes::POLICY_FROZEN_PAGE),
6263 "frozen pages are enforced at write-time, not by validate: {issues:#?}"
6264 );
6265 }
6266
6267 #[test]
6268 fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
6269 let fx = Fixture::new();
6272 fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
6273 let mut body = valid_contact("links to sarah");
6274 body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
6275 fx.write("records/contacts/p.md", &body);
6276 let issues = fx.store_all();
6277 assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
6278 }
6279
6280 #[test]
6283 fn unknown_type_passes_through() {
6284 let fx = Fixture::new();
6288 fx.write(
6289 "records/proposals/x.md",
6290 "---\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",
6291 );
6292 let issues = fx.store_all();
6293 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
6294 assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
6295 assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
6296 assert!(
6298 !issues
6299 .iter()
6300 .any(|i| i.key.as_deref() == Some("custom_field")
6301 || i.key.as_deref() == Some("budget")),
6302 "unknown fields are ambient context: {issues:#?}"
6303 );
6304 }
6305
6306 #[test]
6309 fn incoming_linker_scan_does_not_prefix_match() {
6310 let fx = Fixture::new();
6313 fx.write(
6314 "records/profiles/only-sarah-chen.md",
6315 "---\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",
6316 );
6317 fx.write(
6319 "log.md",
6320 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
6321 );
6322 let issues = validate_working_set(&fx.store(), None).unwrap();
6323 assert!(
6324 !issues
6325 .iter()
6326 .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
6327 "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
6328 );
6329 }
6330
6331 #[test]
6332 fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
6333 let fx = Fixture::new();
6347 fx.write(
6350 "records/contacts/index.md",
6351 "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
6352 );
6353 fx.write(
6355 "log.md",
6356 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
6357 );
6358 let issues = validate_working_set(&fx.store(), None).unwrap();
6359 assert!(
6360 !issues
6361 .iter()
6362 .any(|i| i.file == Path::new("records/contacts/index.md")
6363 && i.code == codes::WIKI_LINK_BROKEN),
6364 "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
6365 working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
6366 );
6367 }
6368
6369 #[test]
6370 fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
6371 let fx = Fixture::new();
6380 fx.write(
6382 "records/profiles/refers-sarah.md",
6383 "---\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",
6384 );
6385 fx.write(
6389 "records/meetings/2026/05/kickoff.md",
6390 "---\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",
6391 );
6392 fx.write(
6394 "log.md",
6395 "---\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",
6396 );
6397
6398 let issues = validate_working_set(&fx.store(), None).unwrap();
6399 assert!(
6400 issues
6401 .iter()
6402 .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
6403 && i.code == codes::WIKI_LINK_BROKEN),
6404 "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
6405 );
6406 assert!(
6407 issues.iter().any(
6408 |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
6409 && i.code == codes::WIKI_LINK_BROKEN
6410 ),
6411 "linker to the SECOND deleted target (typed-field edge) must also be \
6412 pulled in and flagged — proves the scan covers the whole changed set, \
6413 not just one object: {issues:#?}"
6414 );
6415 }
6416
6417 #[test]
6418 fn frontmatter_block_sequence_links_each_get_their_own_line() {
6419 let fx = Fixture::new();
6421 fx.write(
6423 "records/meetings/m.md",
6424 "---\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",
6425 );
6426 let issues = fx.store_all();
6427 let broken_lines: BTreeSet<Option<u32>> = issues
6428 .iter()
6429 .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
6430 .map(|i| i.line)
6431 .collect();
6432 assert_eq!(
6433 broken_lines.len(),
6434 2,
6435 "two distinct broken-link lines: {issues:#?}"
6436 );
6437 }
6438
6439 #[test]
6442 fn null_created_is_missing_not_silently_passed() {
6443 let fx = Fixture::new();
6447 fx.write(
6448 "records/contacts/a.md",
6449 "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6450 );
6451 let issues = fx.store_all();
6452 assert!(
6453 has(&issues, codes::FM_MISSING_CREATED),
6454 "null `created:` must read as missing: {issues:#?}"
6455 );
6456 }
6457
6458 #[test]
6459 fn sequence_created_is_bad_timestamp() {
6460 let fx = Fixture::new();
6462 fx.write(
6463 "records/contacts/a.md",
6464 "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6465 );
6466 let issues = fx.store_all();
6467 assert!(
6468 issues
6469 .iter()
6470 .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6471 "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6472 );
6473 }
6474
6475 #[test]
6478 fn required_field_null_or_empty_collection_is_missing() {
6479 for value in ["", " []", " {}"] {
6484 let mut fx = Fixture::new();
6485 fx.config.schemas.insert(
6486 "contact".into(),
6487 Schema {
6488 fields: vec![FieldSpec {
6489 name: "name".into(),
6490 required: true,
6491 ..Default::default()
6492 }],
6493 ..Default::default()
6494 },
6495 );
6496 fx.write(
6497 "records/contacts/a.md",
6498 &format!(
6499 "---\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"
6500 ),
6501 );
6502 let issues = fx.store_all();
6503 assert!(
6504 issues
6505 .iter()
6506 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6507 && i.key.as_deref() == Some("name")),
6508 "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6509 );
6510 }
6511 }
6512
6513 #[test]
6516 fn wiki_link_to_raw_source_file_resolves() {
6517 let fx = Fixture::new();
6521 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6522 fx.write(
6523 "records/contacts/a.md",
6524 "---\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",
6525 );
6526 let issues = fx.store_all();
6527 assert!(
6528 !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6529 "a link to an existing raw source file must not be broken: {issues:#?}"
6530 );
6531 }
6532
6533 #[test]
6536 fn wrong_case_wiki_link_is_broken_exact_case() {
6537 let fx = Fixture::new();
6543 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6544 let mut body = valid_contact("links with the wrong case");
6545 body.push_str("\nKnows [[records/contacts/BOB]].\n");
6546 fx.write("records/contacts/alice.md", &body);
6547 let issues = fx.store_all();
6548 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6549 assert!(issue.is_error());
6550 assert!(
6551 issue.message.contains("records/contacts/BOB"),
6552 "the wrong-case target must be named in the issue: {issues:#?}"
6553 );
6554 }
6555
6556 #[test]
6557 fn correct_case_wiki_link_still_resolves() {
6558 let fx = Fixture::new();
6562 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6563 let mut body = valid_contact("links with the right case");
6564 body.push_str("\nKnows [[records/contacts/bob]].\n");
6565 fx.write("records/contacts/alice.md", &body);
6566 let issues = fx.store_all();
6567 assert!(
6568 !issues
6569 .iter()
6570 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("contacts/bob")),
6571 "a correct-case link must resolve clean: {issues:#?}"
6572 );
6573 }
6574
6575 #[test]
6576 fn wrong_case_raw_source_wiki_link_is_broken() {
6577 let fx = Fixture::new();
6582 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6583 fx.write(
6584 "records/contacts/a.md",
6585 "---\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",
6586 );
6587 let issues = fx.store_all();
6588 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6589 assert!(issue.is_error());
6590 assert!(
6591 issue.message.contains("2026-05-22-ELENA.eml"),
6592 "the wrong-case raw-source target must be flagged: {issues:#?}"
6593 );
6594 }
6595
6596 #[test]
6599 fn non_utf8_content_file_is_reported() {
6600 let fx = Fixture::new();
6604 let abs = fx.dir.path().join("records/notes/corrupt.md");
6605 fs::create_dir_all(abs.parent().unwrap()).unwrap();
6606 fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6607 let issues = validate_working_set(&fx.store(), None).unwrap();
6608 assert!(
6609 has(&issues, codes::FM_UNREADABLE),
6610 "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6611 );
6612 }
6613
6614 #[test]
6617 fn tilde_fence_containing_backtick_fence_does_not_invert() {
6618 let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6623 let links = extract_wiki_links(body);
6624 assert!(
6625 links.is_empty(),
6626 "wiki-link inside a nested code fence must be skipped: {links:?}"
6627 );
6628 }
6629
6630 #[test]
6633 fn all_sweep_visits_in_layer_log_folder() {
6634 let fx = Fixture::new();
6639 fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6640 let issues = fx.store_all();
6641 assert!(
6642 has(&issues, codes::FM_MISSING_TYPE),
6643 "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6644 );
6645 }
6646
6647 #[test]
6650 fn flow_form_link_list_with_spaces_is_flagged() {
6651 let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6655 assert!(
6656 keys.iter().any(|k| k == "attendees"),
6657 "spaced flow-form list must be detected: {keys:?}"
6658 );
6659 }
6660
6661 #[test]
6664 fn middot_hashtag_summary_tail_round_trips() {
6665 assert_eq!(
6671 extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6672 Some("Standup notes · #standup"),
6673 "a single-spaced middot tail is part of the summary, not a tag block"
6674 );
6675 assert_eq!(
6677 extract_index_entry_summary("— Renewal champion · #renewal #acme").as_deref(),
6678 Some("Renewal champion"),
6679 "the renderer's double-spaced ` · #tag` suffix is stripped"
6680 );
6681 }
6682
6683 #[test]
6686 fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6687 assert!(is_url("http://x"), "an 8-char http URL is valid");
6688 assert!(is_url("https://x"), "a 9-char https URL is valid");
6689 assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6690 assert!(!is_url("https://"), "a bare https scheme is rejected");
6691 }
6692
6693 #[test]
6694 fn email_shape_rejects_double_at() {
6695 assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6696 assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6697 assert!(is_email("sarah@acme.com"), "a normal address still passes");
6698 }
6699
6700 #[test]
6703 fn working_set_does_not_flag_log_md_body_links() {
6704 let fx = Fixture::new();
6710 fx.write("records/contacts/a.md", &valid_contact("A"));
6711 fx.write(
6712 "log.md",
6713 "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6714 );
6715 let issues = validate_working_set(&fx.store(), None).unwrap();
6716 assert!(
6717 !issues
6718 .iter()
6719 .any(|i| i.code == codes::WIKI_LINK_BROKEN
6720 && i.file == std::path::Path::new("log.md")),
6721 "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6722 );
6723 }
6724
6725 #[test]
6728 fn schema_duplicate_field_name_is_flagged() {
6729 let mut fx = Fixture::new();
6730 fx.config.schemas.insert(
6731 "contact".into(),
6732 Schema {
6733 fields: vec![
6734 FieldSpec {
6735 name: "name".into(),
6736 required: true,
6737 ..Default::default()
6738 },
6739 FieldSpec {
6740 name: "name".into(),
6741 ..Default::default()
6742 },
6743 ],
6744 ..Default::default()
6745 },
6746 );
6747 let issues = fx.store_all();
6748 assert!(
6749 issues
6750 .iter()
6751 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6752 "a duplicate schema field name must be flagged: {issues:#?}"
6753 );
6754 }
6755
6756 #[test]
6757 fn schema_unknown_modifier_is_info() {
6758 let mut fx = Fixture::new();
6759 fx.config.schemas.insert(
6760 "contact".into(),
6761 Schema {
6762 fields: vec![FieldSpec {
6763 name: "name".into(),
6764 unknown_modifiers: vec!["requierd".into()],
6765 ..Default::default()
6766 }],
6767 ..Default::default()
6768 },
6769 );
6770 let issues = fx.store_all();
6771 assert!(
6772 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6773 && i.severity == Severity::Info
6774 && i.key.as_deref() == Some("name")),
6775 "an unrecognized schema modifier must surface as Info: {issues:#?}"
6776 );
6777 }
6778
6779 #[test]
6785 fn schema_unique_key_optional_field_is_warning() {
6786 let mut fx = Fixture::new();
6787 fx.config.schemas.insert(
6788 "expense".into(),
6789 Schema {
6790 fields: vec![
6791 FieldSpec {
6792 name: "date".into(),
6793 required: true,
6794 ..Default::default()
6795 },
6796 FieldSpec {
6797 name: "amount".into(),
6798 required: true,
6799 ..Default::default()
6800 },
6801 FieldSpec {
6802 name: "vendor".into(),
6803 ..Default::default()
6804 },
6805 ],
6806 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
6807 ..Default::default()
6808 },
6809 );
6810 let issues = fx.store_all();
6811 assert!(
6812 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6813 && i.severity == Severity::Warning
6814 && i.key.as_deref() == Some("vendor")
6815 && i.message.contains("unique")),
6816 "a `unique:` key field not marked required must warn: {issues:#?}"
6817 );
6818 assert!(
6820 !issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6821 && matches!(i.key.as_deref(), Some("date") | Some("amount"))),
6822 "required key fields must not warn: {issues:#?}"
6823 );
6824 }
6825
6826 #[test]
6831 fn body_leading_frontmatter_block_is_warning() {
6832 let fx = Fixture::new();
6833 fx.write(
6834 "records/notes/imported.md",
6835 "---\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",
6836 );
6837 let issues = fx.store_all();
6838 assert!(
6839 issues
6840 .iter()
6841 .any(|i| i.code == codes::FM_IN_BODY && i.severity == Severity::Warning),
6842 "a body opening with a second frontmatter block must warn: {issues:#?}"
6843 );
6844 }
6845
6846 #[test]
6849 fn body_thematic_break_rules_do_not_warn() {
6850 let fx = Fixture::new();
6851 fx.write(
6852 "records/notes/rules.md",
6853 "---\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",
6854 );
6855 let issues = fx.store_all();
6856 assert!(
6857 !has(&issues, codes::FM_IN_BODY),
6858 "a `---` thematic rule around prose (not a YAML mapping) must NOT warn: {issues:#?}"
6859 );
6860 }
6861
6862 #[test]
6866 fn body_fenced_frontmatter_example_does_not_warn() {
6867 let fx = Fixture::new();
6868 fx.write(
6869 "records/notes/doc.md",
6870 "---\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",
6871 );
6872 let issues = fx.store_all();
6873 assert!(
6874 !has(&issues, codes::FM_IN_BODY),
6875 "a fenced example block (body opens with a code fence, not `---`) must NOT warn: {issues:#?}"
6876 );
6877 }
6878
6879 #[test]
6882 fn schema_unique_key_undeclared_field_is_warning() {
6883 let mut fx = Fixture::new();
6884 fx.config.schemas.insert(
6885 "expense".into(),
6886 Schema {
6887 fields: vec![FieldSpec {
6888 name: "date".into(),
6889 required: true,
6890 ..Default::default()
6891 }],
6892 unique_keys: vec![vec!["date".into(), "vendor".into()]],
6893 ..Default::default()
6894 },
6895 );
6896 let issues = fx.store_all();
6897 assert!(
6898 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6899 && i.severity == Severity::Warning
6900 && i.key.as_deref() == Some("vendor")
6901 && i.message.contains("not declared")),
6902 "a `unique:` key field absent from the schema must warn: {issues:#?}"
6903 );
6904 }
6905
6906 #[test]
6908 fn schema_unique_key_all_required_is_clean() {
6909 let mut fx = Fixture::new();
6910 fx.config.schemas.insert(
6911 "expense".into(),
6912 Schema {
6913 fields: vec![
6914 FieldSpec {
6915 name: "date".into(),
6916 required: true,
6917 ..Default::default()
6918 },
6919 FieldSpec {
6920 name: "amount".into(),
6921 required: true,
6922 ..Default::default()
6923 },
6924 ],
6925 unique_keys: vec![vec!["date".into(), "amount".into()]],
6926 ..Default::default()
6927 },
6928 );
6929 let issues = fx.store_all();
6930 assert!(
6931 !issues
6932 .iter()
6933 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.message.contains("unique")),
6934 "an all-required unique key must not warn: {issues:#?}"
6935 );
6936 }
6937
6938 #[test]
6944 fn every_code_constant_is_documented_in_spec() {
6945 let this_src = include_str!("validate.rs");
6949 let mut codes_in_module: Vec<String> = Vec::new();
6950 let mut in_codes_mod = false;
6951 for line in this_src.lines() {
6952 let t = line.trim();
6953 if t.starts_with("pub mod codes") {
6954 in_codes_mod = true;
6955 continue;
6956 }
6957 if in_codes_mod && line == "}" {
6959 break;
6960 }
6961 if in_codes_mod {
6962 if let Some(rest) = t.strip_prefix("pub const ") {
6963 let value = rest
6965 .split_once('=')
6966 .map(|(_, v)| v.trim())
6967 .and_then(|v| v.strip_prefix('"'))
6968 .and_then(|v| v.strip_suffix("\";"))
6969 .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6970 codes_in_module.push(value.to_string());
6971 }
6972 }
6973 }
6974 assert!(
6975 codes_in_module.len() >= 36,
6976 "parsed only {} code constants from `mod codes`; the parser likely \
6977 broke against a source-format change",
6978 codes_in_module.len()
6979 );
6980
6981 let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6983 let spec = fs::read_to_string(&spec_path)
6984 .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6985
6986 let missing: Vec<&String> = codes_in_module
6988 .iter()
6989 .filter(|code| !spec.contains(&format!("| `{code}` |")))
6990 .collect();
6991 assert!(
6992 missing.is_empty(),
6993 "validation codes emitted by the engine but absent from SPEC.md \
6994 § Validation (the declared complete vocabulary): {missing:?}"
6995 );
6996 }
6997
6998 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";
7001 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";
7002
7003 #[test]
7004 fn loose_file_catalogued_in_layer_jsonl_validates_clean() {
7005 let fx = Fixture::new();
7006 fx.write("records/contacts/alice.md", LOOSE_ALICE);
7007 fx.write("records/bob.md", LOOSE_BOB); fx.rebuild_indexes();
7009 let issues = fx.store_all();
7010 assert!(
7011 issues.is_empty(),
7012 "a rebuilt store with a catalogued loose file must validate clean, got: {issues:?}"
7013 );
7014 }
7015
7016 #[test]
7017 fn loose_file_with_missing_layer_jsonl_is_index_jsonl_missing() {
7018 let fx = Fixture::new();
7019 fx.write("records/contacts/alice.md", LOOSE_ALICE);
7020 fx.write("records/bob.md", LOOSE_BOB);
7021 fx.rebuild_indexes();
7022 fs::remove_file(fx.dir.path().join("records/index.jsonl")).unwrap();
7024 let issues = fx.store_all();
7025 assert!(
7026 has(&issues, codes::INDEX_JSONL_MISSING),
7027 "a loose file with no layer index.jsonl must raise INDEX_JSONL_MISSING, got: {issues:?}"
7028 );
7029 }
7030
7031 #[cfg(unix)]
7032 #[test]
7033 fn validation_reads_opened_root_after_path_replacement() {
7034 use std::os::unix::fs::symlink;
7035
7036 let sandbox = tempfile::tempdir().unwrap();
7037 let root = sandbox.path().join("store");
7038 fs::create_dir_all(root.join("records/notes")).unwrap();
7039 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7040 fs::write(
7041 root.join("records/notes/owned.md"),
7042 "---\ntype: note\n---\nowned body\n",
7043 )
7044 .unwrap();
7045 let store = Store::open_strict(&root).unwrap();
7046 let detached = sandbox.path().join("detached");
7047 fs::rename(&root, &detached).unwrap();
7048
7049 let replacement = sandbox.path().join("replacement");
7050 fs::create_dir_all(replacement.join("records/notes")).unwrap();
7051 fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7052 fs::write(
7053 replacement.join("records/notes/replacement-secret.md"),
7054 "not frontmatter\n",
7055 )
7056 .unwrap();
7057 symlink(&replacement, &root).unwrap();
7058
7059 let issues = validate_content_sweep(&store).unwrap();
7060 assert!(
7061 issues
7062 .iter()
7063 .any(|issue| issue.file == Path::new("records/notes/owned.md")),
7064 "the held original file must be validated: {issues:?}"
7065 );
7066 assert!(
7067 issues
7068 .iter()
7069 .all(|issue| !issue.file.to_string_lossy().contains("replacement-secret")),
7070 "replacement-root files must be invisible: {issues:?}"
7071 );
7072 }
7073}