1mod run_tests;
2mod skeptic;
3
4#[cfg(test)]
5mod tests;
6
7use std::{fs::File, io::prelude::*};
8
9use atty::Stream;
10use colored::{control::set_override, Colorize};
11use glob::glob;
12use mdbook::{
13 book::{Book, BookItem},
14 errors::Error,
15 preprocess::{Preprocessor, PreprocessorContext},
16};
17use serde::{Deserialize, Serialize};
18use slug::slugify;
19use std::{
20 collections::HashMap,
21 path::{Path, PathBuf},
22 process::Command,
23};
24use toml::value::Table;
25
26use run_tests::{handle_test, CompileType, TestResult};
27use skeptic::{create_test_input, extract_tests_from_string, Test};
28
29type PreprocessorConfig<'a> = Option<&'a Table>;
30
31fn get_tests_from_book(book: &Book) -> Vec<Test> {
32 get_tests_from_items(&book.sections)
33}
34
35fn get_tests_from_items(items: &[BookItem]) -> Vec<Test> {
36 let chapters = items.iter().filter_map(|b| match *b {
37 BookItem::Chapter(ref ch) => Some(ch),
38 _ => None,
39 });
40
41 chapters
42 .flat_map(|c| {
43 let file_name = c
44 .path
45 .as_ref()
46 .map(|x| x.to_string_lossy().into_owned())
47 .unwrap_or_else(|| slugify(c.name.clone()).replace('-', "_"));
48 let (mut tests, _) = extract_tests_from_string(&c.content, &file_name);
49 tests.append(&mut get_tests_from_items(&c.sub_items));
50 tests
51 })
52 .collect::<Vec<_>>()
53}
54
55#[derive(Debug, Default, Deserialize, Serialize)]
56struct KeeperConfigParser {
57 #[serde(default)]
64 externs: Vec<String>,
65
66 #[serde(default)]
70 test_dir: Option<String>,
71
72 #[serde(default)]
76 target_dir: Option<String>,
77
78 #[serde(default)]
83 manifest_dir: Option<String>,
84
85 #[serde(default)]
89 is_workspace: Option<bool>,
90
91 #[serde(default)]
97 build_features: Vec<String>,
98
99 #[serde(default)]
101 terminal_colors: Option<bool>,
102}
103
104#[derive(Debug)]
105struct KeeperConfig {
106 test_dir: PathBuf,
107 target_dir: PathBuf,
108 manifest_dir: Option<PathBuf>,
109 is_workspace: bool,
110 build_features: Vec<String>,
111 terminal_colors: bool,
112 externs: Vec<String>,
113}
114
115impl KeeperConfig {
116 fn new(preprocessor_config: PreprocessorConfig, root: &Path) -> KeeperConfig {
117 let keeper_config: KeeperConfigParser = match preprocessor_config {
118 Some(config) => toml::de::from_str(
119 &toml::ser::to_string(&config).expect("this must succeed, it was just toml"),
120 )
121 .unwrap(),
122 None => KeeperConfigParser::default(),
123 };
124
125 let base_dir = root.to_path_buf();
126 let test_dir = keeper_config
127 .test_dir
128 .map(PathBuf::from)
129 .unwrap_or_else(|| {
130 let mut build_dir = base_dir;
131 build_dir.push("doctest_cache");
132 build_dir
133 });
134
135 let target_dir = keeper_config
136 .target_dir
137 .map(PathBuf::from)
138 .unwrap_or_else(|| {
139 let mut target_dir = test_dir.clone();
140 target_dir.push("target");
141 target_dir
142 });
143
144 let manifest_dir = keeper_config.manifest_dir.map(PathBuf::from);
145 let is_workspace = keeper_config.is_workspace.unwrap_or(false);
146
147 let terminal_colors = keeper_config
148 .terminal_colors
149 .unwrap_or_else(|| atty::is(Stream::Stderr));
150
151 set_override(terminal_colors);
152
153 KeeperConfig {
154 test_dir,
155 target_dir,
156 manifest_dir,
157 is_workspace,
158 build_features: keeper_config.build_features,
159 terminal_colors,
160 externs: keeper_config.externs,
161 }
162 }
163
164 fn setup_environment(&self) {
165 if !self.test_dir.is_dir() {
166 std::fs::create_dir(&self.test_dir).unwrap();
167 }
168
169 if let Some(manifest_dir) = &self.manifest_dir {
170 let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo"));
171
172 let mut command = Command::new(cargo);
173 command
174 .arg("build")
175 .current_dir(manifest_dir)
176 .env("CARGO_TARGET_DIR", &self.target_dir)
177 .env("CARGO_MANIFEST_DIR", manifest_dir);
178
179 if self.is_workspace {
180 command.arg("--workspace");
181 }
182
183 if !self.build_features.is_empty() {
184 command.args(["--features", &self.build_features.join(",")]);
185 }
186
187 let mut join_handle = command.spawn().expect("failed to execute process");
188
189 let build_was_ok = join_handle.wait().expect("Could not join on thread");
190
191 if !build_was_ok.success() {
192 panic!("cargo build failed!");
193 }
194 }
195 }
196}
197
198fn get_test_path(test: &Test, test_dir: &Path) -> PathBuf {
199 let mut file_name: PathBuf = test_dir.to_path_buf();
200 file_name.push(format!("keeper_{}.rs", test.hash));
201
202 file_name
203}
204
205fn write_test_to_path(test: &Test, path: &Path) -> Result<(), std::io::Error> {
206 let mut output = File::create(path)?;
207 let test_text = create_test_input(&test.text);
208 write!(output, "{}", test_text)?;
209
210 Ok(())
211}
212
213fn run_tests_with_config(tests: Vec<Test>, config: &KeeperConfig) -> HashMap<Test, TestResult> {
214 let mut results = HashMap::new();
215 for test in tests {
216 if test.ignore {
217 continue;
218 }
219 let testcase_path = get_test_path(&test, &config.test_dir);
220
221 let result: TestResult = if !testcase_path.is_file() {
222 write_test_to_path(&test, &testcase_path).unwrap();
223 handle_test(
224 config.manifest_dir.as_deref(),
225 &config.target_dir,
226 current_platform::CURRENT_PLATFORM,
227 &testcase_path,
228 if test.no_run {
229 CompileType::Check
230 } else {
231 CompileType::Full
232 },
233 config.terminal_colors,
234 &config.externs,
235 )
236 } else {
237 TestResult::Cached
238 };
239 results.insert(test, result);
240 }
241
242 results
243}
244
245fn print_results(results: &HashMap<Test, TestResult>) {
246 let mut cached_tests = 0;
247 for (test, test_result) in results {
248 if !matches!(test_result, &TestResult::Cached) {
249 eprint!(" - Test: {} ", test.name);
250 }
251 let output = match test_result {
252 TestResult::CompileFailed(output) if test.compile_fail => {
253 eprintln!("{}", "(Failed to compile as expected)".green());
254 output
255 }
256 TestResult::CompileFailed(output) => {
257 eprintln!("{}", "(Failed to compile)".red());
258 output
259 }
260 TestResult::RunFailed(output) if test.should_panic => {
261 eprintln!("{}", "(Panicked as expected)".green());
262 output
263 }
264 TestResult::RunFailed(output) => {
265 eprintln!("{}", "(Panicked)".red());
266 output
267 }
268 TestResult::Successful(output) if test.should_panic => {
269 eprintln!("{}", "(Unexpectedly suceeded)".red());
270 output
271 }
272 TestResult::Successful(output) => {
273 eprintln!("{}", "(Passed)".green());
274 output
275 }
276 TestResult::Cached => {
277 cached_tests += 1;
278 continue;
279 }
280 };
281 if !test_result.met_test_expectations(test) {
282 eprintln!(
283 "--------------- {} {} ---------------",
284 "Start of Test Log: ".bold(),
285 test.name
286 );
287 if !output.stdout.is_empty() {
288 eprintln!(
289 "----- {} -----\n{}",
290 "Stdout".bold(),
291 String::from_utf8(output.stdout.to_vec()).unwrap()
292 );
293 } else {
294 eprintln!("{}", "No stdout was captured.".red(),);
295 }
296 if !output.stderr.is_empty() {
297 eprintln!(
298 "----- {} -----\n\n{}",
299 "Stderr".bold(),
300 String::from_utf8(output.stderr.to_vec()).unwrap()
301 );
302 } else {
303 eprintln!("{}", "No stderr was captured.".red(),);
304 }
305 eprintln!("--------------- End Of Test ---------------");
306 }
307 }
308
309 if cached_tests > 0 {
310 eprintln!(
311 "{} {} {}",
312 "Skipped".bold(),
313 cached_tests.to_string().bold().blue(),
314 "tests which had identical code, and previously passed.".bold()
315 );
316 }
317}
318
319fn clean_file(test_results: &HashMap<Test, TestResult>, path: &Path) -> Option<()> {
320 let file_stem = path.file_stem()?;
322 let file_str = file_stem.to_str()?;
323 let hash = file_str.strip_prefix("keeper_")?;
324
325 let matching_test = test_results.iter().find(|(t, _)| t.hash == hash);
326
327 let should_remove = match matching_test {
328 Some((t, tr)) => !tr.met_test_expectations(t),
329 None => true,
330 };
331
332 if should_remove {
333 std::fs::remove_file(path).expect("Should be able to delete cache-file");
334 }
335
336 Some(())
337}
338
339fn cleanup_keepercache(config: &KeeperConfig, test_results: &HashMap<Test, TestResult>) {
340 let glob_str = format!("{}/keeper_*.rs", config.test_dir.display());
343 glob(&glob_str)
344 .expect("Could not list keeper files.")
345 .filter_map(Result::ok)
346 .for_each(|p| {
347 clean_file(test_results, &p);
348 });
349}
350
351#[derive(Default)]
352pub struct BookKeeper;
353
354impl BookKeeper {
355 pub fn new() -> BookKeeper {
356 BookKeeper
357 }
358}
359
360impl BookKeeper {
361 pub fn real_run(
362 &self,
363 preprocessor_config: PreprocessorConfig,
364 root: PathBuf,
365 book: &mut Book,
366 ) -> Result<HashMap<Test, TestResult>, Error> {
367 let config = KeeperConfig::new(preprocessor_config, &root);
368
369 config.setup_environment();
370
371 let tests = get_tests_from_book(book);
372
373 let test_results = run_tests_with_config(tests, &config);
374
375 cleanup_keepercache(&config, &test_results);
376
377 Ok(test_results)
378 }
379}
380
381impl Preprocessor for BookKeeper {
382 fn name(&self) -> &str {
383 "keeper"
384 }
385
386 fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
387 let preprocessor_config = ctx.config.get_preprocessor(self.name());
388 let root = ctx.root.to_path_buf();
389
390 let test_results = self.real_run(preprocessor_config, root, &mut book)?;
391 print_results(&test_results);
392
393 Ok(book)
394 }
395
396 fn supports_renderer(&self, renderer: &str) -> bool {
397 renderer != "not-supported"
398 }
399}