use std::collections::{BTreeMap, HashMap, HashSet};
use regex::Regex;
use serde_norway::Value;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use walkdir::WalkDir;
use crate::body;
use crate::config::{FieldSpec, FieldType};
use crate::doc::Doc;
use crate::error::Result;
use crate::frontmatter::Frontmatter;
use crate::project::{id_format_re, Project, KEBAB_RE};
use crate::project_config::{CheckScope, SectionCheck, SectionKind, SectionSpec};
use crate::refs;
use crate::rules;
use crate::Ctx;
pub fn run(ctx: &Ctx) -> Result<i32> {
let prj = ctx.open()?;
let (mut store, parse_errors) = ctx.load(&prj)?;
let docs: Vec<Doc> = store.all_docs()?.into_iter().map(|(_, d)| d).collect();
let errors = collect_problems(&prj, &docs, parse_errors);
if errors.is_empty() {
println!("verify: OK ({} documents)", docs.len());
Ok(0)
} else {
eprintln!("verify: {} problem(s)", errors.len());
for e in &errors {
eprintln!(" {e}");
}
Ok(1)
}
}
pub fn collect_problems(prj: &Project, docs: &[Doc], parse_errors: Vec<String>) -> Vec<String> {
let mut errors = parse_errors;
let pcfg = &prj.pcfg;
for p in pcfg.validate() {
errors.push(format!("opys.toml: {p}"));
}
for (tname, t) in &pcfg.types {
check_field_specs(&t.fields, &format!("type '{tname}'"), &mut errors);
}
let retired = prj.retired_ids();
let reserved = reserved_fields();
let mut corpus_cache: HashMap<Vec<String>, String> = HashMap::new();
let doc_ids: HashSet<String> = docs
.iter()
.filter_map(|d| d.id())
.map(str::to_string)
.collect();
let mut seen: HashMap<String, String> = HashMap::new();
for d in docs {
let m = &d.frontmatter;
let where_ = d.path.display().to_string();
let Some(id) = d.id() else {
errors.push(format!("{where_}: bad or missing id {}", pyrepr(None)));
continue;
};
let Some(tname) = pcfg.type_name_for_id(id) else {
let mut known: Vec<&str> = pcfg.types.values().map(|t| t.prefix.as_str()).collect();
known.sort_unstable();
errors.push(format!(
"{where_}: unrecognized id prefix in {id} (expected one of: {})",
known.join(", ")
));
continue;
};
let t = &pcfg.types[tname];
if !id_format_re(&t.prefix, pcfg.pad).is_match(id) {
errors.push(format!("{where_}: bad id {id}"));
continue;
}
if d.path.file_stem().and_then(|s| s.to_str()) != Some(id) {
errors.push(format!("{where_}: filename does not match id {id}"));
}
if let Some(prev) = seen.get(id) {
errors.push(format!("{where_}: duplicate id {id} (also in {prev})"));
}
seen.insert(id.to_string(), where_.clone());
if retired.contains(id) {
errors.push(format!(
"{where_}: id {id} is retired and may not be reused"
));
}
let status = d.status();
if !status
.map(|s| t.statuses.iter().any(|x| x == s))
.unwrap_or(false)
{
errors.push(format!("{id}: invalid status {}", pyrepr(status)));
}
if t.tags_required || m.contains_key("tags") {
check_tags(m, id, &mut errors);
}
if d.title.is_empty() {
errors.push(format!("{id}: missing '# Title' heading"));
}
check_timestamps(m, id, &mut errors);
check_references(m, id, &doc_ids, &mut errors);
for snip in crate::links::nested_links(&d.body) {
errors.push(format!(
"{id}: nested markdown link in body (fix by hand, keeping the innermost link): {snip}"
));
}
check_custom_fields(
&t.fields,
&reserved,
&format!("type '{tname}'"),
m,
id,
&mut errors,
);
for sec in &t.sections {
if sec.kind == SectionKind::Structured {
check_structured(d, id, sec, &mut errors);
}
for chk in &sec.checks {
run_check(
d,
id,
&sec.heading,
chk,
prj,
&mut corpus_cache,
&mut errors,
);
}
}
}
check_unique_numbers(docs, &mut errors);
check_rules(prj, docs, &doc_ids, &mut errors);
check_stats(prj, docs, &mut errors);
errors
}
fn check_stats(prj: &Project, docs: &[Doc], errors: &mut Vec<String>) {
if prj.pcfg.stats.is_empty() {
return;
}
let refs: Vec<&Doc> = docs.iter().collect();
let corpus = crate::commands::stats::corpus_json(&prj.pcfg, &refs);
let mut db = match crate::commands::stats::build_db(&corpus) {
Ok(db) => db,
Err(e) => {
errors.push(format!("stats: {e}"));
return;
}
};
for spec in &prj.pcfg.stats {
if let Err(problem) = crate::commands::stats::render_stat_on(&mut db, spec) {
errors.push(problem);
}
}
}
fn reserved_fields() -> Vec<&'static str> {
let mut v = vec!["id", "status", "tags", "created", "updated"];
v.extend(refs::RELATION_FIELDS);
v
}
fn check_timestamps(m: &Frontmatter, id: &str, errors: &mut Vec<String>) {
for key in ["created", "updated"] {
if let Some(v) = m.get(key) {
let ok = v
.as_str()
.is_some_and(|s| OffsetDateTime::parse(s, &Rfc3339).is_ok());
if !ok {
errors.push(format!(
"{id}: '{key}' must be an RFC3339 datetime (e.g. 2026-06-16T14:30:00Z)"
));
}
}
}
}
fn check_references(
m: &Frontmatter,
id: &str,
doc_ids: &HashSet<String>,
errors: &mut Vec<String>,
) {
for field in refs::RELATION_FIELDS {
let is_blocker = field == refs::BLOCKED_BY || field == refs::BLOCKS;
for (tid, val) in refs::parse_in(m, field) {
if is_blocker && tid == id {
errors.push(format!("{id}: '{field}' must not list itself"));
continue;
}
if !doc_ids.contains(&tid) && !refs::is_struck(&val) {
let what = if field == refs::FIELD {
"reference"
} else {
field
};
errors.push(format!(
"{id}: {what} '{tid}' does not resolve to a known document"
));
}
}
}
}
fn check_rules(prj: &Project, docs: &[Doc], doc_ids: &HashSet<String>, errors: &mut Vec<String>) {
let pcfg = &prj.pcfg;
let mut run = |id: Option<&str>, status: Option<&str>, m: &Frontmatter, body: &str| {
let Some(id) = id else { return };
let Some(type_name) = pcfg.type_name_for_id(id) else {
return;
};
for p in rules::evaluate(pcfg, type_name, status.unwrap_or(""), m, body, doc_ids) {
errors.push(format!("{id}: {p}"));
}
if let Some(dt) = pcfg.types.get(type_name) {
for (fname, spec) in &dt.fields {
if let (Some(pat), Some(val)) = (&spec.pattern, m.get_str(fname)) {
if Regex::new(pat).map(|re| !re.is_match(val)).unwrap_or(false) {
errors.push(format!("{id}: field '{fname}' must match /{pat}/"));
}
}
}
for sec in &dt.sections {
if sec.required && !body::has_section(body, &sec.heading) {
errors.push(format!(
"{id}: missing required '## {}' section",
sec.heading
));
}
}
}
};
for d in docs {
run(d.id(), d.status(), &d.frontmatter, &d.body);
}
}
fn check_unique_numbers(docs: &[Doc], errors: &mut Vec<String>) {
let mut by_num: HashMap<u64, String> = HashMap::new();
for id in docs.iter().filter_map(|d| d.id()) {
let Some((_, n)) = id.rsplit_once('-') else {
continue;
};
let Ok(n) = n.parse::<u64>() else { continue };
match by_num.get(&n) {
Some(prev) if prev != id => errors.push(format!(
"{id}: numeric id {n} is also used by {prev} (ids must be globally unique)"
)),
Some(_) => {}
None => {
by_num.insert(n, id.to_string());
}
}
}
}
fn check_tags(m: &Frontmatter, fid: &str, errors: &mut Vec<String>) {
if !m.tags_is_nonempty_list() {
errors.push(format!("{fid}: tags must be a non-empty list"));
return;
}
let Some(Value::Sequence(seq)) = m.get("tags") else {
return;
};
let mut keys: Vec<String> = Vec::new();
for t in seq {
let display = tag_display(t);
match validate_tag(&display) {
Ok(Some(key)) => {
if keys.contains(&key) {
errors.push(format!("{fid}: tag key '{key}' is set more than once"));
} else {
keys.push(key);
}
}
Ok(None) => {}
Err(()) => errors.push(format!(
"{fid}: tag '{display}' must be lowercase kebab-case \
(optionally colon-namespaced, or in key=value form)"
)),
}
}
}
fn is_tag_segment(s: &str) -> bool {
!s.is_empty() && s.split(':').all(|p| KEBAB_RE.is_match(p))
}
fn validate_tag(s: &str) -> std::result::Result<Option<String>, ()> {
if let Some((key, value)) = s.split_once('=') {
if value.contains('=') || !is_tag_segment(key) || !is_tag_segment(value) {
return Err(());
}
Ok(Some(key.to_string()))
} else if is_tag_segment(s) {
Ok(None)
} else {
Err(())
}
}
fn check_custom_fields(
fields: &BTreeMap<String, FieldSpec>,
reserved: &[&str],
config_hint: &str,
m: &Frontmatter,
id: &str,
errors: &mut Vec<String>,
) {
for (k, spec) in fields {
if spec.required && !m.contains_key(k) {
errors.push(format!("{id}: required field '{k}' missing"));
}
if let Some(v) = m.get(k) {
if spec.field_type == FieldType::Enum {
let allowed = spec.values.join(", ");
match v.as_str() {
Some(s) if spec.values.iter().any(|a| a == s) => {}
Some(s) => errors.push(format!(
"{id}: field '{k}' value '{s}' is not one of: {allowed}"
)),
None => errors.push(format!("{id}: field '{k}' should be one of: {allowed}")),
}
} else if !type_ok(v, spec.field_type) {
errors.push(format!(
"{id}: field '{k}' should be {}",
spec.field_type.as_str()
));
}
}
}
for k in m.keys() {
if !reserved.contains(&k) && !fields.contains_key(k) {
errors.push(format!(
"{id}: unknown frontmatter field '{k}' (declare it in {config_hint} [fields.{k}])"
));
}
}
}
fn run_check(
d: &Doc,
id: &str,
heading: &str,
chk: &SectionCheck,
prj: &Project,
corpus_cache: &mut HashMap<Vec<String>, String>,
errors: &mut Vec<String>,
) {
let Ok(pat) = Regex::new(&chk.pattern) else {
return;
};
match chk.scope {
CheckScope::All => {
for line in body::section(&d.body, heading).lines() {
for caps in pat.captures_iter(line) {
validate_match(id, heading, chk, &caps, prj, corpus_cache, errors);
}
}
}
CheckScope::Checked => {
for item in body::checklist_items(&d.body, heading) {
if !item.checked {
continue;
}
let mut matched = false;
for caps in pat.captures_iter(&item.line) {
matched = true;
validate_match(id, heading, chk, &caps, prj, corpus_cache, errors);
}
if !matched {
errors.push(format!(
"{id}: section '{heading}': checked item has no reference: {}",
item.line.trim()
));
}
}
}
}
}
fn validate_match(
id: &str,
heading: &str,
chk: &SectionCheck,
caps: ®ex::Captures,
prj: &Project,
corpus_cache: &mut HashMap<Vec<String>, String>,
errors: &mut Vec<String>,
) {
let haystack: Option<String> = match &chk.file {
Some(group) => {
let rel = caps.name(group).map_or("", |m| m.as_str());
match resolve_file(prj, rel, &chk.roots) {
Some(text) => Some(text),
None => {
errors.push(format!("{id}: section '{heading}': file '{rel}' not found"));
return;
}
}
}
None => None,
};
let Some(mm) = &chk.must_match else {
return; };
let Ok(re) = Regex::new(&interp(mm, caps, true)) else {
return; };
let found = match &haystack {
Some(text) => re.is_match(text),
None => re.is_match(corpus(prj, &chk.roots, corpus_cache)),
};
if !found {
errors.push(match &chk.message {
Some(msg) => format!("{id}: {}", interp(msg, caps, false)),
None => format!(
"{id}: section '{heading}': no match for the check in {:?}",
chk.roots
),
});
}
}
fn interp(template: &str, caps: ®ex::Captures, escape: bool) -> String {
static GROUP_RE: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"\$\{(\w+)\}").unwrap());
GROUP_RE
.replace_all(template, |c: ®ex::Captures| {
let val = caps.name(&c[1]).map_or("", |m| m.as_str());
if escape {
regex::escape(val)
} else {
val.to_string()
}
})
.into_owned()
}
fn corpus<'a>(
prj: &Project,
roots: &[String],
cache: &'a mut HashMap<Vec<String>, String>,
) -> &'a str {
cache.entry(roots.to_vec()).or_insert_with(|| {
let mut text = String::new();
for d in roots {
for entry in WalkDir::new(prj.root.join(d))
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
if let Ok(bytes) = std::fs::read(entry.path()) {
text.push_str(&String::from_utf8_lossy(&bytes));
}
}
}
}
text
})
}
fn resolve_file(prj: &Project, rel: &str, roots: &[String]) -> Option<String> {
roots
.iter()
.map(|root| prj.root.join(root).join(rel))
.find(|p| p.is_file())
.and_then(|p| std::fs::read_to_string(p).ok())
}
fn check_structured(d: &Doc, id: &str, sec: &SectionSpec, errors: &mut Vec<String>) {
let Some(src) = &sec.structure else {
return;
};
if !body::has_section(&d.body, &sec.heading) {
return;
}
let Ok(schema) = crate::mdprism::parse_schema(src) else {
return;
};
let content = body::section(&d.body, &sec.heading);
for p in schema.validate(&content) {
errors.push(format!("{id}: section '{}': {p}", sec.heading));
}
}
fn type_ok(v: &Value, want: FieldType) -> bool {
match want {
FieldType::String => v.is_string(),
FieldType::List => v.is_sequence(),
FieldType::Bool => v.is_bool(),
FieldType::Int => matches!(v, Value::Number(n) if n.is_i64() || n.is_u64()),
FieldType::Enum => v.is_string(),
}
}
fn check_field_specs(
fields: &BTreeMap<String, FieldSpec>,
config_hint: &str,
errors: &mut Vec<String>,
) {
for (k, spec) in fields {
if spec.field_type == FieldType::Enum && spec.values.is_empty() {
errors.push(format!(
"config: field '{k}' is enum but declares no values ({config_hint} [fields.{k}].values)"
));
}
}
}
fn tag_display(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
_ => String::new(),
}
}
fn pyrepr(v: Option<&str>) -> String {
match v {
Some(s) => format!("'{s}'"),
None => "None".to_string(),
}
}