use std::path::{Path, PathBuf};
use std::process::Command;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .unwrap()
.parent() .unwrap()
.to_path_buf()
}
fn tracked_markdown(root: &Path) -> Vec<String> {
let output = Command::new("git")
.args(["ls-files", "-z", "--", "*.md"])
.current_dir(root)
.output()
.expect("failed to run `git ls-files`");
assert!(
output.status.success(),
"`git ls-files` failed in {}: {}",
root.display(),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout)
.expect("non-UTF-8 path")
.split('\0')
.filter(|p| !p.is_empty())
.map(str::to_owned)
.collect()
}
fn fence(line: &str) -> Option<(char, usize, bool)> {
let indent = line.len() - line.trim_start_matches(' ').len();
if indent > 3 {
return None;
}
let rest = &line[indent..];
let ch = rest.chars().next().filter(|c| *c == '`' || *c == '~')?;
let run = rest.chars().take_while(|c| *c == ch).count();
if run < 3 {
return None;
}
let after = rest[run..].trim();
if ch == '`' && after.contains('`') {
return None;
}
Some((ch, run, !after.is_empty()))
}
fn unclosed_fence(text: &str) -> Option<usize> {
let mut open: Option<(char, usize, usize)> = None;
for (i, line) in text.lines().enumerate() {
let Some((ch, run, has_info)) = fence(line) else {
continue;
};
match open {
None => open = Some((ch, run, i + 1)),
Some((open_ch, open_run, _)) if ch == open_ch && run >= open_run && !has_info => {
open = None
}
Some(_) => {}
}
}
open.map(|(_, _, line)| line)
}
#[test]
fn every_tracked_markdown_file_closes_its_code_fences() {
let root = workspace_root();
let files = tracked_markdown(&root);
assert!(
files.iter().any(|f| f == "docs/specification.md"),
"the scan did not see the specification — `git ls-files` is not listing what it should"
);
let unclosed: Vec<String> = files
.iter()
.filter_map(|f| {
let text = std::fs::read_to_string(root.join(f)).ok()?;
unclosed_fence(&text).map(|line| format!("{f}:{line}"))
})
.collect();
assert!(
unclosed.is_empty(),
"code fence opened and never closed — everything after it renders as one code block, \
headings included. Close it, or give an outer fence more backticks than the one it \
contains:\n {}",
unclosed.join("\n ")
);
}
#[test]
fn the_check_tells_a_closed_nest_from_an_early_close() {
assert_eq!(
unclosed_fence("````markdown\n```sql\nx\n```\n````\n# Next\n"),
None
);
assert_eq!(
unclosed_fence("```markdown\n```sql\nx\n```\n```\n# Next\n"),
Some(5)
);
assert_eq!(unclosed_fence("```\nx\n`````\n"), None);
assert_eq!(unclosed_fence("~~~\nx\n```\n"), Some(1));
assert_eq!(unclosed_fence("Use ```x``` inline.\n"), None);
}