use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
struct TestBlock {
start_line: usize,
lines: Vec<String>,
}
fn extract_test_blocks(content: &str) -> Vec<TestBlock> {
let mut blocks = Vec::new();
let mut in_block = false;
let mut current_lines = Vec::new();
let mut block_start = 0;
let mut line_num = 0;
for line in content.lines() {
line_num += 1;
if line.trim().starts_with("```bash,test") {
in_block = true;
current_lines.clear();
block_start = line_num + 1;
} else if in_block && line.trim() == "```" {
in_block = false;
if !current_lines.is_empty() {
blocks.push(TestBlock {
start_line: block_start,
lines: current_lines.clone(),
});
}
} else if in_block {
current_lines.push(line.to_string());
}
}
blocks
}
fn collect_md_files(dir: &Path) -> Vec<PathBuf> {
let mut results = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
results.extend(collect_md_files(&path));
} else if path.extension().map(|e| e == "md").unwrap_or(false) {
results.push(path);
}
}
}
results
}
fn jjj_binary() -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("target");
path.push("debug");
path.push("jjj");
path
}
fn setup_doc_test_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let output = Command::new("jj")
.args(["git", "init"])
.current_dir(dir.path())
.output()
.expect("jj must be installed for doc tests");
assert!(
output.status.success(),
"jj git init failed: {}",
String::from_utf8_lossy(&output.stderr)
);
Command::new("jj")
.args(["config", "set", "--repo", "user.name", "Test User"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("jj")
.args(["config", "set", "--repo", "user.email", "test@example.com"])
.current_dir(dir.path())
.output()
.unwrap();
dir
}
fn split_shell_args(cmd: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in cmd.chars() {
match ch {
'"' => {
in_quotes = !in_quotes;
}
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
args.push(current.clone());
current.clear();
}
}
_ => {
current.push(ch);
}
}
}
if !current.is_empty() {
args.push(current);
}
args
}
fn run_doc_command(dir: &Path, cmd_line: &str) -> (bool, String, String) {
let parts = split_shell_args(cmd_line);
if parts.is_empty() {
return (true, String::new(), String::new());
}
if parts[0] != "jjj" {
return (true, String::new(), String::new());
}
let output = Command::new(jjj_binary())
.args(&parts[1..])
.current_dir(dir)
.output()
.expect("failed to run jjj command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status.success(), stdout, stderr)
}
#[test]
fn test_documentation_examples() {
if jjj::jj::find_executable("jj").is_none() {
eprintln!("Skipping doc tests: jj not found");
return;
}
let docs_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
if !docs_dir.exists() {
eprintln!("Skipping doc tests: docs/ directory not found");
return;
}
let mut failures: Vec<String> = Vec::new();
let mut tested_files = 0;
let mut tested_commands = 0;
let md_files = collect_md_files(&docs_dir);
for path in &md_files {
let path = path.as_path();
if path.to_string_lossy().contains("/plans/") {
continue;
}
let content = fs::read_to_string(path).unwrap();
let blocks = extract_test_blocks(&content);
if blocks.is_empty() {
continue;
}
let rel_path = path.strip_prefix(&docs_dir).unwrap();
let dir = setup_doc_test_repo();
tested_files += 1;
let mut last_stdout = String::new();
for block in &blocks {
for (i, line) in block.lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("# expect:") {
let expected = trimmed
.strip_prefix("# expect:")
.unwrap()
.trim()
.trim_matches('"');
if !last_stdout.contains(expected) {
failures.push(format!(
"{}:{} -- expected '{}' in output\nstdout: {}",
rel_path.display(),
block.start_line + i,
expected,
last_stdout.trim(),
));
}
continue;
}
if trimmed.starts_with('#') {
continue;
}
let (success, stdout, stderr) = run_doc_command(dir.path(), trimmed);
tested_commands += 1;
if !success {
failures.push(format!(
"{}:{} -- command failed: {}\nstderr: {}",
rel_path.display(),
block.start_line + i,
trimmed,
stderr.trim(),
));
break; }
last_stdout = stdout;
}
}
}
eprintln!(
"Doc tests: {} files, {} commands tested",
tested_files, tested_commands
);
if !failures.is_empty() {
panic!(
"\n{} documentation test(s) failed:\n\n{}",
failures.len(),
failures.join("\n\n"),
);
}
}