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