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 = 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 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 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 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 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
279fn 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#[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 } else {
333 parsed.crate_arguments.push(argument.clone());
334 }
335 index += 1;
336 }
337 parsed
338}
339
340#[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 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
410fn 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
420fn 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
457fn 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 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
485fn 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
521pub 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 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 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 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 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 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 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
855pub 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 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 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 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
996pub 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 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
1050pub 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
1055pub 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}