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