use std::path::{Path, PathBuf};
use std::process::Command;
const OUTPUT_START: &str = "<!-- output: ";
const OUTPUT_END: &str = "<!-- end output -->";
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn markdown_in(dirs: &[&str]) -> Vec<PathBuf> {
let mut found = Vec::new();
for dir in dirs {
for entry in std::fs::read_dir(root().join(dir)).expect("a docs folder").flatten() {
let path = entry.path();
if path.extension().is_some_and(|extension| extension == "md") {
found.push(path);
}
}
}
found.sort();
found
}
fn home() -> PathBuf {
PathBuf::from(std::env::var_os("HOME").expect("HOME is set"))
}
fn expand(arg: &str) -> String {
match arg.strip_prefix("~/") {
Some(rest) => home().join(rest).display().to_string(),
None => arg.to_owned(),
}
}
#[derive(Debug, PartialEq)]
struct Shown {
line: usize,
args: Vec<String>,
text: String,
}
fn in_code_block(text: &str, at: usize) -> bool {
text[..at].lines().filter(|line| line.trim_start().starts_with("```")).count() % 2 == 1
}
fn shown(doc: &str) -> Result<Vec<Shown>, String> {
let mut found = Vec::new();
for (start, _) in doc.match_indices(OUTPUT_START) {
if in_code_block(doc, start) {
continue;
}
let line = doc[..start].matches('\n').count() + 1;
let rest = &doc[start + OUTPUT_START.len()..];
let args_end =
rest.find("-->").ok_or_else(|| format!("line {line}: the marker has no `-->`"))?;
let args: Vec<String> = rest[..args_end].split_whitespace().map(str::to_owned).collect();
if args.is_empty() {
return Err(format!("line {line}: the marker names no arguments"));
}
let body = &rest[args_end + "-->".len()..];
let body_end = body
.find(OUTPUT_END)
.ok_or_else(|| format!("line {line}: the output has no `{OUTPUT_END}`"))?;
if body[..body_end].contains(OUTPUT_START) {
return Err(format!("line {line}: the output has no `{OUTPUT_END}`"));
}
let text = body[..body_end]
.lines()
.filter(|line| !line.starts_with("```") && !line.starts_with("$ "))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_owned();
if text.is_empty() {
return Err(format!("line {line}: the output shows nothing"));
}
found.push(Shown { line, args, text });
}
Ok(found)
}
fn run(args: &[String]) -> String {
let output = Command::new(insta_cmd::get_cargo_bin("hereticus"))
.current_dir(root())
.args(["--color", "never", "--progress", "never"])
.args(args)
.output()
.expect("the tool runs");
let stdout = String::from_utf8(output.stdout).expect("the report is text");
let stderr = String::from_utf8(output.stderr).expect("the errors are text");
format!("{stderr}\n{stdout}")
}
fn appears_in(shown: &str, printed: &str) -> bool {
let printed: Vec<&str> = printed.lines().map(str::trim_end).collect();
let printed = format!("\n{}\n", printed.join("\n"));
let shown: Vec<&str> = shown.lines().map(str::trim_end).collect();
let mut rest = printed.as_str();
for run in shown.split(|line| is_elision(line)).filter(|run| !run.is_empty()) {
let run = format!("\n{}\n", run.join("\n"));
let Some(at) = rest.find(&run) else { return false };
rest = &rest[at + run.len() - 1..];
}
true
}
fn is_elision(line: &str) -> bool {
let cells: Vec<&str> = line.trim().trim_matches('|').split('|').map(str::trim).collect();
cells.contains(&"...") && cells.iter().all(|cell| cell.is_empty() || *cell == "...")
}
#[test]
fn every_output_the_docs_show_is_what_the_tool_prints() {
let mut docs = markdown_in(&["book/src"]);
docs.push(root().join("README.md"));
check(&docs);
}
#[test]
fn slow_every_output_the_reports_show_is_what_the_tool_prints() {
check(&markdown_in(&["real-life-reports", "malware-reports"]));
}
fn check(docs: &[PathBuf]) {
let mut problems = Vec::new();
let mut checked = 0;
for doc in docs {
let text = std::fs::read_to_string(doc).expect("a doc");
let name = doc.strip_prefix(root()).unwrap_or(doc).display().to_string();
let found = match shown(&text) {
Ok(found) => found,
Err(problem) => {
problems.push(format!("{name}: {problem}"));
continue;
}
};
for one in found {
checked += 1;
let args: Vec<String> = one.args.iter().map(|arg| expand(arg)).collect();
for (arg, expanded) in one.args.iter().zip(&args) {
let fetched = arg.starts_with("fixtures/") || arg.starts_with("~/");
if fetched && !root().join(expanded).exists() {
panic!("{arg} is missing, run `just test` to fetch it");
}
}
let printed = run(&args);
if !appears_in(&one.text, &printed) {
problems.push(format!(
"{name}:{}: `hereticus {}` prints\n{printed}\nand not\n{}",
one.line,
one.args.join(" "),
one.text
));
}
}
}
assert!(problems.is_empty(), "outputs in the docs do not agree:\n\n{}", problems.join("\n\n"));
assert!(checked > 0, "no doc shows an output with an `{OUTPUT_START}` marker");
}
#[test]
fn the_stub_example_counts_the_bytes_that_changed() {
let read = |release: &str| {
let path = root().join(format!("fixtures/build/openwrt/arm-ex6100v2-stub-{release}.bin"));
std::fs::read(&path)
.unwrap_or_else(|err| panic!("{}: {err}, run `just firmware` first", path.display()))
};
let (before, after) = (read("22.03.2"), read("22.03.5"));
assert_eq!(before.len(), after.len(), "the two stubs have one size");
let changed = before.iter().zip(&after).filter(|(a, b)| a != b).count();
let claim = format!("{changed} of its {} bytes changed", with_commas(before.len()));
let doc = root().join("book/src/examples.md");
let text = std::fs::read_to_string(&doc).expect("a doc").replace('\n', " ");
assert!(text.contains(&claim), "{} does not say `{claim}`", doc.display());
}
fn with_commas(number: usize) -> String {
let digits = number.to_string();
let mut out = String::new();
for (at, digit) in digits.chars().enumerate() {
if at > 0 && (digits.len() - at).is_multiple_of(3) {
out.push(',');
}
out.push(digit);
}
out
}
#[test]
fn a_marked_output_takes_its_arguments_and_its_text() {
let doc = "Text\n<!-- output: --diff f a.o b.o -->\n```console\n$ hereticus --diff f a.o b.o\n\
f\n- mov r1, 0x25\n```\n<!-- end output -->\n";
assert_eq!(
shown(doc),
Ok(vec![Shown {
line: 2,
args: vec!["--diff".into(), "f".into(), "a.o".into(), "b.o".into()],
text: "f\n- mov r1, 0x25".into(),
}])
);
assert_eq!(shown("No marker. <!-- a plain comment -->"), Ok(Vec::new()));
let example = "```markdown\n<!-- output: -h -->\n...\n<!-- end output -->\n```\n";
assert_eq!(shown(example), Ok(Vec::new()));
}
#[test]
fn a_malformed_marker_is_an_error() {
assert!(shown("<!-- output: -h -->\ntext").is_err());
assert!(shown("<!-- output: -->\ntext\n<!-- end output -->").is_err());
assert!(shown("<!-- output: -h -->\n```\n```\n<!-- end output -->").is_err());
assert!(shown("<!-- output: -h\ntext").is_err());
let unclosed = "<!-- output: -h -->\na\n<!-- output: -V -->\nb\n<!-- end output -->";
assert!(shown(unclosed).is_err());
}
#[test]
fn a_shown_output_may_be_a_part_of_what_is_printed() {
assert!(appears_in("b\nc", "a\nb \nc\nd"));
assert!(!appears_in("b\nd", "a\nb\nc\nd"));
assert!(!appears_in("b\nc", "a\nb\nC"));
assert!(appears_in("...\nb\n...\nd\n...", "a\nb\nc\nd\ne"));
assert!(!appears_in("d\n...\nb", "a\nb\nc\nd"));
assert!(!appears_in("b\n...\nx", "a\nb\nc\nd"));
assert!(!appears_in("b", "ab\nc"));
assert!(appears_in("| a |\n| ... | |\n| d |", "| a |\n| b |\n| c |\n| d |"));
assert!(is_elision("..."));
assert!(is_elision("| ... | | |"));
assert!(!is_elision("| | |"));
assert!(!is_elision("| ... | x |"));
}
#[test]
fn large_numbers_take_commas() {
assert_eq!(with_commas(0), "0");
assert_eq!(with_commas(999), "999");
assert_eq!(with_commas(15351), "15,351");
assert_eq!(with_commas(1234567), "1,234,567");
}
#[test]
fn an_argument_under_home_is_expanded() {
assert_eq!(expand("~/.cache/a"), home().join(".cache/a").display().to_string());
assert_eq!(expand("fixtures/a.o"), "fixtures/a.o");
assert_eq!(expand("a~/b"), "a~/b");
}