use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
fn docs_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../site/content/docs")
.canonicalize()
.expect("site/content/docs must exist under the workspace root")
}
fn public_api() -> BTreeSet<String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let lib = std::fs::read_to_string(root.join("lib.rs")).expect("src/lib.rs must be readable");
let mut api = BTreeSet::new();
let mut rest = lib.as_str();
while let Some(pos) = rest.find("pub use ") {
rest = &rest[pos + "pub use ".len()..];
let Some(end) = rest.find(';') else { break };
let stmt = &rest[..end];
rest = &rest[end + 1..];
match stmt.find('{') {
Some(brace) => {
let body = stmt[brace + 1..].trim_end().trim_end_matches('}');
for part in body.split(',') {
let name = part.split_whitespace().last().unwrap_or("");
if !name.is_empty() {
api.insert(name.trim_end_matches('}').to_owned());
}
}
}
None => {
if let Some(name) = stmt.rsplit("::").next() {
let name = name.split_whitespace().last().unwrap_or("").trim();
if !name.is_empty() {
api.insert(name.to_owned());
}
}
}
}
}
for line in lib.lines() {
let line = line.trim_start();
for (prefix, terminators) in [
("pub mod ", &[';'][..]),
("pub fn ", &['(', '<'][..]),
("pub struct ", &['<', '{', '(', ';'][..]),
("pub enum ", &['<', '{'][..]),
("pub trait ", &['<', '{', ':'][..]),
] {
if let Some(tail) = line.strip_prefix(prefix) {
let name: String = tail
.chars()
.take_while(|c| !terminators.contains(c) && !c.is_whitespace())
.collect();
if !name.is_empty() {
api.insert(name);
}
}
}
}
for entry in walk(&root) {
let text = std::fs::read_to_string(&entry).expect("module must be readable");
let mut rest = text.as_str();
while let Some(pos) = rest.find("#[macro_export]") {
rest = &rest[pos + "#[macro_export]".len()..];
if let Some(decl) = rest.find("macro_rules! ") {
let tail = &rest[decl + "macro_rules! ".len()..];
let name: String = tail.chars().take_while(|c| !c.is_whitespace()).collect();
if !name.is_empty() {
api.insert(name);
}
}
}
}
assert!(
api.len() > 80,
"parsed only {} public items from lib.rs — the parser is out of step \
with how the crate declares its exports",
api.len(),
);
api
}
fn walk(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk(&path));
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
out
}
fn imported_names(source: &str) -> BTreeSet<String> {
let normalised: String = source
.lines()
.map(|raw| {
let t = raw.trim_start();
t.strip_prefix("# ")
.unwrap_or_else(|| t.strip_prefix('#').unwrap_or(t))
})
.collect::<Vec<_>>()
.join("\n");
let mut found = BTreeSet::new();
let mut rest = normalised.as_str();
while let Some(pos) = rest.find("use edifact_rs::") {
rest = &rest[pos + "use edifact_rs::".len()..];
let Some(end) = rest.find(';') else { break };
let stmt = rest[..end].trim();
rest = &rest[end + 1..];
let body = stmt
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.unwrap_or(stmt);
for part in body.split(',') {
let name = part
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches('}');
if name.is_empty() || name == "self" || name == "*" {
continue;
}
let head = name.split("::").next().unwrap_or(name);
if head.chars().all(|c| c.is_alphanumeric() || c == '_') && !head.is_empty() {
found.insert(head.to_owned());
}
}
}
found
}
#[test]
fn guides_only_reference_public_api_items() {
let api = public_api();
let mut problems: Vec<String> = Vec::new();
let mut sources: Vec<(String, String)> = Vec::new();
for entry in std::fs::read_dir(docs_dir()).expect("read the guides directory") {
let path = entry.expect("dir entry").path();
if path.extension().is_some_and(|e| e == "md") {
let text = std::fs::read_to_string(&path).expect("read guide");
sources.push((path.display().to_string(), text));
}
}
let readme = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../README.md");
if let Ok(text) = std::fs::read_to_string(&readme) {
sources.push((readme.display().to_string(), text));
}
assert!(!sources.is_empty(), "no guides found to check");
for (name, text) in &sources {
for item in imported_names(text) {
if !api.contains(item.as_str()) {
problems.push(format!("{name}: `edifact_rs::{item}` is not a public item"));
}
}
}
assert!(
problems.is_empty(),
"guides reference items that are not part of the public API:\n {}",
problems.join("\n ")
);
}
#[test]
fn error_reference_documents_every_stable_code() {
let source =
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/error.rs"))
.expect("src/error.rs must be readable");
let body_start = source
.find("pub const fn stable_code(")
.expect("stable_code must exist");
let body_end = source[body_start..]
.find("\n }")
.map(|i| body_start + i)
.expect("stable_code body must be delimited");
let body = &source[body_start..body_end];
let codes: BTreeSet<String> = body
.match_indices("=> \"E")
.map(|(i, _)| {
let rest = &body[i + 4..];
let end = rest.find('"').expect("code literal must be closed");
rest[..end].to_owned()
})
.collect();
assert!(
codes.len() >= 35,
"expected to find every stable code, only parsed {}: {codes:?}",
codes.len()
);
let guide = std::fs::read_to_string(docs_dir().join("error-reference.md"))
.expect("error-reference.md must exist");
let missing: Vec<&String> = codes
.iter()
.filter(|code| !guide.contains(&format!("### {code} —")))
.collect();
assert!(
missing.is_empty(),
"error-reference.md is missing a `### <code> —` section for: {missing:?}"
);
let table_missing: Vec<&String> = codes
.iter()
.filter(|code| !guide.contains(&format!("| {code} | `")))
.collect();
assert!(
table_missing.is_empty(),
"error-reference.md summary table is missing rows for: {table_missing:?}"
);
}