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