1use 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
65pub const WRAPPER_ROOT_ENV: &str = "SUPERCOV_DOCTEST_WRAPPER_ROOT";
68pub const REAL_RUSTDOC_ENV: &str = "SUPERCOV_DOCTEST_REAL_RUSTDOC";
70pub const NAME_ENV: &str = "SUPERCOV_DOCTEST_NAME";
72pub const CHILD_DIR_ENV: &str = "SUPERCOV_DOCTEST_CHILD_DIR";
74pub const CHILD_BINARY_ENV: &str = "SUPERCOV_DOCTEST_CHILD_BINARY";
75pub 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";
79pub const RUNTOOL_MODE_ARGUMENT: &str = "__doctest-runner";
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct DoctestCase {
87 pub name: String,
88 pub status: String,
90 pub evidence: Option<PathBuf>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct PackageDoctests {
98 pub package: String,
99 pub manifest_dir: PathBuf,
100 pub cases: Vec<DoctestCase>,
101 pub exit_code: i32,
103}
104
105#[derive(Debug, Serialize, Deserialize)]
107#[serde(tag = "kind", rename_all = "kebab-case")]
108enum Invocation {
109 Merged { cases: Vec<MergedCase> },
112 Standalone {
114 name: String,
115 exit_code: i32,
116 evidence: PathBuf,
117 },
118 EmptyHarness,
120 Listed,
122}
123
124#[derive(Debug, Serialize, Deserialize)]
125struct MergedCase {
126 name: String,
127 status: String,
128 evidence: Option<PathBuf>,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
133struct ChildRecord {
134 index: usize,
135 exit_code: i32,
136 evidence: PathBuf,
137}
138
139pub(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 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 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 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 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
276fn 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#[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 } else {
330 parsed.crate_arguments.push(argument.clone());
331 }
332 index += 1;
333 }
334 parsed
335}
336
337#[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 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
407fn 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
417fn 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
454fn 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 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
482fn 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
518pub 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 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 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 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 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 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 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
852pub 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 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 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 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
993pub 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 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
1047pub 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
1052pub 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}