1use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::path::{Component, Path, PathBuf};
43
44use chrono::{DateTime, FixedOffset, NaiveDateTime};
45use serde_norway::Value;
46
47use crate::parser::{Schema, Shape};
48use crate::store::Store;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Severity {
54 Error,
56 Warning,
58 Info,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Issue {
67 pub severity: Severity,
69 pub code: &'static str,
71 pub file: PathBuf,
73 pub line: Option<u32>,
75 pub key: Option<String>,
77 pub message: String,
79 pub suggestion: Option<String>,
81 pub related: Vec<PathBuf>,
83}
84
85impl Issue {
86 pub fn is_error(&self) -> bool {
89 matches!(self.severity, Severity::Error)
90 }
91}
92
93pub mod codes {
97 pub const NOT_A_STORE: &str = "NOT_A_STORE";
99 pub const DB_MD_BAD_TYPE: &str = "DB_MD_BAD_TYPE";
101 pub const DB_MD_MISSING_FIELD: &str = "DB_MD_MISSING_FIELD";
103 pub const DB_MD_UNKNOWN_SECTION: &str = "DB_MD_UNKNOWN_SECTION";
105 pub const DB_MD_SCHEMA_FIELD: &str = "DB_MD_SCHEMA_FIELD";
108 pub const FM_MISSING_TYPE: &str = "FM_MISSING_TYPE";
110 pub const FM_MISSING_CREATED: &str = "FM_MISSING_CREATED";
112 pub const FM_MISSING_UPDATED: &str = "FM_MISSING_UPDATED";
114 pub const FM_UNREADABLE: &str = "FM_UNREADABLE";
116 pub const FM_MALFORMED_YAML: &str = "FM_MALFORMED_YAML";
118 pub const FM_BAD_TIMESTAMP: &str = "FM_BAD_TIMESTAMP";
120 pub const FM_BAD_META_TYPE: &str = "FM_BAD_META_TYPE";
122 pub const SUMMARY_MISSING: &str = "SUMMARY_MISSING";
124 pub const SUMMARY_EMPTY: &str = "SUMMARY_EMPTY";
126 pub const SUMMARY_MULTILINE: &str = "SUMMARY_MULTILINE";
128 pub const SUMMARY_TOO_LONG: &str = "SUMMARY_TOO_LONG";
130 pub const WIKI_LINK_SHORT_FORM: &str = "WIKI_LINK_SHORT_FORM";
132 pub const WIKI_LINK_BROKEN: &str = "WIKI_LINK_BROKEN";
134 pub const WIKI_LINK_AMBIGUOUS: &str = "WIKI_LINK_AMBIGUOUS";
136 pub const WIKI_LINK_HAS_EXTENSION: &str = "WIKI_LINK_HAS_EXTENSION";
138 pub const WIKI_LINK_FLOW_FORM_LIST: &str = "WIKI_LINK_FLOW_FORM_LIST";
140 pub const DUP_ID: &str = "DUP_ID";
142 pub const DUP_UNIQUE_KEY: &str = "DUP_UNIQUE_KEY";
144 pub const SCHEMA_MISSING_REQUIRED: &str = "SCHEMA_MISSING_REQUIRED";
146 pub const SCHEMA_SHAPE_MISMATCH: &str = "SCHEMA_SHAPE_MISMATCH";
148 pub const SCHEMA_LINK_PREFIX_MISMATCH: &str = "SCHEMA_LINK_PREFIX_MISMATCH";
150 pub const SCHEMA_ENUM_VIOLATION: &str = "SCHEMA_ENUM_VIOLATION";
152 pub const POLICY_FROZEN_PAGE: &str = "POLICY_FROZEN_PAGE";
154 pub const POLICY_IGNORED_TYPE_PRESENT: &str = "POLICY_IGNORED_TYPE_PRESENT";
156 pub const POLICY_IGNORED_TYPE_DERIVED: &str = "POLICY_IGNORED_TYPE_DERIVED";
158 pub const LOG_BAD_TIMESTAMP: &str = "LOG_BAD_TIMESTAMP";
160 pub const LOG_UNKNOWN_KIND: &str = "LOG_UNKNOWN_KIND";
162 pub const LOG_OUT_OF_ORDER: &str = "LOG_OUT_OF_ORDER";
164 pub const INDEX_MISSING: &str = "INDEX_MISSING";
166 pub const INDEX_STALE_ENTRY: &str = "INDEX_STALE_ENTRY";
168 pub const INDEX_MISSING_ENTRY: &str = "INDEX_MISSING_ENTRY";
170 pub const INDEX_ORPHAN: &str = "INDEX_ORPHAN";
172 pub const INDEX_WRONG_SCOPE: &str = "INDEX_WRONG_SCOPE";
174 pub const INDEX_SUMMARY_MISMATCH: &str = "INDEX_SUMMARY_MISMATCH";
176 pub const INDEX_JSONL_MISSING: &str = "INDEX_JSONL_MISSING";
178 pub const INDEX_JSONL_DESYNC: &str = "INDEX_JSONL_DESYNC";
181 pub const INDEX_JSONL_STALE: &str = "INDEX_JSONL_STALE";
183 pub const TAGS_MALFORMED: &str = "TAGS_MALFORMED";
185 pub const ASSET_MANIFEST_MALFORMED: &str = "ASSET_MANIFEST_MALFORMED";
187 pub const ASSET_UNDECLARED: &str = "ASSET_UNDECLARED";
190 pub const ASSET_WRAPPER_BROKEN: &str = "ASSET_WRAPPER_BROKEN";
192 pub const ASSET_MANIFEST_ORPHAN: &str = "ASSET_MANIFEST_ORPHAN";
194 pub const ASSET_PATH_IS_CONTENT: &str = "ASSET_PATH_IS_CONTENT";
196}
197
198const MAX_SUMMARY_LEN: usize = 200;
200
201const RECOGNIZED_LOG_KINDS: &[&str] = &[
204 "ingest",
205 "create",
206 "update",
207 "delete",
208 "rename",
209 "link",
210 "validate",
211 "index-rebuild",
212 "contradiction",
213];
214
215pub fn validate_working_set(
241 store: &Store,
242 since: Option<DateTime<FixedOffset>>,
243) -> crate::Result<Vec<Issue>> {
244 if !store_marker_present(store) {
245 return Ok(vec![not_a_store_issue(store)]);
246 }
247
248 let cutoff = match since {
249 Some(ts) => Some(ts),
250 None => last_validate_at(store),
251 };
252
253 let changed = changed_objects_since(store, cutoff);
255 if changed.is_empty() && since.is_none() {
256 return validate_content_sweep(store);
257 }
258
259 let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
270 let mut working: BTreeSet<PathBuf> = changed;
271 for linker in store.find_links_to_any(&changed_targets)? {
272 working.insert(linker);
273 }
274
275 let mut issues = Vec::new();
276 for rel in &working {
277 let abs = store.root.join(rel);
278 if !abs.is_file() {
281 continue;
282 }
283 check_content_file(store, rel, &abs, None, &mut issues);
288 }
289 issues.sort_by(issue_order);
290 Ok(issues)
291}
292
293fn validate_content_sweep(store: &Store) -> crate::Result<Vec<Issue>> {
294 let mut issues = Vec::new();
295 for rel in store.walk()? {
296 let abs = store.root.join(&rel);
297 check_content_file(store, &rel, &abs, None, &mut issues);
298 }
299 issues.sort_by(issue_order);
300 Ok(issues)
301}
302
303pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
308 if !store_marker_present(store) {
309 return Ok(vec![not_a_store_issue(store)]);
310 }
311
312 let mut issues = Vec::new();
313
314 check_db_md(store, &mut issues);
318
319 let files = walk_content_files(&store.root);
320
321 let basenames = build_basename_index(&files);
326
327 let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
329 for rel in &files {
330 let abs = store.root.join(rel);
331 if let Some(p) = check_content_file(store, rel, &abs, Some(&basenames), &mut issues) {
332 parsed.push((rel.clone(), p));
333 }
334 }
335
336 check_duplicates(store, &parsed, &mut issues);
338
339 check_indexes(store, &files, &mut issues);
341
342 check_log(store, &mut issues);
344
345 check_assets(store, &parsed, &mut issues);
350
351 issues.sort_by(issue_order);
352 Ok(issues)
353}
354
355struct Parsed {
364 fm: Option<BTreeMap<String, Value>>,
367 fm_yaml: String,
370}
371
372fn check_content_file(
377 store: &Store,
378 rel: &Path,
379 abs: &Path,
380 basenames: Option<&BasenameIndex>,
381 issues: &mut Vec<Issue>,
382) -> Option<Parsed> {
383 let text = match std::fs::read_to_string(abs) {
384 Ok(t) => t,
385 Err(e) => {
386 let detail = if e.kind() == std::io::ErrorKind::InvalidData {
394 "file is not valid UTF-8 text".to_string()
395 } else {
396 format!("file could not be read: {e}")
397 };
398 push(
399 issues,
400 Severity::Error,
401 codes::FM_UNREADABLE,
402 rel,
403 None,
404 None,
405 format!("content file is unreadable: {detail}"),
406 Some(
407 "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
408 .into(),
409 ),
410 vec![],
411 );
412 return None;
413 }
414 };
415
416 let is_content = is_content_file(rel);
417
418 let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
419 Some(split) => split,
420 None => {
421 if is_content {
425 push(
426 issues,
427 Severity::Error,
428 codes::FM_MISSING_TYPE,
429 rel,
430 None,
431 Some("type".into()),
432 "content file has no frontmatter `type:`".into(),
433 Some("add a YAML frontmatter block with `type:`".into()),
434 vec![],
435 );
436 push(
437 issues,
438 Severity::Error,
439 codes::SUMMARY_MISSING,
440 rel,
441 None,
442 Some("summary".into()),
443 "content file has no `summary`".into(),
444 Some("run `dbmd fm init`".into()),
445 vec![],
446 );
447 }
448 return None;
449 }
450 };
451
452 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
454 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
455 Ok(Value::Null) => Some(BTreeMap::new()),
457 Ok(_) => {
458 push(
462 issues,
463 Severity::Error,
464 codes::FM_MALFORMED_YAML,
465 rel,
466 Some(1),
467 None,
468 "frontmatter is not a YAML mapping".into(),
469 Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
470 vec![],
471 );
472 None
473 }
474 Err(e) => {
475 push(
478 issues,
479 Severity::Error,
480 codes::FM_MALFORMED_YAML,
481 rel,
482 Some(1),
483 None,
484 format!("frontmatter block isn't valid YAML: {e}"),
485 Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
486 vec![],
487 );
488 None
489 }
490 };
491
492 if let Some(map) = &fm {
493 check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
495 }
496
497 if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
519 check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
520 }
521
522 Some(Parsed { fm, fm_yaml })
523}
524
525fn check_frontmatter(
527 store: &Store,
528 rel: &Path,
529 fm: &BTreeMap<String, Value>,
530 fm_yaml: &str,
531 basenames: Option<&BasenameIndex>,
532 issues: &mut Vec<Issue>,
533 is_content: bool,
534) {
535 let type_ = fm.get("type").and_then(scalar_string);
536
537 if is_content && type_.is_none() {
539 push(
540 issues,
541 Severity::Error,
542 codes::FM_MISSING_TYPE,
543 rel,
544 fm_key_line_or_top(fm_yaml, "type"),
545 Some("type".into()),
546 "content file has no `type:`".into(),
547 Some("add a `type:` field (e.g. `type: contact`)".into()),
548 vec![],
549 );
550 }
551
552 if is_content {
557 if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
566 match scalar_string(v) {
567 Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
568 Some(mt) => push(
569 issues,
570 Severity::Error,
571 codes::FM_BAD_META_TYPE,
572 rel,
573 fm_key_line_or_top(fm_yaml, "meta-type"),
574 Some("meta-type".into()),
575 format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
576 Some(
577 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
578 .into(),
579 ),
580 vec![],
581 ),
582 None => push(
583 issues,
584 Severity::Error,
585 codes::FM_BAD_META_TYPE,
586 rel,
587 fm_key_line_or_top(fm_yaml, "meta-type"),
588 Some("meta-type".into()),
589 "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
590 string, found a list or mapping"
591 .to_string(),
592 Some(
593 "use one of: fact, operational, conclusion (or omit for the default `fact`)"
594 .into(),
595 ),
596 vec![],
597 ),
598 }
599 }
600 }
601
602 if is_content {
604 check_summary(rel, fm, fm_yaml, issues);
605 }
606
607 if is_content {
611 for (key, missing_code) in [
612 ("created", codes::FM_MISSING_CREATED),
613 ("updated", codes::FM_MISSING_UPDATED),
614 ] {
615 let value = fm.get(key);
620 let missing = value.is_none() || value.is_some_and(Value::is_null);
621 if missing {
622 push(
623 issues,
624 Severity::Error,
625 missing_code,
626 rel,
627 fm_key_line_or_top(fm_yaml, key),
628 Some(key.into()),
629 format!("content file has no `{key}:` timestamp"),
630 Some(format!(
631 "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
632 )),
633 vec![],
634 );
635 } else if let Some(v) = value {
636 match scalar_string(v) {
642 Some(s) if is_iso8601(&s) => {}
643 Some(s) => push(
644 issues,
645 Severity::Error,
646 codes::FM_BAD_TIMESTAMP,
647 rel,
648 fm_key_line(fm_yaml, key),
649 Some(key.into()),
650 format!("`{key}` is not ISO-8601: {s:?}"),
651 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
652 vec![],
653 ),
654 None => push(
655 issues,
656 Severity::Error,
657 codes::FM_BAD_TIMESTAMP,
658 rel,
659 fm_key_line(fm_yaml, key),
660 Some(key.into()),
661 format!(
662 "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
663 ),
664 Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
665 vec![],
666 ),
667 }
668 }
669 }
670 }
671 if let Some(tags) = fm.get("tags") {
673 if !is_flat_scalar_list(tags) {
674 push(
675 issues,
676 Severity::Warning,
677 codes::TAGS_MALFORMED,
678 rel,
679 fm_key_line(fm_yaml, "tags"),
680 Some("tags".into()),
681 "`tags` must be a flat YAML list of short scalar labels".into(),
682 Some("use block form: one `- <tag>` per line".into()),
683 vec![],
684 );
685 }
686 }
687
688 for key in detect_flow_form_link_lists(fm_yaml) {
690 push(
691 issues,
692 Severity::Error,
693 codes::WIKI_LINK_FLOW_FORM_LIST,
694 rel,
695 fm_key_line(fm_yaml, &key),
696 Some(key.clone()),
697 format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
698 Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
699 vec![],
700 );
701 }
702
703 let schema_link_keys: BTreeSet<String> =
708 effective_schema(store, type_.as_deref().unwrap_or(""))
709 .map(|s| {
710 s.fields
711 .iter()
712 .filter(|f| f.link_prefix.is_some())
713 .map(|f| f.name.clone())
714 .collect()
715 })
716 .unwrap_or_default();
717 for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
718 if schema_link_keys.contains(&key) {
719 continue;
720 }
721 check_wiki_link(
722 store,
723 rel,
724 &link,
725 Some(link.line),
726 Some(&key),
727 basenames,
728 issues,
729 );
730 }
731
732 if let Some(t) = &type_ {
734 if store.config.ignored_types.iter().any(|it| it == t) {
735 push(
736 issues,
737 Severity::Info,
738 codes::POLICY_IGNORED_TYPE_PRESENT,
739 rel,
740 fm_key_line(fm_yaml, "type"),
741 Some("type".into()),
742 format!("file has ignored type `{t}` (per DB.md ## Policies)"),
743 Some(
744 "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
745 .into(),
746 ),
747 vec![PathBuf::from("DB.md")],
749 );
750 }
751 let meta_type = fm
757 .get("meta-type")
758 .and_then(scalar_string)
759 .unwrap_or_else(|| "fact".to_string());
760 for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
761 if let Some(hit) =
762 derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
763 {
764 push(
765 issues,
766 Severity::Warning,
767 codes::POLICY_IGNORED_TYPE_DERIVED,
768 rel,
769 Some(link.line),
770 Some("derived_from".into()),
771 format!(
772 "conclusion record derives from ignored-type record `{}` (type `{}`)",
773 hit.target, hit.target_type
774 ),
775 Some(
776 "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
777 .into(),
778 ),
779 vec![
782 PathBuf::from(format!("{}.md", hit.target)),
783 PathBuf::from("DB.md"),
784 ],
785 );
786 }
787 }
788 }
789
790 if let Some(t) = &type_ {
792 if let Some(schema) = effective_schema(store, t) {
793 check_schema(store, rel, fm, fm_yaml, &schema, issues);
794 }
795 }
796}
797
798fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
800 let line = fm_key_line(fm_yaml, "summary");
801 match fm.get("summary") {
802 None => push(
803 issues,
804 Severity::Error,
805 codes::SUMMARY_MISSING,
806 rel,
807 fm_key_line_or_top(fm_yaml, "summary"),
810 Some("summary".into()),
811 "content file has no `summary`".into(),
812 Some("run `dbmd fm init`".into()),
813 vec![],
814 ),
815 Some(v) => {
816 let s = scalar_string(v).unwrap_or_default();
817 if s.trim().is_empty() {
818 push(
819 issues,
820 Severity::Error,
821 codes::SUMMARY_EMPTY,
822 rel,
823 line,
824 Some("summary".into()),
825 "`summary` is present but empty".into(),
826 Some("write a one-line summary, or run `dbmd fm init`".into()),
827 vec![],
828 );
829 } else if s.contains('\n') {
830 push(
831 issues,
832 Severity::Error,
833 codes::SUMMARY_MULTILINE,
834 rel,
835 line,
836 Some("summary".into()),
837 "`summary` must be one line (contains a newline)".into(),
838 Some("collapse the summary to a single line".into()),
839 vec![],
840 );
841 } else if s.chars().count() > MAX_SUMMARY_LEN {
842 push(
843 issues,
844 Severity::Warning,
845 codes::SUMMARY_TOO_LONG,
846 rel,
847 line,
848 Some("summary".into()),
849 format!(
850 "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
851 s.chars().count()
852 ),
853 Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
854 vec![],
855 );
856 }
857 }
858 }
859}
860
861fn check_body_wiki_links(
863 store: &Store,
864 rel: &Path,
865 body: &str,
866 fm_end_line: u32,
867 basenames: Option<&BasenameIndex>,
868 issues: &mut Vec<Issue>,
869) {
870 for link in extract_wiki_links(body) {
871 let abs_line = fm_end_line + link.line;
874 check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
875 }
876}
877
878type BasenameIndex = HashMap<String, Vec<PathBuf>>;
886
887fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
890 let mut idx: BasenameIndex = HashMap::new();
891 for rel in files {
892 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
893 idx.entry(stem.to_string()).or_default().push(rel.clone());
894 }
895 }
896 idx
897}
898
899fn check_wiki_link(
904 store: &Store,
905 rel: &Path,
906 link: &Link,
907 line: Option<u32>,
908 key: Option<&str>,
909 basenames: Option<&BasenameIndex>,
910 issues: &mut Vec<Issue>,
911) {
912 let bare = link.target.trim_end_matches(".md");
913
914 if !is_full_store_path(bare) {
917 if !bare.contains('/') {
922 if let Some(idx) = basenames {
923 if let Some(matches) = idx.get(bare) {
924 if matches.len() >= 2 {
925 let mut related = matches.clone();
926 related.sort();
927 push(
928 issues,
929 Severity::Error,
930 codes::WIKI_LINK_AMBIGUOUS,
931 rel,
932 line,
933 key.map(str::to_string),
934 format!(
935 "short-form wiki-link `[[{}]]` matches multiple files",
936 link.target
937 ),
938 Some("use the full store-relative path to disambiguate".into()),
939 related,
940 );
941 return;
942 }
943 }
944 }
945 }
946 push(
947 issues,
948 Severity::Error,
949 codes::WIKI_LINK_SHORT_FORM,
950 rel,
951 line,
952 key.map(str::to_string),
953 format!(
954 "wiki-link `[[{}]]` is not a full store-relative path",
955 link.target
956 ),
957 short_form_suggestion(bare),
958 vec![],
959 );
960 return;
962 }
963
964 if link.target.ends_with(".md") {
966 push(
967 issues,
968 Severity::Warning,
969 codes::WIKI_LINK_HAS_EXTENSION,
970 rel,
971 line,
972 key.map(str::to_string),
973 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
974 Some(format!("drop the extension: [[{bare}]]")),
975 vec![],
976 );
977 }
978
979 match resolve_wiki_target(store, bare) {
984 TargetResolution::Exists => {}
985 TargetResolution::Missing => push(
986 issues,
987 Severity::Error,
988 codes::WIKI_LINK_BROKEN,
989 rel,
990 line,
991 key.map(str::to_string),
992 format!("wiki-link target `{bare}` doesn't exist"),
993 Some(format!(
994 "create `{bare}.md`, or point the link at an existing file"
995 )),
996 vec![],
997 ),
998 TargetResolution::Unsafe => push(
999 issues,
1000 Severity::Error,
1001 codes::WIKI_LINK_BROKEN,
1002 rel,
1003 line,
1004 key.map(str::to_string),
1005 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1006 Some("use a full store-relative path under sources/ or records/".into()),
1007 vec![],
1008 ),
1009 }
1010}
1011
1012fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1023 store.config.schemas.get(type_).cloned()
1024}
1025
1026fn check_schema(
1028 store: &Store,
1029 rel: &Path,
1030 fm: &BTreeMap<String, Value>,
1031 fm_yaml: &str,
1032 schema: &Schema,
1033 issues: &mut Vec<Issue>,
1034) {
1035 for spec in &schema.fields {
1036 let present = fm.get(&spec.name);
1037 let line = fm_key_line(fm_yaml, &spec.name);
1038
1039 let is_empty = match present {
1047 None => true,
1048 Some(v) => is_empty_value(v),
1049 };
1050 if spec.required && is_empty {
1051 push(
1052 issues,
1053 Severity::Error,
1054 codes::SCHEMA_MISSING_REQUIRED,
1055 rel,
1056 fm_key_line_or_top(fm_yaml, &spec.name),
1059 Some(spec.name.clone()),
1060 format!("required field `{}` is absent or empty", spec.name),
1061 Some(format!("set `{}` to a non-empty value", spec.name)),
1062 vec![],
1063 );
1064 continue;
1065 }
1066 let Some(value) = present else { continue };
1067
1068 let value_empty = value.is_null()
1074 || scalar_string(value)
1075 .map(|s| s.trim().is_empty())
1076 .unwrap_or(false);
1077 if !spec.required && value_empty {
1078 continue;
1079 }
1080
1081 if let Some(prefix) = &spec.link_prefix {
1084 check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1085 continue; }
1087
1088 if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1095 push(
1096 issues,
1097 Severity::Error,
1098 codes::SCHEMA_SHAPE_MISMATCH,
1099 rel,
1100 line,
1101 Some(spec.name.clone()),
1102 format!(
1103 "`{}` must be a scalar value, found a list or mapping",
1104 spec.name
1105 ),
1106 Some(format!("set `{}` to a single scalar value", spec.name)),
1107 vec![],
1108 );
1109 continue;
1110 }
1111
1112 if let Some(allowed) = &spec.enum_values {
1114 if let Some(s) = scalar_string(value) {
1115 if !allowed.iter().any(|a| a == &s) {
1116 push(
1117 issues,
1118 Severity::Error,
1119 codes::SCHEMA_ENUM_VIOLATION,
1120 rel,
1121 line,
1122 Some(spec.name.clone()),
1123 format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1124 Some(format!("use one of: {}", allowed.join(", "))),
1125 vec![],
1126 );
1127 }
1128 }
1129 continue;
1130 }
1131
1132 if let Some(shape) = spec.shape {
1134 check_schema_shape(rel, &spec.name, value, shape, line, issues);
1135 }
1136 }
1137}
1138
1139fn check_schema_link(
1144 store: &Store,
1145 rel: &Path,
1146 field: &str,
1147 fm_yaml: &str,
1148 prefix: &Path,
1149 line: Option<u32>,
1150 issues: &mut Vec<Issue>,
1151) {
1152 let prefix_str = prefix.to_string_lossy();
1153 let prefix_str = prefix_str.trim_end_matches('/');
1154 let suggestion = |target_leaf: &str| {
1155 Some(format!(
1156 "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1157 ))
1158 };
1159
1160 let links = frontmatter_links_for_key(fm_yaml, field, 2);
1161 if links.is_empty() {
1162 let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1164 let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1165 let leaf = slugish(raw);
1166 push(
1167 issues,
1168 Severity::Error,
1169 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1170 rel,
1171 line,
1172 Some(field.to_string()),
1173 format!(
1174 "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1175 ),
1176 suggestion(&leaf),
1177 vec![],
1178 );
1179 return;
1180 }
1181
1182 for link in links {
1183 if link.target.ends_with(".md") {
1184 let bare = link.target.trim_end_matches(".md");
1185 push(
1186 issues,
1187 Severity::Warning,
1188 codes::WIKI_LINK_HAS_EXTENSION,
1189 rel,
1190 Some(link.line),
1191 Some(field.to_string()),
1192 format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1193 Some(format!("drop the extension: [[{bare}]]")),
1194 vec![],
1195 );
1196 }
1197 let bare = link.target.trim_end_matches(".md");
1198 if !path_under_prefix(bare, prefix_str) {
1199 let leaf = bare.rsplit('/').next().unwrap_or(bare);
1200 push(
1201 issues,
1202 Severity::Error,
1203 codes::SCHEMA_LINK_PREFIX_MISMATCH,
1204 rel,
1205 line,
1206 Some(field.to_string()),
1207 format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1208 suggestion(leaf),
1209 vec![],
1210 );
1211 } else {
1212 match resolve_wiki_target(store, bare) {
1217 TargetResolution::Exists => {}
1218 TargetResolution::Missing => push(
1219 issues,
1220 Severity::Error,
1221 codes::WIKI_LINK_BROKEN,
1222 rel,
1223 line,
1224 Some(field.to_string()),
1225 format!("wiki-link target `{bare}` doesn't exist"),
1226 Some(format!(
1227 "create `{bare}.md`, or point the link at an existing file"
1228 )),
1229 vec![],
1230 ),
1231 TargetResolution::Unsafe => push(
1232 issues,
1233 Severity::Error,
1234 codes::WIKI_LINK_BROKEN,
1235 rel,
1236 line,
1237 Some(field.to_string()),
1238 format!("wiki-link target `{bare}` is not a safe store-relative path"),
1239 Some("use a full store-relative path under sources/ or records/".into()),
1240 vec![],
1241 ),
1242 }
1243 }
1244 }
1245}
1246
1247fn check_schema_shape(
1249 rel: &Path,
1250 field: &str,
1251 value: &Value,
1252 shape: Shape,
1253 line: Option<u32>,
1254 issues: &mut Vec<Issue>,
1255) {
1256 let s = scalar_string(value).unwrap_or_default();
1257 let ok = match shape {
1258 Shape::String => true, Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1260 Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1261 Shape::Date => is_iso8601_date_or_datetime(&s),
1262 Shape::Email => is_email(&s),
1263 Shape::Currency => is_currency(&s),
1264 Shape::Url => is_url(&s),
1265 };
1266 if !ok {
1267 push(
1268 issues,
1269 Severity::Error,
1270 codes::SCHEMA_SHAPE_MISMATCH,
1271 rel,
1272 line,
1273 Some(field.to_string()),
1274 format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1275 Some(shape_suggestion(shape)),
1276 vec![],
1277 );
1278 }
1279}
1280
1281fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1300 let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1303 .iter()
1304 .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1305 .collect();
1306
1307 let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1309 for (rel, p) in parsed {
1310 if let Some(map) = &p.fm {
1311 if let Some(id) = map.get("id").and_then(scalar_string) {
1312 if !id.trim().is_empty() {
1313 by_id.entry(id).or_default().push(rel.clone());
1314 }
1315 }
1316 }
1317 }
1318 for (id, files) in &by_id {
1319 if files.len() > 1 {
1320 let (reported, related) = canonical_and_related(files);
1321 let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1322 push(
1323 issues,
1324 Severity::Error,
1325 codes::DUP_ID,
1326 &reported,
1327 line,
1328 Some("id".into()),
1329 format!("id {id:?} is declared by more than one file"),
1330 Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1331 related,
1332 );
1333 }
1334 }
1335
1336 for (type_name, schema) in &store.config.schemas {
1341 for key_fields in &schema.unique_keys {
1342 soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1343 }
1344 }
1345}
1346
1347fn soft_dup(
1356 parsed: &[(PathBuf, Parsed)],
1357 issues: &mut Vec<Issue>,
1358 type_: &str,
1359 key_fields: &[String],
1360 fm_yaml_of: &HashMap<&PathBuf, &str>,
1361) {
1362 if key_fields.is_empty() {
1363 return;
1364 }
1365 let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1366 for (rel, p) in parsed {
1367 let is_type =
1368 p.fm.as_ref()
1369 .and_then(|m| m.get("type"))
1370 .and_then(scalar_string)
1371 .map(|t| t == type_)
1372 .unwrap_or(false);
1373 if !is_type {
1374 continue;
1375 }
1376 if let Some(key) = dedup_key(p, key_fields) {
1377 groups.entry(key).or_default().push(rel.clone());
1378 }
1379 }
1380 let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1383 .values()
1384 .filter(|files| files.len() > 1)
1385 .map(|files| canonical_and_related(files))
1386 .collect();
1387 collisions.sort_by(|a, b| a.0.cmp(&b.0));
1388
1389 let fields_disp = key_fields.join(", ");
1390 for (reported, related) in collisions {
1391 let (line, key) = if key_fields.len() == 1 {
1394 (
1395 fm_yaml_of
1396 .get(&reported)
1397 .and_then(|y| fm_key_line(y, &key_fields[0])),
1398 Some(key_fields[0].clone()),
1399 )
1400 } else {
1401 (Some(1), None)
1402 };
1403 let n = related.len();
1404 push(
1405 issues,
1406 Severity::Warning,
1407 codes::DUP_UNIQUE_KEY,
1408 &reported,
1409 line,
1410 key,
1411 format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1412 Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1413 related,
1414 );
1415 }
1416}
1417
1418fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1422 let mut out = Vec::with_capacity(key_fields.len());
1423 for f in key_fields {
1424 out.push(dedup_token(p, f)?);
1425 }
1426 Some(out)
1427}
1428
1429fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1434 let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1437 if !links.is_empty() {
1438 let set: BTreeSet<String> = links
1439 .into_iter()
1440 .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1441 .filter(|t| !t.is_empty())
1442 .collect();
1443 return if set.is_empty() {
1444 None
1445 } else {
1446 Some(set.into_iter().collect::<Vec<_>>().join(","))
1447 };
1448 }
1449 match p.fm.as_ref()?.get(field) {
1450 Some(Value::Sequence(items)) => {
1451 let set: BTreeSet<String> = items
1452 .iter()
1453 .filter_map(scalar_string)
1454 .map(|s| s.trim().to_lowercase())
1455 .filter(|t| !t.is_empty())
1456 .collect();
1457 if set.is_empty() {
1458 None
1459 } else {
1460 Some(set.into_iter().collect::<Vec<_>>().join(","))
1461 }
1462 }
1463 Some(v) => {
1464 let s = scalar_string(v)?.trim().to_lowercase();
1465 if s.is_empty() {
1466 None
1467 } else {
1468 Some(s)
1469 }
1470 }
1471 None => None,
1472 }
1473}
1474
1475fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1480 let mut sorted = files.to_vec();
1481 sorted.sort();
1482 let reported = sorted[0].clone();
1483 let related = sorted[1..].to_vec();
1484 (reported, related)
1485}
1486
1487fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1493 let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1497 let mut layers_present: BTreeSet<&'static str> = BTreeSet::new();
1498 for rel in files {
1499 if let Some(layer) = rel.iter().next().and_then(|s| s.to_str()) {
1503 match layer {
1504 "sources" => layers_present.insert("sources"),
1505 "records" => layers_present.insert("records"),
1506 _ => false,
1507 };
1508 }
1509 if let Some(tf) = type_folder_of(rel) {
1510 type_folders.entry(tf).or_default().push(rel.clone());
1511 }
1512 }
1513
1514 if !files.is_empty() {
1516 let root_index = store.root.join("index.md");
1517 if !root_index.is_file() {
1518 push(
1519 issues,
1520 Severity::Error,
1521 codes::INDEX_MISSING,
1522 Path::new("index.md"),
1523 None,
1524 None,
1525 "store has files but no root `index.md`".into(),
1526 Some("run `dbmd index rebuild`".into()),
1527 vec![],
1528 );
1529 } else {
1530 check_index_scope(store, Path::new("index.md"), "root", None, issues);
1531 }
1532 }
1533
1534 for layer in &layers_present {
1536 let layer_index_rel = PathBuf::from(layer).join("index.md");
1537 let abs = store.root.join(&layer_index_rel);
1538 if !abs.is_file() {
1539 push(
1540 issues,
1541 Severity::Error,
1542 codes::INDEX_MISSING,
1543 &layer_index_rel,
1544 None,
1545 None,
1546 format!("layer `{layer}/` has files but no `index.md`"),
1547 Some("run `dbmd index rebuild`".into()),
1548 vec![],
1549 );
1550 } else {
1551 check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1552 }
1553 }
1554
1555 for (tf, members) in &type_folders {
1557 let index_md_rel = tf.join("index.md");
1558 let index_md_abs = store.root.join(&index_md_rel);
1559 let index_md_present = index_md_abs.is_file();
1560 if !index_md_present {
1561 push(
1567 issues,
1568 Severity::Error,
1569 codes::INDEX_MISSING,
1570 tf,
1571 None,
1572 None,
1573 format!("non-empty folder `{}` has no index.md", tf.display()),
1574 Some(format!(
1575 "run `dbmd index rebuild --folder {}`",
1576 tf.display()
1577 )),
1578 vec![],
1579 );
1580 continue;
1581 }
1582
1583 check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1584 check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1585
1586 let jsonl_rel = tf.join("index.jsonl");
1590 let jsonl_abs = store.root.join(&jsonl_rel);
1591 if !jsonl_abs.is_file() {
1592 push(
1593 issues,
1594 Severity::Error,
1595 codes::INDEX_JSONL_MISSING,
1596 &jsonl_rel,
1597 None,
1598 None,
1599 format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1600 Some("run `dbmd index rebuild`".into()),
1601 vec![],
1602 );
1603 } else {
1604 check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1605 }
1606 }
1607
1608 for rel in walk_index_files(&store.root) {
1610 let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1611 let parent_str = parent.to_string_lossy().to_string();
1612 let is_canonical = parent_str.is_empty() || matches!(parent_str.as_str(), "sources" | "records")
1614 || type_folders.contains_key(&parent);
1615 if !is_canonical {
1616 push(
1617 issues,
1618 Severity::Warning,
1619 codes::INDEX_ORPHAN,
1620 &rel,
1621 None,
1622 None,
1623 format!(
1624 "`{}` sits in an empty or non-canonical folder",
1625 rel.display()
1626 ),
1627 Some("remove it, or run `dbmd index rebuild`".into()),
1628 vec![],
1629 );
1630 }
1631 }
1632}
1633
1634fn check_type_folder_index_md(
1638 store: &Store,
1639 tf: &Path,
1640 index_rel: &Path,
1641 members: &[PathBuf],
1642 issues: &mut Vec<Issue>,
1643) {
1644 let abs = store.root.join(index_rel);
1645 let Ok(text) = std::fs::read_to_string(&abs) else {
1646 return;
1647 };
1648 let entries = parse_index_entries(&text);
1649
1650 let listed: BTreeSet<PathBuf> = entries
1651 .iter()
1652 .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1653 .collect();
1654
1655 for entry in &entries {
1657 let bare = entry.target.trim_end_matches(".md");
1658 let target_abs = match resolved_target_abs(store, bare) {
1661 Some(abs) => abs,
1662 None => {
1663 if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1664 push(
1665 issues,
1666 Severity::Error,
1667 codes::INDEX_STALE_ENTRY,
1668 index_rel,
1669 Some(entry.line),
1670 None,
1671 format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1672 Some("run `dbmd index rebuild`".into()),
1673 vec![],
1674 );
1675 } else {
1676 push(
1677 issues,
1678 Severity::Error,
1679 codes::INDEX_STALE_ENTRY,
1680 index_rel,
1681 Some(entry.line),
1682 None,
1683 format!("index entry `[[{bare}]]` points at a missing file"),
1684 Some("run `dbmd index rebuild`".into()),
1685 vec![PathBuf::from(format!("{bare}.md"))],
1689 );
1690 }
1691 continue;
1692 }
1693 };
1694 if let Some(expected) = read_summary(&target_abs) {
1701 match &entry.summary_text {
1702 Some(text_part)
1713 if crate::summary::collapse_whitespace(text_part)
1714 != crate::summary::collapse_whitespace(&expected) =>
1715 {
1716 push(
1717 issues,
1718 Severity::Error,
1719 codes::INDEX_SUMMARY_MISMATCH,
1720 index_rel,
1721 Some(entry.line),
1722 None,
1723 format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1724 Some("run `dbmd index rebuild`".into()),
1725 vec![PathBuf::from(format!("{bare}.md"))],
1726 );
1727 }
1728 None if !expected.trim().is_empty() => {
1729 push(
1730 issues,
1731 Severity::Error,
1732 codes::INDEX_SUMMARY_MISMATCH,
1733 index_rel,
1734 Some(entry.line),
1735 None,
1736 format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1737 Some("run `dbmd index rebuild`".into()),
1738 vec![PathBuf::from(format!("{bare}.md"))],
1739 );
1740 }
1741 _ => {}
1742 }
1743 }
1744 }
1745
1746 let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1750 if content_members.len() <= 500 {
1751 for m in content_members {
1752 let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1753 if !listed.contains(&bare) {
1754 push(
1755 issues,
1756 Severity::Error,
1757 codes::INDEX_MISSING_ENTRY,
1758 index_rel,
1759 None,
1760 None,
1761 format!(
1762 "file `{}` is not listed in its folder's `index.md`",
1763 m.display()
1764 ),
1765 Some("run `dbmd index rebuild`".into()),
1766 vec![(*m).clone()],
1767 );
1768 }
1769 }
1770 }
1771 let _ = tf;
1772}
1773
1774fn check_type_folder_index_jsonl(
1778 store: &Store,
1779 tf: &Path,
1780 jsonl_rel: &Path,
1781 members: &[PathBuf],
1782 issues: &mut Vec<Issue>,
1783) {
1784 let abs = store.root.join(jsonl_rel);
1785 let Ok(text) = std::fs::read_to_string(&abs) else {
1786 return;
1787 };
1788
1789 let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1791 for (i, line) in text.lines().enumerate() {
1792 let line = line.trim();
1793 if line.is_empty() {
1794 continue;
1795 }
1796 let rec: serde_json::Value = match serde_json::from_str(line) {
1797 Ok(v) => v,
1798 Err(e) => {
1799 push(
1800 issues,
1801 Severity::Error,
1802 codes::INDEX_JSONL_DESYNC,
1803 jsonl_rel,
1804 Some((i + 1) as u32),
1805 None,
1806 format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
1807 Some("run `dbmd index rebuild`".into()),
1808 vec![],
1809 );
1810 continue;
1811 }
1812 };
1813 if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
1814 if !is_safe_store_relative_path(Path::new(path)) {
1815 push(
1816 issues,
1817 Severity::Error,
1818 codes::INDEX_JSONL_DESYNC,
1819 jsonl_rel,
1820 Some((i + 1) as u32),
1821 None,
1822 format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
1823 Some("run `dbmd index rebuild`".into()),
1824 vec![],
1825 );
1826 continue;
1827 }
1828 records.insert(PathBuf::from(path), rec);
1829 }
1830 }
1831
1832 let member_set: BTreeSet<PathBuf> = members
1833 .iter()
1834 .filter(|m| is_content_file(m))
1835 .cloned()
1836 .collect();
1837
1838 for path in records.keys() {
1840 let target_abs = store.root.join(path);
1841 if !target_abs.is_file() {
1842 push(
1843 issues,
1844 Severity::Error,
1845 codes::INDEX_JSONL_DESYNC,
1846 jsonl_rel,
1847 None,
1848 None,
1849 format!(
1850 "`index.jsonl` record points at missing file `{}`",
1851 path.display()
1852 ),
1853 Some("run `dbmd index rebuild`".into()),
1854 vec![],
1855 );
1856 }
1857 }
1858
1859 for m in &member_set {
1861 if !records.contains_key(m) {
1862 push(
1863 issues,
1864 Severity::Error,
1865 codes::INDEX_JSONL_DESYNC,
1866 jsonl_rel,
1867 None,
1868 None,
1869 format!(
1870 "file `{}` is missing from the complete `index.jsonl`",
1871 m.display()
1872 ),
1873 Some("run `dbmd index rebuild`".into()),
1874 vec![m.clone()],
1875 );
1876 }
1877 }
1878
1879 for (path, rec) in &records {
1893 let target_abs = store.root.join(path);
1894 if !target_abs.is_file() {
1895 continue;
1896 }
1897 let Ok(expected) = crate::index::IndexRecord::expected_from_file(&target_abs, path.clone())
1898 else {
1899 continue; };
1901 let Ok(expected_json) = serde_json::to_value(&expected) else {
1902 continue;
1903 };
1904 let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
1905 continue;
1906 };
1907
1908 let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
1911 for key in have.keys().chain(want.keys()) {
1912 if key == "path" {
1913 continue;
1914 }
1915 if have.get(key) != want.get(key) {
1916 mismatched_keys.insert(key);
1917 }
1918 }
1919
1920 if !mismatched_keys.is_empty() {
1921 let keys: Vec<&str> = mismatched_keys.into_iter().collect();
1922 push(
1923 issues,
1924 Severity::Error,
1925 codes::INDEX_JSONL_STALE,
1926 jsonl_rel,
1927 None,
1928 Some(keys.join(",")),
1929 format!(
1930 "`index.jsonl` record for `{}` is stale ({})",
1931 path.display(),
1932 keys.join(", ")
1933 ),
1934 Some("run `dbmd index rebuild`".into()),
1935 vec![path.clone()],
1936 );
1937 }
1938 }
1939 let _ = tf;
1940}
1941
1942fn check_index_scope(
1944 store: &Store,
1945 index_rel: &Path,
1946 expected_scope: &str,
1947 expected_folder: Option<&str>,
1948 issues: &mut Vec<Issue>,
1949) {
1950 let abs = store.root.join(index_rel);
1951 let Ok(text) = std::fs::read_to_string(&abs) else {
1952 return;
1953 };
1954 let Some((yaml, _, _)) = split_frontmatter(&text) else {
1955 return;
1956 };
1957 let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
1958 return;
1959 };
1960 let fm = yaml_map_to_btree(&map);
1961
1962 if let Some(scope) = fm.get("scope").and_then(scalar_string) {
1963 let scope_ok =
1965 scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
1966 if !scope_ok {
1967 push(
1968 issues,
1969 Severity::Warning,
1970 codes::INDEX_WRONG_SCOPE,
1971 index_rel,
1972 fm_key_line(&yaml, "scope"),
1973 Some("scope".into()),
1974 format!(
1975 "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
1976 ),
1977 Some(format!("set `scope: {expected_scope}`")),
1978 vec![],
1979 );
1980 }
1981 }
1982 if let Some(expected) = expected_folder {
1984 if let Some(folder) = fm.get("folder").and_then(scalar_string) {
1985 if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
1986 push(
1987 issues,
1988 Severity::Warning,
1989 codes::INDEX_WRONG_SCOPE,
1990 index_rel,
1991 fm_key_line(&yaml, "folder"),
1992 Some("folder".into()),
1993 format!("index `folder: {folder}` doesn't match location `{expected}`"),
1994 Some(format!("set `folder: {expected}`")),
1995 vec![],
1996 );
1997 }
1998 }
1999 }
2000}
2001
2002fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2021 let mut prev: Option<DateTime<FixedOffset>> = None;
2022 for rel in log_files_chronological(store) {
2023 check_log_file(store, &rel, &mut prev, issues);
2024 }
2025}
2026
2027fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2031 let mut files: Vec<PathBuf> = Vec::new();
2032 let archive_dir = store.root.join("log");
2033 if let Ok(entries) = std::fs::read_dir(&archive_dir) {
2034 let mut archives: Vec<PathBuf> = entries
2035 .flatten()
2036 .map(|e| e.path())
2037 .filter(|p| {
2038 p.is_file()
2039 && p.file_name()
2040 .and_then(|s| s.to_str())
2041 .and_then(|n| n.strip_suffix(".md"))
2042 .is_some_and(is_year_month_archive)
2043 })
2044 .filter_map(|p| p.strip_prefix(&store.root).ok().map(Path::to_path_buf))
2045 .collect();
2046 archives.sort();
2048 files.extend(archives);
2049 }
2050 if store.root.join("log.md").is_file() {
2052 files.push(PathBuf::from("log.md"));
2053 }
2054 files
2055}
2056
2057fn check_log_file(
2061 store: &Store,
2062 log_rel: &Path,
2063 prev: &mut Option<DateTime<FixedOffset>>,
2064 issues: &mut Vec<Issue>,
2065) {
2066 let abs = store.root.join(log_rel);
2067 let Ok(text) = std::fs::read_to_string(&abs) else {
2068 return;
2069 };
2070
2071 for (i, line) in text.lines().enumerate() {
2072 if !line.starts_with("## [") {
2073 continue;
2074 }
2075 let line_no = (i + 1) as u32;
2076 match parse_log_header(line) {
2077 None => push(
2078 issues,
2079 Severity::Error,
2080 codes::LOG_BAD_TIMESTAMP,
2081 log_rel,
2082 Some(line_no),
2083 None,
2084 format!("log entry header has an unparseable timestamp: {line:?}"),
2085 Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2086 vec![],
2087 ),
2088 Some((ts, kind, _object)) => {
2089 if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2090 push(
2091 issues,
2092 Severity::Warning,
2093 codes::LOG_UNKNOWN_KIND,
2094 log_rel,
2095 Some(line_no),
2096 None,
2097 format!("log entry kind `{kind}` is not recognized"),
2098 Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2099 vec![],
2100 );
2101 }
2102 if let Some(p) = *prev {
2103 if ts < p {
2104 push(
2105 issues,
2106 Severity::Warning,
2107 codes::LOG_OUT_OF_ORDER,
2108 log_rel,
2109 Some(line_no),
2110 None,
2111 "log entry is older than the entry above it (possible rewrite)".into(),
2112 Some("append corrective entries; never reorder past ones".into()),
2113 vec![],
2114 );
2115 }
2116 }
2117 *prev = Some(ts);
2118 }
2119 }
2120 }
2121}
2122
2123#[derive(Debug)]
2129struct Link {
2130 target: String,
2131 line: u32,
2132}
2133
2134fn store_marker_present(store: &Store) -> bool {
2138 let want = store.root.join("DB.md");
2139 if !want.is_file() {
2140 return false;
2141 }
2142 match std::fs::read_dir(&store.root) {
2144 Ok(entries) => entries
2145 .flatten()
2146 .any(|e| e.file_name().to_str() == Some("DB.md")),
2147 Err(_) => true, }
2149}
2150
2151fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2162 let rel = Path::new("DB.md");
2163 let abs = store.root.join("DB.md");
2164 let Ok(text) = std::fs::read_to_string(&abs) else {
2165 return; };
2167
2168 let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2169 push(
2173 issues,
2174 Severity::Error,
2175 codes::DB_MD_BAD_TYPE,
2176 rel,
2177 Some(1),
2178 Some("type".into()),
2179 "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2180 Some("add a `---` frontmatter block with `type: db-md`".into()),
2181 vec![],
2182 );
2183 for field in ["scope", "owner"] {
2184 push(
2185 issues,
2186 Severity::Error,
2187 codes::DB_MD_MISSING_FIELD,
2188 rel,
2189 Some(1),
2190 Some(field.into()),
2191 format!("DB.md frontmatter is missing required field `{field}`"),
2192 Some(format!("add `{field}:` to the DB.md frontmatter")),
2193 vec![],
2194 );
2195 }
2196 return;
2197 };
2198
2199 let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2202 Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2203 Ok(Value::Null) => Some(BTreeMap::new()),
2204 _ => None,
2205 };
2206
2207 match &fm {
2208 Some(map) => {
2209 let type_ = map.get("type").and_then(scalar_string);
2211 if type_.as_deref() != Some("db-md") {
2212 let (line, msg) = match &type_ {
2213 Some(t) => (
2214 fm_key_line(&fm_yaml, "type"),
2215 format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2216 ),
2217 None => (
2218 Some(1),
2219 "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2220 ),
2221 };
2222 push(
2223 issues,
2224 Severity::Error,
2225 codes::DB_MD_BAD_TYPE,
2226 rel,
2227 line,
2228 Some("type".into()),
2229 msg,
2230 Some("set `type: db-md` in the DB.md frontmatter".into()),
2231 vec![],
2232 );
2233 }
2234
2235 for field in ["scope", "owner"] {
2237 let present = map
2238 .get(field)
2239 .and_then(scalar_string)
2240 .map(|s| !s.trim().is_empty())
2241 .unwrap_or(false);
2242 if !present {
2243 push(
2244 issues,
2245 Severity::Error,
2246 codes::DB_MD_MISSING_FIELD,
2247 rel,
2248 fm_key_line_or_top(&fm_yaml, field),
2251 Some(field.into()),
2252 format!("DB.md frontmatter is missing required field `{field}`"),
2253 Some(format!("add `{field}:` to the DB.md frontmatter")),
2254 vec![],
2255 );
2256 }
2257 }
2258 }
2259 None => {
2260 push(
2263 issues,
2264 Severity::Error,
2265 codes::DB_MD_BAD_TYPE,
2266 rel,
2267 Some(1),
2268 Some("type".into()),
2269 "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2270 Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2271 vec![],
2272 );
2273 for field in ["scope", "owner"] {
2274 push(
2275 issues,
2276 Severity::Error,
2277 codes::DB_MD_MISSING_FIELD,
2278 rel,
2279 Some(1),
2280 Some(field.into()),
2281 format!("DB.md frontmatter is missing required field `{field}`"),
2282 Some(format!("add `{field}:` to the DB.md frontmatter")),
2283 vec![],
2284 );
2285 }
2286 }
2287 }
2288
2289 for section in crate::parser::extract_sections(&body) {
2303 if section.level != 2 {
2304 continue;
2305 }
2306 let name = section.heading.trim().to_ascii_lowercase();
2307 if matches!(
2308 name.as_str(),
2309 "agent instructions" | "policies" | "schemas" | "folders"
2310 ) {
2311 continue;
2312 }
2313 let file_line = fm_end_line + section.line;
2316 push(
2317 issues,
2318 Severity::Warning,
2319 codes::DB_MD_UNKNOWN_SECTION,
2320 rel,
2321 Some(file_line),
2322 None,
2323 format!(
2324 "DB.md has an unrecognized `## {}` section",
2325 section.heading.trim()
2326 ),
2327 Some(
2328 "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2329 `## Folders` — remove or rename this heading"
2330 .into(),
2331 ),
2332 vec![],
2333 );
2334 }
2335
2336 check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2341}
2342
2343fn check_db_md_schemas(
2350 store: &Store,
2351 rel: &Path,
2352 body: &str,
2353 fm_end_line: u32,
2354 issues: &mut Vec<Issue>,
2355) {
2356 if store.config.schemas.is_empty() {
2357 return;
2358 }
2359
2360 let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2365 let mut current_h2: Option<String> = None;
2366 for section in crate::parser::extract_sections(body) {
2367 match section.level {
2368 2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2369 3 if current_h2.as_deref() == Some("schemas") => {
2370 type_line
2373 .entry(section.heading.trim().to_string())
2374 .or_insert(fm_end_line + section.line);
2375 }
2376 _ => {}
2377 }
2378 }
2379
2380 for (type_name, schema) in &store.config.schemas {
2381 let line = type_line.get(type_name).copied();
2382 let mut seen: BTreeSet<String> = BTreeSet::new();
2383 for field in &schema.fields {
2384 let name = field.name.trim();
2385
2386 if name.is_empty() {
2390 push(
2391 issues,
2392 Severity::Warning,
2393 codes::DB_MD_SCHEMA_FIELD,
2394 rel,
2395 line,
2396 None,
2397 format!("`### {type_name}` has a schema field bullet with no field name"),
2398 Some(
2399 "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2400 .into(),
2401 ),
2402 vec![],
2403 );
2404 continue;
2405 }
2406
2407 if !seen.insert(name.to_string()) {
2411 push(
2412 issues,
2413 Severity::Warning,
2414 codes::DB_MD_SCHEMA_FIELD,
2415 rel,
2416 line,
2417 Some(name.to_string()),
2418 format!("`### {type_name}` declares field `{name}` more than once"),
2419 Some(
2420 "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2421 ),
2422 vec![],
2423 );
2424 }
2425
2426 for modifier in &field.unknown_modifiers {
2431 let modifier = modifier.trim();
2432 if modifier.is_empty() {
2433 continue;
2434 }
2435 push(
2436 issues,
2437 Severity::Info,
2438 codes::DB_MD_SCHEMA_FIELD,
2439 rel,
2440 line,
2441 Some(name.to_string()),
2442 format!(
2443 "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2444 ),
2445 Some(
2446 "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2447 .into(),
2448 ),
2449 vec![],
2450 );
2451 }
2452 }
2453 }
2454}
2455
2456fn not_a_store_issue(store: &Store) -> Issue {
2458 Issue {
2459 severity: Severity::Error,
2460 code: codes::NOT_A_STORE,
2461 file: store.root.clone(),
2462 line: None,
2463 key: None,
2464 message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2465 suggestion: Some("create a `DB.md` at the store root".into()),
2466 related: vec![],
2467 }
2468}
2469
2470fn is_content_file(rel: &Path) -> bool {
2473 if !is_safe_store_relative_path(rel) {
2479 return false;
2480 }
2481 let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2482 return false;
2483 };
2484 if !matches!(first, "sources" | "records") {
2485 return false;
2486 }
2487 let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2488 if matches!(name, "index.md" | "index.jsonl") {
2494 return false;
2495 }
2496 name.ends_with(".md")
2497}
2498
2499fn is_root_meta_file(rel: &Path) -> bool {
2506 let mut comps = rel.components();
2507 let Some(Component::Normal(only)) = comps.next() else {
2508 return false;
2509 };
2510 if comps.next().is_some() {
2511 return false; }
2513 matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2514}
2515
2516fn is_index_catalog_file(rel: &Path) -> bool {
2524 matches!(
2525 rel.file_name().and_then(|n| n.to_str()),
2526 Some("index.md") | Some("index.jsonl")
2527 )
2528}
2529
2530fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2534 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2539 let mut lines = text.lines();
2540 let first = lines.next()?;
2541 if first.trim_end() != "---" {
2542 return None;
2543 }
2544 let mut yaml = String::new();
2545 let mut close_line: Option<u32> = None;
2546 let mut current = 1u32;
2548 for line in lines {
2549 current += 1;
2550 if line.trim_end() == "---" {
2551 close_line = Some(current);
2552 break;
2553 }
2554 yaml.push_str(line);
2555 yaml.push('\n');
2556 }
2557 let close_line = close_line?;
2558 let body: String = text
2560 .lines()
2561 .skip(close_line as usize)
2562 .collect::<Vec<_>>()
2563 .join("\n");
2564 Some((yaml, body, close_line))
2565}
2566
2567fn read_summary(abs: &Path) -> Option<String> {
2569 let text = std::fs::read_to_string(abs).ok()?;
2570 let (yaml, _, _) = split_frontmatter(&text)?;
2571 let value: Value = serde_norway::from_str(&yaml).ok()?;
2572 if let Value::Mapping(m) = value {
2573 m.get(Value::String("summary".into()))
2574 .and_then(scalar_string)
2575 } else {
2576 None
2577 }
2578}
2579
2580fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2583 let mut out = BTreeMap::new();
2584 for (k, v) in map {
2585 if let Value::String(s) = k {
2586 out.insert(s.clone(), v.clone());
2587 }
2588 }
2589 out
2590}
2591
2592fn scalar_string(v: &Value) -> Option<String> {
2595 match v {
2596 Value::String(s) => Some(s.clone()),
2597 Value::Number(n) => Some(n.to_string()),
2598 Value::Bool(b) => Some(b.to_string()),
2599 _ => None,
2600 }
2601}
2602
2603fn is_empty_value(v: &Value) -> bool {
2610 match v {
2611 Value::Null => true,
2612 Value::Sequence(items) => items.is_empty(),
2613 Value::Mapping(map) => map.is_empty(),
2614 other => scalar_string(other)
2615 .map(|s| s.trim().is_empty())
2616 .unwrap_or(true),
2617 }
2618}
2619
2620fn is_flat_scalar_list(v: &Value) -> bool {
2623 match v {
2624 Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2625 _ => false,
2626 }
2627}
2628
2629fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2639 let mut out = Vec::new();
2640 for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2641 for link in links {
2642 out.push((key.clone(), link));
2643 }
2644 }
2645 out
2646}
2647
2648fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2652 for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2653 if k == key {
2654 return links;
2655 }
2656 }
2657 Vec::new()
2658}
2659
2660fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2664 for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2665 if k == key {
2666 return Some(value_text);
2667 }
2668 }
2669 None
2670}
2671
2672fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2679 let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2680 let mut current: Option<(String, String, Vec<Link>)> = None;
2681
2682 for (idx, raw_line) in fm_yaml.lines().enumerate() {
2683 let file_line = fm_start_line + idx as u32;
2684 let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2685 let trimmed = raw_line.trim();
2686
2687 let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2690 top_level_key(raw_line)
2691 } else {
2692 None
2693 };
2694
2695 if let Some((key, after)) = new_key {
2696 if let Some(done) = current.take() {
2697 blocks.push(done);
2698 }
2699 let mut links = Vec::new();
2700 collect_line_links(after, file_line, &mut links);
2701 current = Some((key, after.trim().to_string(), links));
2702 } else if let Some((_k, value_text, links)) = current.as_mut() {
2703 if !value_text.is_empty() {
2705 value_text.push('\n');
2706 }
2707 value_text.push_str(trimmed);
2708 collect_line_links(raw_line, file_line, links);
2709 }
2710 }
2711 if let Some(done) = current.take() {
2712 blocks.push(done);
2713 }
2714 blocks
2715}
2716
2717fn top_level_key(line: &str) -> Option<(String, &str)> {
2720 let (key, rest) = line.split_once(':')?;
2721 let key = key.trim();
2722 if key.is_empty()
2723 || !key
2724 .chars()
2725 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2726 {
2727 return None;
2728 }
2729 Some((key.to_string(), rest))
2730}
2731
2732fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2735 let bytes = s.as_bytes();
2736 let mut i = 0;
2737 while i + 1 < bytes.len() {
2738 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2739 if let Some(close) = s[i + 2..].find("]]") {
2740 let inner = &s[i + 2..i + 2 + close];
2741 let target = inner
2744 .trim_start_matches('[')
2745 .split('|')
2746 .next()
2747 .unwrap_or(inner)
2748 .trim()
2749 .to_string();
2750 if !target.is_empty() {
2751 links.push(Link {
2752 target,
2753 line: file_line,
2754 });
2755 }
2756 i = i + 2 + close + 2;
2757 continue;
2758 }
2759 }
2760 i += 1;
2761 }
2762}
2763
2764fn extract_wiki_links(body: &str) -> Vec<Link> {
2776 let mut out = Vec::new();
2777 let mut fence: Option<(u8, usize)> = None;
2778 for (idx, line) in body.lines().enumerate() {
2779 let content = line.trim_end_matches('\r');
2780 if let Some(f) = fence {
2781 if fence_closes(content, f) {
2785 fence = None;
2786 }
2787 continue;
2788 }
2789 if let Some(opened) = fence_opens(content) {
2790 fence = Some(opened);
2791 continue;
2792 }
2793 let line_no = (idx + 1) as u32;
2794 let bytes = line.as_bytes();
2795 let mut i = 0;
2796 while i + 1 < bytes.len() {
2797 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2798 if let Some(close) = line[i + 2..].find("]]") {
2799 let inner = &line[i + 2..i + 2 + close];
2800 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
2801 if !target.is_empty() && !target.starts_with('[') {
2809 out.push(Link {
2810 target,
2811 line: line_no,
2812 });
2813 }
2814 i = i + 2 + close + 2;
2815 continue;
2816 }
2817 }
2818 i += 1;
2819 }
2820 }
2821 out
2822}
2823
2824fn fence_opens(line: &str) -> Option<(u8, usize)> {
2830 let indent = line.len() - line.trim_start_matches(' ').len();
2831 if indent > 3 {
2832 return None;
2833 }
2834 let rest = &line[indent..];
2835 let byte = rest.bytes().next()?;
2836 if byte != b'`' && byte != b'~' {
2837 return None;
2838 }
2839 let run = rest.len() - rest.trim_start_matches(byte as char).len();
2840 if run < 3 {
2841 return None;
2842 }
2843 if byte == b'`' && rest[run..].contains('`') {
2845 return None;
2846 }
2847 Some((byte, run))
2848}
2849
2850fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
2855 let (byte, open_len) = fence;
2856 let indent = line.len() - line.trim_start_matches(' ').len();
2857 if indent > 3 {
2858 return false;
2859 }
2860 let rest = &line[indent..];
2861 let run = rest.len() - rest.trim_start_matches(byte as char).len();
2862 if run < open_len {
2863 return false;
2864 }
2865 rest[run..].trim().is_empty()
2866}
2867
2868fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
2885 let mut out = Vec::new();
2886 for line in fm_yaml.lines() {
2887 if line.starts_with(' ') || line.starts_with('\t') {
2889 continue;
2890 }
2891 let Some((key, rest)) = line.split_once(':') else {
2892 continue;
2893 };
2894 let key = key.trim();
2895 if key.is_empty()
2896 || key.starts_with('#')
2897 || key.starts_with('-')
2898 || !key
2899 .chars()
2900 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2901 {
2902 continue;
2903 }
2904 let rest = rest.trim();
2905 if !rest.starts_with('[') {
2908 continue;
2909 }
2910 if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
2915 let nested = items.iter().any(|item| match item {
2916 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
2917 _ => false,
2918 });
2919 if nested {
2920 out.push(key.to_string());
2921 }
2922 }
2923 }
2924 out
2925}
2926
2927fn is_full_store_path(bare: &str) -> bool {
2930 let mut parts = bare.splitn(2, '/');
2931 let first = parts.next().unwrap_or("");
2932 let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
2933 matches!(first, "sources" | "records") && has_rest
2934}
2935
2936fn is_safe_store_relative_path(path: &Path) -> bool {
2940 let mut saw_component = false;
2941 for component in path.components() {
2942 match component {
2943 Component::Normal(_) => saw_component = true,
2944 Component::CurDir => {}
2945 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
2946 }
2947 }
2948 saw_component
2949}
2950
2951fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
2952 let path = Path::new(bare);
2953 if !is_safe_store_relative_path(path) {
2954 return None;
2955 }
2956 Some(PathBuf::from(format!("{bare}.md")))
2957}
2958
2959enum TargetResolution {
2961 Exists,
2963 Missing,
2965 Unsafe,
2967}
2968
2969fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
2978 if !is_safe_store_relative_path(Path::new(bare)) {
2982 return TargetResolution::Unsafe;
2983 }
2984 match resolved_target_abs(store, bare) {
2985 Some(_) => TargetResolution::Exists,
2986 None => TargetResolution::Missing,
2987 }
2988}
2989
2990fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
2996 if !is_safe_store_relative_path(Path::new(bare)) {
2997 return None;
2998 }
2999 let literal = store.root.join(bare);
3002 if literal.is_file() {
3003 return Some(literal);
3004 }
3005 let with_md = store.root.join(format!("{bare}.md"));
3007 if with_md.is_file() {
3008 return Some(with_md);
3009 }
3010 None
3011}
3012
3013fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3015 let prefix = prefix.trim_end_matches('/');
3016 bare == prefix || bare.starts_with(&format!("{prefix}/"))
3017}
3018
3019fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3023 let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3024 if comps.len() < 3 {
3025 return None; }
3027 if !matches!(comps[0], "sources" | "records") {
3028 return None;
3029 }
3030 Some(PathBuf::from(comps[0]).join(comps[1]))
3031}
3032
3033fn walk_content_files(root: &Path) -> Vec<PathBuf> {
3048 let mut out = Vec::new();
3049 for layer in ["sources", "records"] {
3050 let base = root.join(layer);
3051 if !base.is_dir() {
3052 continue;
3053 }
3054 for entry in walkdir::WalkDir::new(&base)
3055 .follow_links(true)
3066 .into_iter()
3067 .filter_entry(|e| {
3068 let name = e.file_name().to_str().unwrap_or("");
3069 !name.starts_with('.')
3070 })
3071 .flatten()
3072 {
3073 if !entry.file_type().is_file() {
3074 continue;
3075 }
3076 let name = entry.file_name().to_str().unwrap_or("");
3077 if name.ends_with(".md") && name != "index.md" {
3078 if let Ok(rel) = entry.path().strip_prefix(root) {
3079 out.push(rel.to_path_buf());
3080 }
3081 }
3082 }
3083 }
3084 out.sort();
3085 out
3086}
3087
3088fn walk_index_files(root: &Path) -> Vec<PathBuf> {
3095 let mut out = Vec::new();
3096 if root.join("index.md").is_file() {
3097 out.push(PathBuf::from("index.md"));
3098 }
3099 for layer in ["sources", "records"] {
3100 let base = root.join(layer);
3101 if !base.is_dir() {
3102 continue;
3103 }
3104 for entry in walkdir::WalkDir::new(&base)
3105 .follow_links(true)
3116 .into_iter()
3117 .filter_entry(|e| {
3118 let name = e.file_name().to_str().unwrap_or("");
3119 !name.starts_with('.')
3120 })
3121 .flatten()
3122 {
3123 if entry.file_type().is_file() && entry.file_name().to_str() == Some("index.md") {
3124 if let Ok(rel) = entry.path().strip_prefix(root) {
3125 out.push(rel.to_path_buf());
3126 }
3127 }
3128 }
3129 }
3130 out.sort();
3131 out
3132}
3133
3134struct IndexEntry {
3137 target: String,
3138 summary_text: Option<String>,
3139 line: u32,
3140}
3141
3142fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3147 let mut out = Vec::new();
3148 let mut in_more = false;
3149 for (idx, line) in text.lines().enumerate() {
3150 let trimmed = line.trim_start();
3151 if trimmed.starts_with("## More") {
3152 in_more = true;
3153 continue;
3154 }
3155 if in_more {
3156 continue;
3157 }
3158 if !trimmed.starts_with("- ") {
3159 continue;
3160 }
3161 let Some(open) = trimmed.find("[[") else {
3163 continue;
3164 };
3165 let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3166 continue;
3167 };
3168 let inner = &trimmed[open + 2..open + 2 + close_rel];
3169 let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3170
3171 let after = &trimmed[open + 2 + close_rel + 2..];
3173 let summary_text = extract_index_entry_summary(after);
3174
3175 out.push(IndexEntry {
3176 target,
3177 summary_text,
3178 line: (idx + 1) as u32,
3179 });
3180 }
3181 out
3182}
3183
3184fn extract_index_entry_summary(after: &str) -> Option<String> {
3190 let mut s = after.trim();
3191 if s.starts_with('(') {
3193 if let Some(close) = s.find(')') {
3194 s = s[close + 1..].trim_start();
3195 }
3196 }
3197 let s = if let Some(rest) = s.strip_prefix('—') {
3199 rest.trim()
3200 } else if let Some(rest) = s.strip_prefix('-') {
3201 rest.trim()
3202 } else {
3203 return None;
3204 };
3205 if s.is_empty() {
3206 return None;
3207 }
3208 let s = match s.rsplit_once(" · ") {
3223 Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3224 _ => s,
3225 };
3226 Some(s.to_string())
3227}
3228
3229fn is_tag_suffix(s: &str) -> bool {
3234 let mut any = false;
3235 for tok in s.split_whitespace() {
3236 if !tok.starts_with('#') || tok.len() < 2 {
3237 return false;
3238 }
3239 any = true;
3240 }
3241 any
3242}
3243
3244fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3248 let rest = line.strip_prefix("## [")?;
3249 let close = rest.find(']')?;
3250 let ts_str = &rest[..close];
3251 let tail = rest[close + 1..].trim();
3252
3253 let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3256 let offset = FixedOffset::east_opt(0)?;
3257 let ts = naive.and_local_timezone(offset).single()?;
3258
3259 let (kind, object) = match tail.split_once('|') {
3261 Some((k, o)) => {
3262 let o = o.trim();
3263 (
3264 k.trim().to_string(),
3265 if o.is_empty() {
3266 None
3267 } else {
3268 Some(o.to_string())
3269 },
3270 )
3271 }
3272 None => (tail.to_string(), None),
3273 };
3274 if kind.is_empty() {
3275 return None;
3276 }
3277 Some((ts, kind, object))
3278}
3279
3280fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3290 let mut files = vec![store.root.join("log.md")];
3291 let archive_dir = store.root.join("log");
3292 if let Ok(entries) = std::fs::read_dir(&archive_dir) {
3293 let mut archives: Vec<PathBuf> = entries
3294 .flatten()
3295 .map(|e| e.path())
3296 .filter(|p| {
3297 p.is_file()
3298 && p.file_name()
3299 .and_then(|s| s.to_str())
3300 .and_then(|n| n.strip_suffix(".md"))
3301 .is_some_and(is_year_month_archive)
3302 })
3303 .collect();
3304 archives.sort();
3308 files.extend(archives);
3309 }
3310 files
3311}
3312
3313fn is_year_month_archive(s: &str) -> bool {
3316 let b = s.as_bytes();
3317 b.len() == 7
3318 && b[..4].iter().all(u8::is_ascii_digit)
3319 && b[4] == b'-'
3320 && b[5..7].iter().all(u8::is_ascii_digit)
3321}
3322
3323fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3329 let mut latest: Option<DateTime<FixedOffset>> = None;
3330 for file in log_files_for_working_set(store) {
3331 let Ok(text) = std::fs::read_to_string(&file) else {
3332 continue;
3333 };
3334 for line in text.lines() {
3335 if !line.starts_with("## [") {
3336 continue;
3337 }
3338 if let Some((ts, kind, _)) = parse_log_header(line) {
3339 if kind == "validate" {
3340 latest = Some(match latest {
3341 Some(p) if p >= ts => p,
3342 _ => ts,
3343 });
3344 }
3345 }
3346 }
3347 }
3348 latest
3349}
3350
3351fn changed_objects_since(
3362 store: &Store,
3363 cutoff: Option<DateTime<FixedOffset>>,
3364) -> BTreeSet<PathBuf> {
3365 let mut out = BTreeSet::new();
3366 for file in log_files_for_working_set(store) {
3367 let Ok(text) = std::fs::read_to_string(&file) else {
3368 continue;
3369 };
3370 for line in text.lines() {
3371 if !line.starts_with("## [") {
3372 continue;
3373 }
3374 let Some((ts, kind, object)) = parse_log_header(line) else {
3375 continue;
3376 };
3377 if let Some(c) = cutoff {
3378 if ts < c {
3379 continue;
3380 }
3381 }
3382 if !matches!(
3383 kind.as_str(),
3384 "create" | "update" | "ingest" | "rename" | "delete" | "link"
3385 ) {
3386 continue;
3387 }
3388 if let Some(obj) = object {
3389 let bare = obj
3391 .trim()
3392 .trim_start_matches("[[")
3393 .trim_end_matches("]]")
3394 .split('|')
3395 .next()
3396 .unwrap_or("")
3397 .trim()
3398 .trim_end_matches(".md")
3399 .to_string();
3400 if bare.is_empty() {
3401 continue;
3402 }
3403 if let Some(rel) = safe_md_target_rel(&bare) {
3413 out.insert(rel);
3414 }
3415 }
3416 }
3417 }
3418 out
3419}
3420
3421#[derive(Debug, Clone, PartialEq, Eq)]
3426pub struct DerivedFromIgnored {
3427 pub target: String,
3430 pub target_type: String,
3433}
3434
3435pub fn derived_from_ignored_type<I, S>(
3449 store: &Store,
3450 meta_type: &str,
3451 derived_from_targets: I,
3452) -> Option<DerivedFromIgnored>
3453where
3454 I: IntoIterator<Item = S>,
3455 S: AsRef<str>,
3456{
3457 if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3458 return None;
3459 }
3460 for target in derived_from_targets {
3461 let target = target.as_ref();
3462 if let Some(target_type) = link_target_type(store, target) {
3463 if store.config.ignored_types.contains(&target_type) {
3464 return Some(DerivedFromIgnored {
3465 target: target.to_string(),
3466 target_type,
3467 });
3468 }
3469 }
3470 }
3471 None
3472}
3473
3474fn link_target_type(store: &Store, target: &str) -> Option<String> {
3476 let bare = target.trim_end_matches(".md");
3477 let abs = store.root.join(safe_md_target_rel(bare)?);
3478 let text = std::fs::read_to_string(&abs).ok()?;
3479 let (yaml, _, _) = split_frontmatter(&text)?;
3480 let value: Value = serde_norway::from_str(&yaml).ok()?;
3481 if let Value::Mapping(m) = value {
3482 m.get(Value::String("type".into())).and_then(scalar_string)
3483 } else {
3484 None
3485 }
3486}
3487
3488fn is_iso8601(s: &str) -> bool {
3493 DateTime::parse_from_rfc3339(s.trim()).is_ok()
3494}
3495
3496fn is_iso8601_date_or_datetime(s: &str) -> bool {
3500 let s = s.trim();
3501 if DateTime::parse_from_rfc3339(s).is_ok() {
3502 return true;
3503 }
3504 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3505}
3506
3507fn is_email(s: &str) -> bool {
3512 let s = s.trim();
3513 let Some((local, domain)) = s.split_once('@') else {
3514 return false;
3515 };
3516 !local.is_empty()
3517 && !domain.contains('@')
3518 && domain.contains('.')
3519 && !domain.starts_with('.')
3520 && !domain.ends_with('.')
3521 && !domain.contains(' ')
3522 && !local.contains(' ')
3523}
3524
3525fn is_currency(s: &str) -> bool {
3532 let mut t = s.trim();
3533 for sym in ["$", "€", "£", "¥"] {
3535 if let Some(rest) = t.strip_prefix(sym) {
3536 t = rest.trim_start();
3537 break;
3538 }
3539 }
3540 if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3544 if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3545 t = rest.trim_start();
3546 }
3547 }
3548
3549 let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3550 is_plain_amount(cleaned.trim())
3551}
3552
3553fn is_plain_amount(s: &str) -> bool {
3556 let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3557 let (int_part, frac_part) = match digits.split_once('.') {
3558 Some((i, f)) => (i, Some(f)),
3559 None => (digits, None),
3560 };
3561 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3562 return false;
3563 }
3564 match frac_part {
3565 None => true,
3566 Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3567 }
3568}
3569
3570fn is_url(s: &str) -> bool {
3576 let s = s.trim();
3577 for scheme in ["http://", "https://"] {
3578 if let Some(rest) = s.strip_prefix(scheme) {
3579 return !rest.is_empty();
3580 }
3581 }
3582 false
3583}
3584
3585fn shape_suggestion(shape: Shape) -> String {
3587 match shape {
3588 Shape::String => "use a scalar string".into(),
3589 Shape::Int => "use an integer".into(),
3590 Shape::Bool => "use `true` or `false`".into(),
3591 Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3592 Shape::Email => "use a `<local>@<domain>` address".into(),
3593 Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3594 Shape::Url => "use an http(s) URL".into(),
3595 }
3596}
3597
3598fn short_form_suggestion(bare: &str) -> Option<String> {
3601 Some(format!(
3602 "use a full store-relative path, e.g. [[records/contacts/{}]]",
3603 slugish(bare)
3604 ))
3605}
3606
3607fn slugish(s: &str) -> String {
3609 s.trim()
3610 .to_lowercase()
3611 .chars()
3612 .map(|c| if c.is_whitespace() { '-' } else { c })
3613 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3614 .collect()
3615}
3616
3617fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3623 use crate::assets;
3624
3625 let manifest_rel = Path::new(assets::MANIFEST_FILE);
3626 let manifest_abs = store.root.join(assets::MANIFEST_FILE);
3627
3628 let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3630 if let Ok(text) = std::fs::read_to_string(&manifest_abs) {
3631 for (i, line) in text.lines().enumerate() {
3632 if line.trim().is_empty() {
3633 continue;
3634 }
3635 match serde_json::from_str::<assets::AssetRecord>(line) {
3636 Ok(rec) => {
3637 manifest.insert(rec.path.clone(), rec);
3638 }
3639 Err(e) => push(
3640 issues,
3641 Severity::Error,
3642 codes::ASSET_MANIFEST_MALFORMED,
3643 manifest_rel,
3644 Some((i as u32) + 1),
3645 None,
3646 format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3647 Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3648 vec![],
3649 ),
3650 }
3651 }
3652 }
3653
3654 let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3657 for (rel, p) in parsed {
3658 let Some(map) = &p.fm else {
3659 continue;
3660 };
3661 for decl in assets::declarations_from_yaml_map(map) {
3662 let norm = match assets::normalize_asset_path(&decl.path) {
3663 Ok(n) => n,
3664 Err(_) => continue, };
3666 declared.insert(norm.clone());
3667 let is_md = Path::new(&norm)
3668 .extension()
3669 .and_then(|e| e.to_str())
3670 .map(|e| e.eq_ignore_ascii_case("md"))
3671 .unwrap_or(false);
3672 if is_md {
3673 push(
3674 issues,
3675 Severity::Warning,
3676 codes::ASSET_PATH_IS_CONTENT,
3677 rel,
3678 None,
3679 Some("asset".to_string()),
3680 format!("asset path `{norm}` points at a markdown content file"),
3681 Some("assets are raw binaries; reference a non-markdown path".to_string()),
3682 vec![PathBuf::from(&norm)],
3683 );
3684 }
3685 if !manifest.contains_key(&norm) {
3686 push(
3687 issues,
3688 Severity::Error,
3689 codes::ASSET_UNDECLARED,
3690 rel,
3691 None,
3692 Some("asset".to_string()),
3693 format!(
3694 "references asset `{norm}` with no record in {}",
3695 assets::MANIFEST_FILE
3696 ),
3697 Some("run `dbmd assets scan` to catalog it".to_string()),
3698 vec![PathBuf::from(&norm)],
3699 );
3700 }
3701 }
3702 }
3703
3704 for (path, rec) in &manifest {
3706 for w in &rec.wrappers {
3707 if !store.root.join(w).is_file() {
3708 push(
3709 issues,
3710 Severity::Error,
3711 codes::ASSET_WRAPPER_BROKEN,
3712 Path::new(path),
3713 None,
3714 None,
3715 format!("manifest record for `{path}` names a missing wrapper `{w}`"),
3716 Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
3717 vec![PathBuf::from(w)],
3718 );
3719 }
3720 }
3721 if !declared.contains(path) {
3722 push(
3723 issues,
3724 Severity::Warning,
3725 codes::ASSET_MANIFEST_ORPHAN,
3726 Path::new(path),
3727 None,
3728 None,
3729 format!(
3730 "`{path}` is in {} but no wrapper references it",
3731 assets::MANIFEST_FILE
3732 ),
3733 Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
3734 vec![],
3735 );
3736 }
3737 }
3738}
3739
3740#[allow(clippy::too_many_arguments)]
3742fn push(
3743 issues: &mut Vec<Issue>,
3744 severity: Severity,
3745 code: &'static str,
3746 file: &Path,
3747 line: Option<u32>,
3748 key: Option<String>,
3749 message: String,
3750 suggestion: Option<String>,
3751 related: Vec<PathBuf>,
3752) {
3753 issues.push(Issue {
3754 severity,
3755 code,
3756 file: file.to_path_buf(),
3757 line,
3758 key,
3759 message,
3760 suggestion,
3761 related,
3762 });
3763}
3764
3765fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
3768 for (i, line) in fm_yaml.lines().enumerate() {
3769 let trimmed = line.trim_start();
3770 if let Some(rest) = trimmed.strip_prefix(key) {
3772 if rest.starts_with(':') && line.starts_with(key) {
3773 return Some((i as u32) + 2);
3775 }
3776 }
3777 }
3778 None
3779}
3780
3781fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
3787 fm_key_line(fm_yaml, key).or(Some(1))
3788}
3789
3790fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
3793 a.file
3794 .cmp(&b.file)
3795 .then(a.line.cmp(&b.line))
3796 .then(a.code.cmp(b.code))
3797 .then(a.key.cmp(&b.key))
3798}
3799
3800#[cfg(test)]
3805mod tests {
3806 use super::*;
3807 use crate::parser::{Config, FieldSpec};
3808 use std::fs;
3809 use tempfile::TempDir;
3810
3811 #[test]
3812 fn split_frontmatter_tolerates_leading_bom() {
3813 let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
3818 let parsed = split_frontmatter(text);
3819 assert!(
3820 parsed.is_some(),
3821 "a leading BOM must not hide frontmatter from validate"
3822 );
3823 let (yaml, body, close_line) = parsed.unwrap();
3824 assert_eq!(yaml, "type: contact\nsummary: hi\n");
3825 assert_eq!(body, "body");
3826 assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
3827 }
3828
3829 struct Fixture {
3832 dir: TempDir,
3833 config: Config,
3834 }
3835
3836 impl Fixture {
3837 fn new() -> Self {
3842 let dir = TempDir::new().unwrap();
3843 fs::write(
3844 dir.path().join("DB.md"),
3845 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
3846 )
3847 .unwrap();
3848 for layer in ["sources", "records"] {
3849 fs::create_dir_all(dir.path().join(layer)).unwrap();
3850 }
3851 Fixture {
3852 dir,
3853 config: Config::default(),
3854 }
3855 }
3856
3857 fn bare() -> Self {
3859 let dir = TempDir::new().unwrap();
3860 Fixture {
3861 dir,
3862 config: Config::default(),
3863 }
3864 }
3865
3866 fn write(&self, rel: &str, contents: &str) {
3868 let abs = self.dir.path().join(rel);
3869 fs::create_dir_all(abs.parent().unwrap()).unwrap();
3870 fs::write(abs, contents).unwrap();
3871 }
3872
3873 fn store(&self) -> Store {
3874 Store {
3875 root: self.dir.path().to_path_buf(),
3876 config: self.config.clone(),
3877 }
3878 }
3879
3880 fn store_all(&self) -> Vec<Issue> {
3881 validate_all(&self.store()).unwrap()
3882 }
3883
3884 fn rebuild_indexes(&self) {
3891 crate::index::Index::rebuild_all(&self.store()).unwrap();
3892 }
3893 }
3894
3895 fn has(issues: &[Issue], code: &str) -> bool {
3897 issues.iter().any(|i| i.code == code)
3898 }
3899
3900 fn count(issues: &[Issue], code: &str) -> usize {
3902 issues.iter().filter(|i| i.code == code).count()
3903 }
3904
3905 fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
3907 issues
3908 .iter()
3909 .find(|i| i.code == code)
3910 .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
3911 }
3912
3913 fn valid_contact(summary: &str) -> String {
3915 format!(
3916 "---\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"
3917 )
3918 }
3919
3920 #[test]
3923 fn not_a_store_when_db_md_absent() {
3924 let fx = Fixture::bare();
3925 let issues = fx.store_all();
3926 assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
3927 assert_eq!(issues[0].code, codes::NOT_A_STORE);
3928 assert!(issues[0].is_error());
3929 }
3930
3931 #[test]
3932 fn working_set_also_reports_not_a_store() {
3933 let fx = Fixture::bare();
3934 let issues = validate_working_set(&fx.store(), None).unwrap();
3935 assert!(has(&issues, codes::NOT_A_STORE));
3936 }
3937
3938 #[test]
3939 fn clean_store_has_no_issues() {
3940 let fx = Fixture::new();
3941 fx.write("records/contacts/a.md", &valid_contact("A contact"));
3942 fx.rebuild_indexes();
3946 let issues = fx.store_all();
3947 assert!(
3948 issues.is_empty(),
3949 "expected a clean store, got: {issues:#?}"
3950 );
3951 }
3952
3953 #[test]
3961 fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
3962 let fx = Fixture::new();
3963 let body = |mt: &str| {
3964 format!(
3965 "---\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"
3966 )
3967 };
3968
3969 for ok in ["fact", "operational", "conclusion"] {
3971 fx.write("records/profiles/ok.md", &body(ok));
3972 let issues = validate_working_set(&fx.store(), None).unwrap();
3973 assert!(
3974 !has(&issues, codes::FM_BAD_META_TYPE),
3975 "`meta-type: {ok}` must be accepted; got {issues:#?}"
3976 );
3977 }
3978 fx.write(
3979 "records/profiles/absent.md",
3980 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
3981 );
3982 assert!(
3983 !has(
3984 &validate_working_set(&fx.store(), None).unwrap(),
3985 codes::FM_BAD_META_TYPE
3986 ),
3987 "an absent meta-type is the default `fact` and must be accepted"
3988 );
3989
3990 for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
3992 let fx2 = Fixture::new();
3993 fx2.write("records/profiles/bad.md", &body(bad));
3994 let issues = validate_working_set(&fx2.store(), None).unwrap();
3995 assert!(
3996 has(&issues, codes::FM_BAD_META_TYPE),
3997 "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
3998 );
3999 }
4000 }
4001
4002 #[test]
4008 fn valid_db_md_emits_no_structure_issue() {
4009 let fx = Fixture::new();
4010 let issues = fx.store_all();
4011 assert!(
4012 !has(&issues, codes::DB_MD_BAD_TYPE)
4013 && !has(&issues, codes::DB_MD_MISSING_FIELD)
4014 && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4015 "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4016 );
4017 }
4018
4019 #[test]
4023 fn db_md_wrong_type_is_error() {
4024 let fx = Fixture::new();
4025 fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4026 let issues = fx.store_all();
4027 let i = find(&issues, codes::DB_MD_BAD_TYPE);
4028 assert!(i.is_error());
4029 assert_eq!(i.file, PathBuf::from("DB.md"));
4030 assert_eq!(i.key.as_deref(), Some("type"));
4031 assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4032 }
4033
4034 #[test]
4037 fn db_md_missing_scope_and_owner_each_report() {
4038 let fx = Fixture::new();
4039 fx.write("DB.md", "---\ntype: db-md\n---\n");
4040 let issues = fx.store_all();
4041 assert_eq!(
4042 count(&issues, codes::DB_MD_MISSING_FIELD),
4043 2,
4044 "both scope and owner absent → two issues: {issues:#?}"
4045 );
4046 let keys: BTreeSet<Option<String>> = issues
4047 .iter()
4048 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4049 .map(|i| i.key.clone())
4050 .collect();
4051 assert_eq!(
4052 keys,
4053 BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4054 "one issue keyed on each missing field"
4055 );
4056 for i in issues
4057 .iter()
4058 .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4059 {
4060 assert!(i.is_error());
4061 assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4062 }
4063 }
4064
4065 #[test]
4069 fn db_md_blank_required_field_is_missing() {
4070 let fx = Fixture::new();
4071 fx.write(
4072 "DB.md",
4073 "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4074 );
4075 let issues = fx.store_all();
4076 let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4077 assert_eq!(i.key.as_deref(), Some("owner"));
4078 assert_eq!(
4079 i.line,
4080 Some(4),
4081 "a present-but-empty field anchors to its line"
4082 );
4083 assert!(
4084 count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4085 "scope is present and non-empty → only owner reported"
4086 );
4087 }
4088
4089 #[test]
4092 fn db_md_unknown_section_is_warning() {
4093 let fx = Fixture::new();
4094 fx.write(
4095 "DB.md",
4096 "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4100 );
4101 let issues = fx.store_all();
4102 let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4103 assert!(!i.is_error(), "unknown section is a warning, not an error");
4104 assert_eq!(i.severity, Severity::Warning);
4105 assert_eq!(
4106 i.line,
4107 Some(11),
4108 "anchors to the `## Glossary` heading line"
4109 );
4110 assert!(
4111 i.message.contains("Glossary"),
4112 "the message names the offending section: {}",
4113 i.message
4114 );
4115 assert_eq!(
4117 count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4118 1,
4119 "only the unrecognized section is flagged: {issues:#?}"
4120 );
4121 }
4122
4123 #[test]
4126 fn db_md_no_frontmatter_reports_type_and_both_fields() {
4127 let fx = Fixture::new();
4128 fx.write("DB.md", "# just a heading, no frontmatter\n");
4129 let issues = fx.store_all();
4130 assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4131 assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4132 }
4133
4134 #[test]
4137 fn missing_type_is_error() {
4138 let fx = Fixture::new();
4139 fx.write(
4140 "records/contacts/a.md",
4141 "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4142 );
4143 let issues = fx.store_all();
4144 assert!(has(&issues, codes::FM_MISSING_TYPE));
4145 assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4146 }
4147
4148 #[test]
4149 fn missing_universal_timestamps_are_errors_on_content_files() {
4150 let fx = Fixture::new();
4151 fx.write(
4152 "records/contacts/a.md",
4153 "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4154 );
4155 let issues = fx.store_all();
4156
4157 let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4158 assert_eq!(missing_created.key.as_deref(), Some("created"));
4159 assert!(missing_created.is_error());
4160
4161 let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4162 assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4163 assert!(missing_updated.is_error());
4164 }
4165
4166 #[test]
4167 fn meta_files_do_not_require_universal_timestamps() {
4168 let fx = Fixture::new();
4169 let issues = fx.store_all();
4170
4171 assert!(
4172 !has(&issues, codes::FM_MISSING_CREATED),
4173 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4174 );
4175 assert!(
4176 !has(&issues, codes::FM_MISSING_UPDATED),
4177 "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4178 );
4179 }
4180
4181 #[test]
4182 fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4183 let fx = Fixture::new();
4184 fx.write(
4185 "records/profiles/a.md",
4186 "# Just a heading\n\nNo frontmatter here.\n",
4187 );
4188 let issues = fx.store_all();
4189 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4190 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4191 }
4192
4193 #[test]
4194 fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4195 let fx = Fixture::new();
4196 fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4197 let issues = fx.store_all();
4198 assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4199 assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4200 }
4201
4202 #[test]
4203 fn malformed_yaml_is_error_and_suppresses_field_checks() {
4204 let fx = Fixture::new();
4205 fx.write(
4207 "records/contacts/a.md",
4208 "---\ntype: contact\n bad: : : :\n: : nope\n---\n\nbody\n",
4209 );
4210 let issues = fx.store_all();
4211 let issue = find(&issues, codes::FM_MALFORMED_YAML);
4212 assert!(issue.is_error());
4213 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4214 assert!(
4217 !has(&issues, codes::SUMMARY_MISSING),
4218 "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4219 );
4220 }
4221
4222 #[test]
4223 fn bad_created_timestamp_is_error() {
4224 let fx = Fixture::new();
4225 fx.write(
4226 "records/contacts/a.md",
4227 "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4228 );
4229 let issues = fx.store_all();
4230 let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4231 assert_eq!(issue.key.as_deref(), Some("created"));
4232 assert!(issue.is_error());
4233 }
4234
4235 #[test]
4236 fn date_only_created_is_rejected_but_type_date_field_accepted() {
4237 let fx = Fixture::new();
4238 fx.write(
4241 "records/contacts/a.md",
4242 "---\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",
4243 );
4244 let issues = fx.store_all();
4245 let created_issues: Vec<_> = issues
4246 .iter()
4247 .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4248 .collect();
4249 assert_eq!(
4250 created_issues.len(),
4251 1,
4252 "date-only `created` must fail: {issues:#?}"
4253 );
4254 assert!(
4255 !issues.iter().any(
4256 |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4257 ),
4258 "date-only `last_touch` is valid: {issues:#?}"
4259 );
4260 }
4261
4262 #[test]
4265 fn summary_missing_empty_multiline_toolong() {
4266 let fx = Fixture::new();
4267 fx.write(
4268 "records/profiles/missing.md",
4269 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4270 );
4271 fx.write(
4272 "records/profiles/empty.md",
4273 "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \" \"\n---\n\nbody\n",
4274 );
4275 let long = "x".repeat(201);
4276 fx.write(
4277 "records/profiles/long.md",
4278 &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"),
4279 );
4280 let issues = fx.store_all();
4281 assert!(has(&issues, codes::SUMMARY_MISSING));
4282 assert_eq!(
4283 find(&issues, codes::SUMMARY_MISSING).file,
4284 PathBuf::from("records/profiles/missing.md")
4285 );
4286 assert!(has(&issues, codes::SUMMARY_EMPTY));
4287 assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4288 assert_eq!(
4289 find(&issues, codes::SUMMARY_TOO_LONG).severity,
4290 Severity::Warning
4291 );
4292 }
4293
4294 #[test]
4295 fn summary_multiline_via_yaml_block_scalar() {
4296 let fx = Fixture::new();
4297 fx.write(
4299 "records/profiles/a.md",
4300 "---\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",
4301 );
4302 let issues = fx.store_all();
4303 assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4304 }
4305
4306 #[test]
4307 fn summary_exactly_200_chars_is_ok() {
4308 let fx = Fixture::new();
4309 let s = "y".repeat(200);
4310 fx.write(
4311 "records/profiles/a.md",
4312 &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"),
4313 );
4314 let issues = fx.store_all();
4315 assert!(
4316 !has(&issues, codes::SUMMARY_TOO_LONG),
4317 "200 is the bound, inclusive: {issues:#?}"
4318 );
4319 }
4320
4321 #[test]
4322 fn meta_files_need_no_summary() {
4323 let fx = Fixture::new();
4324 fx.write("records/contacts/a.md", &valid_contact("A contact"));
4327 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4328 fx.write(
4329 "records/index.md",
4330 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4331 );
4332 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4333 fx.write(
4334 "records/contacts/index.jsonl",
4335 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4336 );
4337 fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4338 let issues = fx.store_all();
4339 assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4340 }
4341
4342 #[test]
4345 fn nested_tags_warns_flat_tags_ok() {
4346 let fx = Fixture::new();
4347 fx.write(
4348 "records/contacts/nested.md",
4349 "---\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",
4350 );
4351 fx.write(
4352 "records/contacts/flat.md",
4353 "---\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",
4354 );
4355 let issues = fx.store_all();
4356 let tag_issues: Vec<_> = issues
4357 .iter()
4358 .filter(|i| i.code == codes::TAGS_MALFORMED)
4359 .collect();
4360 assert_eq!(
4361 tag_issues.len(),
4362 1,
4363 "only the nested-tags file should warn: {issues:#?}"
4364 );
4365 assert_eq!(
4366 tag_issues[0].file,
4367 PathBuf::from("records/contacts/nested.md")
4368 );
4369 assert_eq!(tag_issues[0].severity, Severity::Warning);
4370 }
4371
4372 #[test]
4375 fn short_form_wiki_link_is_error() {
4376 let fx = Fixture::new();
4377 let mut body = valid_contact("links to a short form");
4378 body.push_str("\nSee [[sarah-chen]] for details.\n");
4379 fx.write("records/contacts/a.md", &body);
4380 let issues = fx.store_all();
4381 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4382 assert!(issue.is_error());
4383 assert!(issue.message.contains("sarah-chen"));
4384 assert!(
4386 !issues
4387 .iter()
4388 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4389 "short-form should suppress broken: {issues:#?}"
4390 );
4391 }
4392
4393 #[test]
4394 fn broken_full_path_wiki_link_is_error() {
4395 let fx = Fixture::new();
4396 let mut body = valid_contact("links to a missing file");
4397 body.push_str("\nSee [[records/contacts/ghost]].\n");
4398 fx.write("records/contacts/a.md", &body);
4399 let issues = fx.store_all();
4400 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4401 assert!(issue.is_error());
4402 assert!(issue.message.contains("records/contacts/ghost"));
4403 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4404 }
4405
4406 #[test]
4407 fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4408 let fx = Fixture::new();
4409 let mut body = valid_contact("links with traversal");
4410 body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4411 fx.write("records/contacts/a.md", &body);
4412 let issues = fx.store_all();
4413 let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4414 assert!(issue.message.contains("not a safe store-relative path"));
4415 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4416 }
4417
4418 #[test]
4419 fn valid_full_path_wiki_link_passes() {
4420 let fx = Fixture::new();
4421 fx.write("records/contacts/target.md", &valid_contact("target"));
4422 let mut body = valid_contact("links to target");
4423 body.push_str("\nSee [[records/contacts/target]].\n");
4424 fx.write("records/contacts/a.md", &body);
4425 let issues = fx.store_all();
4426 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4427 assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4428 }
4429
4430 #[test]
4431 fn md_extension_wiki_link_warns_and_resolves() {
4432 let fx = Fixture::new();
4433 fx.write("records/contacts/target.md", &valid_contact("target"));
4434 let mut body = valid_contact("links with extension");
4435 body.push_str("\nSee [[records/contacts/target.md]].\n");
4436 fx.write("records/contacts/a.md", &body);
4437 let issues = fx.store_all();
4438 let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4439 assert_eq!(issue.severity, Severity::Warning);
4440 assert_eq!(
4441 issue.suggestion.as_deref(),
4442 Some("drop the extension: [[records/contacts/target]]")
4443 );
4444 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4446 }
4447
4448 #[test]
4449 fn wiki_links_in_code_fences_are_ignored() {
4450 let fx = Fixture::new();
4451 let mut body = valid_contact("has a fenced example");
4452 body.push_str("\n```\n[[sarah-chen]]\n```\n");
4453 fx.write("records/contacts/a.md", &body);
4454 let issues = fx.store_all();
4455 assert!(
4456 !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4457 "fenced wiki-links must be ignored: {issues:#?}"
4458 );
4459 }
4460
4461 #[test]
4462 fn flow_form_link_list_in_frontmatter_is_error() {
4463 let fx = Fixture::new();
4464 fx.write(
4465 "records/meetings/m.md",
4466 "---\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",
4467 );
4468 let issues = fx.store_all();
4469 let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4470 assert!(issue.is_error());
4471 assert_eq!(issue.key.as_deref(), Some("attendees"));
4472 }
4473
4474 #[test]
4475 fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4476 let fx = Fixture::new();
4477 fx.write("records/contacts/a.md", &valid_contact("a"));
4478 fx.write("records/contacts/b.md", &valid_contact("b"));
4479 fx.write(
4480 "records/meetings/m.md",
4481 "---\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",
4482 );
4483 let issues = fx.store_all();
4484 assert!(
4485 !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4486 "{issues:#?}"
4487 );
4488 assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4490 }
4491
4492 #[test]
4493 fn frontmatter_short_form_link_field_is_error() {
4494 let fx = Fixture::new();
4495 fx.write(
4498 "records/synthesis/a.md",
4499 "---\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",
4500 );
4501 let issues = fx.store_all();
4502 let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4503 assert!(issue.is_error());
4504 assert_eq!(issue.key.as_deref(), Some("related"));
4505 }
4506
4507 #[test]
4508 fn unquoted_frontmatter_link_is_recognized() {
4509 let fx = Fixture::new();
4514 fx.write(
4515 "records/synthesis/short.md",
4516 "---\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",
4517 );
4518 fx.write(
4519 "records/synthesis/broken.md",
4520 "---\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",
4521 );
4522 let issues = fx.store_all();
4523 assert!(
4524 issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4525 && i.file == Path::new("records/synthesis/short.md")
4526 && i.key.as_deref() == Some("related")),
4527 "unquoted short-form frontmatter link must be caught: {issues:#?}"
4528 );
4529 assert!(
4530 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4531 && i.file == Path::new("records/synthesis/broken.md")),
4532 "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4533 );
4534 }
4535
4536 #[test]
4537 fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4538 let mut fx = Fixture::new();
4543 fx.config.schemas.insert(
4544 "contact".into(),
4545 Schema {
4546 fields: vec![FieldSpec {
4547 name: "company".into(),
4548 link_prefix: Some(PathBuf::from("records/companies")),
4549 ..Default::default()
4550 }],
4551 ..Default::default()
4552 },
4553 );
4554 fx.write(
4555 "records/contacts/a.md",
4556 "---\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",
4557 );
4558 let issues = fx.store_all();
4559 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4560 assert_eq!(issue.key.as_deref(), Some("company"));
4561 assert!(
4563 !issues
4564 .iter()
4565 .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4566 && i.key.as_deref() == Some("company")),
4567 "schema link fields are checked once, by the schema path: {issues:#?}"
4568 );
4569 }
4570
4571 #[test]
4572 fn schema_link_field_with_md_extension_still_warns() {
4573 let mut fx = Fixture::new();
4574 fx.config.schemas.insert(
4575 "contact".into(),
4576 Schema {
4577 fields: vec![FieldSpec {
4578 name: "company".into(),
4579 link_prefix: Some(PathBuf::from("records/companies")),
4580 ..Default::default()
4581 }],
4582 ..Default::default()
4583 },
4584 );
4585 fx.write(
4586 "records/companies/acme.md",
4587 "---\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",
4588 );
4589 fx.write(
4590 "records/contacts/a.md",
4591 "---\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",
4592 );
4593 let issues = fx.store_all();
4594 let issue = issues
4595 .iter()
4596 .find(|i| {
4597 i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
4598 })
4599 .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
4600 assert_eq!(issue.severity, Severity::Warning);
4601 assert!(
4602 !issues
4603 .iter()
4604 .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
4605 "extensionless existence check should still find acme.md: {issues:#?}"
4606 );
4607 }
4608
4609 #[test]
4612 fn explicit_schema_required_shape_enum() {
4613 let fx = {
4614 let mut fx = Fixture::new();
4615 let schema = Schema {
4618 fields: vec![
4619 FieldSpec {
4620 name: "name".into(),
4621 required: true,
4622 ..Default::default()
4623 },
4624 FieldSpec {
4625 name: "email".into(),
4626 required: true,
4627 shape: Some(Shape::Email),
4628 ..Default::default()
4629 },
4630 FieldSpec {
4631 name: "status".into(),
4632 enum_values: Some(vec!["active".into(), "inactive".into()]),
4633 ..Default::default()
4634 },
4635 ],
4636 ..Default::default()
4637 };
4638 fx.config.schemas.insert("contact".into(), schema);
4639 fx
4640 };
4641 fx.write(
4642 "records/contacts/a.md",
4643 "---\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",
4644 );
4645 let issues = fx.store_all();
4646 assert!(
4648 issues
4649 .iter()
4650 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
4651 && i.key.as_deref() == Some("name")),
4652 "{issues:#?}"
4653 );
4654 assert!(
4656 issues.iter().any(
4657 |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
4658 ),
4659 "{issues:#?}"
4660 );
4661 assert!(
4663 issues
4664 .iter()
4665 .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
4666 && i.key.as_deref() == Some("status")),
4667 "{issues:#?}"
4668 );
4669 }
4670
4671 #[test]
4672 fn schema_without_link_field_allows_plain_value() {
4673 let mut fx = Fixture::new();
4677 fx.config.schemas.insert(
4678 "contact".into(),
4679 Schema {
4680 fields: vec![FieldSpec {
4681 name: "name".into(),
4682 required: true,
4683 ..Default::default()
4684 }],
4685 ..Default::default()
4686 },
4687 );
4688 fx.write(
4689 "records/contacts/a.md",
4690 "---\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",
4691 );
4692 let issues = fx.store_all();
4693 assert!(
4694 !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
4695 "no declared link field for `company` → a plain value is fine: {issues:#?}"
4696 );
4697 }
4698
4699 #[test]
4700 fn schema_link_field_plain_value_is_prefix_mismatch() {
4701 let mut fx = Fixture::new();
4704 fx.config.schemas.insert(
4705 "contact".into(),
4706 Schema {
4707 fields: vec![FieldSpec {
4708 name: "company".into(),
4709 link_prefix: Some(PathBuf::from("records/companies")),
4710 ..Default::default()
4711 }],
4712 ..Default::default()
4713 },
4714 );
4715 fx.write(
4716 "records/contacts/a.md",
4717 "---\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",
4718 );
4719 let issues = fx.store_all();
4720 let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4721 assert_eq!(issue.key.as_deref(), Some("company"));
4722 assert!(issue
4723 .suggestion
4724 .as_deref()
4725 .unwrap()
4726 .contains("records/companies/"));
4727 }
4728
4729 #[test]
4730 fn schema_shape_int_and_url_and_currency() {
4731 let mut fx = Fixture::new();
4732 fx.config.schemas.insert(
4733 "widget".into(),
4734 Schema {
4735 fields: vec![
4736 FieldSpec {
4737 name: "qty".into(),
4738 shape: Some(Shape::Int),
4739 ..Default::default()
4740 },
4741 FieldSpec {
4742 name: "site".into(),
4743 shape: Some(Shape::Url),
4744 ..Default::default()
4745 },
4746 FieldSpec {
4747 name: "price".into(),
4748 shape: Some(Shape::Currency),
4749 ..Default::default()
4750 },
4751 ],
4752 ..Default::default()
4753 },
4754 );
4755 fx.write(
4758 "records/widgets/ok.md",
4759 "---\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",
4760 );
4761 fx.write(
4765 "records/widgets/bad.md",
4766 "---\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",
4767 );
4768 let issues = fx.store_all();
4769 let bad_shape: Vec<_> = issues
4770 .iter()
4771 .filter(|i| {
4772 i.code == codes::SCHEMA_SHAPE_MISMATCH
4773 && i.file == Path::new("records/widgets/bad.md")
4774 })
4775 .map(|i| i.key.clone().unwrap_or_default())
4776 .collect();
4777 assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
4778 assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
4779 assert!(
4780 bad_shape.contains(&"price".to_string()),
4781 "inf must be rejected as currency: {issues:#?}"
4782 );
4783 assert!(
4784 !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
4785 && i.file == Path::new("records/widgets/ok.md")),
4786 "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
4787 );
4788 }
4789
4790 #[test]
4791 fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
4792 let mut fx = Fixture::new();
4793 fx.config.schemas.insert(
4794 "contact".into(),
4795 Schema {
4796 fields: vec![
4797 FieldSpec {
4798 name: "email".into(),
4799 required: true,
4800 shape: Some(Shape::Email),
4801 ..Default::default()
4802 },
4803 FieldSpec {
4804 name: "status".into(),
4805 enum_values: Some(vec!["active".into(), "inactive".into()]),
4806 ..Default::default()
4807 },
4808 ],
4809 ..Default::default()
4810 },
4811 );
4812 fx.write(
4816 "records/contacts/bad.md",
4817 "---\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",
4818 );
4819 let issues = fx.store_all();
4820 let mismatched: Vec<_> = issues
4821 .iter()
4822 .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
4823 .map(|i| i.key.clone().unwrap_or_default())
4824 .collect();
4825 assert!(
4826 mismatched.contains(&"email".to_string()),
4827 "list-valued required email must flag: {issues:#?}"
4828 );
4829 assert!(
4830 mismatched.contains(&"status".to_string()),
4831 "list-valued enum must flag: {issues:#?}"
4832 );
4833 }
4834
4835 #[test]
4836 fn is_currency_accepts_codes_and_rejects_non_numeric() {
4837 for ok in [
4839 "100",
4840 "1234.56",
4841 "$1,234.50",
4842 "USD 100", "usd 100", "EUR 9.50",
4845 "£12",
4846 "¥1000",
4847 "-5.00", "+5",
4849 "1,000,000",
4850 ] {
4851 assert!(is_currency(ok), "expected currency: {ok:?}");
4852 }
4853 for bad in [
4856 "inf", "-inf", "infinity", "NaN", "nan", "12.999", "1.2345", "USD", "$", "free", "", " ", "1e3", "1.", ".5", "1 000", "USDD 100", ] {
4867 assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
4868 }
4869 }
4870
4871 #[test]
4874 fn ignored_type_present_is_info() {
4875 let mut fx = Fixture::new();
4876 fx.config.ignored_types.push("temp".into());
4877 fx.write(
4878 "records/temps/x.md",
4879 "---\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",
4880 );
4881 let issues = fx.store_all();
4882 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
4883 assert_eq!(issue.severity, Severity::Info);
4884 assert!(!issue.is_error());
4885 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4886 }
4887
4888 #[test]
4889 fn conclusion_record_derived_from_ignored_type_warns() {
4890 let mut fx = Fixture::new();
4891 fx.config.ignored_types.push("temp".into());
4892 fx.write(
4893 "records/temps/x.md",
4894 "---\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",
4895 );
4896 fx.write(
4900 "records/synthesis/t.md",
4901 "---\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",
4902 );
4903 let issues = fx.store_all();
4904 let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
4905 assert_eq!(issue.severity, Severity::Warning);
4906 assert_eq!(issue.key.as_deref(), Some("derived_from"));
4907 assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4908 }
4909
4910 #[test]
4918 fn derived_from_ignored_type_is_the_shared_policy_decision() {
4919 let mut fx = Fixture::new();
4920 fx.config.ignored_types.push("secret".into());
4921 fx.write(
4923 "records/secrets/s.md",
4924 "---\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",
4925 );
4926 fx.write(
4928 "records/contacts/c.md",
4929 "---\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",
4930 );
4931 let store = fx.store();
4932
4933 let hit =
4937 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
4938 .expect("conclusion → ignored-type record must match");
4939 assert_eq!(hit.target, "records/secrets/s");
4940 assert_eq!(hit.target_type, "secret");
4941
4942 assert_eq!(
4945 derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
4946 None,
4947 "only conclusion derivation is policed"
4948 );
4949
4950 assert_eq!(
4952 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
4953 None,
4954 "deriving from a non-ignored type is allowed"
4955 );
4956
4957 let hit = derived_from_ignored_type(
4959 &store,
4960 "conclusion",
4961 ["records/contacts/c", "records/secrets/s"],
4962 )
4963 .expect("a later ignored-type target must still be found");
4964 assert_eq!(hit.target, "records/secrets/s");
4965
4966 fx.config.ignored_types.clear();
4968 let store = fx.store();
4969 assert_eq!(
4970 derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
4971 None,
4972 "an empty ignored-types policy short-circuits"
4973 );
4974 }
4975
4976 #[test]
4979 fn dup_id_is_hard_error_with_related() {
4980 let fx = Fixture::new();
4981 fx.write(
4982 "records/contacts/a.md",
4983 "---\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",
4984 );
4985 fx.write(
4986 "records/contacts/b.md",
4987 "---\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",
4988 );
4989 let issues = fx.store_all();
4990 assert_eq!(
4993 count(&issues, codes::DUP_ID),
4994 1,
4995 "one issue per group: {issues:#?}"
4996 );
4997 let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4998 assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
4999 assert!(a.is_error());
5000 assert_eq!(a.key.as_deref(), Some("id"));
5001 assert_eq!(
5002 a.line,
5003 Some(3),
5004 "anchors to the `id` line on the reported file"
5005 );
5006 assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5007 }
5008
5009 #[test]
5010 fn dup_id_not_fired_in_working_set() {
5011 let fx = Fixture::new();
5013 fx.write(
5014 "records/contacts/a.md",
5015 "---\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",
5016 );
5017 fx.write(
5018 "records/contacts/b.md",
5019 "---\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",
5020 );
5021 fx.write(
5023 "log.md",
5024 "---\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",
5025 );
5026 let issues = validate_working_set(&fx.store(), None).unwrap();
5027 assert!(
5028 !has(&issues, codes::DUP_ID),
5029 "DUP_ID is --all only: {issues:#?}"
5030 );
5031 }
5032
5033 #[test]
5034 fn dup_unique_key_single_field_is_warning() {
5035 let mut fx = Fixture::new();
5036 fx.config.schemas.insert(
5038 "contact".into(),
5039 Schema {
5040 unique_keys: vec![vec!["email".into()]],
5041 ..Default::default()
5042 },
5043 );
5044 for (f, name) in [("a", "A"), ("b", "B")] {
5045 fx.write(
5046 &format!("records/contacts/{f}.md"),
5047 &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"),
5048 );
5049 }
5050 let issues = fx.store_all();
5051 assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5054 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5055 assert_eq!(dup.severity, Severity::Warning);
5056 assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5057 assert_eq!(dup.key.as_deref(), Some("email"));
5058 assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5059 }
5060
5061 #[test]
5062 fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5063 let mut fx = Fixture::new();
5064 fx.config.schemas.insert(
5066 "expense".into(),
5067 Schema {
5068 unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5069 ..Default::default()
5070 },
5071 );
5072 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");
5073 let exp = |f: &str, amount: &str| {
5074 format!(
5075 "---\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"
5076 )
5077 };
5078 fx.write("records/expenses/e1.md", &exp("e1", "100"));
5079 fx.write("records/expenses/e2.md", &exp("e2", "100"));
5080 fx.write("records/expenses/e3.md", &exp("e3", "200")); let issues = fx.store_all();
5082 assert_eq!(
5085 count(&issues, codes::DUP_UNIQUE_KEY),
5086 1,
5087 "only e1+e2 collide, one issue: {issues:#?}"
5088 );
5089 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5090 assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5091 assert_eq!(
5092 dup.line,
5093 Some(1),
5094 "compound-key collision anchors to line 1"
5095 );
5096 assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5097 assert!(
5098 !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5099 && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5100 "e3 differs on amount and must not collide: {issues:#?}"
5101 );
5102 }
5103
5104 #[test]
5105 fn dup_unique_key_list_field_is_order_independent() {
5106 let mut fx = Fixture::new();
5107 fx.config.schemas.insert(
5109 "meeting".into(),
5110 Schema {
5111 unique_keys: vec![vec!["date".into(), "attendees".into()]],
5112 ..Default::default()
5113 },
5114 );
5115 fx.write("records/contacts/a.md", &valid_contact("a"));
5116 fx.write("records/contacts/b.md", &valid_contact("b"));
5117 let m = |f: &str, order: &str| {
5118 let attendees = if order == "ab" {
5119 " - [[records/contacts/a]]\n - [[records/contacts/b]]"
5120 } else {
5121 " - [[records/contacts/b]]\n - [[records/contacts/a]]"
5122 };
5123 format!(
5124 "---\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"
5125 )
5126 };
5127 fx.write("records/meetings/m1.md", &m("m1", "ab"));
5128 fx.write("records/meetings/m2.md", &m("m2", "ba"));
5129 let issues = fx.store_all();
5130 assert_eq!(
5133 count(&issues, codes::DUP_UNIQUE_KEY),
5134 1,
5135 "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5136 );
5137 let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5138 assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5139 assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5140 }
5141
5142 #[test]
5145 fn missing_indexes_at_all_three_levels() {
5146 let fx = Fixture::new();
5147 fx.write("records/contacts/a.md", &valid_contact("a"));
5148 let issues = fx.store_all();
5149 let missing_files: BTreeSet<PathBuf> = issues
5153 .iter()
5154 .filter(|i| i.code == codes::INDEX_MISSING)
5155 .map(|i| i.file.clone())
5156 .collect();
5157 assert!(
5158 missing_files.contains(&PathBuf::from("index.md")),
5159 "{issues:#?}"
5160 );
5161 assert!(
5162 missing_files.contains(&PathBuf::from("records/index.md")),
5163 "{issues:#?}"
5164 );
5165 assert!(
5166 missing_files.contains(&PathBuf::from("records/contacts")),
5167 "{issues:#?}"
5168 );
5169 assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5172 }
5173
5174 #[test]
5175 fn index_stale_entry_and_missing_entry() {
5176 let fx = Fixture::new();
5177 fx.write(
5178 "records/contacts/present.md",
5179 &valid_contact("present contact"),
5180 );
5181 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5183 fx.write(
5184 "records/index.md",
5185 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5186 );
5187 fx.write(
5189 "records/contacts/index.md",
5190 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5191 );
5192 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5193 let issues = fx.store_all();
5194 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5195 assert!(stale.message.contains("ghost"));
5196 assert!(stale.is_error());
5197 let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5198 assert!(
5199 missing.message.contains("present.md"),
5200 "{}",
5201 missing.message
5202 );
5203 }
5204
5205 #[test]
5206 fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5207 let fx = Fixture::new();
5208 fx.write("records/contacts/a.md", &valid_contact("a"));
5209 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5210 fx.write(
5211 "records/index.md",
5212 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5213 );
5214 fx.write(
5215 "records/contacts/index.md",
5216 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5217 );
5218 fx.write(
5219 "records/contacts/index.jsonl",
5220 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5221 );
5222 let issues = fx.store_all();
5223 let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5224 assert!(stale.message.contains("not a safe store-relative path"));
5225 }
5226
5227 #[test]
5228 fn index_summary_mismatch() {
5229 let fx = Fixture::new();
5230 fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5231 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5232 fx.write(
5233 "records/index.md",
5234 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5235 );
5236 fx.write(
5237 "records/contacts/index.md",
5238 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5239 );
5240 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5241 let issues = fx.store_all();
5242 let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5243 assert!(issue.is_error());
5244 assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5245 }
5246
5247 #[test]
5248 fn index_summary_match_passes() {
5249 let fx = Fixture::new();
5250 fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5251 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5252 fx.write(
5253 "records/index.md",
5254 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5255 );
5256 fx.write(
5257 "records/contacts/index.md",
5258 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5259 );
5260 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5261 let issues = fx.store_all();
5262 assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5263 }
5264
5265 #[test]
5266 fn index_entry_with_tag_suffix_matches_summary() {
5267 let fx = Fixture::new();
5268 fx.write("records/contacts/a.md", &valid_contact("clean summary"));
5269 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5270 fx.write(
5271 "records/index.md",
5272 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5273 );
5274 fx.write(
5278 "records/contacts/index.md",
5279 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary · #customer\n",
5280 );
5281 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5282 let issues = fx.store_all();
5283 assert!(
5284 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5285 "tag suffix should be stripped: {issues:#?}"
5286 );
5287 }
5288
5289 #[test]
5290 fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5291 let fx = Fixture::new();
5298 fx.write(
5299 "records/contacts/a.md",
5300 &valid_contact("Standup notes · #standup"),
5301 );
5302 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5303 fx.write(
5304 "records/index.md",
5305 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5306 );
5307 fx.write(
5308 "records/contacts/index.md",
5309 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5310 );
5311 fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5312 let issues = fx.store_all();
5313 assert!(
5314 !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5315 "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5316 );
5317 }
5318
5319 #[test]
5320 fn index_jsonl_desync_missing_file_in_jsonl() {
5321 let fx = Fixture::new();
5322 fx.write("records/contacts/a.md", &valid_contact("a"));
5323 fx.write("records/contacts/b.md", &valid_contact("b"));
5324 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5325 fx.write(
5326 "records/index.md",
5327 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5328 );
5329 fx.write(
5330 "records/contacts/index.md",
5331 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5332 );
5333 fx.write(
5335 "records/contacts/index.jsonl",
5336 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5337 );
5338 let issues = fx.store_all();
5339 let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5340 assert!(desync.message.contains("b.md"), "{}", desync.message);
5341 }
5342
5343 #[test]
5344 fn index_jsonl_desync_record_points_at_missing_file() {
5345 let fx = Fixture::new();
5346 fx.write("records/contacts/a.md", &valid_contact("a"));
5347 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5348 fx.write(
5349 "records/index.md",
5350 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5351 );
5352 fx.write(
5353 "records/contacts/index.md",
5354 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5355 );
5356 fx.write(
5357 "records/contacts/index.jsonl",
5358 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5359 );
5360 let issues = fx.store_all();
5361 assert!(
5362 issues
5363 .iter()
5364 .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5365 "{issues:#?}"
5366 );
5367 }
5368
5369 #[test]
5370 fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5371 let fx = Fixture::new();
5372 fx.write("records/contacts/a.md", &valid_contact("a"));
5373 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5374 fx.write(
5375 "records/index.md",
5376 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5377 );
5378 fx.write(
5379 "records/contacts/index.md",
5380 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5381 );
5382 fx.write(
5383 "records/contacts/index.jsonl",
5384 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5385 );
5386 let issues = fx.store_all();
5387 assert!(
5388 issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5389 && i.message.contains("not a safe store-relative path")),
5390 "{issues:#?}"
5391 );
5392 }
5393
5394 #[test]
5395 fn index_jsonl_stale_summary() {
5396 let fx = Fixture::new();
5397 fx.write("records/contacts/a.md", &valid_contact("real summary"));
5398 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5399 fx.write(
5400 "records/index.md",
5401 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5402 );
5403 fx.write(
5404 "records/contacts/index.md",
5405 "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5406 );
5407 fx.write(
5409 "records/contacts/index.jsonl",
5410 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5411 );
5412 let issues = fx.store_all();
5413 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5414 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5415 assert!(stale.key.as_deref().unwrap().contains("summary"));
5416 }
5417
5418 #[test]
5426 fn index_jsonl_stale_queryable_field_email() {
5427 let fx = Fixture::new();
5428 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";
5429 fx.write("records/contacts/a.md", contact);
5430 fx.rebuild_indexes();
5432 let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5433 let good = fs::read_to_string(&jsonl_path).unwrap();
5434 assert!(
5436 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5437 "freshly-rebuilt sidecar must not be stale"
5438 );
5439 assert!(
5441 good.contains("real@correct.com"),
5442 "sidecar projects email: {good}"
5443 );
5444 fx.write(
5445 "records/contacts/index.jsonl",
5446 &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5447 );
5448
5449 let issues = fx.store_all();
5450 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5451 assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5452 let key = stale.key.as_deref().unwrap();
5455 assert!(
5456 key.contains("email"),
5457 "expected `email` in stale key, got {key:?}"
5458 );
5459 assert!(!key.contains("summary"), "summary still matches: {key:?}");
5460 assert!(!key.contains("type"), "type still matches: {key:?}");
5461 }
5462
5463 #[test]
5467 fn index_jsonl_stale_typed_and_list_fields() {
5468 let fx = Fixture::new();
5469 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";
5470 fx.write("records/expenses/e.md", expense);
5471 fx.rebuild_indexes();
5472 let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5473 let good = fs::read_to_string(&jsonl_path).unwrap();
5474 assert!(
5475 !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5476 "freshly-rebuilt sidecar must not be stale"
5477 );
5478 let stale_line = good
5480 .replace("\"q2\"", "\"WRONG-TAG\"")
5481 .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5482 .replace("1299", "9999");
5483 fx.write("records/expenses/index.jsonl", &stale_line);
5484
5485 let issues = fx.store_all();
5486 let stale = find(&issues, codes::INDEX_JSONL_STALE);
5487 let key = stale.key.as_deref().unwrap();
5488 for expected in ["amount", "tags", "updated"] {
5489 assert!(
5490 key.contains(expected),
5491 "expected `{expected}` in stale key, got {key:?}"
5492 );
5493 }
5494 }
5495
5496 #[test]
5497 fn index_orphan_in_noncanonical_folder() {
5498 let fx = Fixture::new();
5499 fx.write("records/contacts/a.md", &valid_contact("a"));
5500 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5502 fx.write(
5503 "records/index.md",
5504 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5505 );
5506 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5507 fx.write(
5508 "records/contacts/index.jsonl",
5509 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5510 );
5511 fx.write(
5513 "records/contacts/subfolder/index.md",
5514 "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5515 );
5516 let issues = fx.store_all();
5517 let orphan = find(&issues, codes::INDEX_ORPHAN);
5518 assert_eq!(orphan.severity, Severity::Warning);
5519 assert_eq!(
5520 orphan.file,
5521 PathBuf::from("records/contacts/subfolder/index.md")
5522 );
5523 }
5524
5525 #[test]
5526 fn index_wrong_scope() {
5527 let fx = Fixture::new();
5528 fx.write("records/contacts/a.md", &valid_contact("a"));
5529 fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5531 fx.write(
5532 "records/index.md",
5533 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5534 );
5535 fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5536 fx.write(
5537 "records/contacts/index.jsonl",
5538 "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5539 );
5540 let issues = fx.store_all();
5541 let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5542 assert_eq!(issue.severity, Severity::Warning);
5543 assert_eq!(issue.file, PathBuf::from("index.md"));
5544 }
5545
5546 #[test]
5547 fn capped_type_folder_index_does_not_flag_missing_entries() {
5548 let fx = Fixture::new();
5550 for i in 0..501 {
5551 fx.write(
5552 &format!("records/contacts/c{i:04}.md"),
5553 &valid_contact(&format!("contact {i}")),
5554 );
5555 }
5556 fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
5557 fx.write(
5558 "records/index.md",
5559 "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5560 );
5561 fx.write(
5563 "records/contacts/index.md",
5564 "---\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",
5565 );
5566 let mut jsonl = String::new();
5568 for i in 0..501 {
5569 jsonl.push_str(&format!(
5570 "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
5571 ));
5572 }
5573 fx.write("records/contacts/index.jsonl", &jsonl);
5574 let issues = fx.store_all();
5575 assert!(
5576 !has(&issues, codes::INDEX_MISSING_ENTRY),
5577 "over the cap, missing browse entries are expected: {issues:#?}"
5578 );
5579 assert!(
5581 !has(&issues, codes::INDEX_JSONL_DESYNC),
5582 "{:#?}",
5583 issues
5584 .iter()
5585 .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
5586 .collect::<Vec<_>>()
5587 );
5588 }
5589
5590 #[test]
5593 fn log_bad_timestamp_unknown_kind_out_of_order() {
5594 let fx = Fixture::new();
5595 fx.write(
5596 "log.md",
5597 concat!(
5598 "---\ntype: log\n---\n\n# Log\n\n",
5599 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5600 "## [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", ),
5604 );
5605 let issues = fx.store_all();
5606 assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5607 assert_eq!(
5608 find(&issues, codes::LOG_OUT_OF_ORDER).severity,
5609 Severity::Warning
5610 );
5611 let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
5612 assert_eq!(unknown.severity, Severity::Warning);
5613 assert!(unknown.message.contains("frobnicate"));
5614 assert!(unknown
5615 .suggestion
5616 .as_deref()
5617 .is_some_and(|s| s.contains("create")));
5618 let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
5619 assert!(bad.is_error());
5620 }
5621
5622 #[test]
5623 fn log_validate_entry_without_object_is_well_formed() {
5624 let fx = Fixture::new();
5625 fx.write(
5626 "log.md",
5627 "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
5628 );
5629 let issues = fx.store_all();
5630 assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
5631 assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
5632 }
5633
5634 #[test]
5635 fn log_in_order_is_clean() {
5636 let fx = Fixture::new();
5637 fx.write(
5638 "log.md",
5639 concat!(
5640 "---\ntype: log\n---\n\n",
5641 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5642 "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
5643 ),
5644 );
5645 let issues = fx.store_all();
5646 assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5647 }
5648
5649 #[test]
5650 fn log_not_checked_in_working_set() {
5651 let fx = Fixture::new();
5653 fx.write(
5654 "log.md",
5655 concat!(
5656 "---\ntype: log\n---\n\n",
5657 "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5658 "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
5659 ),
5660 );
5661 let issues = validate_working_set(&fx.store(), None).unwrap();
5662 assert!(
5663 !has(&issues, codes::LOG_OUT_OF_ORDER),
5664 "log ordering is --all only: {issues:#?}"
5665 );
5666 }
5667
5668 #[test]
5671 fn working_set_validates_only_changed_files() {
5672 let fx = Fixture::new();
5673 fx.write(
5676 "records/contacts/dirty.md",
5677 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
5678 );
5679 fx.write(
5680 "records/contacts/unlogged.md",
5681 "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
5682 );
5683 fx.write(
5684 "log.md",
5685 "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
5686 );
5687 let issues = validate_working_set(&fx.store(), None).unwrap();
5688 assert!(
5689 issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
5690 && i.file == Path::new("records/contacts/dirty.md")),
5691 "{issues:#?}"
5692 );
5693 assert!(
5694 !issues
5695 .iter()
5696 .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
5697 "unlogged file must not be in the working set: {issues:#?}"
5698 );
5699 }
5700
5701 #[test]
5702 fn working_set_includes_incoming_linkers_to_changed_path() {
5703 let fx = Fixture::new();
5704 fx.write(
5707 "records/profiles/linker.md",
5708 "---\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",
5709 );
5710 fx.write(
5712 "log.md",
5713 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
5714 );
5715 let issues = validate_working_set(&fx.store(), None).unwrap();
5716 assert!(
5717 issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
5718 && i.file == Path::new("records/profiles/linker.md")),
5719 "incoming linker to a removed path must be validated: {issues:#?}"
5720 );
5721 }
5722
5723 #[test]
5724 fn working_set_respects_explicit_since_cutoff() {
5725 let fx = Fixture::new();
5726 fx.write(
5727 "records/contacts/old.md",
5728 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
5729 );
5730 fx.write(
5731 "records/contacts/new.md",
5732 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
5733 );
5734 fx.write(
5735 "log.md",
5736 concat!(
5737 "---\ntype: log\n---\n\n",
5738 "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
5739 "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
5740 ),
5741 );
5742 let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
5744 let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
5745 assert!(
5746 issues
5747 .iter()
5748 .any(|i| i.file == Path::new("records/contacts/new.md")),
5749 "{issues:#?}"
5750 );
5751 assert!(
5752 !issues
5753 .iter()
5754 .any(|i| i.file == Path::new("records/contacts/old.md")),
5755 "old change is before the cutoff: {issues:#?}"
5756 );
5757 }
5758
5759 #[test]
5760 fn working_set_default_since_is_last_validate_entry() {
5761 let fx = Fixture::new();
5762 fx.write(
5764 "records/contacts/before.md",
5765 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
5766 );
5767 fx.write(
5768 "records/contacts/after.md",
5769 "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
5770 );
5771 fx.write(
5772 "log.md",
5773 concat!(
5774 "---\ntype: log\n---\n\n",
5775 "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
5776 "## [2026-05-21 10:00] validate\nPASS\n\n",
5777 "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
5778 ),
5779 );
5780 let issues = validate_working_set(&fx.store(), None).unwrap();
5781 assert!(
5782 issues
5783 .iter()
5784 .any(|i| i.file == Path::new("records/contacts/after.md")),
5785 "{issues:#?}"
5786 );
5787 assert!(
5788 !issues
5789 .iter()
5790 .any(|i| i.file == Path::new("records/contacts/before.md")),
5791 "change before the last validate entry is outside the default window: {issues:#?}"
5792 );
5793 }
5794
5795 #[test]
5798 fn issues_are_sorted_by_file_then_line() {
5799 let fx = Fixture::new();
5800 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");
5801 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");
5802 let issues = fx.store_all();
5803 let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
5804 let mut sorted = files.clone();
5805 sorted.sort();
5806 assert_eq!(
5807 files, sorted,
5808 "issues must be emitted in a stable file order"
5809 );
5810 }
5811
5812 #[test]
5815 fn frozen_page_is_not_a_validate_error() {
5816 let mut fx = Fixture::new();
5819 fx.config
5820 .frozen_pages
5821 .push(PathBuf::from("records/decisions/d.md"));
5822 fx.write(
5823 "records/decisions/d.md",
5824 "---\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",
5825 );
5826 let issues = fx.store_all();
5827 assert!(
5828 !has(&issues, codes::POLICY_FROZEN_PAGE),
5829 "frozen pages are enforced at write-time, not by validate: {issues:#?}"
5830 );
5831 }
5832
5833 #[test]
5834 fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
5835 let fx = Fixture::new();
5838 fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
5839 let mut body = valid_contact("links to sarah");
5840 body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
5841 fx.write("records/contacts/p.md", &body);
5842 let issues = fx.store_all();
5843 assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
5844 }
5845
5846 #[test]
5849 fn unknown_type_passes_through() {
5850 let fx = Fixture::new();
5854 fx.write(
5855 "records/proposals/x.md",
5856 "---\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",
5857 );
5858 let issues = fx.store_all();
5859 assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
5860 assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
5861 assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
5862 assert!(
5864 !issues
5865 .iter()
5866 .any(|i| i.key.as_deref() == Some("custom_field")
5867 || i.key.as_deref() == Some("budget")),
5868 "unknown fields are ambient context: {issues:#?}"
5869 );
5870 }
5871
5872 #[test]
5875 fn incoming_linker_scan_does_not_prefix_match() {
5876 let fx = Fixture::new();
5879 fx.write(
5880 "records/profiles/only-sarah-chen.md",
5881 "---\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",
5882 );
5883 fx.write(
5885 "log.md",
5886 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
5887 );
5888 let issues = validate_working_set(&fx.store(), None).unwrap();
5889 assert!(
5890 !issues
5891 .iter()
5892 .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
5893 "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
5894 );
5895 }
5896
5897 #[test]
5898 fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
5899 let fx = Fixture::new();
5913 fx.write(
5916 "records/contacts/index.md",
5917 "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
5918 );
5919 fx.write(
5921 "log.md",
5922 "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
5923 );
5924 let issues = validate_working_set(&fx.store(), None).unwrap();
5925 assert!(
5926 !issues
5927 .iter()
5928 .any(|i| i.file == Path::new("records/contacts/index.md")
5929 && i.code == codes::WIKI_LINK_BROKEN),
5930 "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
5931 working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
5932 );
5933 }
5934
5935 #[test]
5936 fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
5937 let fx = Fixture::new();
5946 fx.write(
5948 "records/profiles/refers-sarah.md",
5949 "---\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",
5950 );
5951 fx.write(
5955 "records/meetings/2026/05/kickoff.md",
5956 "---\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",
5957 );
5958 fx.write(
5960 "log.md",
5961 "---\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",
5962 );
5963
5964 let issues = validate_working_set(&fx.store(), None).unwrap();
5965 assert!(
5966 issues
5967 .iter()
5968 .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
5969 && i.code == codes::WIKI_LINK_BROKEN),
5970 "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
5971 );
5972 assert!(
5973 issues.iter().any(
5974 |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
5975 && i.code == codes::WIKI_LINK_BROKEN
5976 ),
5977 "linker to the SECOND deleted target (typed-field edge) must also be \
5978 pulled in and flagged — proves the scan covers the whole changed set, \
5979 not just one object: {issues:#?}"
5980 );
5981 }
5982
5983 #[test]
5984 fn frontmatter_block_sequence_links_each_get_their_own_line() {
5985 let fx = Fixture::new();
5987 fx.write(
5989 "records/meetings/m.md",
5990 "---\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",
5991 );
5992 let issues = fx.store_all();
5993 let broken_lines: BTreeSet<Option<u32>> = issues
5994 .iter()
5995 .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
5996 .map(|i| i.line)
5997 .collect();
5998 assert_eq!(
5999 broken_lines.len(),
6000 2,
6001 "two distinct broken-link lines: {issues:#?}"
6002 );
6003 }
6004
6005 #[test]
6008 fn null_created_is_missing_not_silently_passed() {
6009 let fx = Fixture::new();
6013 fx.write(
6014 "records/contacts/a.md",
6015 "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6016 );
6017 let issues = fx.store_all();
6018 assert!(
6019 has(&issues, codes::FM_MISSING_CREATED),
6020 "null `created:` must read as missing: {issues:#?}"
6021 );
6022 }
6023
6024 #[test]
6025 fn sequence_created_is_bad_timestamp() {
6026 let fx = Fixture::new();
6028 fx.write(
6029 "records/contacts/a.md",
6030 "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6031 );
6032 let issues = fx.store_all();
6033 assert!(
6034 issues
6035 .iter()
6036 .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6037 "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6038 );
6039 }
6040
6041 #[test]
6044 fn required_field_null_or_empty_collection_is_missing() {
6045 for value in ["", " []", " {}"] {
6050 let mut fx = Fixture::new();
6051 fx.config.schemas.insert(
6052 "contact".into(),
6053 Schema {
6054 fields: vec![FieldSpec {
6055 name: "name".into(),
6056 required: true,
6057 ..Default::default()
6058 }],
6059 ..Default::default()
6060 },
6061 );
6062 fx.write(
6063 "records/contacts/a.md",
6064 &format!(
6065 "---\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"
6066 ),
6067 );
6068 let issues = fx.store_all();
6069 assert!(
6070 issues
6071 .iter()
6072 .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6073 && i.key.as_deref() == Some("name")),
6074 "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6075 );
6076 }
6077 }
6078
6079 #[test]
6082 fn wiki_link_to_raw_source_file_resolves() {
6083 let fx = Fixture::new();
6087 fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6088 fx.write(
6089 "records/contacts/a.md",
6090 "---\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",
6091 );
6092 let issues = fx.store_all();
6093 assert!(
6094 !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6095 "a link to an existing raw source file must not be broken: {issues:#?}"
6096 );
6097 }
6098
6099 #[test]
6102 fn non_utf8_content_file_is_reported() {
6103 let fx = Fixture::new();
6107 let abs = fx.dir.path().join("records/notes/corrupt.md");
6108 fs::create_dir_all(abs.parent().unwrap()).unwrap();
6109 fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6110 let issues = validate_working_set(&fx.store(), None).unwrap();
6111 assert!(
6112 has(&issues, codes::FM_UNREADABLE),
6113 "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6114 );
6115 }
6116
6117 #[test]
6120 fn tilde_fence_containing_backtick_fence_does_not_invert() {
6121 let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6126 let links = extract_wiki_links(body);
6127 assert!(
6128 links.is_empty(),
6129 "wiki-link inside a nested code fence must be skipped: {links:?}"
6130 );
6131 }
6132
6133 #[test]
6136 fn all_sweep_visits_in_layer_log_folder() {
6137 let fx = Fixture::new();
6142 fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6143 let issues = fx.store_all();
6144 assert!(
6145 has(&issues, codes::FM_MISSING_TYPE),
6146 "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6147 );
6148 }
6149
6150 #[test]
6153 fn flow_form_link_list_with_spaces_is_flagged() {
6154 let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6158 assert!(
6159 keys.iter().any(|k| k == "attendees"),
6160 "spaced flow-form list must be detected: {keys:?}"
6161 );
6162 }
6163
6164 #[test]
6167 fn middot_hashtag_summary_tail_round_trips() {
6168 assert_eq!(
6174 extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6175 Some("Standup notes · #standup"),
6176 "a single-spaced middot tail is part of the summary, not a tag block"
6177 );
6178 assert_eq!(
6180 extract_index_entry_summary("— Renewal champion · #renewal #acme").as_deref(),
6181 Some("Renewal champion"),
6182 "the renderer's double-spaced ` · #tag` suffix is stripped"
6183 );
6184 }
6185
6186 #[test]
6189 fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6190 assert!(is_url("http://x"), "an 8-char http URL is valid");
6191 assert!(is_url("https://x"), "a 9-char https URL is valid");
6192 assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6193 assert!(!is_url("https://"), "a bare https scheme is rejected");
6194 }
6195
6196 #[test]
6197 fn email_shape_rejects_double_at() {
6198 assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6199 assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6200 assert!(is_email("sarah@acme.com"), "a normal address still passes");
6201 }
6202
6203 #[test]
6206 fn working_set_does_not_flag_log_md_body_links() {
6207 let fx = Fixture::new();
6213 fx.write("records/contacts/a.md", &valid_contact("A"));
6214 fx.write(
6215 "log.md",
6216 "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6217 );
6218 let issues = validate_working_set(&fx.store(), None).unwrap();
6219 assert!(
6220 !issues
6221 .iter()
6222 .any(|i| i.code == codes::WIKI_LINK_BROKEN
6223 && i.file == std::path::Path::new("log.md")),
6224 "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6225 );
6226 }
6227
6228 #[test]
6231 fn schema_duplicate_field_name_is_flagged() {
6232 let mut fx = Fixture::new();
6233 fx.config.schemas.insert(
6234 "contact".into(),
6235 Schema {
6236 fields: vec![
6237 FieldSpec {
6238 name: "name".into(),
6239 required: true,
6240 ..Default::default()
6241 },
6242 FieldSpec {
6243 name: "name".into(),
6244 ..Default::default()
6245 },
6246 ],
6247 ..Default::default()
6248 },
6249 );
6250 let issues = fx.store_all();
6251 assert!(
6252 issues
6253 .iter()
6254 .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6255 "a duplicate schema field name must be flagged: {issues:#?}"
6256 );
6257 }
6258
6259 #[test]
6260 fn schema_unknown_modifier_is_info() {
6261 let mut fx = Fixture::new();
6262 fx.config.schemas.insert(
6263 "contact".into(),
6264 Schema {
6265 fields: vec![FieldSpec {
6266 name: "name".into(),
6267 unknown_modifiers: vec!["requierd".into()],
6268 ..Default::default()
6269 }],
6270 ..Default::default()
6271 },
6272 );
6273 let issues = fx.store_all();
6274 assert!(
6275 issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6276 && i.severity == Severity::Info
6277 && i.key.as_deref() == Some("name")),
6278 "an unrecognized schema modifier must surface as Info: {issues:#?}"
6279 );
6280 }
6281
6282 #[test]
6288 fn every_code_constant_is_documented_in_spec() {
6289 let this_src = include_str!("validate.rs");
6293 let mut codes_in_module: Vec<String> = Vec::new();
6294 let mut in_codes_mod = false;
6295 for line in this_src.lines() {
6296 let t = line.trim();
6297 if t.starts_with("pub mod codes") {
6298 in_codes_mod = true;
6299 continue;
6300 }
6301 if in_codes_mod && line == "}" {
6303 break;
6304 }
6305 if in_codes_mod {
6306 if let Some(rest) = t.strip_prefix("pub const ") {
6307 let value = rest
6309 .split_once('=')
6310 .map(|(_, v)| v.trim())
6311 .and_then(|v| v.strip_prefix('"'))
6312 .and_then(|v| v.strip_suffix("\";"))
6313 .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6314 codes_in_module.push(value.to_string());
6315 }
6316 }
6317 }
6318 assert!(
6319 codes_in_module.len() >= 36,
6320 "parsed only {} code constants from `mod codes`; the parser likely \
6321 broke against a source-format change",
6322 codes_in_module.len()
6323 );
6324
6325 let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6327 let spec = fs::read_to_string(&spec_path)
6328 .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6329
6330 let missing: Vec<&String> = codes_in_module
6332 .iter()
6333 .filter(|code| !spec.contains(&format!("| `{code}` |")))
6334 .collect();
6335 assert!(
6336 missing.is_empty(),
6337 "validation codes emitted by the engine but absent from SPEC.md \
6338 § Validation (the declared complete vocabulary): {missing:?}"
6339 );
6340 }
6341}