use std::fs;
use std::path::{Path, PathBuf};
const BLOB_PREFIX: &str = "https://github.com/ablative-io/aion/blob/main/";
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.map_or_else(
|| unreachable!("crates/aion-cli always has two ancestors"),
Path::to_path_buf,
)
}
#[derive(Debug, PartialEq, Eq)]
struct Pointer {
source: String,
written: String,
resolved: PathBuf,
}
fn files_under(directory: &Path, extensions: &[&str]) -> Vec<PathBuf> {
let mut found = Vec::new();
let Ok(entries) = fs::read_dir(directory) else {
return found;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
found.extend(files_under(&path, extensions));
} else if path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extensions.contains(&extension))
{
found.push(path);
}
}
found.sort();
found
}
fn files_in_each_child(root: &Path, name: &str) -> Vec<PathBuf> {
let mut found = Vec::new();
let Ok(entries) = fs::read_dir(root) else {
return found;
};
for entry in entries.flatten() {
let candidate = entry.path().join(name);
if candidate.is_file() {
found.push(candidate);
}
}
found.sort();
found
}
fn scanned(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
files.extend(files_under(
&root.join("crates/aion-cli/templates"),
&["md", "toml", "gleam", "awl"],
));
files.extend(files_under(&root.join("docs/authoring"), &["md"]));
files.extend(files_under(&root.join("docs/workers"), &["md"]));
files.extend(files_under(&root.join("docs/operations"), &["md"]));
files.extend(files_under(&root.join("docs/assistant"), &["md"]));
files.extend(files_under(&root.join("docs/assistant/grounding"), &["md"]));
files.extend(files_in_each_child(&root.join("crates"), "README.md"));
files.extend(files_in_each_child(&root.join("examples"), "README.md"));
for named in [
"README.md",
"docs/README.md",
"docs/GETTING-STARTED.md",
"gleam/aion_flow/README.md",
] {
let path = root.join(named);
if path.is_file() {
files.push(path);
}
}
files
}
fn path_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '/' | '.' | '-' | '_')
}
fn is_repository_target(target: &str) -> bool {
!(target.contains("://")
|| target.starts_with('#')
|| target.starts_with("mailto:")
|| target.contains("{{"))
}
fn pointers_in(root: &Path, source: &Path, text: &str) -> Vec<Pointer> {
let relative = source
.strip_prefix(root)
.unwrap_or(source)
.to_string_lossy()
.into_owned();
let directory = source.parent().unwrap_or(root);
let mut pointers = Vec::new();
let mut rest = text;
while let Some(index) = rest.find(BLOB_PREFIX) {
let tail = &rest[index + BLOB_PREFIX.len()..];
let end = tail
.find(|character| !path_character(character))
.unwrap_or(tail.len());
let written = &tail[..end];
if !written.is_empty() {
pointers.push(Pointer {
source: relative.clone(),
written: format!("{BLOB_PREFIX}{written}"),
resolved: root.join(written),
});
}
rest = &tail[end..];
}
let mut rest = text;
while let Some(index) = rest.find("](") {
let tail = &rest[index + 2..];
let Some(end) = tail.find(')') else {
break;
};
let written = tail[..end].trim();
if is_repository_target(written) && !written.is_empty() {
let target = written.split('#').next().unwrap_or_default();
if !target.is_empty() {
pointers.push(Pointer {
source: relative.clone(),
written: written.to_owned(),
resolved: directory.join(target),
});
}
}
rest = &tail[end..];
}
pointers
}
fn all_pointers(root: &Path) -> Vec<Pointer> {
let mut pointers = Vec::new();
for file in scanned(root) {
let Ok(text) = fs::read_to_string(&file) else {
continue;
};
pointers.extend(pointers_in(root, &file, &text));
}
pointers
}
#[test]
fn every_documentation_pointer_names_a_file_that_exists() {
let root = repo_root();
let pointers = all_pointers(&root);
assert!(
pointers.len() > 100,
"only {} pointers were read; the scanned set has collapsed and a green run would mean \
nothing",
pointers.len()
);
let mut dead: Vec<String> = pointers
.iter()
.filter(|pointer| !pointer.resolved.exists())
.map(|pointer| format!(" {} -> {}", pointer.source, pointer.written))
.collect();
dead.sort();
assert!(
dead.is_empty(),
"{} documentation pointer(s) name a path that does not exist:\n{}",
dead.len(),
dead.join("\n")
);
}
#[test]
fn the_project_templates_still_point_somewhere() {
let root = repo_root();
let templates = root.join("crates/aion-cli/templates");
let pointers: Vec<Pointer> = all_pointers(&root)
.into_iter()
.filter(|pointer| pointer.source.starts_with("crates/aion-cli/templates"))
.collect();
assert!(
templates.is_dir(),
"the template directory must exist for this gate to mean anything"
);
assert!(
pointers.len() > 5,
"the project templates carry only {} documentation pointer(s)",
pointers.len()
);
}
#[test]
fn the_extractor_reads_both_pointer_forms_and_skips_what_is_not_a_path() {
let root = repo_root();
let source = root.join("docs/authoring/AWL.md");
let text = "\
See [the guide](../workers/DECLARED-COMMANDS.md) and
<https://github.com/ablative-io/aion/blob/main/docs/authoring/AWL.md>, plus
[the site](https://example.com/x), [a section](#anchor), and
[a placeholder]({{name}}.awl).
";
let pointers = pointers_in(&root, &source, text);
let written: Vec<&str> = pointers
.iter()
.map(|pointer| pointer.written.as_str())
.collect();
assert_eq!(
written,
vec![
"https://github.com/ablative-io/aion/blob/main/docs/authoring/AWL.md",
"../workers/DECLARED-COMMANDS.md",
],
"the extractor must read both real forms and skip a scheme, an anchor, and a placeholder"
);
assert!(
pointers.iter().all(|pointer| pointer.resolved.exists()),
"both real pointers in the fixture resolve"
);
}
#[test]
fn the_gate_rejects_a_pointer_at_a_path_that_is_not_there() {
let root = repo_root();
let source = root.join("docs/authoring/AWL.md");
let text = "[gone](../guides/workflows.md) and \
<https://github.com/ablative-io/aion/blob/main/docs/guides/workflows.md>\n";
let pointers = pointers_in(&root, &source, text);
assert_eq!(pointers.len(), 2, "both forms must be extracted");
assert!(
pointers.iter().all(|pointer| !pointer.resolved.exists()),
"a pointer into the departed docs/guides/ directory must resolve to nothing"
);
}