mdbook_keeper_lib/
lib.rs

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    /// This is unfortunately necessary thanks to how
58    /// rust-skeptic parses code examples, and how rustc
59    /// works. Any libraries named here will be passed as
60    /// `--extern <lib>` options to rustc. This has
61    /// the equivalent effect of putting an `extern crate <lib>;`
62    /// line at the start of every example.
63    #[serde(default)]
64    externs: Vec<String>,
65
66    /// This is where we keep all the intermediate work
67    /// of testing. If it's not specified, it's a folder
68    /// inside build. If it doesn't exist, we create it.
69    #[serde(default)]
70    test_dir: Option<String>,
71
72    /// If you're building this book in the repo for a
73    /// real binary/library; this should point to the target
74    /// dir for that binary/library.
75    #[serde(default)]
76    target_dir: Option<String>,
77
78    /// This is the path of a folder that should contain
79    /// a `Cargo.toml`. If there is one there, you should
80    /// assume a `Cargo.lock` will be created in the same
81    /// place if it doesn't already exist.
82    #[serde(default)]
83    manifest_dir: Option<String>,
84
85    /// This allows you to specify if the manifest dir is
86    /// of a cargo workspace. If set to true, `--workspace`
87    /// will be passed to the invocation of `cargo build`.
88    #[serde(default)]
89    is_workspace: Option<bool>,
90
91    /// This allows you to specify the features you want to
92    /// invoke `cargo build` with. If you set this to
93    /// `["first", "second"], it  causes `--features
94    /// first,second` to be added to the invocation of
95    /// `cargo build`.
96    #[serde(default)]
97    build_features: Vec<String>,
98
99    /// Whether to show terminal colours.
100    #[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    // If the file doesn't contain a hash in the right format, we quit.
321    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    // Go through every file that's like keeper_*.rs
341    // If the test passed, keep the file otherwise, delete it.
342    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}