use axum::http::StatusCode;
use cratefield_core::problem_registry;
use serde_json::Value;
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use syn::spanned::Spanned;
use syn::visit::{self, Visit};
use syn::{Expr, ExprStruct, Member};
struct Found {
crate_name: String,
file: PathBuf,
line: usize,
slug: String,
status: u16,
title: String,
description: String,
}
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
fn members(root: &Path) -> Vec<(String, PathBuf)> {
let output =
std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(root)
.output()
.expect("cargo metadata runs");
assert!(
output.status.success(),
"cargo metadata failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let metadata: Value =
serde_json::from_slice(&output.stdout).expect("cargo metadata emits JSON");
let crates = root.join("crates");
let mut out = Vec::new();
for package in metadata["packages"].as_array().expect("packages array") {
let manifest = PathBuf::from(package["manifest_path"].as_str().expect("manifest path"));
if !manifest.starts_with(&crates) {
continue;
}
out.push((
package["name"].as_str().expect("package name").to_string(),
manifest
.parent()
.expect("manifest has a parent")
.join("src"),
));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut paths: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
fn scan(root: &Path) -> (Vec<Found>, Vec<String>) {
let mut found = Vec::new();
let mut errors = Vec::new();
for (name, src) in members(root) {
let mut files = Vec::new();
rust_files(&src, &mut files);
for file in files {
let source = std::fs::read_to_string(&file)
.unwrap_or_else(|err| panic!("read {}: {err}", file.display()));
let rel = file.strip_prefix(root).unwrap_or(&file).to_path_buf();
let ast = match syn::parse_file(&source) {
Ok(ast) => ast,
Err(err) => {
errors.push(format!("{}: does not parse: {err}", rel.display()));
continue;
}
};
let mut scanner = Scanner {
crate_name: name.clone(),
file: rel,
found: Vec::new(),
errors: Vec::new(),
};
scanner.visit_file(&ast);
found.append(&mut scanner.found);
errors.append(&mut scanner.errors);
}
}
(found, errors)
}
struct Scanner {
crate_name: String,
file: PathBuf,
found: Vec<Found>,
errors: Vec<String>,
}
fn cfg_test(attr: &syn::Attribute) -> bool {
let syn::Meta::List(list) = &attr.meta else {
return false;
};
list.path.is_ident("cfg") && list.tokens.to_string() == "test"
}
macro_rules! item_visits {
($($visit:ident => $item:ident),* $(,)?) => {$(
fn $visit(&mut self, item: &syn::$item) {
if item.attrs.iter().any(cfg_test) {
return;
}
visit::$visit(self, item);
}
)*};
}
impl Scanner {
fn string(&mut self, expr: &Expr, line: usize, field: &str) -> Option<String> {
let value = match expr {
Expr::Lit(lit) => match &lit.lit {
syn::Lit::Str(s) => return Some(s.value()),
_ => None,
},
_ => None,
};
if value.is_none() {
self.errors.push(format!(
"{}:{line}: `{field}` must be a string literal",
self.file.display()
));
}
value
}
}
impl Visit<'_> for Scanner {
fn visit_expr_struct(&mut self, expr: &ExprStruct) {
if expr
.path
.segments
.last()
.is_none_or(|seg| seg.ident != "ProblemDef")
{
visit::visit_expr_struct(self, expr);
return;
}
let line = expr.span().start().line;
let (mut slug, mut title, mut description, mut status) = (None, None, None, None);
for field in &expr.fields {
let Member::Named(name) = &field.member else {
self.errors.push(format!(
"{}:{line}: ProblemDef fields must be named",
self.file.display()
));
return;
};
match name.to_string().as_str() {
"slug" => slug = self.string(&field.expr, line, "slug"),
"title" => title = self.string(&field.expr, line, "title"),
"description" => description = self.string(&field.expr, line, "description"),
"status" => match status_code(&field.expr) {
Some(code) => status = Some(code),
None => self.errors.push(format!(
"{}:{line}: `status` must be a `StatusCode::SOME_REASON` path the http crate knows",
self.file.display()
)),
},
_ => {}
}
}
match (slug, title, description, status) {
(Some(slug), Some(title), Some(description), Some(status)) => {
self.found.push(Found {
crate_name: self.crate_name.clone(),
file: self.file.clone(),
line,
slug,
status,
title,
description,
});
}
_ => self.errors.push(format!(
"{}:{line}: ProblemDef literal is missing a slug, status, title or description",
self.file.display()
)),
}
}
item_visits! {
visit_item_const => ItemConst,
visit_item_fn => ItemFn,
visit_item_impl => ItemImpl,
visit_item_mod => ItemMod,
visit_item_static => ItemStatic,
visit_item_trait => ItemTrait,
}
}
fn status_code(expr: &Expr) -> Option<u16> {
let Expr::Path(path) = expr else {
return None;
};
let name = path.path.segments.last()?.ident.to_string();
(100..=599).find(|code| {
StatusCode::from_u16(*code)
.ok()
.and_then(|status| status.canonical_reason())
.is_some_and(|reason| reason.replace([' ', '-'], "_").to_uppercase() == name)
})
}
fn markdown(defs: &[Found]) -> String {
let mut out = String::new();
out.push_str("# Error taxonomy\n\n");
out.push_str(
"Every problem slug the harness can emit, generated by scanning every\n\
`ProblemDef` in the workspace's crates — core's registry and the\n\
modules' own — with `cargo run -p cratefield-core --example\n\
errors-doc`, and checked in CI for drift and for slugs defined\n\
twice: each slug is defined exactly once, and a module's slugs are\n\
emitted only where that module is mounted. Responses are RFC 9457\n\
`application/problem+json` with `instance` = the request id, and\n\
`type` = `<public_url>/problems/<slug>`: the serving venture's own\n\
base, from its `Venture::public_url` (`Venture::problem_base`\n\
overrides it), so every venture names its problems under its own\n\
domain. A venture with no public URL — and any problem rendered\n\
outside a venture's context — carries the RFC 9457 §4.2.1 default\n\
`about:blank` instead of a URI that would name the wrong venture.\n\
The slug is the stable part to match on; a venture may also serve a\n\
human-readable page at each `<public_url>/problems/<slug>`.\n\n",
);
out.push_str("| Slug | Status | Title | Crate | Description |\n");
out.push_str("|---|---|---|---|\n");
for def in defs {
let _ = writeln!(
out,
"| `{}` | {} | {} | `{}` | {} |",
def.slug, def.status, def.title, def.crate_name, def.description
);
}
out
}
fn main() {
let root = std::fs::canonicalize(workspace_root()).expect("workspace root resolves");
let path = root.join("docs/ERRORS.md");
let (mut defs, mut errors) = scan(&root);
defs.sort_by(|a, b| a.slug.cmp(&b.slug));
for group in defs.chunk_by(|a, b| a.slug == b.slug) {
if group.len() == 1 {
continue;
}
let at = group
.iter()
.map(|def| format!("{}:{}", def.file.display(), def.line))
.collect::<Vec<_>>()
.join(", ");
errors.push(format!(
"slug `{}` is defined {} times: {at}",
group[0].slug,
group.len()
));
}
let registered: BTreeSet<&str> = problem_registry().iter().map(|def| def.slug).collect();
let scanned: BTreeSet<&str> = defs
.iter()
.filter(|def| def.crate_name == "cratefield-core")
.map(|def| def.slug.as_str())
.collect();
if scanned != registered {
errors.push(format!(
"cratefield-core: the slugs scanned from `SLUGS` do not match `problem_registry()` \
(in SLUGS only: {:?}; in the registry only: {:?})",
scanned.difference(®istered).collect::<Vec<_>>(),
registered.difference(&scanned).collect::<Vec<_>>(),
));
}
if !errors.is_empty() {
for error in &errors {
eprintln!("errors-doc: {error}");
}
std::process::exit(1);
}
let generated = markdown(&defs);
let check = std::env::args().any(|arg| arg == "--check");
if check {
let current = std::fs::read_to_string(&path).unwrap_or_default();
if current != generated {
eprintln!(
"docs/ERRORS.md is stale; regenerate with `cargo run -p cratefield-core \
--example errors-doc` and commit it"
);
std::process::exit(1);
}
println!("docs/ERRORS.md is up to date ({} slugs)", defs.len());
return;
}
std::fs::write(&path, generated).expect("write docs/ERRORS.md");
println!("wrote {} ({} slugs)", path.display(), defs.len());
}