mod run_tests;
mod skeptic;
#[cfg(test)]
mod tests;
use std::{fs::File, io::prelude::*};
use atty::Stream;
use colored::{control::set_override, Colorize};
use glob::glob;
use mdbook::{
book::{Book, BookItem},
errors::Error,
preprocess::{Preprocessor, PreprocessorContext},
};
use serde::{Deserialize, Serialize};
use slug::slugify;
use std::{
collections::HashMap,
path::{Path, PathBuf},
process::Command,
};
use toml::value::Table;
use run_tests::{handle_test, CompileType, TestResult};
use skeptic::{create_test_input, extract_tests_from_string, Test};
type PreprocessorConfig<'a> = Option<&'a Table>;
fn get_tests_from_book(book: &Book) -> Vec<Test> {
get_tests_from_items(&book.sections)
}
fn get_tests_from_items(items: &[BookItem]) -> Vec<Test> {
let chapters = items.iter().filter_map(|b| match *b {
BookItem::Chapter(ref ch) => Some(ch),
_ => None,
});
chapters
.flat_map(|c| {
let file_name = c
.path
.as_ref()
.map(|x| x.to_string_lossy().into_owned())
.unwrap_or_else(|| slugify(c.name.clone()).replace('-', "_"));
let (mut tests, _) = extract_tests_from_string(&c.content, &file_name);
tests.append(&mut get_tests_from_items(&c.sub_items));
tests
})
.collect::<Vec<_>>()
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct KeeperConfigParser {
#[serde(default)]
externs: Vec<String>,
#[serde(default)]
test_dir: Option<String>,
#[serde(default)]
target_dir: Option<String>,
#[serde(default)]
manifest_dir: Option<String>,
#[serde(default)]
is_workspace: Option<bool>,
#[serde(default)]
build_features: Vec<String>,
#[serde(default)]
terminal_colors: Option<bool>,
}
#[derive(Debug)]
struct KeeperConfig {
test_dir: PathBuf,
target_dir: PathBuf,
manifest_dir: Option<PathBuf>,
is_workspace: bool,
build_features: Vec<String>,
terminal_colors: bool,
externs: Vec<String>,
}
impl KeeperConfig {
fn new(preprocessor_config: PreprocessorConfig, root: &Path) -> KeeperConfig {
let keeper_config: KeeperConfigParser = match preprocessor_config {
Some(config) => toml::de::from_str(
&toml::ser::to_string(&config).expect("this must succeed, it was just toml"),
)
.unwrap(),
None => KeeperConfigParser::default(),
};
let base_dir = root.to_path_buf();
let test_dir = keeper_config
.test_dir
.map(PathBuf::from)
.unwrap_or_else(|| {
let mut build_dir = base_dir;
build_dir.push("doctest_cache");
build_dir
});
let target_dir = keeper_config
.target_dir
.map(PathBuf::from)
.unwrap_or_else(|| {
let mut target_dir = test_dir.clone();
target_dir.push("target");
target_dir
});
let manifest_dir = keeper_config.manifest_dir.map(PathBuf::from);
let is_workspace = keeper_config.is_workspace.unwrap_or(false);
let terminal_colors = keeper_config
.terminal_colors
.unwrap_or_else(|| atty::is(Stream::Stderr));
set_override(terminal_colors);
KeeperConfig {
test_dir,
target_dir,
manifest_dir,
is_workspace,
build_features: keeper_config.build_features,
terminal_colors,
externs: keeper_config.externs,
}
}
fn setup_environment(&self) {
if !self.test_dir.is_dir() {
std::fs::create_dir(&self.test_dir).unwrap();
}
if let Some(manifest_dir) = &self.manifest_dir {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo"));
let mut command = Command::new(cargo);
command
.arg("build")
.current_dir(manifest_dir)
.env("CARGO_TARGET_DIR", &self.target_dir)
.env("CARGO_MANIFEST_DIR", manifest_dir);
if self.is_workspace {
command.arg("--workspace");
}
if !self.build_features.is_empty() {
command.args(["--features", &self.build_features.join(",")]);
}
let mut join_handle = command.spawn().expect("failed to execute process");
let build_was_ok = join_handle.wait().expect("Could not join on thread");
if !build_was_ok.success() {
panic!("cargo build failed!");
}
}
}
}
fn get_test_path(test: &Test, test_dir: &Path) -> PathBuf {
let mut file_name: PathBuf = test_dir.to_path_buf();
file_name.push(format!("keeper_{}.rs", test.hash));
file_name
}
fn write_test_to_path(test: &Test, path: &Path) -> Result<(), std::io::Error> {
let mut output = File::create(path)?;
let test_text = create_test_input(&test.text);
write!(output, "{}", test_text)?;
Ok(())
}
fn run_tests_with_config(tests: Vec<Test>, config: &KeeperConfig) -> HashMap<Test, TestResult> {
let mut results = HashMap::new();
for test in tests {
if test.ignore {
continue;
}
let testcase_path = get_test_path(&test, &config.test_dir);
let result: TestResult = if !testcase_path.is_file() {
write_test_to_path(&test, &testcase_path).unwrap();
handle_test(
config.manifest_dir.as_deref(),
&config.target_dir,
current_platform::CURRENT_PLATFORM,
&testcase_path,
if test.no_run {
CompileType::Check
} else {
CompileType::Full
},
config.terminal_colors,
&config.externs,
)
} else {
TestResult::Cached
};
results.insert(test, result);
}
results
}
fn print_results(results: &HashMap<Test, TestResult>) {
let mut cached_tests = 0;
for (test, test_result) in results {
if !matches!(test_result, &TestResult::Cached) {
eprint!(" - Test: {} ", test.name);
}
let output = match test_result {
TestResult::CompileFailed(output) if test.compile_fail => {
eprintln!("{}", "(Failed to compile as expected)".green());
output
}
TestResult::CompileFailed(output) => {
eprintln!("{}", "(Failed to compile)".red());
output
}
TestResult::RunFailed(output) if test.should_panic => {
eprintln!("{}", "(Panicked as expected)".green());
output
}
TestResult::RunFailed(output) => {
eprintln!("{}", "(Panicked)".red());
output
}
TestResult::Successful(output) if test.should_panic => {
eprintln!("{}", "(Unexpectedly suceeded)".red());
output
}
TestResult::Successful(output) => {
eprintln!("{}", "(Passed)".green());
output
}
TestResult::Cached => {
cached_tests += 1;
continue;
}
};
if !test_result.met_test_expectations(test) {
eprintln!(
"--------------- {} {} ---------------",
"Start of Test Log: ".bold(),
test.name
);
if !output.stdout.is_empty() {
eprintln!(
"----- {} -----\n{}",
"Stdout".bold(),
String::from_utf8(output.stdout.to_vec()).unwrap()
);
} else {
eprintln!("{}", "No stdout was captured.".red(),);
}
if !output.stderr.is_empty() {
eprintln!(
"----- {} -----\n\n{}",
"Stderr".bold(),
String::from_utf8(output.stderr.to_vec()).unwrap()
);
} else {
eprintln!("{}", "No stderr was captured.".red(),);
}
eprintln!("--------------- End Of Test ---------------");
}
}
if cached_tests > 0 {
eprintln!(
"{} {} {}",
"Skipped".bold(),
cached_tests.to_string().bold().blue(),
"tests which had identical code, and previously passed.".bold()
);
}
}
fn clean_file(test_results: &HashMap<Test, TestResult>, path: &Path) -> Option<()> {
let file_stem = path.file_stem()?;
let file_str = file_stem.to_str()?;
let hash = file_str.strip_prefix("keeper_")?;
let matching_test = test_results.iter().find(|(t, _)| t.hash == hash);
let should_remove = match matching_test {
Some((t, tr)) => !tr.met_test_expectations(t),
None => true,
};
if should_remove {
std::fs::remove_file(path).expect("Should be able to delete cache-file");
}
Some(())
}
fn cleanup_keepercache(config: &KeeperConfig, test_results: &HashMap<Test, TestResult>) {
let glob_str = format!("{}/keeper_*.rs", config.test_dir.display());
glob(&glob_str)
.expect("Could not list keeper files.")
.filter_map(Result::ok)
.for_each(|p| {
clean_file(test_results, &p);
});
}
#[derive(Default)]
pub struct BookKeeper;
impl BookKeeper {
pub fn new() -> BookKeeper {
BookKeeper
}
}
impl BookKeeper {
pub fn real_run(
&self,
preprocessor_config: PreprocessorConfig,
root: PathBuf,
book: &mut Book,
) -> Result<HashMap<Test, TestResult>, Error> {
let config = KeeperConfig::new(preprocessor_config, &root);
config.setup_environment();
let tests = get_tests_from_book(book);
let test_results = run_tests_with_config(tests, &config);
cleanup_keepercache(&config, &test_results);
Ok(test_results)
}
}
impl Preprocessor for BookKeeper {
fn name(&self) -> &str {
"keeper"
}
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
let preprocessor_config = ctx.config.get_preprocessor(self.name());
let root = ctx.root.to_path_buf();
let test_results = self.real_run(preprocessor_config, root, &mut book)?;
print_results(&test_results);
Ok(book)
}
fn supports_renderer(&self, renderer: &str) -> bool {
renderer != "not-supported"
}
}