use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
process::Command,
};
fn tracked_files(root: &Path) -> Vec<PathBuf> {
let listing = Command::new("git")
.arg("-C")
.arg(root)
.args(["ls-files", "-z"])
.output()
.expect("git ls-files");
assert!(listing.status.success(), "git ls-files failed");
String::from_utf8_lossy(&listing.stdout)
.split('\0')
.filter(|path| !path.is_empty())
.filter(|path| {
Path::new(path).extension().is_some_and(|extension| {
matches!(extension.as_encoded_bytes(), b"md" | b"rs" | b"yml")
})
})
.map(|path| root.join(path))
.collect()
}
fn repository_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("workspace root is two levels above the crate")
.to_path_buf()
}
fn markdown_link_targets(text: &str) -> Vec<String> {
let mut targets = Vec::new();
for tail in text.split("](").skip(1) {
let Some(end) = tail.find(')') else { continue };
let target = tail[..end].trim();
let target = target.split_whitespace().next().unwrap_or(target);
let target = target.split('#').next().unwrap_or(target);
if target.is_empty() || target.contains("://") || target.starts_with("mailto:") {
continue;
}
targets.push(target.to_owned());
}
targets
}
fn bare_docs_paths(text: &str) -> Vec<String> {
let mut paths = Vec::new();
for tail in text.split("docs/").skip(1) {
let path: String = tail
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '/'))
.collect();
if Path::new(&path).extension().is_some_and(|e| e == "md") {
paths.push(format!("docs/{path}"));
}
}
paths
}
#[test]
fn every_relative_link_in_the_repository_resolves() {
let root = repository_root();
let mut broken: BTreeSet<String> = BTreeSet::new();
let mut checked = 0_u32;
for file in tracked_files(&root) {
if file.ends_with("tests/docs_links.rs") {
continue;
}
let Ok(text) = fs::read_to_string(&file) else {
continue;
};
let here = file.parent().expect("a tracked file has a parent");
let shown = file.strip_prefix(&root).unwrap_or(&file).display();
for target in markdown_link_targets(&text) {
if target.starts_with('/') {
continue;
}
checked += 1;
if !here.join(&target).exists() {
broken.insert(format!("{shown} -> {target}"));
}
}
for target in bare_docs_paths(&text) {
checked += 1;
if !root.join(&target).exists() {
broken.insert(format!("{shown} -> {target}"));
}
}
}
assert!(
broken.is_empty(),
"{} of {checked} links do not resolve:\n {}",
broken.len(),
broken.into_iter().collect::<Vec<_>>().join("\n ")
);
assert!(
checked > 50,
"only {checked} links were examined; the extraction has stopped finding them"
);
}
#[test]
fn the_release_workflow_finds_this_versions_notes() {
let root = repository_root();
let workflow = fs::read_to_string(root.join(".github/workflows/release.yml")).unwrap();
let template = workflow
.lines()
.find_map(|line| line.trim().strip_prefix("notes_file=\""))
.and_then(|rest| rest.strip_suffix('"'))
.expect("release.yml names its notes file");
let tag = format!("v{}", env!("CARGO_PKG_VERSION"));
let notes = template.replace("${GITHUB_REF_NAME}", &tag);
assert!(
root.join(¬es).is_file(),
"release.yml would look for {notes}, which does not exist"
);
}