use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
fn repo() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn markdown_files() -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
let hidden = name.starts_with('.') && name != ".github";
if hidden || name == "target" || name == "node_modules" {
continue;
}
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "md") {
out.push(p);
}
}
}
let mut out = Vec::new();
walk(&repo(), &mut out);
out.sort();
out
}
fn links(body: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes: Vec<char> = body.chars().collect();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == ']' && i + 1 < bytes.len() && bytes[i + 1] == '(' {
let mut depth = 1;
let mut j = i + 2;
let mut target = String::new();
while j < bytes.len() && depth > 0 {
match bytes[j] {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
target.push(bytes[j]);
j += 1;
}
if depth == 0 {
let t = target.split_whitespace().next().unwrap_or("").to_string();
out.push(t);
}
i = j;
}
i += 1;
}
out
}
fn is_external(target: &str) -> bool {
target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("mailto:")
|| target.starts_with('#')
|| target.is_empty()
}
#[test]
fn every_relative_link_resolves() {
let mut dead: Vec<String> = Vec::new();
for file in markdown_files() {
let body = fs::read_to_string(&file).unwrap();
let dir = file.parent().unwrap();
let shown = file.strip_prefix(repo()).unwrap().display().to_string();
for target in links(&body) {
if is_external(&target) {
continue;
}
let path_part = target.split('#').next().unwrap_or(&target);
if path_part.is_empty() {
continue;
}
if !dir.join(path_part).exists() {
dead.push(format!("{shown} → {target}"));
}
}
}
assert!(dead.is_empty(), "dead links:\n {}", dead.join("\n "));
}
fn slug(heading: &str) -> String {
let text: String = heading
.trim_start_matches('#')
.trim()
.chars()
.filter(|c| !matches!(c, '`' | '*' | '[' | ']' | '(' | ')'))
.collect();
text.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_'))
.map(|c| if c == ' ' { '-' } else { c })
.collect()
}
fn anchors(body: &str) -> BTreeSet<String> {
body.lines()
.filter(|l| l.starts_with('#'))
.map(slug)
.collect()
}
#[test]
fn every_anchor_resolves() {
let mut dead: Vec<String> = Vec::new();
for file in markdown_files() {
let body = fs::read_to_string(&file).unwrap();
let dir = file.parent().unwrap();
let shown = file.strip_prefix(repo()).unwrap().display().to_string();
for target in links(&body) {
if target.starts_with("http") || target.starts_with("mailto:") {
continue;
}
let Some((path_part, anchor)) = target.split_once('#') else {
continue;
};
if anchor.is_empty() {
continue;
}
let target_body = if path_part.is_empty() {
Some(body.clone())
} else {
fs::read_to_string(dir.join(path_part)).ok()
};
let Some(target_body) = target_body else {
continue;
};
if !anchors(&target_body).contains(anchor) {
dead.push(format!("{shown} → {target}"));
}
}
}
assert!(
dead.is_empty(),
"anchors pointing at no heading:\n {}",
dead.join("\n ")
);
}
#[test]
fn every_docs_page_is_reachable() {
let docs = repo().join("docs");
let mut linked: BTreeSet<PathBuf> = BTreeSet::new();
for file in markdown_files() {
let body = fs::read_to_string(&file).unwrap();
let dir = file.parent().unwrap();
for target in links(&body) {
if is_external(&target) {
continue;
}
let path_part = target.split('#').next().unwrap_or(&target);
if let Ok(c) = dir.join(path_part).canonicalize() {
linked.insert(c);
}
}
}
let orphans: Vec<String> = markdown_files()
.into_iter()
.filter(|f| f.starts_with(&docs))
.filter(|f| f.file_name().is_some_and(|n| n != "README.md"))
.filter(|f| {
!f.canonicalize()
.map(|c| linked.contains(&c))
.unwrap_or(false)
})
.map(|f| f.strip_prefix(repo()).unwrap().display().to_string())
.collect();
assert!(
orphans.is_empty(),
"unreachable pages:\n {}",
orphans.join("\n ")
);
}
fn stated_versions(body: &str) -> Vec<(usize, String)> {
body.lines()
.enumerate()
.flat_map(|(i, line)| {
line.split('`')
.skip(1)
.step_by(2)
.filter(|span| {
let parts: Vec<&str> = span.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
})
.map(move |span| (i + 1, span.to_string()))
})
.collect()
}
#[test]
fn no_page_states_a_version_the_crate_does_not() {
let crate_version = env!("CARGO_PKG_VERSION");
let mut stale: Vec<String> = Vec::new();
for file in markdown_files() {
let body = fs::read_to_string(&file).unwrap();
let shown = file.strip_prefix(repo()).unwrap().display().to_string();
for (line, stated) in stated_versions(&body) {
if stated != crate_version {
stale.push(format!("{shown}:{line} says `{stated}`"));
}
}
}
assert!(
stale.is_empty(),
"the crate is at {crate_version}:\n {}",
stale.join("\n ")
);
}
#[test]
fn the_readme_points_into_the_docs_tree() {
let body = fs::read_to_string(repo().join("README.md")).unwrap();
assert!(
links(&body).iter().any(|l| l.starts_with("docs/")),
"README should route readers into docs/"
);
}