Skip to main content

supercov_engine/
rust_owned_doctests.rs

1//! Doctests for the owned Rust frontend, measured one doctest at a time.
2//!
3//! `cargo test` runs doctests, and for a long time the owned frontend did not:
4//! it built with `cargo test --no-run`, ran each libtest case in its own
5//! process, and never asked rustdoc for anything. A crate whose only test was
6//! a doctest reported zero tests and succeeded.
7//!
8//! rustdoc gives a stable toolchain exactly two handles on doctest execution,
9//! and this module is built on nothing else:
10//!
11//! * Cargo honours `RUSTDOC`, so this program stands in for rustdoc during
12//!   `cargo test --doc`, sees the exact arguments for each package, and calls
13//!   the real rustdoc as many times as it needs.
14//! * rustdoc runs every compiled doctest binary through the `--test-runtool`
15//!   it is given, so this program receives each binary before it runs.
16//!
17//! What a binary is depends on the edition. From edition 2024 rustdoc merges
18//! a package's doctests into one libtest harness whose arguments are baked in
19//! at compile time -- `--list` and `--exact` on the command line are ignored
20//! -- and which runs one case per process only through rustdoc's own
21//! protocol: with `RUSTDOC_DOCTEST_BIN_PATH` set it spawns that program once
22//! per case with `RUSTDOC_DOCTEST_RUN_NB_TEST` naming the case's index, and a
23//! process with only the index set runs that one case and exits. rustdoc
24//! sorts doctests by name before generating the harness, so the index is the
25//! position in sorted order, which is also the order the harness prints. The
26//! child this module supplies runs its case under its own evidence directory,
27//! and the runtool reads the case's name off the harness's own output. Every
28//! non-ignored, non-`no_run` case spawns a child, so nothing is inferred.
29//!
30//! Earlier editions, and cases rustdoc cannot merge, compile to one binary per
31//! doctest, and rustdoc names none of them for the runtool. Those cases run
32//! one rustdoc invocation each, filtered to that name with `--exact`, with the
33//! name in the runtool's environment; a list pass first learns the names. It
34//! costs one crate scan per doctest and nothing is guessed.
35//!
36//! Every case ends up with the name rustdoc gives it -- `src/lib.rs - classify
37//! (line 1)` -- its status as rustdoc's harness reported it, and the evidence
38//! directory its own process wrote, or none for a case that never ran. When
39//! the accounting does not balance the run fails rather than attribute
40//! evidence to a guess.
41
42use std::{
43    collections::{BTreeMap, BTreeSet},
44    fs,
45    io::Write,
46    path::{Path, PathBuf},
47    process::{Command, Output},
48    sync::{
49        Mutex,
50        atomic::{AtomicUsize, Ordering},
51    },
52};
53
54use serde::{Deserialize, Serialize};
55
56use crate::{
57    coverage_report::{ExecutionScope, RawTestResult, TestProvenance},
58    rust_project::PreparedRustProject,
59    rust_test_runner::{
60        CargoTestInvocation, RustCargoExecutionSelection, RustTestRunnerError, capped_rustflags,
61        io_error, relative_source, snapshot,
62    },
63};
64
65/// Set on `cargo test --doc` so this program, named as `RUSTDOC`, knows it is
66/// the wrapper and where the run collects results.
67pub const WRAPPER_ROOT_ENV: &str = "SUPERCOV_DOCTEST_WRAPPER_ROOT";
68/// The rustdoc the wrapper calls.
69pub const REAL_RUSTDOC_ENV: &str = "SUPERCOV_DOCTEST_REAL_RUSTDOC";
70/// The one doctest a per-name rustdoc invocation runs; the runtool inherits it.
71pub const NAME_ENV: &str = "SUPERCOV_DOCTEST_NAME";
72/// Where a merged harness's children record themselves, and which binary they run.
73pub const CHILD_DIR_ENV: &str = "SUPERCOV_DOCTEST_CHILD_DIR";
74pub const CHILD_BINARY_ENV: &str = "SUPERCOV_DOCTEST_CHILD_BINARY";
75/// rustdoc's own protocol for merged doctest harnesses.
76pub const RUSTDOC_BIN_PATH_ENV: &str = "RUSTDOC_DOCTEST_BIN_PATH";
77pub const RUSTDOC_RUN_NB_TEST_ENV: &str = "RUSTDOC_DOCTEST_RUN_NB_TEST";
78const EVIDENCE_DIR_ENV: &str = "SUPERCOV_RUST_EVIDENCE_DIR";
79/// The argument that puts this program in runtool mode.
80pub const RUNTOOL_MODE_ARGUMENT: &str = "__doctest-runner";
81
82// ---------------------------------------------------------------- records
83
84/// One doctest as the run reports it.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct DoctestCase {
87    pub name: String,
88    /// `passed`, `failed` or `skipped`, from the harness line rustdoc printed.
89    pub status: String,
90    /// The evidence directory the case's own process wrote; none for a case
91    /// that never ran (ignored, `no_run`, `compile_fail`).
92    pub evidence: Option<PathBuf>,
93}
94
95/// Everything the wrapper learned about one package.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct PackageDoctests {
98    pub package: String,
99    pub manifest_dir: PathBuf,
100    pub cases: Vec<DoctestCase>,
101    /// rustdoc's combined exit code over every pass.
102    pub exit_code: i32,
103}
104
105/// What the runtool recorded for one binary it was handed.
106#[derive(Debug, Serialize, Deserialize)]
107#[serde(tag = "kind", rename_all = "kebab-case")]
108enum Invocation {
109    /// A merged harness: its printed lines, in order, and the child each
110    /// spawned (by index).
111    Merged { cases: Vec<MergedCase> },
112    /// One doctest binary in a per-name pass.
113    Standalone {
114        name: String,
115        exit_code: i32,
116        evidence: PathBuf,
117    },
118    /// A merged harness that matched no case in a per-name pass.
119    EmptyHarness,
120    /// A binary run in the list pass; nothing to attribute.
121    Listed,
122}
123
124#[derive(Debug, Serialize, Deserialize)]
125struct MergedCase {
126    name: String,
127    status: String,
128    evidence: Option<PathBuf>,
129}
130
131/// What a merged harness's child recorded for its case.
132#[derive(Debug, Serialize, Deserialize)]
133struct ChildRecord {
134    index: usize,
135    exit_code: i32,
136    evidence: PathBuf,
137}
138
139// ------------------------------------------------------------ engine side
140
141/// Run the doctests through `cargo test --doc` with this program as rustdoc,
142/// then read back what the wrapper recorded for each package.
143pub(crate) fn run_doctests(
144    project: &PreparedRustProject,
145    invocation: &CargoTestInvocation,
146    selection: &RustCargoExecutionSelection,
147    root: &Path,
148    run_id: &str,
149    diagnostics: &mut dyn Write,
150    overall_exit: &mut i32,
151) -> Result<Vec<RawTestResult>, RustTestRunnerError> {
152    fs::create_dir_all(root.join("packages")).map_err(io_error)?;
153    let program = std::env::current_exe()
154        .and_then(fs::canonicalize)
155        .map_err(io_error)?;
156    let real_rustdoc = real_rustdoc(&project.workspace_root)?;
157    let output = Command::new(&invocation.program)
158        .args(&selection.doctest_arguments)
159        .current_dir(&project.workspace_root)
160        .env("CARGO_TARGET_DIR", &project.target_directory)
161        .env("RUSTFLAGS", capped_rustflags())
162        .env("RUSTDOC", &program)
163        .env(WRAPPER_ROOT_ENV, root)
164        .env(REAL_RUSTDOC_ENV, &real_rustdoc)
165        .output()
166        .map_err(|error| RustTestRunnerError::Launch(error.to_string()))?;
167    let exit = output.status.code().unwrap_or(1);
168
169    let mut packages = Vec::new();
170    let mut errors = Vec::new();
171    let mut entries = fs::read_dir(root.join("packages"))
172        .map_err(io_error)?
173        .filter_map(Result::ok)
174        .map(|entry| entry.path())
175        .collect::<Vec<_>>();
176    entries.sort();
177    for path in entries {
178        match path.extension().and_then(|extension| extension.to_str()) {
179            Some("json") => {
180                let package: PackageDoctests =
181                    serde_json::from_slice(&fs::read(&path).map_err(io_error)?)?;
182                packages.push(package);
183            }
184            Some("error") => errors.push(fs::read_to_string(&path).map_err(io_error)?),
185            _ => {}
186        }
187    }
188    if !errors.is_empty() {
189        return Err(RustTestRunnerError::Context(format!(
190            "doctest attribution could not be established: {}",
191            errors.join("; ")
192        )));
193    }
194    let any_failed = packages
195        .iter()
196        .any(|package| package.cases.iter().any(|case| case.status == "failed"));
197    if exit != 0 && !any_failed {
198        // Doctests that do not compile, or rustdoc itself failing, is a build
199        // failure, not a test failure.
200        return Err(RustTestRunnerError::CargoFailed(
201            String::from_utf8_lossy(&output.stderr).trim().to_owned(),
202        ));
203    }
204    if exit != 0 {
205        *overall_exit = exit;
206    }
207
208    let mut results = Vec::new();
209    let mut index = 0_usize;
210    for package in packages {
211        for case in package.cases {
212            // rustdoc names a case by its path relative to the package; the
213            // manifest is keyed by paths relative to the workspace.
214            let path_in_package = case
215                .name
216                .split_once(" - ")
217                .map_or(case.name.as_str(), |(path, _)| path);
218            let test_file = relative_source(
219                &project.workspace_root,
220                &package.manifest_dir.join(path_in_package),
221            )
222            .unwrap_or_else(|_| path_in_package.to_owned());
223            if case.status == "failed" {
224                writeln!(diagnostics, "[supercov] Rust doctest failed: {}", case.name)
225                    .map_err(io_error)?;
226            }
227            // A case that never ran has no evidence directory; an empty one
228            // yields the empty snapshot the report expects.
229            let evidence = match &case.evidence {
230                Some(directory) => directory.clone(),
231                None => {
232                    let directory = root.join("none").join(format!("{index:08}"));
233                    fs::create_dir_all(&directory).map_err(io_error)?;
234                    directory
235                }
236            };
237            // rustdoc writes the path with the platform's separator; test
238            // identities use `/` everywhere, as the libtest path does.
239            let name = case.name.replace('\\', "/");
240            results.push(RawTestResult {
241                test_id: Some(name.clone()),
242                scope: Some(ExecutionScope {
243                    version: 1,
244                    run_id: run_id.into(),
245                    worker_id: format!("doctest-{index:04}"),
246                    test_id: name.clone(),
247                    test_key: name.clone(),
248                    retry: 0,
249                    attempt_id: format!("{run_id}:doctest:{index:08}"),
250                }),
251                test: name.clone(),
252                test_file: Some(test_file),
253                title: Some(name),
254                retry: Some(0),
255                status: Some(case.status.clone()),
256                expected_status: Some("passed".into()),
257                flaky: false,
258                provenance: TestProvenance {
259                    runner: "rustdoc".into(),
260                    kind: "doctest".into(),
261                    project: Some(package.package.clone()),
262                    source: "supercov-owned-process-per-test".into(),
263                },
264                role: "test".into(),
265                phases: Vec::new(),
266                runtime: vec![snapshot(&project.manifest, &evidence)?],
267                browser: Vec::new(),
268                server: Vec::new(),
269            });
270            index += 1;
271        }
272    }
273    Ok(results)
274}
275
276/// The rustdoc Cargo would have run: whatever `RUSTDOC` already named, else
277/// the one beside the selected toolchain's rustc.
278fn real_rustdoc(workspace_root: &Path) -> Result<PathBuf, RustTestRunnerError> {
279    if let Some(configured) = std::env::var_os("RUSTDOC") {
280        return Ok(PathBuf::from(configured));
281    }
282    let output = Command::new("rustc")
283        .args(["--print", "sysroot"])
284        .current_dir(workspace_root)
285        .output()
286        .map_err(|error| RustTestRunnerError::Launch(format!("rustc --print sysroot: {error}")))?;
287    if !output.status.success() {
288        return Err(RustTestRunnerError::Launch(format!(
289            "rustc --print sysroot exited with {}",
290            output.status
291        )));
292    }
293    let sysroot = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
294    let rustdoc = sysroot
295        .join("bin")
296        .join(format!("rustdoc{}", std::env::consts::EXE_SUFFIX));
297    if !rustdoc.is_file() {
298        return Err(RustTestRunnerError::Launch(format!(
299            "the selected toolchain has no rustdoc at {}",
300            rustdoc.display()
301        )));
302    }
303    Ok(rustdoc)
304}
305
306// ------------------------------------------------------------- the wrapper
307
308/// The arguments Cargo gave rustdoc, taken apart: everything that describes
309/// the crate, the harness arguments the user passed after `--`, and any
310/// runtool Cargo configured from a target runner.
311#[derive(Debug, Default, Clone, PartialEq, Eq)]
312struct RustdocArguments {
313    crate_arguments: Vec<String>,
314    harness_arguments: Vec<String>,
315    test: bool,
316}
317
318fn parse_rustdoc_arguments(arguments: &[String]) -> RustdocArguments {
319    let mut parsed = RustdocArguments::default();
320    let mut index = 0;
321    while index < arguments.len() {
322        let argument = &arguments[index];
323        if argument == "--test" {
324            parsed.test = true;
325            parsed.crate_arguments.push(argument.clone());
326        } else if argument == "--test-args"
327            || argument == "--test-runtool"
328            || argument == "--test-runtool-arg"
329        {
330            if let Some(value) = arguments.get(index + 1) {
331                if argument == "--test-args" {
332                    parsed.harness_arguments.push(value.clone());
333                }
334                index += 1;
335            }
336        } else if let Some(value) = argument.strip_prefix("--test-args=") {
337            parsed.harness_arguments.push(value.to_owned());
338        } else if argument.starts_with("--test-runtool") {
339            // `--test-runtool=X` / `--test-runtool-arg=X`: replaced below.
340        } else {
341            parsed.crate_arguments.push(argument.clone());
342        }
343        index += 1;
344    }
345    parsed
346}
347
348/// libtest's harness arguments, taken apart the way libtest reads them:
349/// positional filters, `--skip` patterns, whether `--exact` was given, and
350/// everything else passed through unchanged.
351#[derive(Debug, Default, Clone, PartialEq, Eq)]
352struct HarnessArguments {
353    filters: Vec<String>,
354    skips: Vec<String>,
355    exact: bool,
356    list: bool,
357    passthrough: Vec<String>,
358}
359
360fn parse_harness_arguments(arguments: &[String]) -> HarnessArguments {
361    const TAKES_VALUE: &[&str] = &[
362        "--skip",
363        "--test-threads",
364        "--logfile",
365        "--format",
366        "--color",
367        "--shuffle-seed",
368        "-Z",
369    ];
370    let mut parsed = HarnessArguments::default();
371    let mut index = 0;
372    while index < arguments.len() {
373        let argument = &arguments[index];
374        if let Some(pattern) = argument.strip_prefix("--skip=") {
375            parsed.skips.push(pattern.to_owned());
376        } else if argument == "--skip" {
377            if let Some(pattern) = arguments.get(index + 1) {
378                parsed.skips.push(pattern.clone());
379                index += 1;
380            }
381        } else if argument == "--exact" {
382            parsed.exact = true;
383        } else if argument == "--list" {
384            parsed.list = true;
385        } else if TAKES_VALUE.contains(&argument.as_str()) {
386            parsed.passthrough.push(argument.clone());
387            if let Some(value) = arguments.get(index + 1) {
388                parsed.passthrough.push(value.clone());
389                index += 1;
390            }
391        } else if argument.starts_with('-') {
392            parsed.passthrough.push(argument.clone());
393        } else {
394            parsed.filters.push(argument.clone());
395        }
396        index += 1;
397    }
398    parsed
399}
400
401impl HarnessArguments {
402    /// libtest's selection: a test runs when it matches any filter (all, if
403    /// there are none) and no skip; `--exact` compares whole names, otherwise
404    /// a filter is a substring.
405    fn selects(&self, name: &str) -> bool {
406        let matches = |pattern: &String| {
407            if self.exact {
408                name == pattern
409            } else {
410                name.contains(pattern.as_str())
411            }
412        };
413        (self.filters.is_empty() || self.filters.iter().any(matches))
414            && !self.skips.iter().any(matches)
415    }
416}
417
418/// rustdoc splits every `--test-args` value on whitespace before handing the
419/// pieces to libtest; read them the same way.
420fn split_test_args(arguments: &[String]) -> Vec<String> {
421    arguments
422        .iter()
423        .flat_map(|argument| argument.split_whitespace())
424        .map(str::to_owned)
425        .collect()
426}
427
428/// A substring filter and skips that select `name` alone among `names`:
429/// libtest matches filters and skips as substrings, and every piece reaching
430/// it is whitespace-free, so a piece of the name is the filter and every
431/// other doctest it also matches is skipped by a piece of its own the name
432/// lacks. None when some other doctest has no such piece.
433fn isolate(name: &str, names: &[String]) -> Option<(String, Vec<String>)> {
434    let mut best: Option<(String, Vec<String>)> = None;
435    for token in name.split_whitespace() {
436        let mut skips = Vec::new();
437        let mut isolated = true;
438        for other in names
439            .iter()
440            .filter(|other| other.as_str() != name && other.contains(token))
441        {
442            match other.split_whitespace().find(|piece| !name.contains(piece)) {
443                Some(piece) => skips.push(piece.to_owned()),
444                None => {
445                    isolated = false;
446                    break;
447                }
448            }
449        }
450        if !isolated {
451            continue;
452        }
453        skips.sort();
454        skips.dedup();
455        if best
456            .as_ref()
457            .is_none_or(|(_, current)| skips.len() < current.len())
458        {
459            best = Some((token.to_owned(), skips));
460        }
461    }
462    best
463}
464
465/// The lines libtest prints for each test, as `(name, status)`.
466fn harness_lines(text: &str) -> Vec<(String, String)> {
467    text.lines()
468        .filter_map(|line| {
469            let rest = line.strip_prefix("test ")?;
470            let (name, outcome) = rest.rsplit_once(" ... ")?;
471            // libtest appends the test mode to `no_run` and `compile_fail`
472            // doctests when it prints them; the listing names them plainly.
473            let name = name
474                .strip_suffix(" - compile fail")
475                .or_else(|| name.strip_suffix(" - compile"))
476                .unwrap_or(name);
477            let status = if outcome.starts_with("ok") {
478                "passed"
479            } else if outcome.starts_with("FAILED") {
480                "failed"
481            } else if outcome.starts_with("ignored") {
482                "skipped"
483            } else {
484                return None;
485            };
486            Some((name.to_owned(), status.to_owned()))
487        })
488        .collect()
489}
490
491/// The names `--list --format terse` prints.
492fn listed_names(text: &str) -> Vec<String> {
493    text.lines()
494        .filter_map(|line| line.strip_suffix(": test"))
495        .map(str::to_owned)
496        .collect()
497}
498
499fn next_sequence(directory: &Path, prefix: &str) -> Result<usize, RustTestRunnerError> {
500    fs::create_dir_all(directory).map_err(io_error)?;
501    for candidate in 0..1_000_000_usize {
502        let marker = directory.join(format!("{prefix}{candidate:08}.claim"));
503        match fs::OpenOptions::new()
504            .write(true)
505            .create_new(true)
506            .open(&marker)
507        {
508            Ok(_) => return Ok(candidate),
509            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
510            Err(error) => return Err(io_error(error)),
511        }
512    }
513    Err(RustTestRunnerError::Io(
514        "too many doctest sequence claims".into(),
515    ))
516}
517
518fn relay(output: &Output) {
519    let mut stdout = std::io::stdout();
520    let mut stderr = std::io::stderr();
521    let _ = stdout.write_all(&output.stdout);
522    let _ = stderr.write_all(&output.stderr);
523    let _ = stdout.flush();
524    let _ = stderr.flush();
525}
526
527/// This program standing in for rustdoc during `cargo test --doc`.
528pub fn rustdoc_wrapper(arguments: Vec<String>) -> i32 {
529    match rustdoc_wrapper_inner(&arguments) {
530        Ok(code) => code,
531        Err(error) => {
532            eprintln!("[supercov] {error}");
533            101
534        }
535    }
536}
537
538fn rustdoc_wrapper_inner(arguments: &[String]) -> Result<i32, RustTestRunnerError> {
539    let root = PathBuf::from(std::env::var_os(WRAPPER_ROOT_ENV).ok_or_else(|| {
540        RustTestRunnerError::Context("the doctest wrapper has no results root".into())
541    })?);
542    let real = PathBuf::from(std::env::var_os(REAL_RUSTDOC_ENV).ok_or_else(|| {
543        RustTestRunnerError::Context("the doctest wrapper does not know the real rustdoc".into())
544    })?);
545    let program = std::env::current_exe()
546        .and_then(fs::canonicalize)
547        .map_err(io_error)?;
548    let parsed = parse_rustdoc_arguments(arguments);
549    let harness_arguments = split_test_args(&parsed.harness_arguments);
550    let harness = parse_harness_arguments(&harness_arguments);
551    // Anything but a doctest run -- or the user asking for a listing -- is
552    // rustdoc's business alone.
553    if !parsed.test || harness.list {
554        let status = Command::new(&real)
555            .args(arguments)
556            .env_remove(WRAPPER_ROOT_ENV)
557            .env_remove(REAL_RUSTDOC_ENV)
558            .status()
559            .map_err(|error| RustTestRunnerError::Launch(error.to_string()))?;
560        return Ok(status.code().unwrap_or(1));
561    }
562
563    let package = std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "package".into());
564    let manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR")
565        .map(PathBuf::from)
566        .unwrap_or_else(|| PathBuf::from("."));
567    let sequence = next_sequence(&root.join("packages"), "")?;
568    let package_dir = root.join("packages").join(format!("{sequence:08}"));
569    fs::create_dir_all(package_dir.join("invocations")).map_err(io_error)?;
570
571    let rustdoc = |harness_arguments: &[String], mode: &str, environment: &[(&str, &str)]| {
572        let mut command = Command::new(&real);
573        command
574            .args(&parsed.crate_arguments)
575            .env_remove(WRAPPER_ROOT_ENV)
576            .env_remove(REAL_RUSTDOC_ENV);
577        for argument in harness_arguments {
578            command.args(["--test-args", argument]);
579        }
580        command
581            .args(["--test-runtool", &program.to_string_lossy()])
582            .args(["--test-runtool-arg", RUNTOOL_MODE_ARGUMENT])
583            .args(["--test-runtool-arg", &package_dir.to_string_lossy()])
584            .args(["--test-runtool-arg", mode]);
585        for (key, value) in environment {
586            command.env(key, value);
587        }
588        command
589            .output()
590            .map_err(|error| RustTestRunnerError::Launch(error.to_string()))
591    };
592
593    // List pass: every doctest of the package, unfiltered, so a merged
594    // harness index maps to its name. rustdoc lists the cases it would run
595    // standalone; a merged harness lists its own through the runtool. The
596    // user's harness arguments stay out of it: the listing must be complete.
597    let listing = rustdoc(
598        &["--list".into(), "--format".into(), "terse".into()],
599        "list",
600        &[],
601    )?;
602    if !listing.status.success() {
603        relay(&listing);
604        return Ok(listing.status.code().unwrap_or(1));
605    }
606    let all_names = listed_names(&String::from_utf8_lossy(&listing.stdout));
607    let merged_listed = read_listed_names(&package_dir)?;
608    let merged_set = merged_listed.iter().cloned().collect::<BTreeSet<_>>();
609    let mut merged_sorted = merged_listed.clone();
610    merged_sorted.sort();
611    merged_sorted.dedup();
612    let standalone = all_names
613        .iter()
614        .filter(|name| !merged_set.contains(*name))
615        .cloned()
616        .collect::<Vec<_>>();
617
618    let mut cases = Vec::new();
619    let mut exit_code = 0;
620
621    // Merged pass: one rustdoc invocation with the user's own harness
622    // arguments, so libtest selects exactly what it would have; each case
623    // runs in a child of its own and is attributed by its index.
624    let selected_merged = merged_sorted
625        .iter()
626        .filter(|name| harness.selects(name))
627        .cloned()
628        .collect::<BTreeSet<_>>();
629    if !selected_merged.is_empty() {
630        fs::write(
631            package_dir.join("merged-order.json"),
632            serde_json::to_vec(&merged_sorted)?,
633        )
634        .map_err(io_error)?;
635        let output = rustdoc(&harness_arguments, "merged", &[])?;
636        relay(&output);
637        if !output.status.success() {
638            exit_code = output.status.code().unwrap_or(1);
639        }
640        let mut reported = BTreeSet::new();
641        for (_, invocation) in read_invocations(&package_dir)? {
642            if let Invocation::Merged { cases: merged } = invocation {
643                for case in merged {
644                    if !merged_set.contains(&case.name) {
645                        return fail_package(
646                            &package_dir,
647                            format!(
648                                "the merged doctest harness reported a case the listing lacks: {}",
649                                case.name
650                            ),
651                        );
652                    }
653                    if !reported.insert(case.name.clone()) {
654                        return fail_package(
655                            &package_dir,
656                            format!("doctest {} was reported more than once", case.name),
657                        );
658                    }
659                    cases.push(DoctestCase {
660                        name: case.name,
661                        status: case.status,
662                        evidence: case.evidence,
663                    });
664                }
665            }
666        }
667        // libtest's selection and the wrapper's reading of it must agree, or
668        // a doctest could run unattributed; when they differ, say so.
669        if reported != selected_merged && output.status.success() {
670            let difference = selected_merged
671                .symmetric_difference(&reported)
672                .cloned()
673                .collect::<Vec<_>>();
674            return fail_package(
675                &package_dir,
676                format!(
677                    "the merged doctest harness ran a different selection than its arguments \
678                     describe: {}",
679                    difference.join(", ")
680                ),
681            );
682        }
683    }
684
685    // Standalone passes: one rustdoc invocation per selected name, in
686    // parallel, each isolating its doctest with a substring filter and skips
687    // (rustdoc splits harness arguments on whitespace, so the name itself
688    // can never be a filter) and named in the runtool's environment.
689    let selected_standalone = standalone
690        .iter()
691        .filter(|name| harness.selects(name))
692        .cloned()
693        .collect::<Vec<_>>();
694    let mut plans = Vec::new();
695    for name in &selected_standalone {
696        let Some((filter, skips)) = isolate(name, &all_names) else {
697            return fail_package(
698                &package_dir,
699                format!("doctest {name} cannot be told apart from another doctest by a filter"),
700            );
701        };
702        let mut arguments = harness.passthrough.clone();
703        arguments.push(filter);
704        for skip in skips {
705            arguments.push("--skip".to_owned());
706            arguments.push(skip);
707        }
708        plans.push((name.clone(), arguments));
709    }
710    let outputs = Mutex::new(Vec::<(String, Output)>::new());
711    let next = AtomicUsize::new(0);
712    let workers = std::thread::available_parallelism()
713        .map(usize::from)
714        .unwrap_or(1)
715        .min(plans.len().max(1));
716    let launch_error = Mutex::new(None::<RustTestRunnerError>);
717    std::thread::scope(|scope| {
718        for _ in 0..workers {
719            scope.spawn(|| {
720                loop {
721                    let index = next.fetch_add(1, Ordering::Relaxed);
722                    let Some((name, arguments)) = plans.get(index) else {
723                        break;
724                    };
725                    match rustdoc(arguments, "named", &[(NAME_ENV, name)]) {
726                        Ok(output) => outputs
727                            .lock()
728                            .expect("doctest outputs lock")
729                            .push((name.clone(), output)),
730                        Err(error) => {
731                            *launch_error.lock().expect("doctest error lock") = Some(error);
732                        }
733                    }
734                }
735            });
736        }
737    });
738    if let Some(error) = launch_error.into_inner().expect("doctest error lock") {
739        return Err(error);
740    }
741    let mut outputs = outputs.into_inner().expect("doctest outputs lock");
742    outputs.sort_by(|left, right| left.0.cmp(&right.0));
743    let mut standalone_evidence = BTreeMap::new();
744    for (_, invocation) in read_invocations(&package_dir)? {
745        if let Invocation::Standalone { name, evidence, .. } = invocation
746            && standalone_evidence.insert(name.clone(), evidence).is_some()
747        {
748            return fail_package(&package_dir, format!("doctest {name} ran more than once"));
749        }
750    }
751    for (name, output) in &outputs {
752        relay(output);
753        if !output.status.success() {
754            exit_code = output.status.code().unwrap_or(1);
755        }
756        let text = String::from_utf8_lossy(&output.stdout);
757        let lines = harness_lines(&text);
758        // The pass must have run this doctest and nothing else; the filter
759        // is a substring, so check rather than trust.
760        let [(printed, status)] = lines.as_slice() else {
761            let ran = lines
762                .iter()
763                .map(|(printed, _)| printed.as_str())
764                .collect::<Vec<_>>();
765            return fail_package(
766                &package_dir,
767                format!(
768                    "the pass for doctest {name} ran {} doctest(s) instead of that one alone: {}",
769                    ran.len(),
770                    ran.join(", ")
771                ),
772            );
773        };
774        if printed != name {
775            return fail_package(
776                &package_dir,
777                format!("the pass for doctest {name} ran {printed} instead"),
778            );
779        }
780        cases.push(DoctestCase {
781            evidence: standalone_evidence.remove(name),
782            name: name.clone(),
783            status: status.clone(),
784        });
785    }
786    if let Some((name, _)) = standalone_evidence.into_iter().next() {
787        return fail_package(
788            &package_dir,
789            format!("a doctest ran that no pass selected: {name}"),
790        );
791    }
792
793    cases.sort_by(|left, right| left.name.cmp(&right.name));
794    let record = PackageDoctests {
795        package,
796        manifest_dir,
797        cases,
798        exit_code,
799    };
800    fs::write(
801        root.join("packages").join(format!("{sequence:08}.json")),
802        serde_json::to_vec(&record)?,
803    )
804    .map_err(io_error)?;
805    Ok(exit_code)
806}
807
808fn fail_package(package_dir: &Path, reason: String) -> Result<i32, RustTestRunnerError> {
809    let error_path = package_dir.parent().unwrap_or(package_dir).join(format!(
810        "{}.error",
811        package_dir
812            .file_name()
813            .and_then(|name| name.to_str())
814            .unwrap_or("package")
815    ));
816    fs::write(&error_path, &reason).map_err(io_error)?;
817    eprintln!("[supercov] {reason}");
818    Ok(101)
819}
820
821fn read_invocations(package_dir: &Path) -> Result<Vec<(usize, Invocation)>, RustTestRunnerError> {
822    let directory = package_dir.join("invocations");
823    let mut records = Vec::new();
824    let Ok(entries) = fs::read_dir(&directory) else {
825        return Ok(records);
826    };
827    for entry in entries.filter_map(Result::ok) {
828        let path = entry.path();
829        if path
830            .extension()
831            .is_some_and(|extension| extension == "json")
832        {
833            let index = path
834                .file_stem()
835                .and_then(|stem| stem.to_str())
836                .and_then(|stem| stem.parse::<usize>().ok())
837                .unwrap_or(usize::MAX);
838            let invocation: Invocation =
839                serde_json::from_slice(&fs::read(&path).map_err(io_error)?)?;
840            records.push((index, invocation));
841        }
842    }
843    records.sort_by_key(|(index, _)| *index);
844    Ok(records)
845}
846
847fn read_listed_names(package_dir: &Path) -> Result<Vec<String>, RustTestRunnerError> {
848    let directory = package_dir.join("listed");
849    let mut names = Vec::new();
850    let Ok(entries) = fs::read_dir(&directory) else {
851        return Ok(names);
852    };
853    for entry in entries.filter_map(Result::ok) {
854        names.extend(listed_names(
855            &fs::read_to_string(entry.path()).map_err(io_error)?,
856        ));
857    }
858    Ok(names)
859}
860
861// ------------------------------------------------------------- the runtool
862
863/// This program as rustdoc's `--test-runtool`: `__doctest-runner <package
864/// dir> <mode> <binary>`, where the mode says which pass is running.
865pub fn doctest_runtool(arguments: Vec<String>) -> i32 {
866    match doctest_runtool_inner(&arguments) {
867        Ok(code) => code,
868        Err(error) => {
869            eprintln!("[supercov] {error}");
870            101
871        }
872    }
873}
874
875fn doctest_runtool_inner(arguments: &[String]) -> Result<i32, RustTestRunnerError> {
876    let [package_dir, mode, binary, ..] = arguments else {
877        return Err(RustTestRunnerError::UnsupportedCommand(
878            "the doctest runtool needs a package directory, a mode and a binary".into(),
879        ));
880    };
881    let package_dir = PathBuf::from(package_dir);
882    let binary = PathBuf::from(binary);
883    let index = next_sequence(&package_dir.join("invocations"), "")?;
884    let directory = package_dir.join("invocations").join(format!("{index:08}"));
885    fs::create_dir_all(&directory).map_err(io_error)?;
886    let record_path = package_dir
887        .join("invocations")
888        .join(format!("{index:08}.json"));
889    let write_record = |record: &Invocation| -> Result<(), RustTestRunnerError> {
890        fs::write(&record_path, serde_json::to_vec(record)?).map_err(io_error)
891    };
892
893    match mode.as_str() {
894        "list" => {
895            // A merged harness lists its cases; a standalone binary would run
896            // its doctest, but the list pass never reaches one: rustdoc lists
897            // standalone cases itself and does not invoke the runtool.
898            let output = Command::new(&binary)
899                .output()
900                .map_err(|error| RustTestRunnerError::Launch(error.to_string()))?;
901            let text = String::from_utf8_lossy(&output.stdout);
902            if !listed_names(&text).is_empty() {
903                let listed = package_dir.join("listed");
904                fs::create_dir_all(&listed).map_err(io_error)?;
905                fs::write(listed.join(format!("{index:08}.txt")), text.as_bytes())
906                    .map_err(io_error)?;
907            }
908            write_record(&Invocation::Listed)?;
909            relay(&output);
910            Ok(output.status.code().unwrap_or(1))
911        }
912        "merged" => {
913            let program = std::env::current_exe()
914                .and_then(fs::canonicalize)
915                .map_err(io_error)?;
916            let children = directory.join("children");
917            fs::create_dir_all(&children).map_err(io_error)?;
918            let output = Command::new(&binary)
919                .env(RUSTDOC_BIN_PATH_ENV, &program)
920                .env(CHILD_DIR_ENV, &children)
921                .env(CHILD_BINARY_ENV, &binary)
922                .output()
923                .map_err(|error| RustTestRunnerError::Launch(error.to_string()))?;
924            let text = String::from_utf8_lossy(&output.stdout);
925            let lines = harness_lines(&text);
926            // rustdoc built the harness from doctests sorted by name, so a
927            // child's index is its position in that order; the order itself
928            // was written by the wrapper from the list pass.
929            let order: Vec<String> = serde_json::from_slice(
930                &fs::read(package_dir.join("merged-order.json")).map_err(io_error)?,
931            )?;
932            let mut by_index = BTreeMap::new();
933            if let Ok(entries) = fs::read_dir(&children) {
934                for entry in entries.filter_map(Result::ok) {
935                    let path = entry.path();
936                    if path
937                        .extension()
938                        .is_some_and(|extension| extension == "json")
939                    {
940                        let child: ChildRecord =
941                            serde_json::from_slice(&fs::read(&path).map_err(io_error)?)?;
942                        by_index.insert(child.index, child);
943                    }
944                }
945            }
946            let mut cases = Vec::new();
947            for (name, status) in lines {
948                let position = order.iter().position(|candidate| candidate == &name);
949                let evidence = position
950                    .and_then(|position| by_index.remove(&position))
951                    .map(|child| child.evidence);
952                cases.push(MergedCase {
953                    name,
954                    status,
955                    evidence,
956                });
957            }
958            if let Some((index, _)) = by_index.into_iter().next() {
959                return Err(RustTestRunnerError::Context(format!(
960                    "merged doctest child {index} ran for a case the harness did not report"
961                )));
962            }
963            write_record(&Invocation::Merged { cases })?;
964            relay(&output);
965            Ok(output.status.code().unwrap_or(1))
966        }
967        "named" => {
968            let evidence = directory.join("evidence");
969            fs::create_dir_all(&evidence).map_err(io_error)?;
970            let output = Command::new(&binary)
971                .env(EVIDENCE_DIR_ENV, &evidence)
972                .output()
973                .map_err(|error| RustTestRunnerError::Launch(error.to_string()))?;
974            let text = String::from_utf8_lossy(&output.stdout);
975            let exit = output.status.code().unwrap_or(1);
976            // A merged harness that the per-name filter matched nothing in
977            // announces itself; anything else is the named doctest.
978            if text.contains("running 0 tests") {
979                write_record(&Invocation::EmptyHarness)?;
980            } else {
981                let name = std::env::var(NAME_ENV).map_err(|_| {
982                    RustTestRunnerError::Context(
983                        "a per-name doctest pass did not name its doctest".into(),
984                    )
985                })?;
986                write_record(&Invocation::Standalone {
987                    name,
988                    exit_code: exit,
989                    evidence,
990                })?;
991            }
992            relay(&output);
993            Ok(exit)
994        }
995        other => Err(RustTestRunnerError::UnsupportedCommand(format!(
996            "unknown doctest runtool mode {other}"
997        ))),
998    }
999}
1000
1001// --------------------------------------------------------------- the child
1002
1003/// This program as the child a merged harness spawns for one case: run the
1004/// harness binary in rustdoc's single-case mode under this case's own
1005/// evidence directory, record how it went, and exit as it exited.
1006pub fn doctest_child() -> i32 {
1007    match doctest_child_inner() {
1008        Ok(code) => code,
1009        Err(error) => {
1010            eprintln!("[supercov] {error}");
1011            101
1012        }
1013    }
1014}
1015
1016fn doctest_child_inner() -> Result<i32, RustTestRunnerError> {
1017    let index = std::env::var(RUSTDOC_RUN_NB_TEST_ENV)
1018        .ok()
1019        .and_then(|value| value.parse::<usize>().ok())
1020        .ok_or_else(|| {
1021            RustTestRunnerError::Context("the doctest child has no case index".into())
1022        })?;
1023    let children = PathBuf::from(std::env::var_os(CHILD_DIR_ENV).ok_or_else(|| {
1024        RustTestRunnerError::Context("the doctest child has no record directory".into())
1025    })?);
1026    let binary = PathBuf::from(std::env::var_os(CHILD_BINARY_ENV).ok_or_else(|| {
1027        RustTestRunnerError::Context("the doctest child has no harness binary".into())
1028    })?);
1029    let evidence = children.join(format!("{index:08}"));
1030    fs::create_dir_all(&evidence).map_err(io_error)?;
1031    // The harness cleared RUSTDOC_DOCTEST_BIN_PATH before spawning us; make
1032    // sure of it, or the case would run the whole harness again.
1033    let output = Command::new(&binary)
1034        .env_remove(RUSTDOC_BIN_PATH_ENV)
1035        .env_remove(CHILD_DIR_ENV)
1036        .env_remove(CHILD_BINARY_ENV)
1037        .env(EVIDENCE_DIR_ENV, &evidence)
1038        .output()
1039        .map_err(|error| RustTestRunnerError::Launch(error.to_string()))?;
1040    let exit = output.status.code().unwrap_or(1);
1041    fs::write(
1042        children.join(format!("{index:08}.json")),
1043        serde_json::to_vec(&ChildRecord {
1044            index,
1045            exit_code: exit,
1046            evidence,
1047        })?,
1048    )
1049    .map_err(io_error)?;
1050    relay(&output);
1051    Ok(exit)
1052}
1053
1054/// Whether this process was started by a merged harness as a case's child.
1055pub fn is_doctest_child() -> bool {
1056    std::env::var_os(RUSTDOC_RUN_NB_TEST_ENV).is_some() && std::env::var_os(CHILD_DIR_ENV).is_some()
1057}
1058
1059/// Whether this process is standing in for rustdoc.
1060pub fn is_rustdoc_wrapper(arguments: &[String]) -> bool {
1061    std::env::var_os(WRAPPER_ROOT_ENV).is_some()
1062        && arguments
1063            .first()
1064            .is_none_or(|first| first != RUNTOOL_MODE_ARGUMENT)
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070
1071    #[test]
1072    fn isolation_picks_a_filter_and_skips_for_one_doctest() {
1073        let names = vec![
1074            "src/lib.rs - classify (line 1)".to_owned(),
1075            "src/lib.rs - classify (line 11)".to_owned(),
1076            "src/lib.rs - classify_more (line 1)".to_owned(),
1077            "src/other.rs - render (line 3)".to_owned(),
1078        ];
1079        for name in &names {
1080            let (filter, skips) = isolate(name, &names).expect("isolated");
1081            assert!(name.contains(&filter));
1082            for other in names.iter().filter(|other| other != &name) {
1083                let selected =
1084                    other.contains(&filter) && !skips.iter().any(|skip| other.contains(skip));
1085                assert!(!selected, "{name}: {other} would also run");
1086            }
1087            assert!(!skips.iter().any(|skip| name.contains(skip)));
1088        }
1089        let twins = vec!["a b".to_owned(), "b a".to_owned()];
1090        assert_eq!(isolate("a b", &twins), None);
1091    }
1092
1093    #[test]
1094    fn test_args_split_on_whitespace_like_rustdoc() {
1095        assert_eq!(
1096            split_test_args(&[
1097                "--exact  src/lib.rs - f (line 1)".to_owned(),
1098                "x".to_owned()
1099            ]),
1100            ["--exact", "src/lib.rs", "-", "f", "(line", "1)", "x"]
1101        );
1102    }
1103
1104    #[test]
1105    fn rustdoc_arguments_separate_crate_harness_and_runtool() {
1106        let parsed = parse_rustdoc_arguments(&[
1107            "--edition=2024".into(),
1108            "--crate-name".into(),
1109            "probe".into(),
1110            "--test".into(),
1111            "src/lib.rs".into(),
1112            "--test-args".into(),
1113            "--nocapture".into(),
1114            "--test-args=foo".into(),
1115            "--test-runtool".into(),
1116            "/usr/bin/env".into(),
1117            "--test-runtool-arg".into(),
1118            "x".into(),
1119            "-L".into(),
1120            "dependency=deps".into(),
1121        ]);
1122        assert!(parsed.test);
1123        assert_eq!(parsed.harness_arguments, ["--nocapture", "foo"]);
1124        assert_eq!(
1125            parsed.crate_arguments,
1126            [
1127                "--edition=2024",
1128                "--crate-name",
1129                "probe",
1130                "--test",
1131                "src/lib.rs",
1132                "-L",
1133                "dependency=deps"
1134            ]
1135        );
1136    }
1137
1138    #[test]
1139    fn harness_arguments_select_the_way_libtest_does() {
1140        let harness = parse_harness_arguments(&[
1141            "--nocapture".into(),
1142            "--skip".into(),
1143            "slow".into(),
1144            "--test-threads".into(),
1145            "2".into(),
1146            "classify".into(),
1147        ]);
1148        assert_eq!(harness.filters, ["classify"]);
1149        assert_eq!(harness.skips, ["slow"]);
1150        assert_eq!(harness.passthrough, ["--nocapture", "--test-threads", "2"]);
1151        assert!(harness.selects("src/lib.rs - classify (line 1)"));
1152        assert!(!harness.selects("src/lib.rs - classify_slow (line 9)"));
1153        assert!(!harness.selects("src/lib.rs - doubled (line 12)"));
1154        let exact =
1155            parse_harness_arguments(&["--exact".into(), "src/lib.rs - doubled (line 12)".into()]);
1156        assert!(exact.selects("src/lib.rs - doubled (line 12)"));
1157        assert!(!exact.selects("src/lib.rs - doubled (line 120)"));
1158        assert!(parse_harness_arguments(&[]).selects("anything"));
1159    }
1160
1161    #[test]
1162    fn harness_lines_drop_the_test_mode_libtest_appends() {
1163        assert_eq!(
1164            harness_lines(
1165                "test src/lib.rs - f (line 9) - compile ... ok\ntest src/lib.rs - g (line 2) - compile fail ... FAILED\n"
1166            ),
1167            [
1168                ("src/lib.rs - f (line 9)".to_owned(), "passed".to_owned()),
1169                ("src/lib.rs - g (line 2)".to_owned(), "failed".to_owned()),
1170            ]
1171        );
1172    }
1173
1174    #[test]
1175    fn harness_lines_and_listings_are_read_as_libtest_prints_them() {
1176        let text = "\nrunning 3 tests\ntest src/lib.rs - a (line 1) ... ok\ntest src/lib.rs - b (line 5) ... FAILED\ntest src/lib.rs - c (line 9) ... ignored, needs network\n\ntest result: FAILED. 1 passed; 1 failed; 1 ignored; 0 measured; 0 filtered out\n";
1177        assert_eq!(
1178            harness_lines(text),
1179            [
1180                ("src/lib.rs - a (line 1)".to_owned(), "passed".to_owned()),
1181                ("src/lib.rs - b (line 5)".to_owned(), "failed".to_owned()),
1182                ("src/lib.rs - c (line 9)".to_owned(), "skipped".to_owned()),
1183            ]
1184        );
1185        assert_eq!(
1186            listed_names(
1187                "src/lib.rs - a (line 1): test\nsrc/lib.rs - b (line 5): test\n\n3 tests, 0 benchmarks\n"
1188            ),
1189            ["src/lib.rs - a (line 1)", "src/lib.rs - b (line 5)"]
1190        );
1191    }
1192}