1use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::path::{Component, Path, PathBuf};
43
44use chrono::{DateTime, FixedOffset, NaiveDateTime};
45use serde_norway::Value;
46
47use crate::parser::{Schema, Shape};
48use crate::store::Store;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Severity {
54 Error,
56 Warning,
58 Info,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Issue {
67 pub severity: Severity,
69 pub code: &'static str,
71 pub file: PathBuf,
73 pub line: Option<u32>,
75 pub key: Option<String>,
77 pub message: String,
79 pub suggestion: Option<String>,
81 pub related: Vec<PathBuf>,
83}
84
85impl Issue {
86 pub fn is_error(&self) -> bool {
89 matches!(self.severity, Severity::Error)
90 }
91}
92
93pub mod codes {
97 pub const NOT_A_STORE: &str = "NOT_A_STORE";
99 pub const NESTED_STORE: &str = "NESTED_STORE";
101 pub const DB_MD_BAD_TYPE: &str = "DB_MD_BAD_TYPE";
103 pub const DB_MD_MISSING_FIELD: &str = "DB_MD_MISSING_FIELD";
105 pub const DB_MD_UNKNOWN_SECTION: &str = "DB_MD_UNKNOWN_SECTION";
107 pub const DB_MD_SCHEMA_FIELD: &str = "DB_MD_SCHEMA_FIELD";
110 pub const FM_MISSING_TYPE: &str = "FM_MISSING_TYPE";
112 pub const FM_MISSING_CREATED: &str = "FM_MISSING_CREATED";
114 pub const FM_MISSING_UPDATED: &str = "FM_MISSING_UPDATED";
116 pub const FM_UNREADABLE: &str = "FM_UNREADABLE";
118 pub const FM_MALFORMED_YAML: &str = "FM_MALFORMED_YAML";
120 pub const FM_BAD_TIMESTAMP: &str = "FM_BAD_TIMESTAMP";
122 pub const FM_BAD_META_TYPE: &str = "FM_BAD_META_TYPE";
124 pub const FM_BAD_ID: &str = "FM_BAD_ID";
128 pub const FM_IN_BODY: &str = "FM_IN_BODY";
133 pub const SUMMARY_MISSING: &str = "SUMMARY_MISSING";
135 pub const SUMMARY_EMPTY: &str = "SUMMARY_EMPTY";
137 pub const SUMMARY_MULTILINE: &str = "SUMMARY_MULTILINE";
139 pub const SUMMARY_TOO_LONG: &str = "SUMMARY_TOO_LONG";
141 pub const WIKI_LINK_SHORT_FORM: &str = "WIKI_LINK_SHORT_FORM";
143 pub const WIKI_LINK_BROKEN: &str = "WIKI_LINK_BROKEN";
145 pub const WIKI_LINK_AMBIGUOUS: &str = "WIKI_LINK_AMBIGUOUS";
147 pub const WIKI_LINK_HAS_EXTENSION: &str = "WIKI_LINK_HAS_EXTENSION";
149 pub const WIKI_LINK_FLOW_FORM_LIST: &str = "WIKI_LINK_FLOW_FORM_LIST";
151 pub const DUP_ID: &str = "DUP_ID";
153 pub const DUP_UNIQUE_KEY: &str = "DUP_UNIQUE_KEY";
155 pub const SCHEMA_MISSING_REQUIRED: &str = "SCHEMA_MISSING_REQUIRED";
157 pub const SCHEMA_SHAPE_MISMATCH: &str = "SCHEMA_SHAPE_MISMATCH";
159 pub const SCHEMA_LINK_PREFIX_MISMATCH: &str = "SCHEMA_LINK_PREFIX_MISMATCH";
161 pub const SCHEMA_ENUM_VIOLATION: &str = "SCHEMA_ENUM_VIOLATION";
163 pub const POLICY_FROZEN_PAGE: &str = "POLICY_FROZEN_PAGE";
165 pub const POLICY_IGNORED_TYPE_PRESENT: &str = "POLICY_IGNORED_TYPE_PRESENT";
167 pub const POLICY_IGNORED_TYPE_DERIVED: &str = "POLICY_IGNORED_TYPE_DERIVED";
169 pub const LOG_BAD_TIMESTAMP: &str = "LOG_BAD_TIMESTAMP";
171 pub const LOG_UNKNOWN_KIND: &str = "LOG_UNKNOWN_KIND";
173 pub const LOG_OUT_OF_ORDER: &str = "LOG_OUT_OF_ORDER";
175 pub const INDEX_MISSING: &str = "INDEX_MISSING";
177 pub const INDEX_STALE_ENTRY: &str = "INDEX_STALE_ENTRY";
179 pub const INDEX_MISSING_ENTRY: &str = "INDEX_MISSING_ENTRY";
181 pub const INDEX_ORPHAN: &str = "INDEX_ORPHAN";
183 pub const INDEX_WRONG_SCOPE: &str = "INDEX_WRONG_SCOPE";
185 pub const INDEX_SUMMARY_MISMATCH: &str = "INDEX_SUMMARY_MISMATCH";
187 pub const INDEX_JSONL_MISSING: &str = "INDEX_JSONL_MISSING";
189 pub const INDEX_JSONL_DESYNC: &str = "INDEX_JSONL_DESYNC";
192 pub const INDEX_JSONL_STALE: &str = "INDEX_JSONL_STALE";
194 pub const TAGS_MALFORMED: &str = "TAGS_MALFORMED";
196 pub const ASSET_MANIFEST_MALFORMED: &str = "ASSET_MANIFEST_MALFORMED";
198 pub const ASSET_UNDECLARED: &str = "ASSET_UNDECLARED";
201 pub const ASSET_WRAPPER_BROKEN: &str = "ASSET_WRAPPER_BROKEN";
203 pub const ASSET_MANIFEST_ORPHAN: &str = "ASSET_MANIFEST_ORPHAN";
205 pub const ASSET_SUPERSESSION_INVALID: &str = "ASSET_SUPERSESSION_INVALID";
208 pub const ASSET_PATH_IS_CONTENT: &str = "ASSET_PATH_IS_CONTENT";
210}
211
212const MAX_SUMMARY_LEN: usize = 200;
214
215const RECOGNIZED_LOG_KINDS: &[&str] = &[
218 "ingest",
219 "create",
220 "update",
221 "delete",
222 "rename",
223 "link",
224 "validate",
225 "index-rebuild",
226 "contradiction",
227];
228
229pub fn validate_working_set(
255 store: &Store,
256 since: Option<DateTime<FixedOffset>>,
257) -> crate::Result<Vec<Issue>> {
258 if !store_marker_present(store) {
259 return Ok(vec![not_a_store_issue(store)]);
260 }
261
262 let cutoff = match since {
263 Some(ts) => Some(ts),
264 None => last_validate_at(store),
265 };
266
267 let changed = changed_objects_since(store, cutoff);
269 if changed.is_empty() && since.is_none() {
270 return validate_content_sweep(store);
271 }
272
273 let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
284 let mut working: BTreeSet<PathBuf> = changed;
285 for linker in store.find_links_to_any(&changed_targets)? {
286 working.insert(linker);
287 }
288
289 let mut issues = nested_store_issues(store)?;
290 for rel in &working {
291 if !store.regular_file_exists(rel).unwrap_or(false) {
294 continue;
295 }
296 check_content_file(store, rel, None, &mut issues);
301 }
302 issues.sort_by(issue_order);
303 Ok(issues)
304}
305
306fn validate_content_sweep(store: &Store) -> crate::Result<Vec<Issue>> {
307 let mut issues = nested_store_issues(store)?;
308 for rel in store.walk()? {
309 check_content_file(store, &rel, None, &mut issues);
310 }
311 issues.sort_by(issue_order);
312 Ok(issues)
313}
314
315fn nested_store_issues(store: &Store) -> crate::Result<Vec<Issue>> {
319 let mut issues = Vec::new();
320 for nested in store.nested_store_roots()? {
321 let marker = nested.join("DB.md");
322 push(
323 &mut issues,
324 Severity::Error,
325 codes::NESTED_STORE,
326 &marker,
327 None,
328 None,
329 format!(
330 "`{}` is a db.md store nested inside this store",
331 nested.display()
332 ),
333 Some(
334 "move the nested store outside this store, or run dbmd from the nested root"
335 .to_string(),
336 ),
337 vec![],
338 );
339 }
340 Ok(issues)
341}
342
343pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
348 if !store_marker_present(store) {
349 return Ok(vec![not_a_store_issue(store)]);
350 }
351
352 let mut issues = nested_store_issues(store)?;
353
354 check_db_md(store, &mut issues);
358
359 let files = store.walk()?;
360
361 let basenames = build_basename_index(&files);
366
367 let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
369 for rel in &files {
370 if let Some(p) = check_content_file(store, rel, Some(&basenames), &mut issues) {
371 parsed.push((rel.clone(), p));
372 }
373 }
374
375 check_duplicates(store, &parsed, &mut issues);
377
378 check_indexes(store, &files, &mut issues);
380
381 check_log(store, &mut issues);
383
384 check_assets(store, &parsed, &mut issues);
389
390 issues.sort_by(issue_order);
391 Ok(issues)
392}
393
394struct Parsed {
403 fm: Option<BTreeMap<String, Value>>,
406 fm_yaml: String,
409}
410
411fn check_content_file(
416 store: &Store,
417 rel: &Path,
418 basenames: Option<&BasenameIndex>,
419 issues: &mut Vec<Issue>,
420) -> Option<Parsed> {
421 let text = match store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) {
422 Ok(t) => t,
423 Err(e) => {
424 let detail = if e.kind() == std::io::ErrorKind::InvalidData {
432 "file is not valid UTF-8 text".to_string()
433 } else {
434 format!("file could not be read: {e}")
435 };
436 push(
437 issues,
438 Severity::Error,
439 codes::FM_UNREADABLE,
440 rel,
441 None,
442 None,
443 format!("content file is unreadable: {detail}"),
444 Some(
445 "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
446 .into(),
447 ),
448 vec![],
449 );
450 return None;
451 }
452 };
453
454 let is_content = is_content_file(rel);
455
456 let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
457 Some(split) => split,
458 None => {
459 if is_content {
463 push(
464 issues,
465 Severity::Error,
466 codes::FM_MISSING_TYPE,
467 rel,
468 None,
469 Some("type".into()),
470 "content file has no frontmatter `type:`".into(),
471 Some("add a YAML frontmatter block with `type:`".into()),
472 vec![],
473 );
474 push(
475 issues,
476 Severity::Error,
477 codes::SUMMARY_MISSING,
478 rel,
479 None,
480 Some("summary".into()),
481 "content file has no `summary`".into(),
482 Some("run `dbmd fm init`".into()),
483 vec![],
484 );
485 }
486 return None;
487 }
488 };
489
490 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
492 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
493 Ok(Value::Null) => Some(BTreeMap::new()),
495 Ok(_) => {
496 push(
500 issues,
501 Severity::Error,
502 codes::FM_MALFORMED_YAML,
503 rel,
504 Some(1),
505 None,
506 "frontmatter is not a YAML mapping".into(),
507 Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
508 vec![],
509 );
510 None
511 }
512 Err(e) => {
513 push(
516 issues,
517 Severity::Error,
518 codes::FM_MALFORMED_YAML,
519 rel,
520 Some(1),
521 None,
522 format!("frontmatter block isn't valid YAML: {e}"),
523 Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
524 vec![],
525 );
526 None
527 }
528 };
529
530 if let Some(map) = &fm {
531 check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
533 }
534
535 if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
557 check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
558 }
559
560 if is_content && body_opens_with_frontmatter(&body) {
567 push(
568 issues,
569 Severity::Warning,
570 codes::FM_IN_BODY,
571 rel,
572 Some(fm_end_line + 1),
573 None,
574 "the body opens with a second `---` frontmatter block; the record's \
575 frontmatter is the block at the top of the file, so this one is body \
576 text (usually an imported file's own frontmatter left in place)"
577 .into(),
578 Some(
579 "delete the leftover `---…---` block from the body, or move its \
580 fields into the record's frontmatter"
581 .into(),
582 ),
583 vec![],
584 );
585 }
586
587 Some(Parsed { fm, fm_yaml })
588}
589
590fn check_frontmatter(
592 store: &Store,
593 rel: &Path,
594 fm: &BTreeMap<String, Value>,
595 fm_yaml: &str,
596 basenames: Option<&BasenameIndex>,
597 issues: &mut Vec<Issue>,
598 is_content: bool,
599) {
600 let type_ = fm.get("type").and_then(scalar_string);
601
602 if is_content && type_.is_none() {
604 push(
605 issues,
606 Severity::Error,
607 codes::FM_MISSING_TYPE,
608 rel,
609 fm_key_line_or_top(fm_yaml, "type"),
610 Some("type".into()),
611 "content file has no `type:`".into(),
612 Some("add a `type:` field (e.g. `type: contact`)".into()),
613 vec![],
614 );
615 }
616
617 if is_content {
622 if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
631 match scalar_string(v) {
632 Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
633 Some(mt) => push(
634 issues,
635 Severity::Error,
636 codes::FM_BAD_META_TYPE,
637 rel,
638 fm_key_line_or_top(fm_yaml, "meta-type"),
639 Some("meta-type".into()),
640 format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
641 Some(
642 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
643 .into(),
644 ),
645 vec![],
646 ),
647 None => push(
648 issues,
649 Severity::Error,
650 codes::FM_BAD_META_TYPE,
651 rel,
652 fm_key_line_or_top(fm_yaml, "meta-type"),
653 Some("meta-type".into()),
654 "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
655 string, found a list or mapping"
656 .to_string(),
657 Some(
658 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
659 .into(),
660 ),
661 vec![],
662 ),
663 }
664 }
665 }
666
667 if is_content {
678 if let Some(v) = fm.get("id").filter(|v| !v.is_null()) {
679 let problem = match scalar_string(v) {
680 Some(id) if id.trim().is_empty() => Some("`id` is empty".to_string()),
681 Some(id) if id.chars().any(char::is_whitespace) => {
682 Some(format!("`id` {id:?} contains whitespace"))
683 }
684 Some(_) => None,
685 None => Some(
686 "`id` is not a scalar (found a list or mapping), so duplicate detection \
687 (DUP_ID) cannot see it"
688 .to_string(),
689 ),
690 };
691 if let Some(message) = problem {
692 push(
693 issues,
694 Severity::Warning,
695 codes::FM_BAD_ID,
696 rel,
697 fm_key_line_or_top(fm_yaml, "id"),
698 Some("id".into()),
699 message,
700 Some(
701 "use one opaque token with no whitespace — the recommended form is a \
702 lowercase ULID (`dbmd write` mints one) — or drop `id` to fall back to \
703 filename identity"
704 .into(),
705 ),
706 vec![],
707 );
708 }
709 }
710 }
711
712 if is_content {
714 check_summary(rel, fm, fm_yaml, issues);
715 }
716
717 if is_content {
721 for (key, missing_code) in [
722 ("created", codes::FM_MISSING_CREATED),
723 ("updated", codes::FM_MISSING_UPDATED),
724 ] {
725 let value = fm.get(key);
730 let missing = value.is_none() || value.is_some_and(Value::is_null);
731 if missing {
732 push(
733 issues,
734 Severity::Error,
735 missing_code,
736 rel,
737 fm_key_line_or_top(fm_yaml, key),
738 Some(key.into()),
739 format!("content file has no `{key}:` timestamp"),
740 Some(format!(
741 "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
742 )),
743 vec![],
744 );
745 } else if let Some(v) = value {
746 match scalar_string(v) {
752 Some(s) if is_iso8601(&s) => {}
753 Some(s) => push(
754 issues,
755 Severity::Error,
756 codes::FM_BAD_TIMESTAMP,
757 rel,
758 fm_key_line(fm_yaml, key),
759 Some(key.into()),
760 format!("`{key}` is not ISO-8601: {s:?}"),
761 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
762 vec![],
763 ),
764 None => push(
765 issues,
766 Severity::Error,
767 codes::FM_BAD_TIMESTAMP,
768 rel,
769 fm_key_line(fm_yaml, key),
770 Some(key.into()),
771 format!(
772 "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
773 ),
774 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
775 vec![],
776 ),
777 }
778 }
779 }
780 }
781 if let Some(tags) = fm.get("tags") {
783 if !is_flat_scalar_list(tags) {
784 push(
785 issues,
786 Severity::Warning,
787 codes::TAGS_MALFORMED,
788 rel,
789 fm_key_line(fm_yaml, "tags"),
790 Some("tags".into()),
791 "`tags` must be a flat YAML list of short scalar labels".into(),
792 Some("use block form: one `- <tag>` per line".into()),
793 vec![],
794 );
795 }
796 }
797
798 for key in detect_flow_form_link_lists(fm_yaml) {
800 push(
801 issues,
802 Severity::Error,
803 codes::WIKI_LINK_FLOW_FORM_LIST,
804 rel,
805 fm_key_line(fm_yaml, &key),
806 Some(key.clone()),
807 format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
808 Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
809 vec![],
810 );
811 }
812
813 let schema_link_keys: BTreeSet<String> =
818 effective_schema(store, type_.as_deref().unwrap_or(""))
819 .map(|s| {
820 s.fields
821 .iter()
822 .filter(|f| f.link_prefix.is_some())
823 .map(|f| f.name.clone())
824 .collect()
825 })
826 .unwrap_or_default();
827 for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
828 if schema_link_keys.contains(&key) {
829 continue;
830 }
831 check_wiki_link(
832 store,
833 rel,
834 &link,
835 Some(link.line),
836 Some(&key),
837 basenames,
838 issues,
839 );
840 }
841
842 if let Some(t) = &type_ {
844 if store.config.ignored_types.iter().any(|it| it == t) {
845 push(
846 issues,
847 Severity::Info,
848 codes::POLICY_IGNORED_TYPE_PRESENT,
849 rel,
850 fm_key_line(fm_yaml, "type"),
851 Some("type".into()),
852 format!("file has ignored type `{t}` (per DB.md ## Policies)"),
853 Some(
854 "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
855 .into(),
856 ),
857 vec![PathBuf::from("DB.md")],
859 );
860 }
861 let meta_type = fm
867 .get("meta-type")
868 .and_then(scalar_string)
869 .unwrap_or_else(|| "fact".to_string());
870 for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
871 if let Some(hit) =
872 derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
873 {
874 push(
875 issues,
876 Severity::Warning,
877 codes::POLICY_IGNORED_TYPE_DERIVED,
878 rel,
879 Some(link.line),
880 Some("derived_from".into()),
881 format!(
882 "conclusion record derives from ignored-type record `{}` (type `{}`)",
883 hit.target, hit.target_type
884 ),
885 Some(
886 "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
887 .into(),
888 ),
889 vec![
892 PathBuf::from(format!("{}.md", hit.target)),
893 PathBuf::from("DB.md"),
894 ],
895 );
896 }
897 }
898 }
899
900 if let Some(t) = &type_ {
902 if let Some(schema) = effective_schema(store, t) {
903 check_schema(store, rel, fm, fm_yaml, &schema, issues);
904 }
905 }
906}
907
908fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
910 let line = fm_key_line(fm_yaml, "summary");
911 match fm.get("summary") {
912 None => push(
913 issues,
914 Severity::Error,
915 codes::SUMMARY_MISSING,
916 rel,
917 fm_key_line_or_top(fm_yaml, "summary"),
920 Some("summary".into()),
921 "content file has no `summary`".into(),
922 Some("run `dbmd fm init`".into()),
923 vec![],
924 ),
925 Some(v) => {
926 let s = scalar_string(v).unwrap_or_default();
927 if s.trim().is_empty() {
928 push(
929 issues,
930 Severity::Error,
931 codes::SUMMARY_EMPTY,
932 rel,
933 line,
934 Some("summary".into()),
935 "`summary` is present but empty".into(),
936 Some("write a one-line summary, or run `dbmd fm init`".into()),
937 vec![],
938 );
939 } else if s.contains('\n') {
940 push(
941 issues,
942 Severity::Error,
943 codes::SUMMARY_MULTILINE,
944 rel,
945 line,
946 Some("summary".into()),
947 "`summary` must be one line (contains a newline)".into(),
948 Some("collapse the summary to a single line".into()),
949 vec![],
950 );
951 } else if s.chars().count() > MAX_SUMMARY_LEN {
952 push(
953 issues,
954 Severity::Warning,
955 codes::SUMMARY_TOO_LONG,
956 rel,
957 line,
958 Some("summary".into()),
959 format!(
960 "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
961 s.chars().count()
962 ),
963 Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
964 vec![],
965 );
966 }
967 }
968 }
969}
970
971fn check_body_wiki_links(
973 store: &Store,
974 rel: &Path,
975 body: &str,
976 fm_end_line: u32,
977 basenames: Option<&BasenameIndex>,
978 issues: &mut Vec<Issue>,
979) {
980 for link in extract_wiki_links(body) {
981 let abs_line = fm_end_line + link.line;
984 check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
985 }
986}
987
988type BasenameIndex = HashMap<String, Vec<PathBuf>>;
996
997fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
1000 let mut idx: BasenameIndex = HashMap::new();
1001 for rel in files {
1002 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
1003 idx.entry(stem.to_string()).or_default().push(rel.clone());
1004 }
1005 }
1006 idx
1007}
1008
1009fn check_wiki_link(
1014 store: &Store,
1015 rel: &Path,
1016 link: &Link,
1017 line: Option<u32>,
1018 key: Option<&str>,
1019 basenames: Option<&BasenameIndex>,
1020 issues: &mut Vec<Issue>,
1021) {
1022 let bare = link.target.trim_end_matches(".md");
1023
1024 if !is_full_store_path(bare) {
1027 if !bare.contains('/') {
1032 if let Some(idx) = basenames {
1033 if let Some(matches) = idx.get(bare) {
1034 if matches.len() >= 2 {
1035 let mut related = matches.clone();
1036 related.sort();
1037 push(
1038 issues,
1039 Severity::Error,
1040 codes::WIKI_LINK_AMBIGUOUS,
1041 rel,
1042 line,
1043 key.map(str::to_string),
1044 format!(
1045 "short-form wiki-link `[[{}]]` matches multiple files",
1046 link.target
1047 ),
1048 Some("use the full store-relative path to disambiguate".into()),
1049 related,
1050 );
1051 return;
1052 }
1053 }
1054 }
1055 }
1056 push(
1057 issues,
1058 Severity::Error,
1059 codes::WIKI_LINK_SHORT_FORM,
1060 rel,
1061 line,
1062 key.map(str::to_string),
1063 format!(
1064 "wiki-link `[[{}]]` is not a full store-relative path",
1065 link.target
1066 ),
1067 short_form_suggestion(bare),
1068 vec![],
1069 );
1070 return;
1072 }
1073
1074 if link.target.ends_with(".md") {
1076 push(
1077 issues,
1078 Severity::Warning,
1079 codes::WIKI_LINK_HAS_EXTENSION,
1080 rel,
1081 line,
1082 key.map(str::to_string),
1083 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1084 Some(format!("drop the extension: [[{bare}]]")),
1085 vec![],
1086 );
1087 }
1088
1089 match resolve_wiki_target(store, bare) {
1094 TargetResolution::Exists => {}
1095 TargetResolution::Missing => push(
1096 issues,
1097 Severity::Error,
1098 codes::WIKI_LINK_BROKEN,
1099 rel,
1100 line,
1101 key.map(str::to_string),
1102 format!("wiki-link target `{bare}` doesn't exist"),
1103 Some(format!(
1104 "create `{bare}.md`, or point the link at an existing file"
1105 )),
1106 vec![],
1107 ),
1108 TargetResolution::Unsafe => push(
1109 issues,
1110 Severity::Error,
1111 codes::WIKI_LINK_BROKEN,
1112 rel,
1113 line,
1114 key.map(str::to_string),
1115 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1116 Some("use a full store-relative path under sources/ or records/".into()),
1117 vec![],
1118 ),
1119 }
1120}
1121
1122fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1133 store.config.schemas.get(type_).cloned()
1134}
1135
1136fn check_schema(
1138 store: &Store,
1139 rel: &Path,
1140 fm: &BTreeMap<String, Value>,
1141 fm_yaml: &str,
1142 schema: &Schema,
1143 issues: &mut Vec<Issue>,
1144) {
1145 for spec in &schema.fields {
1146 let present = fm.get(&spec.name);
1147 let line = fm_key_line(fm_yaml, &spec.name);
1148
1149 let is_empty = match present {
1157 None => true,
1158 Some(v) => is_empty_value(v),
1159 };
1160 if spec.required && is_empty {
1161 push(
1162 issues,
1163 Severity::Error,
1164 codes::SCHEMA_MISSING_REQUIRED,
1165 rel,
1166 fm_key_line_or_top(fm_yaml, &spec.name),
1169 Some(spec.name.clone()),
1170 format!("required field `{}` is absent or empty", spec.name),
1171 Some(format!("set `{}` to a non-empty value", spec.name)),
1172 vec![],
1173 );
1174 continue;
1175 }
1176 let Some(value) = present else { continue };
1177
1178 let value_empty = value.is_null()
1184 || scalar_string(value)
1185 .map(|s| s.trim().is_empty())
1186 .unwrap_or(false);
1187 if !spec.required && value_empty {
1188 continue;
1189 }
1190
1191 if let Some(prefix) = &spec.link_prefix {
1194 check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1195 continue; }
1197
1198 if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1205 push(
1206 issues,
1207 Severity::Error,
1208 codes::SCHEMA_SHAPE_MISMATCH,
1209 rel,
1210 line,
1211 Some(spec.name.clone()),
1212 format!(
1213 "`{}` must be a scalar value, found a list or mapping",
1214 spec.name
1215 ),
1216 Some(format!("set `{}` to a single scalar value", spec.name)),
1217 vec![],
1218 );
1219 continue;
1220 }
1221
1222 if let Some(allowed) = &spec.enum_values {
1224 if let Some(s) = scalar_string(value) {
1225 if !allowed.iter().any(|a| a == &s) {
1226 push(
1227 issues,
1228 Severity::Error,
1229 codes::SCHEMA_ENUM_VIOLATION,
1230 rel,
1231 line,
1232 Some(spec.name.clone()),
1233 format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1234 Some(format!("use one of: {}", allowed.join(", "))),
1235 vec![],
1236 );
1237 }
1238 }
1239 continue;
1240 }
1241
1242 if let Some(shape) = spec.shape {
1244 check_schema_shape(rel, &spec.name, value, shape, line, issues);
1245 }
1246 }
1247}
1248
1249fn check_schema_link(
1254 store: &Store,
1255 rel: &Path,
1256 field: &str,
1257 fm_yaml: &str,
1258 prefix: &Path,
1259 line: Option<u32>,
1260 issues: &mut Vec<Issue>,
1261) {
1262 let prefix_str = prefix.to_string_lossy();
1263 let prefix_str = prefix_str.trim_end_matches('/');
1264 let suggestion = |target_leaf: &str| {
1265 Some(format!(
1266 "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1267 ))
1268 };
1269
1270 let links = frontmatter_links_for_key(fm_yaml, field, 2);
1271 if links.is_empty() {
1272 let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1274 let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1275 let leaf = slugish(raw);
1276 push(
1277 issues,
1278 Severity::Error,
1279 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1280 rel,
1281 line,
1282 Some(field.to_string()),
1283 format!(
1284 "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1285 ),
1286 suggestion(&leaf),
1287 vec![],
1288 );
1289 return;
1290 }
1291
1292 for link in links {
1293 if link.target.ends_with(".md") {
1294 let bare = link.target.trim_end_matches(".md");
1295 push(
1296 issues,
1297 Severity::Warning,
1298 codes::WIKI_LINK_HAS_EXTENSION,
1299 rel,
1300 Some(link.line),
1301 Some(field.to_string()),
1302 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1303 Some(format!("drop the extension: [[{bare}]]")),
1304 vec![],
1305 );
1306 }
1307 let bare = link.target.trim_end_matches(".md");
1308 if !path_under_prefix(bare, prefix_str) {
1309 let leaf = bare.rsplit('/').next().unwrap_or(bare);
1310 push(
1311 issues,
1312 Severity::Error,
1313 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1314 rel,
1315 line,
1316 Some(field.to_string()),
1317 format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1318 suggestion(leaf),
1319 vec![],
1320 );
1321 } else {
1322 match resolve_wiki_target(store, bare) {
1327 TargetResolution::Exists => {}
1328 TargetResolution::Missing => push(
1329 issues,
1330 Severity::Error,
1331 codes::WIKI_LINK_BROKEN,
1332 rel,
1333 line,
1334 Some(field.to_string()),
1335 format!("wiki-link target `{bare}` doesn't exist"),
1336 Some(format!(
1337 "create `{bare}.md`, or point the link at an existing file"
1338 )),
1339 vec![],
1340 ),
1341 TargetResolution::Unsafe => push(
1342 issues,
1343 Severity::Error,
1344 codes::WIKI_LINK_BROKEN,
1345 rel,
1346 line,
1347 Some(field.to_string()),
1348 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1349 Some("use a full store-relative path under sources/ or records/".into()),
1350 vec![],
1351 ),
1352 }
1353 }
1354 }
1355}
1356
1357fn check_schema_shape(
1359 rel: &Path,
1360 field: &str,
1361 value: &Value,
1362 shape: Shape,
1363 line: Option<u32>,
1364 issues: &mut Vec<Issue>,
1365) {
1366 let s = scalar_string(value).unwrap_or_default();
1367 let ok = match shape {
1368 Shape::String => true, Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1370 Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1371 Shape::Date => is_iso8601_date_or_datetime(&s),
1372 Shape::Email => is_email(&s),
1373 Shape::Currency => is_currency(&s),
1374 Shape::Url => is_url(&s),
1375 };
1376 if !ok {
1377 push(
1378 issues,
1379 Severity::Error,
1380 codes::SCHEMA_SHAPE_MISMATCH,
1381 rel,
1382 line,
1383 Some(field.to_string()),
1384 format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1385 Some(shape_suggestion(shape)),
1386 vec![],
1387 );
1388 }
1389}
1390
1391fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1410 let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1413 .iter()
1414 .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1415 .collect();
1416
1417 let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1419 for (rel, p) in parsed {
1420 if let Some(map) = &p.fm {
1421 if let Some(id) = map.get("id").and_then(scalar_string) {
1422 if !id.trim().is_empty() {
1423 by_id.entry(id).or_default().push(rel.clone());
1424 }
1425 }
1426 }
1427 }
1428 for (id, files) in &by_id {
1429 if files.len() > 1 {
1430 let (reported, related) = canonical_and_related(files);
1431 let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1432 push(
1433 issues,
1434 Severity::Error,
1435 codes::DUP_ID,
1436 &reported,
1437 line,
1438 Some("id".into()),
1439 format!("id {id:?} is declared by more than one file"),
1440 Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1441 related,
1442 );
1443 }
1444 }
1445
1446 for (type_name, schema) in &store.config.schemas {
1451 for key_fields in &schema.unique_keys {
1452 soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1453 }
1454 }
1455}
1456
1457fn soft_dup(
1466 parsed: &[(PathBuf, Parsed)],
1467 issues: &mut Vec<Issue>,
1468 type_: &str,
1469 key_fields: &[String],
1470 fm_yaml_of: &HashMap<&PathBuf, &str>,
1471) {
1472 if key_fields.is_empty() {
1473 return;
1474 }
1475 let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1476 for (rel, p) in parsed {
1477 let is_type =
1478 p.fm.as_ref()
1479 .and_then(|m| m.get("type"))
1480 .and_then(scalar_string)
1481 .map(|t| t == type_)
1482 .unwrap_or(false);
1483 if !is_type {
1484 continue;
1485 }
1486 if let Some(key) = dedup_key(p, key_fields) {
1487 groups.entry(key).or_default().push(rel.clone());
1488 }
1489 }
1490 let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1493 .values()
1494 .filter(|files| files.len() > 1)
1495 .map(|files| canonical_and_related(files))
1496 .collect();
1497 collisions.sort_by(|a, b| a.0.cmp(&b.0));
1498
1499 let fields_disp = key_fields.join(", ");
1500 for (reported, related) in collisions {
1501 let (line, key) = if key_fields.len() == 1 {
1504 (
1505 fm_yaml_of
1506 .get(&reported)
1507 .and_then(|y| fm_key_line(y, &key_fields[0])),
1508 Some(key_fields[0].clone()),
1509 )
1510 } else {
1511 (Some(1), None)
1512 };
1513 let n = related.len();
1514 push(
1515 issues,
1516 Severity::Warning,
1517 codes::DUP_UNIQUE_KEY,
1518 &reported,
1519 line,
1520 key,
1521 format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1522 Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1523 related,
1524 );
1525 }
1526}
1527
1528fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1532 let mut out = Vec::with_capacity(key_fields.len());
1533 for f in key_fields {
1534 out.push(dedup_token(p, f)?);
1535 }
1536 Some(out)
1537}
1538
1539fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1544 let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1547 if !links.is_empty() {
1548 let set: BTreeSet<String> = links
1549 .into_iter()
1550 .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1551 .filter(|t| !t.is_empty())
1552 .collect();
1553 return if set.is_empty() {
1554 None
1555 } else {
1556 Some(set.into_iter().collect::<Vec<_>>().join(","))
1557 };
1558 }
1559 match p.fm.as_ref()?.get(field) {
1560 Some(Value::Sequence(items)) => {
1561 let set: BTreeSet<String> = items
1562 .iter()
1563 .filter_map(scalar_string)
1564 .map(|s| s.trim().to_lowercase())
1565 .filter(|t| !t.is_empty())
1566 .collect();
1567 if set.is_empty() {
1568 None
1569 } else {
1570 Some(set.into_iter().collect::<Vec<_>>().join(","))
1571 }
1572 }
1573 Some(v) => {
1574 let s = scalar_string(v)?.trim().to_lowercase();
1575 if s.is_empty() {
1576 None
1577 } else {
1578 Some(s)
1579 }
1580 }
1581 None => None,
1582 }
1583}
1584
1585fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1590 let mut sorted = files.to_vec();
1591 sorted.sort();
1592 let reported = sorted[0].clone();
1593 let related = sorted[1..].to_vec();
1594 (reported, related)
1595}
1596
1597fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1603 let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1607 for rel in files {
1608 if let Some(tf) = type_folder_of(rel) {
1609 type_folders.entry(tf).or_default().push(rel.clone());
1610 }
1611 }
1612
1613 let mut layers_with_type_folders: BTreeSet<&'static str> = BTreeSet::new();
1625 for tf in type_folders.keys() {
1626 match tf.iter().next().and_then(|s| s.to_str()) {
1627 Some("sources") => {
1628 layers_with_type_folders.insert("sources");
1629 }
1630 Some("records") => {
1631 layers_with_type_folders.insert("records");
1632 }
1633 _ => {}
1634 }
1635 }
1636
1637 if !type_folders.is_empty() {
1639 if !store
1640 .regular_file_exists(Path::new("index.md"))
1641 .unwrap_or(false)
1642 {
1643 push(
1644 issues,
1645 Severity::Error,
1646 codes::INDEX_MISSING,
1647 Path::new("index.md"),
1648 None,
1649 None,
1650 "store has files but no root `index.md`".into(),
1651 Some("run `dbmd index rebuild`".into()),
1652 vec![],
1653 );
1654 } else {
1655 check_index_scope(store, Path::new("index.md"), "root", None, issues);
1656 }
1657 }
1658
1659 for layer in &layers_with_type_folders {
1661 let layer_index_rel = PathBuf::from(layer).join("index.md");
1662 if !store.regular_file_exists(&layer_index_rel).unwrap_or(false) {
1663 push(
1664 issues,
1665 Severity::Error,
1666 codes::INDEX_MISSING,
1667 &layer_index_rel,
1668 None,
1669 None,
1670 format!("layer `{layer}/` has files but no `index.md`"),
1671 Some("run `dbmd index rebuild`".into()),
1672 vec![],
1673 );
1674 } else {
1675 check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1676 }
1677 }
1678
1679 for (tf, members) in &type_folders {
1681 let index_md_rel = tf.join("index.md");
1682 let index_md_present = store.regular_file_exists(&index_md_rel).unwrap_or(false);
1683 if !index_md_present {
1684 push(
1690 issues,
1691 Severity::Error,
1692 codes::INDEX_MISSING,
1693 tf,
1694 None,
1695 None,
1696 format!("non-empty folder `{}` has no index.md", tf.display()),
1697 Some(format!(
1698 "run `dbmd index rebuild --folder {}`",
1699 tf.display()
1700 )),
1701 vec![],
1702 );
1703 continue;
1704 }
1705
1706 check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1707 check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1708
1709 let jsonl_rel = tf.join("index.jsonl");
1713 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1714 push(
1715 issues,
1716 Severity::Error,
1717 codes::INDEX_JSONL_MISSING,
1718 &jsonl_rel,
1719 None,
1720 None,
1721 format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1722 Some("run `dbmd index rebuild`".into()),
1723 vec![],
1724 );
1725 } else {
1726 check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1727 }
1728 }
1729
1730 let mut loose_by_layer: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1738 for rel in files {
1739 if !is_content_file(rel) || type_folder_of(rel).is_some() {
1740 continue;
1741 }
1742 if let Some(layer_dir) = loose_layer_dir(rel) {
1743 loose_by_layer
1744 .entry(layer_dir)
1745 .or_default()
1746 .push(rel.clone());
1747 }
1748 }
1749 for (layer_dir, members) in &loose_by_layer {
1750 let jsonl_rel = layer_dir.join("index.jsonl");
1751 if !store.regular_file_exists(&jsonl_rel).unwrap_or(false) {
1752 push(
1753 issues,
1754 Severity::Error,
1755 codes::INDEX_JSONL_MISSING,
1756 &jsonl_rel,
1757 None,
1758 None,
1759 format!(
1760 "loose files at `{}/` are not catalogued — the layer has no `index.jsonl`",
1761 layer_dir.display()
1762 ),
1763 Some("run `dbmd index rebuild`".into()),
1764 members.clone(),
1765 );
1766 } else {
1767 check_type_folder_index_jsonl(store, layer_dir, &jsonl_rel, members, issues);
1771 }
1772 }
1773
1774 for rel in walk_index_files(store) {
1776 let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1777 let parent_str = parent.to_string_lossy().to_string();
1778 let is_canonical = parent_str.is_empty() || matches!(parent_str.as_str(), "sources" | "records")
1780 || type_folders.contains_key(&parent);
1781 if !is_canonical {
1782 push(
1783 issues,
1784 Severity::Warning,
1785 codes::INDEX_ORPHAN,
1786 &rel,
1787 None,
1788 None,
1789 format!(
1790 "`{}` sits in an empty or non-canonical folder",
1791 rel.display()
1792 ),
1793 Some("remove it, or run `dbmd index rebuild`".into()),
1794 vec![],
1795 );
1796 }
1797 }
1798}
1799
1800fn check_type_folder_index_md(
1804 store: &Store,
1805 tf: &Path,
1806 index_rel: &Path,
1807 members: &[PathBuf],
1808 issues: &mut Vec<Issue>,
1809) {
1810 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1811 return;
1812 };
1813 let entries = parse_index_entries(&text);
1814
1815 let listed: BTreeSet<PathBuf> = entries
1816 .iter()
1817 .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1818 .collect();
1819
1820 for entry in &entries {
1822 let bare = entry.target.trim_end_matches(".md");
1823 let target_abs = match resolved_target_abs(store, bare) {
1826 Some(abs) => abs,
1827 None => {
1828 if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1829 push(
1830 issues,
1831 Severity::Error,
1832 codes::INDEX_STALE_ENTRY,
1833 index_rel,
1834 Some(entry.line),
1835 None,
1836 format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1837 Some("run `dbmd index rebuild`".into()),
1838 vec![],
1839 );
1840 } else {
1841 push(
1842 issues,
1843 Severity::Error,
1844 codes::INDEX_STALE_ENTRY,
1845 index_rel,
1846 Some(entry.line),
1847 None,
1848 format!("index entry `[[{bare}]]` points at a missing file"),
1849 Some("run `dbmd index rebuild`".into()),
1850 vec![PathBuf::from(format!("{bare}.md"))],
1854 );
1855 }
1856 continue;
1857 }
1858 };
1859 if let Some(expected) = read_summary(store, &target_abs) {
1866 match &entry.summary_text {
1867 Some(text_part)
1878 if crate::summary::collapse_whitespace(text_part)
1879 != crate::summary::collapse_whitespace(&expected) =>
1880 {
1881 push(
1882 issues,
1883 Severity::Error,
1884 codes::INDEX_SUMMARY_MISMATCH,
1885 index_rel,
1886 Some(entry.line),
1887 None,
1888 format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1889 Some("run `dbmd index rebuild`".into()),
1890 vec![PathBuf::from(format!("{bare}.md"))],
1891 );
1892 }
1893 None if !expected.trim().is_empty() => {
1894 push(
1895 issues,
1896 Severity::Error,
1897 codes::INDEX_SUMMARY_MISMATCH,
1898 index_rel,
1899 Some(entry.line),
1900 None,
1901 format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1902 Some("run `dbmd index rebuild`".into()),
1903 vec![PathBuf::from(format!("{bare}.md"))],
1904 );
1905 }
1906 _ => {}
1907 }
1908 }
1909 }
1910
1911 let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1915 if content_members.len() <= 500 {
1916 for m in content_members {
1917 let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1918 if !listed.contains(&bare) {
1919 push(
1920 issues,
1921 Severity::Error,
1922 codes::INDEX_MISSING_ENTRY,
1923 index_rel,
1924 None,
1925 None,
1926 format!(
1927 "file `{}` is not listed in its folder's `index.md`",
1928 m.display()
1929 ),
1930 Some("run `dbmd index rebuild`".into()),
1931 vec![(*m).clone()],
1932 );
1933 }
1934 }
1935 }
1936 let _ = tf;
1937}
1938
1939fn check_type_folder_index_jsonl(
1943 store: &Store,
1944 tf: &Path,
1945 jsonl_rel: &Path,
1946 members: &[PathBuf],
1947 issues: &mut Vec<Issue>,
1948) {
1949 let Ok(text) = store.read_text_bounded(jsonl_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
1950 return;
1951 };
1952
1953 let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1955 for (i, line) in text.lines().enumerate() {
1956 let line = line.trim();
1957 if line.is_empty() {
1958 continue;
1959 }
1960 let rec: serde_json::Value = match serde_json::from_str(line) {
1961 Ok(v) => v,
1962 Err(e) => {
1963 push(
1964 issues,
1965 Severity::Error,
1966 codes::INDEX_JSONL_DESYNC,
1967 jsonl_rel,
1968 Some((i + 1) as u32),
1969 None,
1970 format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
1971 Some("run `dbmd index rebuild`".into()),
1972 vec![],
1973 );
1974 continue;
1975 }
1976 };
1977 if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
1978 if !is_safe_store_relative_path(Path::new(path)) {
1979 push(
1980 issues,
1981 Severity::Error,
1982 codes::INDEX_JSONL_DESYNC,
1983 jsonl_rel,
1984 Some((i + 1) as u32),
1985 None,
1986 format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
1987 Some("run `dbmd index rebuild`".into()),
1988 vec![],
1989 );
1990 continue;
1991 }
1992 records.insert(PathBuf::from(path), rec);
1993 }
1994 }
1995
1996 let member_set: BTreeSet<PathBuf> = members
1997 .iter()
1998 .filter(|m| is_content_file(m))
1999 .cloned()
2000 .collect();
2001
2002 for path in records.keys() {
2004 if !store.regular_file_exists(path).unwrap_or(false) {
2005 push(
2006 issues,
2007 Severity::Error,
2008 codes::INDEX_JSONL_DESYNC,
2009 jsonl_rel,
2010 None,
2011 None,
2012 format!(
2013 "`index.jsonl` record points at missing file `{}`",
2014 path.display()
2015 ),
2016 Some("run `dbmd index rebuild`".into()),
2017 vec![],
2018 );
2019 }
2020 }
2021
2022 for m in &member_set {
2024 if !records.contains_key(m) {
2025 push(
2026 issues,
2027 Severity::Error,
2028 codes::INDEX_JSONL_DESYNC,
2029 jsonl_rel,
2030 None,
2031 None,
2032 format!(
2033 "file `{}` is missing from the complete `index.jsonl`",
2034 m.display()
2035 ),
2036 Some("run `dbmd index rebuild`".into()),
2037 vec![m.clone()],
2038 );
2039 }
2040 }
2041
2042 for (path, rec) in &records {
2056 if !store.regular_file_exists(path).unwrap_or(false) {
2057 continue;
2058 }
2059 let Ok(expected) =
2060 crate::index::IndexRecord::expected_from_store(store, path, path.clone())
2061 else {
2062 continue; };
2064 let Ok(expected_json) = serde_json::to_value(&expected) else {
2065 continue;
2066 };
2067 let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
2068 continue;
2069 };
2070
2071 let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
2074 for key in have.keys().chain(want.keys()) {
2075 if key == "path" {
2076 continue;
2077 }
2078 if have.get(key) != want.get(key) {
2079 mismatched_keys.insert(key);
2080 }
2081 }
2082
2083 if !mismatched_keys.is_empty() {
2084 let keys: Vec<&str> = mismatched_keys.into_iter().collect();
2085 push(
2086 issues,
2087 Severity::Error,
2088 codes::INDEX_JSONL_STALE,
2089 jsonl_rel,
2090 None,
2091 Some(keys.join(",")),
2092 format!(
2093 "`index.jsonl` record for `{}` is stale ({})",
2094 path.display(),
2095 keys.join(", ")
2096 ),
2097 Some("run `dbmd index rebuild`".into()),
2098 vec![path.clone()],
2099 );
2100 }
2101 }
2102 let _ = tf;
2103}
2104
2105fn check_index_scope(
2107 store: &Store,
2108 index_rel: &Path,
2109 expected_scope: &str,
2110 expected_folder: Option<&str>,
2111 issues: &mut Vec<Issue>,
2112) {
2113 let Ok(text) = store.read_text_bounded(index_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2114 return;
2115 };
2116 let Some((yaml, _, _)) = split_frontmatter(&text) else {
2117 return;
2118 };
2119 let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
2120 return;
2121 };
2122 let fm = yaml_map_to_btree(&map);
2123
2124 if let Some(scope) = fm.get("scope").and_then(scalar_string) {
2125 let scope_ok =
2127 scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
2128 if !scope_ok {
2129 push(
2130 issues,
2131 Severity::Warning,
2132 codes::INDEX_WRONG_SCOPE,
2133 index_rel,
2134 fm_key_line(&yaml, "scope"),
2135 Some("scope".into()),
2136 format!(
2137 "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
2138 ),
2139 Some(format!("set `scope: {expected_scope}`")),
2140 vec![],
2141 );
2142 }
2143 }
2144 if let Some(expected) = expected_folder {
2146 if let Some(folder) = fm.get("folder").and_then(scalar_string) {
2147 if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
2148 push(
2149 issues,
2150 Severity::Warning,
2151 codes::INDEX_WRONG_SCOPE,
2152 index_rel,
2153 fm_key_line(&yaml, "folder"),
2154 Some("folder".into()),
2155 format!("index `folder: {folder}` doesn't match location `{expected}`"),
2156 Some(format!("set `folder: {expected}`")),
2157 vec![],
2158 );
2159 }
2160 }
2161 }
2162}
2163
2164fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2183 let mut prev: Option<DateTime<FixedOffset>> = None;
2184 for rel in log_files_chronological(store) {
2185 check_log_file(store, &rel, &mut prev, issues);
2186 }
2187}
2188
2189fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2193 let mut files: Vec<PathBuf> = Vec::new();
2194 let archive_dir = Path::new("log");
2195 if let Ok(entries) = store.regular_file_names(archive_dir) {
2196 let mut archives: Vec<PathBuf> = entries
2197 .into_iter()
2198 .filter(|name| {
2199 name.to_str()
2200 .and_then(|n| n.strip_suffix(".md"))
2201 .is_some_and(is_year_month_archive)
2202 })
2203 .map(|name| archive_dir.join(name))
2204 .collect();
2205 archives.sort();
2207 files.extend(archives);
2208 }
2209 if store
2211 .regular_file_exists(Path::new("log.md"))
2212 .unwrap_or(false)
2213 {
2214 files.push(PathBuf::from("log.md"));
2215 }
2216 files
2217}
2218
2219fn check_log_file(
2223 store: &Store,
2224 log_rel: &Path,
2225 prev: &mut Option<DateTime<FixedOffset>>,
2226 issues: &mut Vec<Issue>,
2227) {
2228 let Ok(text) = store.read_text_bounded(log_rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2229 return;
2230 };
2231
2232 for (i, line) in text.lines().enumerate() {
2233 if !line.starts_with("## [") {
2234 continue;
2235 }
2236 let line_no = (i + 1) as u32;
2237 match parse_log_header(line) {
2238 None => push(
2239 issues,
2240 Severity::Error,
2241 codes::LOG_BAD_TIMESTAMP,
2242 log_rel,
2243 Some(line_no),
2244 None,
2245 format!("log entry header has an unparseable timestamp: {line:?}"),
2246 Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2247 vec![],
2248 ),
2249 Some((ts, kind, _object)) => {
2250 if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2251 push(
2252 issues,
2253 Severity::Warning,
2254 codes::LOG_UNKNOWN_KIND,
2255 log_rel,
2256 Some(line_no),
2257 None,
2258 format!("log entry kind `{kind}` is not recognized"),
2259 Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2260 vec![],
2261 );
2262 }
2263 if let Some(p) = *prev {
2264 if ts < p {
2265 push(
2266 issues,
2267 Severity::Warning,
2268 codes::LOG_OUT_OF_ORDER,
2269 log_rel,
2270 Some(line_no),
2271 None,
2272 "log entry is older than the entry above it (possible rewrite)".into(),
2273 Some("append corrective entries; never reorder past ones".into()),
2274 vec![],
2275 );
2276 }
2277 }
2278 *prev = Some(ts);
2279 }
2280 }
2281 }
2282}
2283
2284#[derive(Debug)]
2290struct Link {
2291 target: String,
2292 line: u32,
2293}
2294
2295fn store_marker_present(store: &Store) -> bool {
2299 store
2300 .regular_file_exists(Path::new("DB.md"))
2301 .unwrap_or(false)
2302}
2303
2304fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2315 let rel = Path::new("DB.md");
2316 let Ok(text) = store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) else {
2317 return; };
2319
2320 let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2321 push(
2325 issues,
2326 Severity::Error,
2327 codes::DB_MD_BAD_TYPE,
2328 rel,
2329 Some(1),
2330 Some("type".into()),
2331 "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2332 Some("add a `---` frontmatter block with `type: db-md`".into()),
2333 vec![],
2334 );
2335 for field in ["scope", "owner"] {
2336 push(
2337 issues,
2338 Severity::Error,
2339 codes::DB_MD_MISSING_FIELD,
2340 rel,
2341 Some(1),
2342 Some(field.into()),
2343 format!("DB.md frontmatter is missing required field `{field}`"),
2344 Some(format!("add `{field}:` to the DB.md frontmatter")),
2345 vec![],
2346 );
2347 }
2348 return;
2349 };
2350
2351 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2354 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2355 Ok(Value::Null) => Some(BTreeMap::new()),
2356 _ => None,
2357 };
2358
2359 match &fm {
2360 Some(map) => {
2361 let type_ = map.get("type").and_then(scalar_string);
2363 if type_.as_deref() != Some("db-md") {
2364 let (line, msg) = match &type_ {
2365 Some(t) => (
2366 fm_key_line(&fm_yaml, "type"),
2367 format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2368 ),
2369 None => (
2370 Some(1),
2371 "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2372 ),
2373 };
2374 push(
2375 issues,
2376 Severity::Error,
2377 codes::DB_MD_BAD_TYPE,
2378 rel,
2379 line,
2380 Some("type".into()),
2381 msg,
2382 Some("set `type: db-md` in the DB.md frontmatter".into()),
2383 vec![],
2384 );
2385 }
2386
2387 for field in ["scope", "owner"] {
2389 let present = map
2390 .get(field)
2391 .and_then(scalar_string)
2392 .map(|s| !s.trim().is_empty())
2393 .unwrap_or(false);
2394 if !present {
2395 push(
2396 issues,
2397 Severity::Error,
2398 codes::DB_MD_MISSING_FIELD,
2399 rel,
2400 fm_key_line_or_top(&fm_yaml, field),
2403 Some(field.into()),
2404 format!("DB.md frontmatter is missing required field `{field}`"),
2405 Some(format!("add `{field}:` to the DB.md frontmatter")),
2406 vec![],
2407 );
2408 }
2409 }
2410 }
2411 None => {
2412 push(
2415 issues,
2416 Severity::Error,
2417 codes::DB_MD_BAD_TYPE,
2418 rel,
2419 Some(1),
2420 Some("type".into()),
2421 "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2422 Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2423 vec![],
2424 );
2425 for field in ["scope", "owner"] {
2426 push(
2427 issues,
2428 Severity::Error,
2429 codes::DB_MD_MISSING_FIELD,
2430 rel,
2431 Some(1),
2432 Some(field.into()),
2433 format!("DB.md frontmatter is missing required field `{field}`"),
2434 Some(format!("add `{field}:` to the DB.md frontmatter")),
2435 vec![],
2436 );
2437 }
2438 }
2439 }
2440
2441 for section in crate::parser::extract_sections(&body) {
2455 if section.level != 2 {
2456 continue;
2457 }
2458 let name = section.heading.trim().to_ascii_lowercase();
2459 if matches!(
2460 name.as_str(),
2461 "agent instructions" | "policies" | "schemas" | "folders"
2462 ) {
2463 continue;
2464 }
2465 let file_line = fm_end_line + section.line;
2468 push(
2469 issues,
2470 Severity::Warning,
2471 codes::DB_MD_UNKNOWN_SECTION,
2472 rel,
2473 Some(file_line),
2474 None,
2475 format!(
2476 "DB.md has an unrecognized `## {}` section",
2477 section.heading.trim()
2478 ),
2479 Some(
2480 "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2481 `## Folders` — remove or rename this heading"
2482 .into(),
2483 ),
2484 vec![],
2485 );
2486 }
2487
2488 check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2493}
2494
2495fn check_db_md_schemas(
2502 store: &Store,
2503 rel: &Path,
2504 body: &str,
2505 fm_end_line: u32,
2506 issues: &mut Vec<Issue>,
2507) {
2508 if store.config.schemas.is_empty() {
2509 return;
2510 }
2511
2512 let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2517 let mut current_h2: Option<String> = None;
2518 for section in crate::parser::extract_sections(body) {
2519 match section.level {
2520 2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2521 3 if current_h2.as_deref() == Some("schemas") => {
2522 type_line
2525 .entry(section.heading.trim().to_string())
2526 .or_insert(fm_end_line + section.line);
2527 }
2528 _ => {}
2529 }
2530 }
2531
2532 for (type_name, schema) in &store.config.schemas {
2533 let line = type_line.get(type_name).copied();
2534 let mut seen: BTreeSet<String> = BTreeSet::new();
2535 for field in &schema.fields {
2536 let name = field.name.trim();
2537
2538 if name.is_empty() {
2542 push(
2543 issues,
2544 Severity::Warning,
2545 codes::DB_MD_SCHEMA_FIELD,
2546 rel,
2547 line,
2548 None,
2549 format!("`### {type_name}` has a schema field bullet with no field name"),
2550 Some(
2551 "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2552 .into(),
2553 ),
2554 vec![],
2555 );
2556 continue;
2557 }
2558
2559 if !seen.insert(name.to_string()) {
2563 push(
2564 issues,
2565 Severity::Warning,
2566 codes::DB_MD_SCHEMA_FIELD,
2567 rel,
2568 line,
2569 Some(name.to_string()),
2570 format!("`### {type_name}` declares field `{name}` more than once"),
2571 Some(
2572 "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2573 ),
2574 vec![],
2575 );
2576 }
2577
2578 for modifier in &field.unknown_modifiers {
2583 let modifier = modifier.trim();
2584 if modifier.is_empty() {
2585 continue;
2586 }
2587 push(
2588 issues,
2589 Severity::Info,
2590 codes::DB_MD_SCHEMA_FIELD,
2591 rel,
2592 line,
2593 Some(name.to_string()),
2594 format!(
2595 "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2596 ),
2597 Some(
2598 "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2599 .into(),
2600 ),
2601 vec![],
2602 );
2603 }
2604 }
2605
2606 let mut declared: BTreeMap<&str, bool> = BTreeMap::new();
2615 for f in &schema.fields {
2616 let e = declared.entry(f.name.trim()).or_insert(false);
2617 *e = *e || f.required;
2618 }
2619 let mut flagged: BTreeSet<&str> = BTreeSet::new();
2620 for key_fields in &schema.unique_keys {
2621 for field in key_fields {
2622 let name = field.trim();
2623 if name.is_empty()
2624 || declared.get(name).copied() == Some(true)
2625 || !flagged.insert(name)
2626 {
2627 continue;
2628 }
2629 let message = if declared.contains_key(name) {
2630 format!(
2631 "`### {type_name}` `unique:` key field `{name}` is not `required` — a record missing or leaving it empty is silently skipped by the unique check"
2632 )
2633 } else {
2634 format!(
2635 "`### {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"
2636 )
2637 };
2638 push(
2639 issues,
2640 Severity::Warning,
2641 codes::DB_MD_SCHEMA_FIELD,
2642 rel,
2643 line,
2644 Some(name.to_string()),
2645 message,
2646 Some(format!(
2647 "mark `{name}` `required` in `### {type_name}`, or build the `unique:` key from required fields only"
2648 )),
2649 vec![],
2650 );
2651 }
2652 }
2653 }
2654}
2655
2656fn not_a_store_issue(store: &Store) -> Issue {
2658 Issue {
2659 severity: Severity::Error,
2660 code: codes::NOT_A_STORE,
2661 file: store.root.clone(),
2662 line: None,
2663 key: None,
2664 message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2665 suggestion: Some("create a `DB.md` at the store root".into()),
2666 related: vec![],
2667 }
2668}
2669
2670fn is_content_file(rel: &Path) -> bool {
2673 if !is_safe_store_relative_path(rel) {
2679 return false;
2680 }
2681 let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2682 return false;
2683 };
2684 if !matches!(first, "sources" | "records") {
2685 return false;
2686 }
2687 let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2688 if matches!(name, "index.md" | "index.jsonl") {
2692 return false;
2693 }
2694 name.ends_with(".md")
2695}
2696
2697fn is_root_meta_file(rel: &Path) -> bool {
2704 let mut comps = rel.components();
2705 let Some(Component::Normal(only)) = comps.next() else {
2706 return false;
2707 };
2708 if comps.next().is_some() {
2709 return false; }
2711 matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2712}
2713
2714fn is_index_catalog_file(rel: &Path) -> bool {
2722 matches!(
2723 rel.file_name().and_then(|n| n.to_str()),
2724 Some("index.md") | Some("index.jsonl")
2725 )
2726}
2727
2728fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2732 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2737 let mut lines = text.lines();
2738 let first = lines.next()?;
2739 if first.trim_end() != "---" {
2740 return None;
2741 }
2742 let mut yaml = String::new();
2743 let mut close_line: Option<u32> = None;
2744 let mut current = 1u32;
2746 for line in lines {
2747 current += 1;
2748 if line.trim_end() == "---" {
2749 close_line = Some(current);
2750 break;
2751 }
2752 yaml.push_str(line);
2753 yaml.push('\n');
2754 }
2755 let close_line = close_line?;
2756 let body: String = text
2758 .lines()
2759 .skip(close_line as usize)
2760 .collect::<Vec<_>>()
2761 .join("\n");
2762 Some((yaml, body, close_line))
2763}
2764
2765fn body_opens_with_frontmatter(body: &str) -> bool {
2773 let start: String = body
2774 .lines()
2775 .skip_while(|l| l.trim().is_empty())
2776 .collect::<Vec<_>>()
2777 .join("\n");
2778 match split_frontmatter(&start) {
2779 Some((yaml, _, _)) => matches!(
2780 serde_norway::from_str::<Value>(&yaml),
2781 Ok(Value::Mapping(m)) if !m.is_empty()
2782 ),
2783 None => false,
2784 }
2785}
2786
2787fn read_summary(store: &Store, abs: &Path) -> Option<String> {
2789 let text = store
2790 .read_text_bounded(abs, crate::parser::MAX_DBMD_FILE_BYTES)
2791 .ok()?;
2792 let (yaml, _, _) = split_frontmatter(&text)?;
2793 let value: Value = serde_norway::from_str(&yaml).ok()?;
2794 if let Value::Mapping(m) = value {
2795 m.get(Value::String("summary".into()))
2796 .and_then(scalar_string)
2797 } else {
2798 None
2799 }
2800}
2801
2802fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2805 let mut out = BTreeMap::new();
2806 for (k, v) in map {
2807 if let Value::String(s) = k {
2808 out.insert(s.clone(), v.clone());
2809 }
2810 }
2811 out
2812}
2813
2814fn scalar_string(v: &Value) -> Option<String> {
2817 match v {
2818 Value::String(s) => Some(s.clone()),
2819 Value::Number(n) => Some(n.to_string()),
2820 Value::Bool(b) => Some(b.to_string()),
2821 _ => None,
2822 }
2823}
2824
2825fn is_empty_value(v: &Value) -> bool {
2832 match v {
2833 Value::Null => true,
2834 Value::Sequence(items) => items.is_empty(),
2835 Value::Mapping(map) => map.is_empty(),
2836 other => scalar_string(other)
2837 .map(|s| s.trim().is_empty())
2838 .unwrap_or(true),
2839 }
2840}
2841
2842fn is_flat_scalar_list(v: &Value) -> bool {
2845 match v {
2846 Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2847 _ => false,
2848 }
2849}
2850
2851fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2861 let mut out = Vec::new();
2862 for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2863 for link in links {
2864 out.push((key.clone(), link));
2865 }
2866 }
2867 out
2868}
2869
2870fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2874 for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2875 if k == key {
2876 return links;
2877 }
2878 }
2879 Vec::new()
2880}
2881
2882fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2886 for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2887 if k == key {
2888 return Some(value_text);
2889 }
2890 }
2891 None
2892}
2893
2894fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2901 let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2902 let mut current: Option<(String, String, Vec<Link>)> = None;
2903
2904 for (idx, raw_line) in fm_yaml.lines().enumerate() {
2905 let file_line = fm_start_line + idx as u32;
2906 let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2907 let trimmed = raw_line.trim();
2908
2909 let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2912 top_level_key(raw_line)
2913 } else {
2914 None
2915 };
2916
2917 if let Some((key, after)) = new_key {
2918 if let Some(done) = current.take() {
2919 blocks.push(done);
2920 }
2921 let mut links = Vec::new();
2922 collect_line_links(after, file_line, &mut links);
2923 current = Some((key, after.trim().to_string(), links));
2924 } else if let Some((_k, value_text, links)) = current.as_mut() {
2925 if !value_text.is_empty() {
2927 value_text.push('\n');
2928 }
2929 value_text.push_str(trimmed);
2930 collect_line_links(raw_line, file_line, links);
2931 }
2932 }
2933 if let Some(done) = current.take() {
2934 blocks.push(done);
2935 }
2936 blocks
2937}
2938
2939fn top_level_key(line: &str) -> Option<(String, &str)> {
2942 let (key, rest) = line.split_once(':')?;
2943 let key = key.trim();
2944 if key.is_empty()
2945 || !key
2946 .chars()
2947 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2948 {
2949 return None;
2950 }
2951 Some((key.to_string(), rest))
2952}
2953
2954fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2957 let bytes = s.as_bytes();
2958 let mut i = 0;
2959 while i + 1 < bytes.len() {
2960 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2961 if let Some(close) = s[i + 2..].find("]]") {
2962 let inner = &s[i + 2..i + 2 + close];
2963 let target = inner
2966 .trim_start_matches('[')
2967 .split('|')
2968 .next()
2969 .unwrap_or(inner)
2970 .trim()
2971 .to_string();
2972 if !target.is_empty() {
2973 links.push(Link {
2974 target,
2975 line: file_line,
2976 });
2977 }
2978 i = i + 2 + close + 2;
2979 continue;
2980 }
2981 }
2982 i += 1;
2983 }
2984}
2985
2986fn extract_wiki_links(body: &str) -> Vec<Link> {
2998 let mut out = Vec::new();
2999 let mut fence: Option<(u8, usize)> = None;
3000 for (idx, line) in body.lines().enumerate() {
3001 let content = line.trim_end_matches('\r');
3002 if let Some(f) = fence {
3003 if fence_closes(content, f) {
3007 fence = None;
3008 }
3009 continue;
3010 }
3011 if let Some(opened) = fence_opens(content) {
3012 fence = Some(opened);
3013 continue;
3014 }
3015 let line_no = (idx + 1) as u32;
3016 let bytes = line.as_bytes();
3017 let mut i = 0;
3018 while i + 1 < bytes.len() {
3019 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
3020 if let Some(close) = line[i + 2..].find("]]") {
3021 let inner = &line[i + 2..i + 2 + close];
3022 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3023 if !target.is_empty() && !target.starts_with('[') {
3031 out.push(Link {
3032 target,
3033 line: line_no,
3034 });
3035 }
3036 i = i + 2 + close + 2;
3037 continue;
3038 }
3039 }
3040 i += 1;
3041 }
3042 }
3043 out
3044}
3045
3046fn fence_opens(line: &str) -> Option<(u8, usize)> {
3052 let indent = line.len() - line.trim_start_matches(' ').len();
3053 if indent > 3 {
3054 return None;
3055 }
3056 let rest = &line[indent..];
3057 let byte = rest.bytes().next()?;
3058 if byte != b'`' && byte != b'~' {
3059 return None;
3060 }
3061 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3062 if run < 3 {
3063 return None;
3064 }
3065 if byte == b'`' && rest[run..].contains('`') {
3067 return None;
3068 }
3069 Some((byte, run))
3070}
3071
3072fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
3077 let (byte, open_len) = fence;
3078 let indent = line.len() - line.trim_start_matches(' ').len();
3079 if indent > 3 {
3080 return false;
3081 }
3082 let rest = &line[indent..];
3083 let run = rest.len() - rest.trim_start_matches(byte as char).len();
3084 if run < open_len {
3085 return false;
3086 }
3087 rest[run..].trim().is_empty()
3088}
3089
3090fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
3107 let mut out = Vec::new();
3108 for line in fm_yaml.lines() {
3109 if line.starts_with(' ') || line.starts_with('\t') {
3111 continue;
3112 }
3113 let Some((key, rest)) = line.split_once(':') else {
3114 continue;
3115 };
3116 let key = key.trim();
3117 if key.is_empty()
3118 || key.starts_with('#')
3119 || key.starts_with('-')
3120 || !key
3121 .chars()
3122 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
3123 {
3124 continue;
3125 }
3126 let rest = rest.trim();
3127 if !rest.starts_with('[') {
3130 continue;
3131 }
3132 if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
3137 let nested = items.iter().any(|item| match item {
3138 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
3139 _ => false,
3140 });
3141 if nested {
3142 out.push(key.to_string());
3143 }
3144 }
3145 }
3146 out
3147}
3148
3149fn is_full_store_path(bare: &str) -> bool {
3152 let mut parts = bare.splitn(2, '/');
3153 let first = parts.next().unwrap_or("");
3154 let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
3155 matches!(first, "sources" | "records") && has_rest
3156}
3157
3158fn is_safe_store_relative_path(path: &Path) -> bool {
3162 let mut saw_component = false;
3163 for component in path.components() {
3164 match component {
3165 Component::Normal(_) => saw_component = true,
3166 Component::CurDir => {}
3167 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
3168 }
3169 }
3170 saw_component
3171}
3172
3173fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
3174 let path = Path::new(bare);
3175 if !is_safe_store_relative_path(path) {
3176 return None;
3177 }
3178 Some(PathBuf::from(format!("{bare}.md")))
3179}
3180
3181enum TargetResolution {
3183 Exists,
3185 Missing,
3187 Unsafe,
3189}
3190
3191fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
3200 if !is_safe_store_relative_path(Path::new(bare)) {
3204 return TargetResolution::Unsafe;
3205 }
3206 match resolved_target_abs(store, bare) {
3207 Some(_) => TargetResolution::Exists,
3208 None => TargetResolution::Missing,
3209 }
3210}
3211
3212fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
3238 if !is_safe_store_relative_path(Path::new(bare)) {
3239 return None;
3240 }
3241 let literal = PathBuf::from(bare);
3244 if store.regular_file_exists(&literal).ok()? && disk_case_matches(store, &literal, bare) {
3245 return Some(literal);
3246 }
3247 let with_md_rel = format!("{bare}.md");
3249 let with_md = PathBuf::from(&with_md_rel);
3250 if store.regular_file_exists(&with_md).ok()? && disk_case_matches(store, &with_md, &with_md_rel)
3251 {
3252 return Some(with_md);
3253 }
3254 None
3255}
3256
3257fn disk_case_matches(store: &Store, abs: &Path, requested: &str) -> bool {
3272 abs == Path::new(requested) && store.path_case_matches(abs).unwrap_or(true)
3273}
3274
3275fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3277 let prefix = prefix.trim_end_matches('/');
3278 bare == prefix || bare.starts_with(&format!("{prefix}/"))
3279}
3280
3281fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3285 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3286 if comps.len() < 3 {
3287 return None; }
3289 if !matches!(comps[0], "sources" | "records") {
3290 return None;
3291 }
3292 Some(PathBuf::from(comps[0]).join(comps[1]))
3293}
3294
3295fn loose_layer_dir(rel: &Path) -> Option<PathBuf> {
3300 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3301 if comps.len() != 2 || !matches!(comps[0], "sources" | "records") {
3302 return None;
3303 }
3304 Some(PathBuf::from(comps[0]))
3305}
3306
3307fn walk_index_files(store: &Store) -> Vec<PathBuf> {
3312 let mut out = Vec::new();
3313 if store
3314 .regular_file_exists(Path::new("index.md"))
3315 .unwrap_or(false)
3316 {
3317 out.push(PathBuf::from("index.md"));
3318 }
3319 for layer in ["sources", "records"] {
3320 if let Ok(files) = store.walk_regular_files(Path::new(layer)) {
3321 for rel in files {
3322 if rel.file_name().and_then(|name| name.to_str()) == Some("index.md") {
3323 out.push(rel);
3324 }
3325 }
3326 }
3327 }
3328 out.sort();
3329 out
3330}
3331
3332struct IndexEntry {
3335 target: String,
3336 summary_text: Option<String>,
3337 line: u32,
3338}
3339
3340fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3345 let mut out = Vec::new();
3346 let mut in_more = false;
3347 for (idx, line) in text.lines().enumerate() {
3348 let trimmed = line.trim_start();
3349 if trimmed.starts_with("## More") {
3350 in_more = true;
3351 continue;
3352 }
3353 if in_more {
3354 continue;
3355 }
3356 if !trimmed.starts_with("- ") {
3357 continue;
3358 }
3359 let Some(open) = trimmed.find("[[") else {
3361 continue;
3362 };
3363 let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3364 continue;
3365 };
3366 let inner = &trimmed[open + 2..open + 2 + close_rel];
3367 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3368
3369 let after = &trimmed[open + 2 + close_rel + 2..];
3371 let summary_text = extract_index_entry_summary(after);
3372
3373 out.push(IndexEntry {
3374 target,
3375 summary_text,
3376 line: (idx + 1) as u32,
3377 });
3378 }
3379 out
3380}
3381
3382fn extract_index_entry_summary(after: &str) -> Option<String> {
3388 let mut s = after.trim();
3389 if s.starts_with('(') {
3391 if let Some(close) = s.find(')') {
3392 s = s[close + 1..].trim_start();
3393 }
3394 }
3395 let s = s.strip_prefix('—').or_else(|| s.strip_prefix('-'))?.trim();
3397 if s.is_empty() {
3398 return None;
3399 }
3400 let s = match s.rsplit_once(" · ") {
3415 Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3416 _ => s,
3417 };
3418 Some(s.to_string())
3419}
3420
3421fn is_tag_suffix(s: &str) -> bool {
3426 let mut any = false;
3427 for tok in s.split_whitespace() {
3428 if !tok.starts_with('#') || tok.len() < 2 {
3429 return false;
3430 }
3431 any = true;
3432 }
3433 any
3434}
3435
3436fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3440 let rest = line.strip_prefix("## [")?;
3441 let close = rest.find(']')?;
3442 let ts_str = &rest[..close];
3443 let tail = rest[close + 1..].trim();
3444
3445 let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3448 let offset = FixedOffset::east_opt(0)?;
3449 let ts = naive.and_local_timezone(offset).single()?;
3450
3451 let (kind, object) = match tail.split_once('|') {
3453 Some((k, o)) => {
3454 let o = o.trim();
3455 (
3456 k.trim().to_string(),
3457 if o.is_empty() {
3458 None
3459 } else {
3460 Some(o.to_string())
3461 },
3462 )
3463 }
3464 None => (tail.to_string(), None),
3465 };
3466 if kind.is_empty() {
3467 return None;
3468 }
3469 Some((ts, kind, object))
3470}
3471
3472fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3482 let mut files = vec![PathBuf::from("log.md")];
3483 let archive_dir = Path::new("log");
3484 if let Ok(entries) = store.regular_file_names(archive_dir) {
3485 let mut archives: Vec<PathBuf> = entries
3486 .into_iter()
3487 .filter(|name| {
3488 name.to_str()
3489 .and_then(|n| n.strip_suffix(".md"))
3490 .is_some_and(is_year_month_archive)
3491 })
3492 .map(|name| archive_dir.join(name))
3493 .collect();
3494 archives.sort();
3498 files.extend(archives);
3499 }
3500 files.retain(|path| store.regular_file_exists(path).unwrap_or(false));
3501 files
3502}
3503
3504fn is_year_month_archive(s: &str) -> bool {
3507 let b = s.as_bytes();
3508 b.len() == 7
3509 && b[..4].iter().all(u8::is_ascii_digit)
3510 && b[4] == b'-'
3511 && b[5..7].iter().all(u8::is_ascii_digit)
3512}
3513
3514fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3520 let mut latest: Option<DateTime<FixedOffset>> = None;
3521 for file in log_files_for_working_set(store) {
3522 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3523 continue;
3524 };
3525 for line in text.lines() {
3526 if !line.starts_with("## [") {
3527 continue;
3528 }
3529 if let Some((ts, kind, _)) = parse_log_header(line) {
3530 if kind == "validate" {
3531 latest = Some(match latest {
3532 Some(p) if p >= ts => p,
3533 _ => ts,
3534 });
3535 }
3536 }
3537 }
3538 }
3539 latest
3540}
3541
3542fn changed_objects_since(
3553 store: &Store,
3554 cutoff: Option<DateTime<FixedOffset>>,
3555) -> BTreeSet<PathBuf> {
3556 let mut out = BTreeSet::new();
3557 for file in log_files_for_working_set(store) {
3558 let Ok(text) = store.read_text_bounded(&file, crate::parser::MAX_DBMD_FILE_BYTES) else {
3559 continue;
3560 };
3561 for line in text.lines() {
3562 if !line.starts_with("## [") {
3563 continue;
3564 }
3565 let Some((ts, kind, object)) = parse_log_header(line) else {
3566 continue;
3567 };
3568 if let Some(c) = cutoff {
3569 if ts < c {
3570 continue;
3571 }
3572 }
3573 if !matches!(
3574 kind.as_str(),
3575 "create" | "update" | "ingest" | "rename" | "delete" | "link"
3576 ) {
3577 continue;
3578 }
3579 if let Some(obj) = object {
3580 let bare = obj
3582 .trim()
3583 .trim_start_matches("[[")
3584 .trim_end_matches("]]")
3585 .split('|')
3586 .next()
3587 .unwrap_or("")
3588 .trim()
3589 .trim_end_matches(".md")
3590 .to_string();
3591 if bare.is_empty() {
3592 continue;
3593 }
3594 if let Some(rel) = safe_md_target_rel(&bare) {
3604 out.insert(rel);
3605 }
3606 }
3607 }
3608 }
3609 out
3610}
3611
3612#[derive(Debug, Clone, PartialEq, Eq)]
3617pub struct DerivedFromIgnored {
3618 pub target: String,
3621 pub target_type: String,
3624}
3625
3626pub fn derived_from_ignored_type<I, S>(
3640 store: &Store,
3641 meta_type: &str,
3642 derived_from_targets: I,
3643) -> Option<DerivedFromIgnored>
3644where
3645 I: IntoIterator<Item = S>,
3646 S: AsRef<str>,
3647{
3648 if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3649 return None;
3650 }
3651 for target in derived_from_targets {
3652 let target = target.as_ref();
3653 if let Some(target_type) = link_target_type(store, target) {
3654 if store.config.ignored_types.contains(&target_type) {
3655 return Some(DerivedFromIgnored {
3656 target: target.to_string(),
3657 target_type,
3658 });
3659 }
3660 }
3661 }
3662 None
3663}
3664
3665fn link_target_type(store: &Store, target: &str) -> Option<String> {
3667 let bare = target.trim_end_matches(".md");
3668 let rel = safe_md_target_rel(bare)?;
3669 let text = store
3670 .read_text_bounded(&rel, crate::parser::MAX_DBMD_FILE_BYTES)
3671 .ok()?;
3672 let (yaml, _, _) = split_frontmatter(&text)?;
3673 let value: Value = serde_norway::from_str(&yaml).ok()?;
3674 if let Value::Mapping(m) = value {
3675 m.get(Value::String("type".into())).and_then(scalar_string)
3676 } else {
3677 None
3678 }
3679}
3680
3681fn is_iso8601(s: &str) -> bool {
3686 DateTime::parse_from_rfc3339(s.trim()).is_ok()
3687}
3688
3689fn is_iso8601_date_or_datetime(s: &str) -> bool {
3693 let s = s.trim();
3694 if DateTime::parse_from_rfc3339(s).is_ok() {
3695 return true;
3696 }
3697 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3698}
3699
3700fn is_email(s: &str) -> bool {
3705 let s = s.trim();
3706 let Some((local, domain)) = s.split_once('@') else {
3707 return false;
3708 };
3709 !local.is_empty()
3710 && !domain.contains('@')
3711 && domain.contains('.')
3712 && !domain.starts_with('.')
3713 && !domain.ends_with('.')
3714 && !domain.contains(' ')
3715 && !local.contains(' ')
3716}
3717
3718fn is_currency(s: &str) -> bool {
3725 let mut t = s.trim();
3726 for sym in ["$", "€", "£", "¥"] {
3728 if let Some(rest) = t.strip_prefix(sym) {
3729 t = rest.trim_start();
3730 break;
3731 }
3732 }
3733 if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3737 if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3738 t = rest.trim_start();
3739 }
3740 }
3741
3742 let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3743 is_plain_amount(cleaned.trim())
3744}
3745
3746fn is_plain_amount(s: &str) -> bool {
3749 let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3750 let (int_part, frac_part) = match digits.split_once('.') {
3751 Some((i, f)) => (i, Some(f)),
3752 None => (digits, None),
3753 };
3754 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3755 return false;
3756 }
3757 match frac_part {
3758 None => true,
3759 Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3760 }
3761}
3762
3763fn is_url(s: &str) -> bool {
3769 let s = s.trim();
3770 for scheme in ["http://", "https://"] {
3771 if let Some(rest) = s.strip_prefix(scheme) {
3772 return !rest.is_empty();
3773 }
3774 }
3775 false
3776}
3777
3778fn shape_suggestion(shape: Shape) -> String {
3780 match shape {
3781 Shape::String => "use a scalar string".into(),
3782 Shape::Int => "use an integer".into(),
3783 Shape::Bool => "use `true` or `false`".into(),
3784 Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3785 Shape::Email => "use a `<local>@<domain>` address".into(),
3786 Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3787 Shape::Url => "use an http(s) URL".into(),
3788 }
3789}
3790
3791fn short_form_suggestion(bare: &str) -> Option<String> {
3794 Some(format!(
3795 "use a full store-relative path, e.g. [[records/contacts/{}]]",
3796 slugish(bare)
3797 ))
3798}
3799
3800fn slugish(s: &str) -> String {
3802 s.trim()
3803 .to_lowercase()
3804 .chars()
3805 .map(|c| if c.is_whitespace() { '-' } else { c })
3806 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3807 .collect()
3808}
3809
3810fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3816 use crate::assets;
3817
3818 let manifest_rel = Path::new(assets::MANIFEST_FILE);
3819 let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3821 if store.regular_file_exists(manifest_rel).unwrap_or(false) {
3822 if let Ok(text) = store.read_text_bounded(manifest_rel, crate::parser::MAX_DBMD_FILE_BYTES)
3823 {
3824 for (i, line) in text.lines().enumerate() {
3825 if line.trim().is_empty() {
3826 continue;
3827 }
3828 match serde_json::from_str::<assets::AssetRecord>(line) {
3829 Ok(rec) => {
3830 manifest.insert(rec.path.clone(), rec);
3831 }
3832 Err(e) => push(
3833 issues,
3834 Severity::Error,
3835 codes::ASSET_MANIFEST_MALFORMED,
3836 manifest_rel,
3837 Some((i as u32) + 1),
3838 None,
3839 format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3840 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3841 vec![],
3842 ),
3843 }
3844 }
3845 }
3846 }
3847
3848 let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3851 let mut supersessions: BTreeMap<String, (String, PathBuf)> = BTreeMap::new();
3852 for (rel, p) in parsed {
3853 let Some(map) = &p.fm else {
3854 continue;
3855 };
3856 for decl in assets::declarations_from_yaml_map(map) {
3857 let norm = match assets::normalize_asset_path(&decl.path) {
3858 Ok(n) => n,
3859 Err(_) => continue, };
3861 declared.insert(norm.clone());
3862 let is_md = Path::new(&norm)
3863 .extension()
3864 .and_then(|e| e.to_str())
3865 .map(|e| e.eq_ignore_ascii_case("md"))
3866 .unwrap_or(false);
3867 if is_md {
3868 push(
3869 issues,
3870 Severity::Warning,
3871 codes::ASSET_PATH_IS_CONTENT,
3872 rel,
3873 None,
3874 Some("asset".to_string()),
3875 format!("asset path `{norm}` points at a markdown content file"),
3876 Some("assets are raw binaries; reference a non-markdown path".to_string()),
3877 vec![PathBuf::from(&norm)],
3878 );
3879 }
3880 if !manifest.contains_key(&norm) {
3881 push(
3882 issues,
3883 Severity::Error,
3884 codes::ASSET_UNDECLARED,
3885 rel,
3886 None,
3887 Some("asset".to_string()),
3888 format!(
3889 "references asset `{norm}` with no record in {}",
3890 assets::MANIFEST_FILE
3891 ),
3892 Some("run `dbmd assets scan` to catalog it".to_string()),
3893 vec![PathBuf::from(&norm)],
3894 );
3895 }
3896 }
3897 match assets::asset_supersession_from_yaml_map(map) {
3898 Ok(Some(supersession)) => {
3899 declared.insert(supersession.original.clone());
3900 let wrapper = rel.to_string_lossy().replace('\\', "/");
3901 if let Some((prior_replacement, prior_wrapper)) =
3902 supersessions.get(&supersession.original)
3903 {
3904 if prior_replacement != &supersession.replacement {
3905 push(
3906 issues,
3907 Severity::Error,
3908 codes::ASSET_SUPERSESSION_INVALID,
3909 rel,
3910 None,
3911 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3912 format!(
3913 "asset `{}` is superseded by both `{}` and `{}` ({})",
3914 supersession.original,
3915 prior_replacement,
3916 supersession.replacement,
3917 prior_wrapper.display()
3918 ),
3919 Some(
3920 "keep exactly one replacement for an asset coordinate".to_string(),
3921 ),
3922 vec![prior_wrapper.clone()],
3923 );
3924 }
3925 } else {
3926 supersessions.insert(
3927 supersession.original.clone(),
3928 (supersession.replacement.clone(), rel.clone()),
3929 );
3930 }
3931 match manifest.get(&supersession.original) {
3932 Some(record)
3933 if !record.required && record.wrappers.contains(&wrapper) => {}
3934 Some(_) => push(
3935 issues,
3936 Severity::Error,
3937 codes::ASSET_SUPERSESSION_INVALID,
3938 rel,
3939 None,
3940 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3941 format!(
3942 "superseded asset `{}` must remain cataloged as optional evidence under this wrapper",
3943 supersession.original
3944 ),
3945 Some(format!(
3946 "run `dbmd assets refresh {}` --wrapper {wrapper}",
3947 supersession.replacement
3948 )),
3949 vec![PathBuf::from(&supersession.original)],
3950 ),
3951 None => push(
3952 issues,
3953 Severity::Error,
3954 codes::ASSET_SUPERSESSION_INVALID,
3955 rel,
3956 None,
3957 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3958 format!(
3959 "superseded asset `{}` has no record in {}",
3960 supersession.original,
3961 assets::MANIFEST_FILE
3962 ),
3963 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3964 vec![PathBuf::from(&supersession.original)],
3965 ),
3966 }
3967 }
3968 Ok(None) => {}
3969 Err(error) => push(
3970 issues,
3971 Severity::Error,
3972 codes::ASSET_SUPERSESSION_INVALID,
3973 rel,
3974 None,
3975 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
3976 error,
3977 Some(format!(
3978 "remove `{}` or declare exactly one required replacement asset",
3979 assets::SUPERSEDES_ASSET_KEY
3980 )),
3981 vec![],
3982 ),
3983 }
3984 }
3985
3986 let mut reported_cycle_members = BTreeSet::new();
3987 for origin in supersessions.keys() {
3988 let mut order: Vec<String> = Vec::new();
3989 let mut positions = BTreeMap::new();
3990 let mut current = origin.as_str();
3991 while let Some((next, _)) = supersessions.get(current) {
3992 if let Some(start) = positions.get(current).copied() {
3993 for member in &order[start..] {
3994 if reported_cycle_members.insert(member.clone()) {
3995 let (_, wrapper) = &supersessions[member];
3996 push(
3997 issues,
3998 Severity::Error,
3999 codes::ASSET_SUPERSESSION_INVALID,
4000 wrapper,
4001 None,
4002 Some(assets::SUPERSEDES_ASSET_KEY.to_string()),
4003 format!("asset replacement cycle includes `{member}`"),
4004 Some("replace the cycle with a one-way provenance chain".to_string()),
4005 vec![],
4006 );
4007 }
4008 }
4009 break;
4010 }
4011 positions.insert(current.to_string(), order.len());
4012 order.push(current.to_string());
4013 current = next;
4014 }
4015 }
4016
4017 for (path, rec) in &manifest {
4019 for w in &rec.wrappers {
4020 if !store.regular_file_exists(Path::new(w)).unwrap_or(false) {
4021 push(
4022 issues,
4023 Severity::Error,
4024 codes::ASSET_WRAPPER_BROKEN,
4025 Path::new(path),
4026 None,
4027 None,
4028 format!("manifest record for `{path}` names a missing wrapper `{w}`"),
4029 Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
4030 vec![PathBuf::from(w)],
4031 );
4032 }
4033 }
4034 if !declared.contains(path) {
4035 push(
4036 issues,
4037 Severity::Warning,
4038 codes::ASSET_MANIFEST_ORPHAN,
4039 Path::new(path),
4040 None,
4041 None,
4042 format!(
4043 "`{path}` is in {} but no wrapper references it",
4044 assets::MANIFEST_FILE
4045 ),
4046 Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
4047 vec![],
4048 );
4049 }
4050 }
4051}
4052
4053#[allow(clippy::too_many_arguments)]
4055fn push(
4056 issues: &mut Vec<Issue>,
4057 severity: Severity,
4058 code: &'static str,
4059 file: &Path,
4060 line: Option<u32>,
4061 key: Option<String>,
4062 message: String,
4063 suggestion: Option<String>,
4064 related: Vec<PathBuf>,
4065) {
4066 issues.push(Issue {
4067 severity,
4068 code,
4069 file: file.to_path_buf(),
4070 line,
4071 key,
4072 message,
4073 suggestion,
4074 related,
4075 });
4076}
4077
4078fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
4081 for (i, line) in fm_yaml.lines().enumerate() {
4082 let trimmed = line.trim_start();
4083 if let Some(rest) = trimmed.strip_prefix(key) {
4085 if rest.starts_with(':') && line.starts_with(key) {
4086 return Some((i as u32) + 2);
4088 }
4089 }
4090 }
4091 None
4092}
4093
4094fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
4100 fm_key_line(fm_yaml, key).or(Some(1))
4101}
4102
4103fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
4106 a.file
4107 .cmp(&b.file)
4108 .then(a.line.cmp(&b.line))
4109 .then(a.code.cmp(b.code))
4110 .then(a.key.cmp(&b.key))
4111}
4112
4113#[cfg(test)]
4118mod tests {
4119 use super::*;
4120 use crate::parser::{Config, FieldSpec};
4121 use std::fs;
4122 use tempfile::TempDir;
4123
4124 #[test]
4125 fn split_frontmatter_tolerates_leading_bom() {
4126 let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
4131 let parsed = split_frontmatter(text);
4132 assert!(
4133 parsed.is_some(),
4134 "a leading BOM must not hide frontmatter from validate"
4135 );
4136 let (yaml, body, close_line) = parsed.unwrap();
4137 assert_eq!(yaml, "type: contact\nsummary: hi\n");
4138 assert_eq!(body, "body");
4139 assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
4140 }
4141
4142 struct Fixture {
4145 dir: TempDir,
4146 config: Config,
4147 }
4148
4149 impl Fixture {
4150 fn new() -> Self {
4155 let dir = TempDir::new().unwrap();
4156 fs::write(
4157 dir.path().join("DB.md"),
4158 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
4159 )
4160 .unwrap();
4161 for layer in ["sources", "records"] {
4162 fs::create_dir_all(dir.path().join(layer)).unwrap();
4163 }
4164 Fixture {
4165 dir,
4166 config: Config::default(),
4167 }
4168 }
4169
4170 fn bare() -> Self {
4172 let dir = TempDir::new().unwrap();
4173 Fixture {
4174 dir,
4175 config: Config::default(),
4176 }
4177 }
4178
4179 fn write(&self, rel: &str, contents: &str) {
4181 let abs = self.dir.path().join(rel);
4182 fs::create_dir_all(abs.parent().unwrap()).unwrap();
4183 fs::write(abs, contents).unwrap();
4184 }
4185
4186 fn store(&self) -> Store {
4187 Store::from_root_and_config(self.dir.path(), self.config.clone()).unwrap()
4188 }
4189
4190 fn store_all(&self) -> Vec<Issue> {
4191 validate_all(&self.store()).unwrap()
4192 }
4193
4194 fn rebuild_indexes(&self) {
4201 crate::index::Index::rebuild_all(&self.store()).unwrap();
4202 }
4203 }
4204
4205 fn has(issues: &[Issue], code: &str) -> bool {
4207 issues.iter().any(|i| i.code == code)
4208 }
4209
4210 fn count(issues: &[Issue], code: &str) -> usize {
4212 issues.iter().filter(|i| i.code == code).count()
4213 }
4214
4215 fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
4217 issues
4218 .iter()
4219 .find(|i| i.code == code)
4220 .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
4221 }
4222
4223 fn valid_contact(summary: &str) -> String {
4225 format!(
4226 "---\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"
4227 )
4228 }
4229
4230 #[test]
4233 fn not_a_store_when_db_md_absent() {
4234 let fx = Fixture::bare();
4235 let issues = fx.store_all();
4236 assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
4237 assert_eq!(issues[0].code, codes::NOT_A_STORE);
4238 assert!(issues[0].is_error());
4239 }
4240
4241 #[test]
4242 fn working_set_also_reports_not_a_store() {
4243 let fx = Fixture::bare();
4244 let issues = validate_working_set(&fx.store(), None).unwrap();
4245 assert!(has(&issues, codes::NOT_A_STORE));
4246 }
4247
4248 #[test]
4249 fn both_scopes_report_nested_store_without_validating_its_content() {
4250 let fx = Fixture::new();
4251 fx.write(
4252 "records/nested/DB.md",
4253 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
4254 );
4255 fx.write("records/nested/records/notes/bad.md", "not frontmatter");
4258
4259 for issues in [
4260 validate_working_set(&fx.store(), None).unwrap(),
4261 validate_all(&fx.store()).unwrap(),
4262 ] {
4263 assert_eq!(count(&issues, codes::NESTED_STORE), 1, "{issues:#?}");
4264 assert_eq!(
4265 find(&issues, codes::NESTED_STORE).file,
4266 PathBuf::from("records/nested/DB.md")
4267 );
4268 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4269 }
4270 }
4271
4272 #[test]
4273 fn clean_store_has_no_issues() {
4274 let fx = Fixture::new();
4275 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4276 fx.rebuild_indexes();
4280 let issues = fx.store_all();
4281 assert!(
4282 issues.is_empty(),
4283 "expected a clean store, got: {issues:#?}"
4284 );
4285 }
4286
4287 #[test]
4295 fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
4296 let fx = Fixture::new();
4297 let body = |mt: &str| {
4298 format!(
4299 "---\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"
4300 )
4301 };
4302
4303 for ok in ["fact", "operational", "conclusion"] {
4305 fx.write("records/profiles/ok.md", &body(ok));
4306 let issues = validate_working_set(&fx.store(), None).unwrap();
4307 assert!(
4308 !has(&issues, codes::FM_BAD_META_TYPE),
4309 "`meta-type: {ok}` must be accepted; got {issues:#?}"
4310 );
4311 }
4312 fx.write(
4313 "records/profiles/absent.md",
4314 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
4315 );
4316 assert!(
4317 !has(
4318 &validate_working_set(&fx.store(), None).unwrap(),
4319 codes::FM_BAD_META_TYPE
4320 ),
4321 "an absent meta-type is the default `fact` and must be accepted"
4322 );
4323
4324 for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
4326 let fx2 = Fixture::new();
4327 fx2.write("records/profiles/bad.md", &body(bad));
4328 let issues = validate_working_set(&fx2.store(), None).unwrap();
4329 assert!(
4330 has(&issues, codes::FM_BAD_META_TYPE),
4331 "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
4332 );
4333 }
4334 }
4335
4336 #[test]
4345 fn id_absent_slug_ulid_and_numeric_are_all_silent() {
4346 let body = |id_line: &str| {
4347 format!(
4348 "---\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"
4349 )
4350 };
4351 for (case, id_line) in [
4352 ("absent", ""),
4353 ("slug", "id: sarah-chen\n"),
4354 ("ulid", "id: 01j5qc3v9k4ym8rwbn2tqe6f7d\n"),
4355 ("numeric-scalar", "id: 100\n"),
4356 ] {
4357 let fx = Fixture::new();
4358 fx.write("records/contacts/a.md", &body(id_line));
4359 let issues = validate_working_set(&fx.store(), None).unwrap();
4360 assert!(
4361 !has(&issues, codes::FM_BAD_ID),
4362 "id case `{case}` must be silent; got {issues:#?}"
4363 );
4364 }
4365 }
4366
4367 #[test]
4372 fn id_unusable_as_identifier_warns_fm_bad_id() {
4373 let body = |id_line: &str| {
4374 format!(
4375 "---\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"
4376 )
4377 };
4378 for bad in [
4379 "id: \"\"",
4380 "id: \" \"",
4381 "id: two words",
4382 "id: [a, b]",
4383 "id: {k: v}",
4384 ] {
4385 let fx = Fixture::new();
4386 fx.write("records/contacts/a.md", &body(bad));
4387 let issues = validate_working_set(&fx.store(), None).unwrap();
4388 let issue = issues
4389 .iter()
4390 .find(|i| i.code == codes::FM_BAD_ID)
4391 .unwrap_or_else(|| panic!("`{bad}` must fire FM_BAD_ID; got {issues:#?}"));
4392 assert!(
4393 matches!(issue.severity, Severity::Warning),
4394 "FM_BAD_ID is a warning (additive v0.4 — it must never block a store): {issue:#?}"
4395 );
4396 assert_eq!(issue.key.as_deref(), Some("id"));
4397 assert!(
4398 !issue.is_error(),
4399 "FM_BAD_ID must not fail validation: {issue:#?}"
4400 );
4401 }
4402 }
4403
4404 #[test]
4408 fn dup_id_fires_on_shared_ulid_ids() {
4409 let fx = Fixture::new();
4410 let rec = |name: &str| {
4411 format!(
4412 "---\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"
4413 )
4414 };
4415 fx.write("records/contacts/a.md", &rec("A"));
4416 fx.write("records/contacts/b.md", &rec("B"));
4417 let issues = fx.store_all();
4418 assert_eq!(count(&issues, codes::DUP_ID), 1, "{issues:#?}");
4419 let issue = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4420 assert!(issue.is_error());
4421 assert!(!has(&issues, codes::FM_BAD_ID), "{issues:#?}");
4423 }
4424
4425 #[test]
4431 fn valid_db_md_emits_no_structure_issue() {
4432 let fx = Fixture::new();
4433 let issues = fx.store_all();
4434 assert!(
4435 !has(&issues, codes::DB_MD_BAD_TYPE)
4436 && !has(&issues, codes::DB_MD_MISSING_FIELD)
4437 && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4438 "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4439 );
4440 }
4441
4442 #[test]
4446 fn db_md_wrong_type_is_error() {
4447 let fx = Fixture::new();
4448 fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4449 let issues = fx.store_all();
4450 let i = find(&issues, codes::DB_MD_BAD_TYPE);
4451 assert!(i.is_error());
4452 assert_eq!(i.file, PathBuf::from("DB.md"));
4453 assert_eq!(i.key.as_deref(), Some("type"));
4454 assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4455 }
4456
4457 #[test]
4460 fn db_md_missing_scope_and_owner_each_report() {
4461 let fx = Fixture::new();
4462 fx.write("DB.md", "---\ntype: db-md\n---\n");
4463 let issues = fx.store_all();
4464 assert_eq!(
4465 count(&issues, codes::DB_MD_MISSING_FIELD),
4466 2,
4467 "both scope and owner absent → two issues: {issues:#?}"
4468 );
4469 let keys: BTreeSet<Option<String>> = issues
4470 .iter()
4471 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4472 .map(|i| i.key.clone())
4473 .collect();
4474 assert_eq!(
4475 keys,
4476 BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4477 "one issue keyed on each missing field"
4478 );
4479 for i in issues
4480 .iter()
4481 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4482 {
4483 assert!(i.is_error());
4484 assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4485 }
4486 }
4487
4488 #[test]
4492 fn db_md_blank_required_field_is_missing() {
4493 let fx = Fixture::new();
4494 fx.write(
4495 "DB.md",
4496 "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4497 );
4498 let issues = fx.store_all();
4499 let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4500 assert_eq!(i.key.as_deref(), Some("owner"));
4501 assert_eq!(
4502 i.line,
4503 Some(4),
4504 "a present-but-empty field anchors to its line"
4505 );
4506 assert!(
4507 count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4508 "scope is present and non-empty → only owner reported"
4509 );
4510 }
4511
4512 #[test]
4515 fn db_md_unknown_section_is_warning() {
4516 let fx = Fixture::new();
4517 fx.write(
4518 "DB.md",
4519 "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4523 );
4524 let issues = fx.store_all();
4525 let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4526 assert!(!i.is_error(), "unknown section is a warning, not an error");
4527 assert_eq!(i.severity, Severity::Warning);
4528 assert_eq!(
4529 i.line,
4530 Some(11),
4531 "anchors to the `## Glossary` heading line"
4532 );
4533 assert!(
4534 i.message.contains("Glossary"),
4535 "the message names the offending section: {}",
4536 i.message
4537 );
4538 assert_eq!(
4540 count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4541 1,
4542 "only the unrecognized section is flagged: {issues:#?}"
4543 );
4544 }
4545
4546 #[test]
4549 fn db_md_no_frontmatter_reports_type_and_both_fields() {
4550 let fx = Fixture::new();
4551 fx.write("DB.md", "# just a heading, no frontmatter\n");
4552 let issues = fx.store_all();
4553 assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4554 assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4555 }
4556
4557 #[test]
4560 fn missing_type_is_error() {
4561 let fx = Fixture::new();
4562 fx.write(
4563 "records/contacts/a.md",
4564 "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4565 );
4566 let issues = fx.store_all();
4567 assert!(has(&issues, codes::FM_MISSING_TYPE));
4568 assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4569 }
4570
4571 #[test]
4572 fn missing_universal_timestamps_are_errors_on_content_files() {
4573 let fx = Fixture::new();
4574 fx.write(
4575 "records/contacts/a.md",
4576 "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4577 );
4578 let issues = fx.store_all();
4579
4580 let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4581 assert_eq!(missing_created.key.as_deref(), Some("created"));
4582 assert!(missing_created.is_error());
4583
4584 let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4585 assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4586 assert!(missing_updated.is_error());
4587 }
4588
4589 #[test]
4590 fn meta_files_do_not_require_universal_timestamps() {
4591 let fx = Fixture::new();
4592 let issues = fx.store_all();
4593
4594 assert!(
4595 !has(&issues, codes::FM_MISSING_CREATED),
4596 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4597 );
4598 assert!(
4599 !has(&issues, codes::FM_MISSING_UPDATED),
4600 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4601 );
4602 }
4603
4604 #[test]
4605 fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4606 let fx = Fixture::new();
4607 fx.write(
4608 "records/profiles/a.md",
4609 "# Just a heading\n\nNo frontmatter here.\n",
4610 );
4611 let issues = fx.store_all();
4612 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4613 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4614 }
4615
4616 #[test]
4617 fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4618 let fx = Fixture::new();
4619 fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4620 let issues = fx.store_all();
4621 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4622 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4623 }
4624
4625 #[test]
4626 fn malformed_yaml_is_error_and_suppresses_field_checks() {
4627 let fx = Fixture::new();
4628 fx.write(
4630 "records/contacts/a.md",
4631 "---\ntype: contact\n bad: : : :\n: : nope\n---\n\nbody\n",
4632 );
4633 let issues = fx.store_all();
4634 let issue = find(&issues, codes::FM_MALFORMED_YAML);
4635 assert!(issue.is_error());
4636 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4637 assert!(
4640 !has(&issues, codes::SUMMARY_MISSING),
4641 "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4642 );
4643 }
4644
4645 #[test]
4646 fn bad_created_timestamp_is_error() {
4647 let fx = Fixture::new();
4648 fx.write(
4649 "records/contacts/a.md",
4650 "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4651 );
4652 let issues = fx.store_all();
4653 let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4654 assert_eq!(issue.key.as_deref(), Some("created"));
4655 assert!(issue.is_error());
4656 }
4657
4658 #[test]
4659 fn date_only_created_is_rejected_but_type_date_field_accepted() {
4660 let fx = Fixture::new();
4661 fx.write(
4664 "records/contacts/a.md",
4665 "---\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",
4666 );
4667 let issues = fx.store_all();
4668 let created_issues: Vec<_> = issues
4669 .iter()
4670 .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4671 .collect();
4672 assert_eq!(
4673 created_issues.len(),
4674 1,
4675 "date-only `created` must fail: {issues:#?}"
4676 );
4677 assert!(
4678 !issues.iter().any(
4679 |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4680 ),
4681 "date-only `last_touch` is valid: {issues:#?}"
4682 );
4683 }
4684
4685 #[test]
4688 fn summary_missing_empty_multiline_toolong() {
4689 let fx = Fixture::new();
4690 fx.write(
4691 "records/profiles/missing.md",
4692 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4693 );
4694 fx.write(
4695 "records/profiles/empty.md",
4696 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \" \"\n---\n\nbody\n",
4697 );
4698 let long = "x".repeat(201);
4699 fx.write(
4700 "records/profiles/long.md",
4701 &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"),
4702 );
4703 let issues = fx.store_all();
4704 assert!(has(&issues, codes::SUMMARY_MISSING));
4705 assert_eq!(
4706 find(&issues, codes::SUMMARY_MISSING).file,
4707 PathBuf::from("records/profiles/missing.md")
4708 );
4709 assert!(has(&issues, codes::SUMMARY_EMPTY));
4710 assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4711 assert_eq!(
4712 find(&issues, codes::SUMMARY_TOO_LONG).severity,
4713 Severity::Warning
4714 );
4715 }
4716
4717 #[test]
4718 fn summary_multiline_via_yaml_block_scalar() {
4719 let fx = Fixture::new();
4720 fx.write(
4722 "records/profiles/a.md",
4723 "---\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",
4724 );
4725 let issues = fx.store_all();
4726 assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4727 }
4728
4729 #[test]
4730 fn summary_exactly_200_chars_is_ok() {
4731 let fx = Fixture::new();
4732 let s = "y".repeat(200);
4733 fx.write(
4734 "records/profiles/a.md",
4735 &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"),
4736 );
4737 let issues = fx.store_all();
4738 assert!(
4739 !has(&issues, codes::SUMMARY_TOO_LONG),
4740 "200 is the bound, inclusive: {issues:#?}"
4741 );
4742 }
4743
4744 #[test]
4745 fn meta_files_need_no_summary() {
4746 let fx = Fixture::new();
4747 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4750 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4751 fx.write(
4752 "records/index.md",
4753 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4754 );
4755 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4756 fx.write(
4757 "records/contacts/index.jsonl",
4758 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4759 );
4760 fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4761 let issues = fx.store_all();
4762 assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4763 }
4764
4765 #[test]
4768 fn nested_tags_warns_flat_tags_ok() {
4769 let fx = Fixture::new();
4770 fx.write(
4771 "records/contacts/nested.md",
4772 "---\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",
4773 );
4774 fx.write(
4775 "records/contacts/flat.md",
4776 "---\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",
4777 );
4778 let issues = fx.store_all();
4779 let tag_issues: Vec<_> = issues
4780 .iter()
4781 .filter(|i| i.code == codes::TAGS_MALFORMED)
4782 .collect();
4783 assert_eq!(
4784 tag_issues.len(),
4785 1,
4786 "only the nested-tags file should warn: {issues:#?}"
4787 );
4788 assert_eq!(
4789 tag_issues[0].file,
4790 PathBuf::from("records/contacts/nested.md")
4791 );
4792 assert_eq!(tag_issues[0].severity, Severity::Warning);
4793 }
4794
4795 #[test]
4798 fn short_form_wiki_link_is_error() {
4799 let fx = Fixture::new();
4800 let mut body = valid_contact("links to a short form");
4801 body.push_str("\nSee [[sarah-chen]] for details.\n");
4802 fx.write("records/contacts/a.md", &body);
4803 let issues = fx.store_all();
4804 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4805 assert!(issue.is_error());
4806 assert!(issue.message.contains("sarah-chen"));
4807 assert!(
4809 !issues
4810 .iter()
4811 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4812 "short-form should suppress broken: {issues:#?}"
4813 );
4814 }
4815
4816 #[test]
4817 fn broken_full_path_wiki_link_is_error() {
4818 let fx = Fixture::new();
4819 let mut body = valid_contact("links to a missing file");
4820 body.push_str("\nSee [[records/contacts/ghost]].\n");
4821 fx.write("records/contacts/a.md", &body);
4822 let issues = fx.store_all();
4823 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4824 assert!(issue.is_error());
4825 assert!(issue.message.contains("records/contacts/ghost"));
4826 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4827 }
4828
4829 #[test]
4830 fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4831 let fx = Fixture::new();
4832 let mut body = valid_contact("links with traversal");
4833 body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4834 fx.write("records/contacts/a.md", &body);
4835 let issues = fx.store_all();
4836 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4837 assert!(issue.message.contains("not a safe store-relative path"));
4838 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4839 }
4840
4841 #[test]
4842 fn valid_full_path_wiki_link_passes() {
4843 let fx = Fixture::new();
4844 fx.write("records/contacts/target.md", &valid_contact("target"));
4845 let mut body = valid_contact("links to target");
4846 body.push_str("\nSee [[records/contacts/target]].\n");
4847 fx.write("records/contacts/a.md", &body);
4848 let issues = fx.store_all();
4849 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4850 assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4851 }
4852
4853 #[test]
4854 fn md_extension_wiki_link_warns_and_resolves() {
4855 let fx = Fixture::new();
4856 fx.write("records/contacts/target.md", &valid_contact("target"));
4857 let mut body = valid_contact("links with extension");
4858 body.push_str("\nSee [[records/contacts/target.md]].\n");
4859 fx.write("records/contacts/a.md", &body);
4860 let issues = fx.store_all();
4861 let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4862 assert_eq!(issue.severity, Severity::Warning);
4863 assert_eq!(
4864 issue.suggestion.as_deref(),
4865 Some("drop the extension: [[records/contacts/target]]")
4866 );
4867 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4869 }
4870
4871 #[test]
4872 fn wiki_links_in_code_fences_are_ignored() {
4873 let fx = Fixture::new();
4874 let mut body = valid_contact("has a fenced example");
4875 body.push_str("\n```\n[[sarah-chen]]\n```\n");
4876 fx.write("records/contacts/a.md", &body);
4877 let issues = fx.store_all();
4878 assert!(
4879 !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4880 "fenced wiki-links must be ignored: {issues:#?}"
4881 );
4882 }
4883
4884 #[test]
4885 fn flow_form_link_list_in_frontmatter_is_error() {
4886 let fx = Fixture::new();
4887 fx.write(
4888 "records/meetings/m.md",
4889 "---\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",
4890 );
4891 let issues = fx.store_all();
4892 let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4893 assert!(issue.is_error());
4894 assert_eq!(issue.key.as_deref(), Some("attendees"));
4895 }
4896
4897 #[test]
4898 fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4899 let fx = Fixture::new();
4900 fx.write("records/contacts/a.md", &valid_contact("a"));
4901 fx.write("records/contacts/b.md", &valid_contact("b"));
4902 fx.write(
4903 "records/meetings/m.md",
4904 "---\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",
4905 );
4906 let issues = fx.store_all();
4907 assert!(
4908 !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4909 "{issues:#?}"
4910 );
4911 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4913 }
4914
4915 #[test]
4916 fn frontmatter_short_form_link_field_is_error() {
4917 let fx = Fixture::new();
4918 fx.write(
4921 "records/synthesis/a.md",
4922 "---\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",
4923 );
4924 let issues = fx.store_all();
4925 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4926 assert!(issue.is_error());
4927 assert_eq!(issue.key.as_deref(), Some("related"));
4928 }
4929
4930 #[test]
4931 fn unquoted_frontmatter_link_is_recognized() {
4932 let fx = Fixture::new();
4937 fx.write(
4938 "records/synthesis/short.md",
4939 "---\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",
4940 );
4941 fx.write(
4942 "records/synthesis/broken.md",
4943 "---\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",
4944 );
4945 let issues = fx.store_all();
4946 assert!(
4947 issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4948 && i.file == Path::new("records/synthesis/short.md")
4949 && i.key.as_deref() == Some("related")),
4950 "unquoted short-form frontmatter link must be caught: {issues:#?}"
4951 );
4952 assert!(
4953 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4954 && i.file == Path::new("records/synthesis/broken.md")),
4955 "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4956 );
4957 }
4958
4959 #[test]
4960 fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4961 let mut fx = Fixture::new();
4966 fx.config.schemas.insert(
4967 "contact".into(),
4968 Schema {
4969 fields: vec![FieldSpec {
4970 name: "company".into(),
4971 link_prefix: Some(PathBuf::from("records/companies")),
4972 ..Default::default()
4973 }],
4974 ..Default::default()
4975 },
4976 );
4977 fx.write(
4978 "records/contacts/a.md",
4979 "---\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",
4980 );
4981 let issues = fx.store_all();
4982 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4983 assert_eq!(issue.key.as_deref(), Some("company"));
4984 assert!(
4986 !issues
4987 .iter()
4988 .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4989 && i.key.as_deref() == Some("company")),
4990 "schema link fields are checked once, by the schema path: {issues:#?}"
4991 );
4992 }
4993
4994 #[test]
4995 fn schema_link_field_with_md_extension_still_warns() {
4996 let mut fx = Fixture::new();
4997 fx.config.schemas.insert(
4998 "contact".into(),
4999 Schema {
5000 fields: vec![FieldSpec {
5001 name: "company".into(),
5002 link_prefix: Some(PathBuf::from("records/companies")),
5003 ..Default::default()
5004 }],
5005 ..Default::default()
5006 },
5007 );
5008 fx.write(
5009 "records/companies/acme.md",
5010 "---\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",
5011 );
5012 fx.write(
5013 "records/contacts/a.md",
5014 "---\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",
5015 );
5016 let issues = fx.store_all();
5017 let issue = issues
5018 .iter()
5019 .find(|i| {
5020 i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
5021 })
5022 .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
5023 assert_eq!(issue.severity, Severity::Warning);
5024 assert!(
5025 !issues
5026 .iter()
5027 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
5028 "extensionless existence check should still find acme.md: {issues:#?}"
5029 );
5030 }
5031
5032 #[test]
5035 fn explicit_schema_required_shape_enum() {
5036 let fx = {
5037 let mut fx = Fixture::new();
5038 let schema = Schema {
5041 fields: vec![
5042 FieldSpec {
5043 name: "name".into(),
5044 required: true,
5045 ..Default::default()
5046 },
5047 FieldSpec {
5048 name: "email".into(),
5049 required: true,
5050 shape: Some(Shape::Email),
5051 ..Default::default()
5052 },
5053 FieldSpec {
5054 name: "status".into(),
5055 enum_values: Some(vec!["active".into(), "inactive".into()]),
5056 ..Default::default()
5057 },
5058 ],
5059 ..Default::default()
5060 };
5061 fx.config.schemas.insert("contact".into(), schema);
5062 fx
5063 };
5064 fx.write(
5065 "records/contacts/a.md",
5066 "---\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",
5067 );
5068 let issues = fx.store_all();
5069 assert!(
5071 issues
5072 .iter()
5073 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
5074 && i.key.as_deref() == Some("name")),
5075 "{issues:#?}"
5076 );
5077 assert!(
5079 issues.iter().any(
5080 |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
5081 ),
5082 "{issues:#?}"
5083 );
5084 assert!(
5086 issues
5087 .iter()
5088 .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
5089 && i.key.as_deref() == Some("status")),
5090 "{issues:#?}"
5091 );
5092 }
5093
5094 #[test]
5095 fn schema_without_link_field_allows_plain_value() {
5096 let mut fx = Fixture::new();
5100 fx.config.schemas.insert(
5101 "contact".into(),
5102 Schema {
5103 fields: vec![FieldSpec {
5104 name: "name".into(),
5105 required: true,
5106 ..Default::default()
5107 }],
5108 ..Default::default()
5109 },
5110 );
5111 fx.write(
5112 "records/contacts/a.md",
5113 "---\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",
5114 );
5115 let issues = fx.store_all();
5116 assert!(
5117 !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
5118 "no declared link field for `company` → a plain value is fine: {issues:#?}"
5119 );
5120 }
5121
5122 #[test]
5123 fn schema_link_field_plain_value_is_prefix_mismatch() {
5124 let mut fx = Fixture::new();
5127 fx.config.schemas.insert(
5128 "contact".into(),
5129 Schema {
5130 fields: vec![FieldSpec {
5131 name: "company".into(),
5132 link_prefix: Some(PathBuf::from("records/companies")),
5133 ..Default::default()
5134 }],
5135 ..Default::default()
5136 },
5137 );
5138 fx.write(
5139 "records/contacts/a.md",
5140 "---\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",
5141 );
5142 let issues = fx.store_all();
5143 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5144 assert_eq!(issue.key.as_deref(), Some("company"));
5145 assert!(issue
5146 .suggestion
5147 .as_deref()
5148 .unwrap()
5149 .contains("records/companies/"));
5150 }
5151
5152 #[test]
5153 fn schema_shape_int_and_url_and_currency() {
5154 let mut fx = Fixture::new();
5155 fx.config.schemas.insert(
5156 "widget".into(),
5157 Schema {
5158 fields: vec![
5159 FieldSpec {
5160 name: "qty".into(),
5161 shape: Some(Shape::Int),
5162 ..Default::default()
5163 },
5164 FieldSpec {
5165 name: "site".into(),
5166 shape: Some(Shape::Url),
5167 ..Default::default()
5168 },
5169 FieldSpec {
5170 name: "price".into(),
5171 shape: Some(Shape::Currency),
5172 ..Default::default()
5173 },
5174 ],
5175 ..Default::default()
5176 },
5177 );
5178 fx.write(
5181 "records/widgets/ok.md",
5182 "---\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",
5183 );
5184 fx.write(
5188 "records/widgets/bad.md",
5189 "---\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",
5190 );
5191 let issues = fx.store_all();
5192 let bad_shape: Vec<_> = issues
5193 .iter()
5194 .filter(|i| {
5195 i.code == codes::SCHEMA_SHAPE_MISMATCH
5196 && i.file == Path::new("records/widgets/bad.md")
5197 })
5198 .map(|i| i.key.clone().unwrap_or_default())
5199 .collect();
5200 assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
5201 assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
5202 assert!(
5203 bad_shape.contains(&"price".to_string()),
5204 "inf must be rejected as currency: {issues:#?}"
5205 );
5206 assert!(
5207 !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
5208 && i.file == Path::new("records/widgets/ok.md")),
5209 "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
5210 );
5211 }
5212
5213 #[test]
5214 fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
5215 let mut fx = Fixture::new();
5216 fx.config.schemas.insert(
5217 "contact".into(),
5218 Schema {
5219 fields: vec![
5220 FieldSpec {
5221 name: "email".into(),
5222 required: true,
5223 shape: Some(Shape::Email),
5224 ..Default::default()
5225 },
5226 FieldSpec {
5227 name: "status".into(),
5228 enum_values: Some(vec!["active".into(), "inactive".into()]),
5229 ..Default::default()
5230 },
5231 ],
5232 ..Default::default()
5233 },
5234 );
5235 fx.write(
5239 "records/contacts/bad.md",
5240 "---\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",
5241 );
5242 let issues = fx.store_all();
5243 let mismatched: Vec<_> = issues
5244 .iter()
5245 .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
5246 .map(|i| i.key.clone().unwrap_or_default())
5247 .collect();
5248 assert!(
5249 mismatched.contains(&"email".to_string()),
5250 "list-valued required email must flag: {issues:#?}"
5251 );
5252 assert!(
5253 mismatched.contains(&"status".to_string()),
5254 "list-valued enum must flag: {issues:#?}"
5255 );
5256 }
5257
5258 #[test]
5259 fn is_currency_accepts_codes_and_rejects_non_numeric() {
5260 for ok in [
5262 "100",
5263 "1234.56",
5264 "$1,234.50",
5265 "USD 100", "usd 100", "EUR 9.50",
5268 "£12",
5269 "¥1000",
5270 "-5.00", "+5",
5272 "1,000,000",
5273 ] {
5274 assert!(is_currency(ok), "expected currency: {ok:?}");
5275 }
5276 for bad in [
5279 "inf", "-inf", "infinity", "NaN", "nan", "12.999", "1.2345", "USD", "$", "free", "", " ", "1e3", "1.", ".5", "1 000", "USDD 100", ] {
5290 assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
5291 }
5292 }
5293
5294 #[test]
5297 fn ignored_type_present_is_info() {
5298 let mut fx = Fixture::new();
5299 fx.config.ignored_types.push("temp".into());
5300 fx.write(
5301 "records/temps/x.md",
5302 "---\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",
5303 );
5304 let issues = fx.store_all();
5305 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
5306 assert_eq!(issue.severity, Severity::Info);
5307 assert!(!issue.is_error());
5308 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5309 }
5310
5311 #[test]
5312 fn conclusion_record_derived_from_ignored_type_warns() {
5313 let mut fx = Fixture::new();
5314 fx.config.ignored_types.push("temp".into());
5315 fx.write(
5316 "records/temps/x.md",
5317 "---\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",
5318 );
5319 fx.write(
5323 "records/synthesis/t.md",
5324 "---\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",
5325 );
5326 let issues = fx.store_all();
5327 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
5328 assert_eq!(issue.severity, Severity::Warning);
5329 assert_eq!(issue.key.as_deref(), Some("derived_from"));
5330 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5331 }
5332
5333 #[test]
5341 fn derived_from_ignored_type_is_the_shared_policy_decision() {
5342 let mut fx = Fixture::new();
5343 fx.config.ignored_types.push("secret".into());
5344 fx.write(
5346 "records/secrets/s.md",
5347 "---\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",
5348 );
5349 fx.write(
5351 "records/contacts/c.md",
5352 "---\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",
5353 );
5354 let store = fx.store();
5355
5356 let hit =
5360 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
5361 .expect("conclusion → ignored-type record must match");
5362 assert_eq!(hit.target, "records/secrets/s");
5363 assert_eq!(hit.target_type, "secret");
5364
5365 assert_eq!(
5368 derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
5369 None,
5370 "only conclusion derivation is policed"
5371 );
5372
5373 assert_eq!(
5375 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
5376 None,
5377 "deriving from a non-ignored type is allowed"
5378 );
5379
5380 let hit = derived_from_ignored_type(
5382 &store,
5383 "conclusion",
5384 ["records/contacts/c", "records/secrets/s"],
5385 )
5386 .expect("a later ignored-type target must still be found");
5387 assert_eq!(hit.target, "records/secrets/s");
5388
5389 fx.config.ignored_types.clear();
5391 let store = fx.store();
5392 assert_eq!(
5393 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
5394 None,
5395 "an empty ignored-types policy short-circuits"
5396 );
5397 }
5398
5399 #[test]
5402 fn dup_id_is_hard_error_with_related() {
5403 let fx = Fixture::new();
5404 fx.write(
5405 "records/contacts/a.md",
5406 "---\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",
5407 );
5408 fx.write(
5409 "records/contacts/b.md",
5410 "---\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",
5411 );
5412 let issues = fx.store_all();
5413 assert_eq!(
5416 count(&issues, codes::DUP_ID),
5417 1,
5418 "one issue per group: {issues:#?}"
5419 );
5420 let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
5421 assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
5422 assert!(a.is_error());
5423 assert_eq!(a.key.as_deref(), Some("id"));
5424 assert_eq!(
5425 a.line,
5426 Some(3),
5427 "anchors to the `id` line on the reported file"
5428 );
5429 assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5430 }
5431
5432 #[test]
5433 fn dup_id_not_fired_in_working_set() {
5434 let fx = Fixture::new();
5436 fx.write(
5437 "records/contacts/a.md",
5438 "---\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",
5439 );
5440 fx.write(
5441 "records/contacts/b.md",
5442 "---\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",
5443 );
5444 fx.write(
5446 "log.md",
5447 "---\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",
5448 );
5449 let issues = validate_working_set(&fx.store(), None).unwrap();
5450 assert!(
5451 !has(&issues, codes::DUP_ID),
5452 "DUP_ID is --all only: {issues:#?}"
5453 );
5454 }
5455
5456 #[test]
5457 fn dup_unique_key_single_field_is_warning() {
5458 let mut fx = Fixture::new();
5459 fx.config.schemas.insert(
5461 "contact".into(),
5462 Schema {
5463 unique_keys: vec![vec!["email".into()]],
5464 ..Default::default()
5465 },
5466 );
5467 for (f, name) in [("a", "A"), ("b", "B")] {
5468 fx.write(
5469 &format!("records/contacts/{f}.md"),
5470 &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"),
5471 );
5472 }
5473 let issues = fx.store_all();
5474 assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5477 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5478 assert_eq!(dup.severity, Severity::Warning);
5479 assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5480 assert_eq!(dup.key.as_deref(), Some("email"));
5481 assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5482 }
5483
5484 #[test]
5485 fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5486 let mut fx = Fixture::new();
5487 fx.config.schemas.insert(
5489 "expense".into(),
5490 Schema {
5491 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5492 ..Default::default()
5493 },
5494 );
5495 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");
5496 let exp = |f: &str, amount: &str| {
5497 format!(
5498 "---\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"
5499 )
5500 };
5501 fx.write("records/expenses/e1.md", &exp("e1", "100"));
5502 fx.write("records/expenses/e2.md", &exp("e2", "100"));
5503 fx.write("records/expenses/e3.md", &exp("e3", "200")); let issues = fx.store_all();
5505 assert_eq!(
5508 count(&issues, codes::DUP_UNIQUE_KEY),
5509 1,
5510 "only e1+e2 collide, one issue: {issues:#?}"
5511 );
5512 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5513 assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5514 assert_eq!(
5515 dup.line,
5516 Some(1),
5517 "compound-key collision anchors to line 1"
5518 );
5519 assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5520 assert!(
5521 !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5522 && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5523 "e3 differs on amount and must not collide: {issues:#?}"
5524 );
5525 }
5526
5527 #[test]
5528 fn dup_unique_key_list_field_is_order_independent() {
5529 let mut fx = Fixture::new();
5530 fx.config.schemas.insert(
5532 "meeting".into(),
5533 Schema {
5534 unique_keys: vec![vec!["date".into(), "attendees".into()]],
5535 ..Default::default()
5536 },
5537 );
5538 fx.write("records/contacts/a.md", &valid_contact("a"));
5539 fx.write("records/contacts/b.md", &valid_contact("b"));
5540 let m = |f: &str, order: &str| {
5541 let attendees = if order == "ab" {
5542 " - [[records/contacts/a]]\n - [[records/contacts/b]]"
5543 } else {
5544 " - [[records/contacts/b]]\n - [[records/contacts/a]]"
5545 };
5546 format!(
5547 "---\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"
5548 )
5549 };
5550 fx.write("records/meetings/m1.md", &m("m1", "ab"));
5551 fx.write("records/meetings/m2.md", &m("m2", "ba"));
5552 let issues = fx.store_all();
5553 assert_eq!(
5556 count(&issues, codes::DUP_UNIQUE_KEY),
5557 1,
5558 "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5559 );
5560 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5561 assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5562 assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5563 }
5564
5565 #[test]
5568 fn missing_indexes_at_all_three_levels() {
5569 let fx = Fixture::new();
5570 fx.write("records/contacts/a.md", &valid_contact("a"));
5571 let issues = fx.store_all();
5572 let missing_files: BTreeSet<PathBuf> = issues
5576 .iter()
5577 .filter(|i| i.code == codes::INDEX_MISSING)
5578 .map(|i| i.file.clone())
5579 .collect();
5580 assert!(
5581 missing_files.contains(&PathBuf::from("index.md")),
5582 "{issues:#?}"
5583 );
5584 assert!(
5585 missing_files.contains(&PathBuf::from("records/index.md")),
5586 "{issues:#?}"
5587 );
5588 assert!(
5589 missing_files.contains(&PathBuf::from("records/contacts")),
5590 "{issues:#?}"
5591 );
5592 assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5595 }
5596
5597 #[test]
5598 fn index_stale_entry_and_missing_entry() {
5599 let fx = Fixture::new();
5600 fx.write(
5601 "records/contacts/present.md",
5602 &valid_contact("present contact"),
5603 );
5604 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5606 fx.write(
5607 "records/index.md",
5608 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5609 );
5610 fx.write(
5612 "records/contacts/index.md",
5613 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5614 );
5615 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5616 let issues = fx.store_all();
5617 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5618 assert!(stale.message.contains("ghost"));
5619 assert!(stale.is_error());
5620 let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5621 assert!(
5622 missing.message.contains("present.md"),
5623 "{}",
5624 missing.message
5625 );
5626 }
5627
5628 #[test]
5629 fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5630 let fx = Fixture::new();
5631 fx.write("records/contacts/a.md", &valid_contact("a"));
5632 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5633 fx.write(
5634 "records/index.md",
5635 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5636 );
5637 fx.write(
5638 "records/contacts/index.md",
5639 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5640 );
5641 fx.write(
5642 "records/contacts/index.jsonl",
5643 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5644 );
5645 let issues = fx.store_all();
5646 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5647 assert!(stale.message.contains("not a safe store-relative path"));
5648 }
5649
5650 #[test]
5651 fn index_summary_mismatch() {
5652 let fx = Fixture::new();
5653 fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5654 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5655 fx.write(
5656 "records/index.md",
5657 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5658 );
5659 fx.write(
5660 "records/contacts/index.md",
5661 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5662 );
5663 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5664 let issues = fx.store_all();
5665 let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5666 assert!(issue.is_error());
5667 assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5668 }
5669
5670 #[test]
5671 fn index_summary_match_passes() {
5672 let fx = Fixture::new();
5673 fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5674 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5675 fx.write(
5676 "records/index.md",
5677 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5678 );
5679 fx.write(
5680 "records/contacts/index.md",
5681 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5682 );
5683 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5684 let issues = fx.store_all();
5685 assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5686 }
5687
5688 #[test]
5689 fn index_entry_with_tag_suffix_matches_summary() {
5690 let fx = Fixture::new();
5691 fx.write("records/contacts/a.md", &valid_contact("clean summary"));
5692 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5693 fx.write(
5694 "records/index.md",
5695 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5696 );
5697 fx.write(
5701 "records/contacts/index.md",
5702 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary · #customer\n",
5703 );
5704 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5705 let issues = fx.store_all();
5706 assert!(
5707 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5708 "tag suffix should be stripped: {issues:#?}"
5709 );
5710 }
5711
5712 #[test]
5713 fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5714 let fx = Fixture::new();
5721 fx.write(
5722 "records/contacts/a.md",
5723 &valid_contact("Standup notes · #standup"),
5724 );
5725 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5726 fx.write(
5727 "records/index.md",
5728 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5729 );
5730 fx.write(
5731 "records/contacts/index.md",
5732 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5733 );
5734 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5735 let issues = fx.store_all();
5736 assert!(
5737 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5738 "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5739 );
5740 }
5741
5742 #[test]
5743 fn index_jsonl_desync_missing_file_in_jsonl() {
5744 let fx = Fixture::new();
5745 fx.write("records/contacts/a.md", &valid_contact("a"));
5746 fx.write("records/contacts/b.md", &valid_contact("b"));
5747 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5748 fx.write(
5749 "records/index.md",
5750 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5751 );
5752 fx.write(
5753 "records/contacts/index.md",
5754 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5755 );
5756 fx.write(
5758 "records/contacts/index.jsonl",
5759 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5760 );
5761 let issues = fx.store_all();
5762 let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5763 assert!(desync.message.contains("b.md"), "{}", desync.message);
5764 }
5765
5766 #[test]
5767 fn index_jsonl_desync_record_points_at_missing_file() {
5768 let fx = Fixture::new();
5769 fx.write("records/contacts/a.md", &valid_contact("a"));
5770 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5771 fx.write(
5772 "records/index.md",
5773 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5774 );
5775 fx.write(
5776 "records/contacts/index.md",
5777 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5778 );
5779 fx.write(
5780 "records/contacts/index.jsonl",
5781 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5782 );
5783 let issues = fx.store_all();
5784 assert!(
5785 issues
5786 .iter()
5787 .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5788 "{issues:#?}"
5789 );
5790 }
5791
5792 #[test]
5793 fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5794 let fx = Fixture::new();
5795 fx.write("records/contacts/a.md", &valid_contact("a"));
5796 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5797 fx.write(
5798 "records/index.md",
5799 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5800 );
5801 fx.write(
5802 "records/contacts/index.md",
5803 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5804 );
5805 fx.write(
5806 "records/contacts/index.jsonl",
5807 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5808 );
5809 let issues = fx.store_all();
5810 assert!(
5811 issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5812 && i.message.contains("not a safe store-relative path")),
5813 "{issues:#?}"
5814 );
5815 }
5816
5817 #[test]
5818 fn index_jsonl_stale_summary() {
5819 let fx = Fixture::new();
5820 fx.write("records/contacts/a.md", &valid_contact("real summary"));
5821 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5822 fx.write(
5823 "records/index.md",
5824 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5825 );
5826 fx.write(
5827 "records/contacts/index.md",
5828 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5829 );
5830 fx.write(
5832 "records/contacts/index.jsonl",
5833 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5834 );
5835 let issues = fx.store_all();
5836 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5837 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5838 assert!(stale.key.as_deref().unwrap().contains("summary"));
5839 }
5840
5841 #[test]
5849 fn index_jsonl_stale_queryable_field_email() {
5850 let fx = Fixture::new();
5851 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";
5852 fx.write("records/contacts/a.md", contact);
5853 fx.rebuild_indexes();
5855 let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5856 let good = fs::read_to_string(&jsonl_path).unwrap();
5857 assert!(
5859 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5860 "freshly-rebuilt sidecar must not be stale"
5861 );
5862 assert!(
5864 good.contains("real@correct.com"),
5865 "sidecar projects email: {good}"
5866 );
5867 fx.write(
5868 "records/contacts/index.jsonl",
5869 &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5870 );
5871
5872 let issues = fx.store_all();
5873 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5874 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5875 let key = stale.key.as_deref().unwrap();
5878 assert!(
5879 key.contains("email"),
5880 "expected `email` in stale key, got {key:?}"
5881 );
5882 assert!(!key.contains("summary"), "summary still matches: {key:?}");
5883 assert!(!key.contains("type"), "type still matches: {key:?}");
5884 }
5885
5886 #[test]
5890 fn index_jsonl_stale_typed_and_list_fields() {
5891 let fx = Fixture::new();
5892 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";
5893 fx.write("records/expenses/e.md", expense);
5894 fx.rebuild_indexes();
5895 let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5896 let good = fs::read_to_string(&jsonl_path).unwrap();
5897 assert!(
5898 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5899 "freshly-rebuilt sidecar must not be stale"
5900 );
5901 let stale_line = good
5903 .replace("\"q2\"", "\"WRONG-TAG\"")
5904 .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5905 .replace("1299", "9999");
5906 fx.write("records/expenses/index.jsonl", &stale_line);
5907
5908 let issues = fx.store_all();
5909 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5910 let key = stale.key.as_deref().unwrap();
5911 for expected in ["amount", "tags", "updated"] {
5912 assert!(
5913 key.contains(expected),
5914 "expected `{expected}` in stale key, got {key:?}"
5915 );
5916 }
5917 }
5918
5919 #[test]
5920 fn index_orphan_in_noncanonical_folder() {
5921 let fx = Fixture::new();
5922 fx.write("records/contacts/a.md", &valid_contact("a"));
5923 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5925 fx.write(
5926 "records/index.md",
5927 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5928 );
5929 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5930 fx.write(
5931 "records/contacts/index.jsonl",
5932 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5933 );
5934 fx.write(
5936 "records/contacts/subfolder/index.md",
5937 "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5938 );
5939 let issues = fx.store_all();
5940 let orphan = find(&issues, codes::INDEX_ORPHAN);
5941 assert_eq!(orphan.severity, Severity::Warning);
5942 assert_eq!(
5943 orphan.file,
5944 PathBuf::from("records/contacts/subfolder/index.md")
5945 );
5946 }
5947
5948 #[test]
5949 fn index_wrong_scope() {
5950 let fx = Fixture::new();
5951 fx.write("records/contacts/a.md", &valid_contact("a"));
5952 fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5954 fx.write(
5955 "records/index.md",
5956 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5957 );
5958 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5959 fx.write(
5960 "records/contacts/index.jsonl",
5961 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5962 );
5963 let issues = fx.store_all();
5964 let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5965 assert_eq!(issue.severity, Severity::Warning);
5966 assert_eq!(issue.file, PathBuf::from("index.md"));
5967 }
5968
5969 #[test]
5970 fn capped_type_folder_index_does_not_flag_missing_entries() {
5971 let fx = Fixture::new();
5973 for i in 0..501 {
5974 fx.write(
5975 &format!("records/contacts/c{i:04}.md"),
5976 &valid_contact(&format!("contact {i}")),
5977 );
5978 }
5979 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
5980 fx.write(
5981 "records/index.md",
5982 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5983 );
5984 fx.write(
5986 "records/contacts/index.md",
5987 "---\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",
5988 );
5989 let mut jsonl = String::new();
5991 for i in 0..501 {
5992 jsonl.push_str(&format!(
5993 "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
5994 ));
5995 }
5996 fx.write("records/contacts/index.jsonl", &jsonl);
5997 let issues = fx.store_all();
5998 assert!(
5999 !has(&issues, codes::INDEX_MISSING_ENTRY),
6000 "over the cap, missing browse entries are expected: {issues:#?}"
6001 );
6002 assert!(
6004 !has(&issues, codes::INDEX_JSONL_DESYNC),
6005 "{:#?}",
6006 issues
6007 .iter()
6008 .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
6009 .collect::<Vec<_>>()
6010 );
6011 }
6012
6013 #[test]
6016 fn log_bad_timestamp_unknown_kind_out_of_order() {
6017 let fx = Fixture::new();
6018 fx.write(
6019 "log.md",
6020 concat!(
6021 "---\ntype: log\n---\n\n# Log\n\n",
6022 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6023 "## [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", ),
6027 );
6028 let issues = fx.store_all();
6029 assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6030 assert_eq!(
6031 find(&issues, codes::LOG_OUT_OF_ORDER).severity,
6032 Severity::Warning
6033 );
6034 let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
6035 assert_eq!(unknown.severity, Severity::Warning);
6036 assert!(unknown.message.contains("frobnicate"));
6037 assert!(unknown
6038 .suggestion
6039 .as_deref()
6040 .is_some_and(|s| s.contains("create")));
6041 let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
6042 assert!(bad.is_error());
6043 }
6044
6045 #[test]
6046 fn log_validate_entry_without_object_is_well_formed() {
6047 let fx = Fixture::new();
6048 fx.write(
6049 "log.md",
6050 "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
6051 );
6052 let issues = fx.store_all();
6053 assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
6054 assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
6055 }
6056
6057 #[test]
6058 fn log_in_order_is_clean() {
6059 let fx = Fixture::new();
6060 fx.write(
6061 "log.md",
6062 concat!(
6063 "---\ntype: log\n---\n\n",
6064 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6065 "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
6066 ),
6067 );
6068 let issues = fx.store_all();
6069 assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
6070 }
6071
6072 #[test]
6073 fn log_not_checked_in_working_set() {
6074 let fx = Fixture::new();
6076 fx.write(
6077 "log.md",
6078 concat!(
6079 "---\ntype: log\n---\n\n",
6080 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
6081 "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
6082 ),
6083 );
6084 let issues = validate_working_set(&fx.store(), None).unwrap();
6085 assert!(
6086 !has(&issues, codes::LOG_OUT_OF_ORDER),
6087 "log ordering is --all only: {issues:#?}"
6088 );
6089 }
6090
6091 #[test]
6094 fn working_set_validates_only_changed_files() {
6095 let fx = Fixture::new();
6096 fx.write(
6099 "records/contacts/dirty.md",
6100 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6101 );
6102 fx.write(
6103 "records/contacts/unlogged.md",
6104 "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6105 );
6106 fx.write(
6107 "log.md",
6108 "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
6109 );
6110 let issues = validate_working_set(&fx.store(), None).unwrap();
6111 assert!(
6112 issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
6113 && i.file == Path::new("records/contacts/dirty.md")),
6114 "{issues:#?}"
6115 );
6116 assert!(
6117 !issues
6118 .iter()
6119 .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
6120 "unlogged file must not be in the working set: {issues:#?}"
6121 );
6122 }
6123
6124 #[test]
6125 fn working_set_includes_incoming_linkers_to_changed_path() {
6126 let fx = Fixture::new();
6127 fx.write(
6130 "records/profiles/linker.md",
6131 "---\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",
6132 );
6133 fx.write(
6135 "log.md",
6136 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
6137 );
6138 let issues = validate_working_set(&fx.store(), None).unwrap();
6139 assert!(
6140 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
6141 && i.file == Path::new("records/profiles/linker.md")),
6142 "incoming linker to a removed path must be validated: {issues:#?}"
6143 );
6144 }
6145
6146 #[test]
6147 fn working_set_respects_explicit_since_cutoff() {
6148 let fx = Fixture::new();
6149 fx.write(
6150 "records/contacts/old.md",
6151 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6152 );
6153 fx.write(
6154 "records/contacts/new.md",
6155 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6156 );
6157 fx.write(
6158 "log.md",
6159 concat!(
6160 "---\ntype: log\n---\n\n",
6161 "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
6162 "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
6163 ),
6164 );
6165 let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
6167 let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
6168 assert!(
6169 issues
6170 .iter()
6171 .any(|i| i.file == Path::new("records/contacts/new.md")),
6172 "{issues:#?}"
6173 );
6174 assert!(
6175 !issues
6176 .iter()
6177 .any(|i| i.file == Path::new("records/contacts/old.md")),
6178 "old change is before the cutoff: {issues:#?}"
6179 );
6180 }
6181
6182 #[test]
6183 fn working_set_default_since_is_last_validate_entry() {
6184 let fx = Fixture::new();
6185 fx.write(
6187 "records/contacts/before.md",
6188 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6189 );
6190 fx.write(
6191 "records/contacts/after.md",
6192 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6193 );
6194 fx.write(
6195 "log.md",
6196 concat!(
6197 "---\ntype: log\n---\n\n",
6198 "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
6199 "## [2026-05-21 10:00] validate\nPASS\n\n",
6200 "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
6201 ),
6202 );
6203 let issues = validate_working_set(&fx.store(), None).unwrap();
6204 assert!(
6205 issues
6206 .iter()
6207 .any(|i| i.file == Path::new("records/contacts/after.md")),
6208 "{issues:#?}"
6209 );
6210 assert!(
6211 !issues
6212 .iter()
6213 .any(|i| i.file == Path::new("records/contacts/before.md")),
6214 "change before the last validate entry is outside the default window: {issues:#?}"
6215 );
6216 }
6217
6218 #[test]
6221 fn issues_are_sorted_by_file_then_line() {
6222 let fx = Fixture::new();
6223 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");
6224 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");
6225 let issues = fx.store_all();
6226 let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
6227 let mut sorted = files.clone();
6228 sorted.sort();
6229 assert_eq!(
6230 files, sorted,
6231 "issues must be emitted in a stable file order"
6232 );
6233 }
6234
6235 #[test]
6238 fn frozen_page_is_not_a_validate_error() {
6239 let mut fx = Fixture::new();
6242 fx.config
6243 .frozen_pages
6244 .push(PathBuf::from("records/decisions/d.md"));
6245 fx.write(
6246 "records/decisions/d.md",
6247 "---\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",
6248 );
6249 let issues = fx.store_all();
6250 assert!(
6251 !has(&issues, codes::POLICY_FROZEN_PAGE),
6252 "frozen pages are enforced at write-time, not by validate: {issues:#?}"
6253 );
6254 }
6255
6256 #[test]
6257 fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
6258 let fx = Fixture::new();
6261 fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
6262 let mut body = valid_contact("links to sarah");
6263 body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
6264 fx.write("records/contacts/p.md", &body);
6265 let issues = fx.store_all();
6266 assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
6267 }
6268
6269 #[test]
6272 fn unknown_type_passes_through() {
6273 let fx = Fixture::new();
6277 fx.write(
6278 "records/proposals/x.md",
6279 "---\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",
6280 );
6281 let issues = fx.store_all();
6282 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
6283 assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
6284 assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
6285 assert!(
6287 !issues
6288 .iter()
6289 .any(|i| i.key.as_deref() == Some("custom_field")
6290 || i.key.as_deref() == Some("budget")),
6291 "unknown fields are ambient context: {issues:#?}"
6292 );
6293 }
6294
6295 #[test]
6298 fn incoming_linker_scan_does_not_prefix_match() {
6299 let fx = Fixture::new();
6302 fx.write(
6303 "records/profiles/only-sarah-chen.md",
6304 "---\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",
6305 );
6306 fx.write(
6308 "log.md",
6309 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
6310 );
6311 let issues = validate_working_set(&fx.store(), None).unwrap();
6312 assert!(
6313 !issues
6314 .iter()
6315 .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
6316 "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
6317 );
6318 }
6319
6320 #[test]
6321 fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
6322 let fx = Fixture::new();
6336 fx.write(
6339 "records/contacts/index.md",
6340 "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
6341 );
6342 fx.write(
6344 "log.md",
6345 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
6346 );
6347 let issues = validate_working_set(&fx.store(), None).unwrap();
6348 assert!(
6349 !issues
6350 .iter()
6351 .any(|i| i.file == Path::new("records/contacts/index.md")
6352 && i.code == codes::WIKI_LINK_BROKEN),
6353 "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
6354 working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
6355 );
6356 }
6357
6358 #[test]
6359 fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
6360 let fx = Fixture::new();
6369 fx.write(
6371 "records/profiles/refers-sarah.md",
6372 "---\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",
6373 );
6374 fx.write(
6378 "records/meetings/2026/05/kickoff.md",
6379 "---\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",
6380 );
6381 fx.write(
6383 "log.md",
6384 "---\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",
6385 );
6386
6387 let issues = validate_working_set(&fx.store(), None).unwrap();
6388 assert!(
6389 issues
6390 .iter()
6391 .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
6392 && i.code == codes::WIKI_LINK_BROKEN),
6393 "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
6394 );
6395 assert!(
6396 issues.iter().any(
6397 |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
6398 && i.code == codes::WIKI_LINK_BROKEN
6399 ),
6400 "linker to the SECOND deleted target (typed-field edge) must also be \
6401 pulled in and flagged — proves the scan covers the whole changed set, \
6402 not just one object: {issues:#?}"
6403 );
6404 }
6405
6406 #[test]
6407 fn frontmatter_block_sequence_links_each_get_their_own_line() {
6408 let fx = Fixture::new();
6410 fx.write(
6412 "records/meetings/m.md",
6413 "---\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",
6414 );
6415 let issues = fx.store_all();
6416 let broken_lines: BTreeSet<Option<u32>> = issues
6417 .iter()
6418 .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
6419 .map(|i| i.line)
6420 .collect();
6421 assert_eq!(
6422 broken_lines.len(),
6423 2,
6424 "two distinct broken-link lines: {issues:#?}"
6425 );
6426 }
6427
6428 #[test]
6431 fn null_created_is_missing_not_silently_passed() {
6432 let fx = Fixture::new();
6436 fx.write(
6437 "records/contacts/a.md",
6438 "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6439 );
6440 let issues = fx.store_all();
6441 assert!(
6442 has(&issues, codes::FM_MISSING_CREATED),
6443 "null `created:` must read as missing: {issues:#?}"
6444 );
6445 }
6446
6447 #[test]
6448 fn sequence_created_is_bad_timestamp() {
6449 let fx = Fixture::new();
6451 fx.write(
6452 "records/contacts/a.md",
6453 "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6454 );
6455 let issues = fx.store_all();
6456 assert!(
6457 issues
6458 .iter()
6459 .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6460 "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6461 );
6462 }
6463
6464 #[test]
6467 fn required_field_null_or_empty_collection_is_missing() {
6468 for value in ["", " []", " {}"] {
6473 let mut fx = Fixture::new();
6474 fx.config.schemas.insert(
6475 "contact".into(),
6476 Schema {
6477 fields: vec![FieldSpec {
6478 name: "name".into(),
6479 required: true,
6480 ..Default::default()
6481 }],
6482 ..Default::default()
6483 },
6484 );
6485 fx.write(
6486 "records/contacts/a.md",
6487 &format!(
6488 "---\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"
6489 ),
6490 );
6491 let issues = fx.store_all();
6492 assert!(
6493 issues
6494 .iter()
6495 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6496 && i.key.as_deref() == Some("name")),
6497 "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6498 );
6499 }
6500 }
6501
6502 #[test]
6505 fn wiki_link_to_raw_source_file_resolves() {
6506 let fx = Fixture::new();
6510 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6511 fx.write(
6512 "records/contacts/a.md",
6513 "---\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",
6514 );
6515 let issues = fx.store_all();
6516 assert!(
6517 !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6518 "a link to an existing raw source file must not be broken: {issues:#?}"
6519 );
6520 }
6521
6522 #[test]
6525 fn wrong_case_wiki_link_is_broken_exact_case() {
6526 let fx = Fixture::new();
6532 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6533 let mut body = valid_contact("links with the wrong case");
6534 body.push_str("\nKnows [[records/contacts/BOB]].\n");
6535 fx.write("records/contacts/alice.md", &body);
6536 let issues = fx.store_all();
6537 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6538 assert!(issue.is_error());
6539 assert!(
6540 issue.message.contains("records/contacts/BOB"),
6541 "the wrong-case target must be named in the issue: {issues:#?}"
6542 );
6543 }
6544
6545 #[test]
6546 fn correct_case_wiki_link_still_resolves() {
6547 let fx = Fixture::new();
6551 fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6552 let mut body = valid_contact("links with the right case");
6553 body.push_str("\nKnows [[records/contacts/bob]].\n");
6554 fx.write("records/contacts/alice.md", &body);
6555 let issues = fx.store_all();
6556 assert!(
6557 !issues
6558 .iter()
6559 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("contacts/bob")),
6560 "a correct-case link must resolve clean: {issues:#?}"
6561 );
6562 }
6563
6564 #[test]
6565 fn wrong_case_raw_source_wiki_link_is_broken() {
6566 let fx = Fixture::new();
6571 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6572 fx.write(
6573 "records/contacts/a.md",
6574 "---\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",
6575 );
6576 let issues = fx.store_all();
6577 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6578 assert!(issue.is_error());
6579 assert!(
6580 issue.message.contains("2026-05-22-ELENA.eml"),
6581 "the wrong-case raw-source target must be flagged: {issues:#?}"
6582 );
6583 }
6584
6585 #[test]
6588 fn non_utf8_content_file_is_reported() {
6589 let fx = Fixture::new();
6593 let abs = fx.dir.path().join("records/notes/corrupt.md");
6594 fs::create_dir_all(abs.parent().unwrap()).unwrap();
6595 fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6596 let issues = validate_working_set(&fx.store(), None).unwrap();
6597 assert!(
6598 has(&issues, codes::FM_UNREADABLE),
6599 "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6600 );
6601 }
6602
6603 #[test]
6606 fn tilde_fence_containing_backtick_fence_does_not_invert() {
6607 let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6612 let links = extract_wiki_links(body);
6613 assert!(
6614 links.is_empty(),
6615 "wiki-link inside a nested code fence must be skipped: {links:?}"
6616 );
6617 }
6618
6619 #[test]
6622 fn all_sweep_visits_in_layer_log_folder() {
6623 let fx = Fixture::new();
6628 fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6629 let issues = fx.store_all();
6630 assert!(
6631 has(&issues, codes::FM_MISSING_TYPE),
6632 "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6633 );
6634 }
6635
6636 #[test]
6639 fn flow_form_link_list_with_spaces_is_flagged() {
6640 let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6644 assert!(
6645 keys.iter().any(|k| k == "attendees"),
6646 "spaced flow-form list must be detected: {keys:?}"
6647 );
6648 }
6649
6650 #[test]
6653 fn middot_hashtag_summary_tail_round_trips() {
6654 assert_eq!(
6660 extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6661 Some("Standup notes · #standup"),
6662 "a single-spaced middot tail is part of the summary, not a tag block"
6663 );
6664 assert_eq!(
6666 extract_index_entry_summary("— Renewal champion · #renewal #acme").as_deref(),
6667 Some("Renewal champion"),
6668 "the renderer's double-spaced ` · #tag` suffix is stripped"
6669 );
6670 }
6671
6672 #[test]
6675 fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6676 assert!(is_url("http://x"), "an 8-char http URL is valid");
6677 assert!(is_url("https://x"), "a 9-char https URL is valid");
6678 assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6679 assert!(!is_url("https://"), "a bare https scheme is rejected");
6680 }
6681
6682 #[test]
6683 fn email_shape_rejects_double_at() {
6684 assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6685 assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6686 assert!(is_email("sarah@acme.com"), "a normal address still passes");
6687 }
6688
6689 #[test]
6692 fn working_set_does_not_flag_log_md_body_links() {
6693 let fx = Fixture::new();
6699 fx.write("records/contacts/a.md", &valid_contact("A"));
6700 fx.write(
6701 "log.md",
6702 "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6703 );
6704 let issues = validate_working_set(&fx.store(), None).unwrap();
6705 assert!(
6706 !issues
6707 .iter()
6708 .any(|i| i.code == codes::WIKI_LINK_BROKEN
6709 && i.file == std::path::Path::new("log.md")),
6710 "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6711 );
6712 }
6713
6714 #[test]
6717 fn schema_duplicate_field_name_is_flagged() {
6718 let mut fx = Fixture::new();
6719 fx.config.schemas.insert(
6720 "contact".into(),
6721 Schema {
6722 fields: vec![
6723 FieldSpec {
6724 name: "name".into(),
6725 required: true,
6726 ..Default::default()
6727 },
6728 FieldSpec {
6729 name: "name".into(),
6730 ..Default::default()
6731 },
6732 ],
6733 ..Default::default()
6734 },
6735 );
6736 let issues = fx.store_all();
6737 assert!(
6738 issues
6739 .iter()
6740 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6741 "a duplicate schema field name must be flagged: {issues:#?}"
6742 );
6743 }
6744
6745 #[test]
6746 fn schema_unknown_modifier_is_info() {
6747 let mut fx = Fixture::new();
6748 fx.config.schemas.insert(
6749 "contact".into(),
6750 Schema {
6751 fields: vec![FieldSpec {
6752 name: "name".into(),
6753 unknown_modifiers: vec!["requierd".into()],
6754 ..Default::default()
6755 }],
6756 ..Default::default()
6757 },
6758 );
6759 let issues = fx.store_all();
6760 assert!(
6761 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6762 && i.severity == Severity::Info
6763 && i.key.as_deref() == Some("name")),
6764 "an unrecognized schema modifier must surface as Info: {issues:#?}"
6765 );
6766 }
6767
6768 #[test]
6774 fn schema_unique_key_optional_field_is_warning() {
6775 let mut fx = Fixture::new();
6776 fx.config.schemas.insert(
6777 "expense".into(),
6778 Schema {
6779 fields: vec![
6780 FieldSpec {
6781 name: "date".into(),
6782 required: true,
6783 ..Default::default()
6784 },
6785 FieldSpec {
6786 name: "amount".into(),
6787 required: true,
6788 ..Default::default()
6789 },
6790 FieldSpec {
6791 name: "vendor".into(),
6792 ..Default::default()
6793 },
6794 ],
6795 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
6796 ..Default::default()
6797 },
6798 );
6799 let issues = fx.store_all();
6800 assert!(
6801 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6802 && i.severity == Severity::Warning
6803 && i.key.as_deref() == Some("vendor")
6804 && i.message.contains("unique")),
6805 "a `unique:` key field not marked required must warn: {issues:#?}"
6806 );
6807 assert!(
6809 !issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6810 && matches!(i.key.as_deref(), Some("date") | Some("amount"))),
6811 "required key fields must not warn: {issues:#?}"
6812 );
6813 }
6814
6815 #[test]
6820 fn body_leading_frontmatter_block_is_warning() {
6821 let fx = Fixture::new();
6822 fx.write(
6823 "records/notes/imported.md",
6824 "---\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",
6825 );
6826 let issues = fx.store_all();
6827 assert!(
6828 issues
6829 .iter()
6830 .any(|i| i.code == codes::FM_IN_BODY && i.severity == Severity::Warning),
6831 "a body opening with a second frontmatter block must warn: {issues:#?}"
6832 );
6833 }
6834
6835 #[test]
6838 fn body_thematic_break_rules_do_not_warn() {
6839 let fx = Fixture::new();
6840 fx.write(
6841 "records/notes/rules.md",
6842 "---\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",
6843 );
6844 let issues = fx.store_all();
6845 assert!(
6846 !has(&issues, codes::FM_IN_BODY),
6847 "a `---` thematic rule around prose (not a YAML mapping) must NOT warn: {issues:#?}"
6848 );
6849 }
6850
6851 #[test]
6855 fn body_fenced_frontmatter_example_does_not_warn() {
6856 let fx = Fixture::new();
6857 fx.write(
6858 "records/notes/doc.md",
6859 "---\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",
6860 );
6861 let issues = fx.store_all();
6862 assert!(
6863 !has(&issues, codes::FM_IN_BODY),
6864 "a fenced example block (body opens with a code fence, not `---`) must NOT warn: {issues:#?}"
6865 );
6866 }
6867
6868 #[test]
6871 fn schema_unique_key_undeclared_field_is_warning() {
6872 let mut fx = Fixture::new();
6873 fx.config.schemas.insert(
6874 "expense".into(),
6875 Schema {
6876 fields: vec![FieldSpec {
6877 name: "date".into(),
6878 required: true,
6879 ..Default::default()
6880 }],
6881 unique_keys: vec![vec!["date".into(), "vendor".into()]],
6882 ..Default::default()
6883 },
6884 );
6885 let issues = fx.store_all();
6886 assert!(
6887 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6888 && i.severity == Severity::Warning
6889 && i.key.as_deref() == Some("vendor")
6890 && i.message.contains("not declared")),
6891 "a `unique:` key field absent from the schema must warn: {issues:#?}"
6892 );
6893 }
6894
6895 #[test]
6897 fn schema_unique_key_all_required_is_clean() {
6898 let mut fx = Fixture::new();
6899 fx.config.schemas.insert(
6900 "expense".into(),
6901 Schema {
6902 fields: vec![
6903 FieldSpec {
6904 name: "date".into(),
6905 required: true,
6906 ..Default::default()
6907 },
6908 FieldSpec {
6909 name: "amount".into(),
6910 required: true,
6911 ..Default::default()
6912 },
6913 ],
6914 unique_keys: vec![vec!["date".into(), "amount".into()]],
6915 ..Default::default()
6916 },
6917 );
6918 let issues = fx.store_all();
6919 assert!(
6920 !issues
6921 .iter()
6922 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.message.contains("unique")),
6923 "an all-required unique key must not warn: {issues:#?}"
6924 );
6925 }
6926
6927 #[test]
6933 fn every_code_constant_is_documented_in_spec() {
6934 let this_src = include_str!("validate.rs");
6938 let mut codes_in_module: Vec<String> = Vec::new();
6939 let mut in_codes_mod = false;
6940 for line in this_src.lines() {
6941 let t = line.trim();
6942 if t.starts_with("pub mod codes") {
6943 in_codes_mod = true;
6944 continue;
6945 }
6946 if in_codes_mod && line == "}" {
6948 break;
6949 }
6950 if in_codes_mod {
6951 if let Some(rest) = t.strip_prefix("pub const ") {
6952 let value = rest
6954 .split_once('=')
6955 .map(|(_, v)| v.trim())
6956 .and_then(|v| v.strip_prefix('"'))
6957 .and_then(|v| v.strip_suffix("\";"))
6958 .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6959 codes_in_module.push(value.to_string());
6960 }
6961 }
6962 }
6963 assert!(
6964 codes_in_module.len() >= 36,
6965 "parsed only {} code constants from `mod codes`; the parser likely \
6966 broke against a source-format change",
6967 codes_in_module.len()
6968 );
6969
6970 let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6972 let spec = fs::read_to_string(&spec_path)
6973 .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6974
6975 let missing: Vec<&String> = codes_in_module
6977 .iter()
6978 .filter(|code| !spec.contains(&format!("| `{code}` |")))
6979 .collect();
6980 assert!(
6981 missing.is_empty(),
6982 "validation codes emitted by the engine but absent from SPEC.md \
6983 § Validation (the declared complete vocabulary): {missing:?}"
6984 );
6985 }
6986
6987 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";
6990 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";
6991
6992 #[test]
6993 fn loose_file_catalogued_in_layer_jsonl_validates_clean() {
6994 let fx = Fixture::new();
6995 fx.write("records/contacts/alice.md", LOOSE_ALICE);
6996 fx.write("records/bob.md", LOOSE_BOB); fx.rebuild_indexes();
6998 let issues = fx.store_all();
6999 assert!(
7000 issues.is_empty(),
7001 "a rebuilt store with a catalogued loose file must validate clean, got: {issues:?}"
7002 );
7003 }
7004
7005 #[test]
7006 fn loose_file_with_missing_layer_jsonl_is_index_jsonl_missing() {
7007 let fx = Fixture::new();
7008 fx.write("records/contacts/alice.md", LOOSE_ALICE);
7009 fx.write("records/bob.md", LOOSE_BOB);
7010 fx.rebuild_indexes();
7011 fs::remove_file(fx.dir.path().join("records/index.jsonl")).unwrap();
7013 let issues = fx.store_all();
7014 assert!(
7015 has(&issues, codes::INDEX_JSONL_MISSING),
7016 "a loose file with no layer index.jsonl must raise INDEX_JSONL_MISSING, got: {issues:?}"
7017 );
7018 }
7019
7020 #[cfg(unix)]
7021 #[test]
7022 fn validation_reads_opened_root_after_path_replacement() {
7023 use std::os::unix::fs::symlink;
7024
7025 let sandbox = tempfile::tempdir().unwrap();
7026 let root = sandbox.path().join("store");
7027 fs::create_dir_all(root.join("records/notes")).unwrap();
7028 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7029 fs::write(
7030 root.join("records/notes/owned.md"),
7031 "---\ntype: note\n---\nowned body\n",
7032 )
7033 .unwrap();
7034 let store = Store::open_strict(&root).unwrap();
7035 let detached = sandbox.path().join("detached");
7036 fs::rename(&root, &detached).unwrap();
7037
7038 let replacement = sandbox.path().join("replacement");
7039 fs::create_dir_all(replacement.join("records/notes")).unwrap();
7040 fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
7041 fs::write(
7042 replacement.join("records/notes/replacement-secret.md"),
7043 "not frontmatter\n",
7044 )
7045 .unwrap();
7046 symlink(&replacement, &root).unwrap();
7047
7048 let issues = validate_content_sweep(&store).unwrap();
7049 assert!(
7050 issues
7051 .iter()
7052 .any(|issue| issue.file == Path::new("records/notes/owned.md")),
7053 "the held original file must be validated: {issues:?}"
7054 );
7055 assert!(
7056 issues
7057 .iter()
7058 .all(|issue| !issue.file.to_string_lossy().contains("replacement-secret")),
7059 "replacement-root files must be invisible: {issues:?}"
7060 );
7061 }
7062}