nextest_runner/list/
test_list.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::{DisplayFilterMatcher, TestListDisplayFilter};
5use crate::{
6    cargo_config::EnvironmentMap,
7    config::{
8        core::EvaluatableProfile,
9        overrides::{ListSettings, TestSettings},
10        scripts::{WrapperScriptConfig, WrapperScriptTargetRunner},
11    },
12    double_spawn::DoubleSpawnInfo,
13    errors::{CreateTestListError, FromMessagesError, WriteTestListError},
14    helpers::{convert_build_platform, dylib_path, dylib_path_envvar, write_test_name},
15    indenter::indented,
16    list::{BinaryList, OutputFormat, RustBuildMeta, Styles, TestListState},
17    reuse_build::PathMapper,
18    run_mode::NextestRunMode,
19    runner::Interceptor,
20    target_runner::{PlatformRunner, TargetRunner},
21    test_command::{LocalExecuteContext, TestCommand, TestCommandPhase},
22    test_filter::{BinaryMismatchReason, FilterBinaryMatch, FilterBound, TestFilterBuilder},
23    write_str::WriteStr,
24};
25use camino::{Utf8Path, Utf8PathBuf};
26use debug_ignore::DebugIgnore;
27use futures::prelude::*;
28use guppy::{
29    PackageId,
30    graph::{PackageGraph, PackageMetadata},
31};
32use iddqd::{IdOrdItem, IdOrdMap, id_upcast};
33use nextest_filtering::{BinaryQuery, EvalContext, TestQuery};
34use nextest_metadata::{
35    BuildPlatform, FilterMatch, MismatchReason, RustBinaryId, RustNonTestBinaryKind,
36    RustTestBinaryKind, RustTestBinarySummary, RustTestCaseSummary, RustTestKind,
37    RustTestSuiteStatusSummary, RustTestSuiteSummary, TestCaseName, TestListSummary,
38};
39use owo_colors::OwoColorize;
40use quick_junit::ReportUuid;
41use serde::Serialize;
42use std::{
43    borrow::Cow,
44    collections::{BTreeMap, BTreeSet},
45    ffi::{OsStr, OsString},
46    fmt, io,
47    path::PathBuf,
48    sync::{Arc, OnceLock},
49};
50use swrite::{SWrite, swrite};
51use tokio::runtime::Runtime;
52use tracing::debug;
53
54/// A Rust test binary built by Cargo. This artifact hasn't been run yet so there's no information
55/// about the tests within it.
56///
57/// Accepted as input to [`TestList::new`].
58#[derive(Clone, Debug)]
59pub struct RustTestArtifact<'g> {
60    /// A unique identifier for this test artifact.
61    pub binary_id: RustBinaryId,
62
63    /// Metadata for the package this artifact is a part of. This is used to set the correct
64    /// environment variables.
65    pub package: PackageMetadata<'g>,
66
67    /// The path to the binary artifact.
68    pub binary_path: Utf8PathBuf,
69
70    /// The unique binary name defined in `Cargo.toml` or inferred by the filename.
71    pub binary_name: String,
72
73    /// The kind of Rust test binary this is.
74    pub kind: RustTestBinaryKind,
75
76    /// Non-test binaries to be exposed to this artifact at runtime (name, path).
77    pub non_test_binaries: BTreeSet<(String, Utf8PathBuf)>,
78
79    /// The working directory that this test should be executed in.
80    pub cwd: Utf8PathBuf,
81
82    /// The platform for which this test artifact was built.
83    pub build_platform: BuildPlatform,
84}
85
86impl<'g> RustTestArtifact<'g> {
87    /// Constructs a list of test binaries from the list of built binaries.
88    pub fn from_binary_list(
89        graph: &'g PackageGraph,
90        binary_list: Arc<BinaryList>,
91        rust_build_meta: &RustBuildMeta<TestListState>,
92        path_mapper: &PathMapper,
93        platform_filter: Option<BuildPlatform>,
94    ) -> Result<Vec<Self>, FromMessagesError> {
95        let mut binaries = vec![];
96
97        for binary in &binary_list.rust_binaries {
98            if platform_filter.is_some() && platform_filter != Some(binary.build_platform) {
99                continue;
100            }
101
102            // Look up the executable by package ID.
103            let package_id = PackageId::new(binary.package_id.clone());
104            let package = graph
105                .metadata(&package_id)
106                .map_err(FromMessagesError::PackageGraph)?;
107
108            // Tests are run in the directory containing Cargo.toml
109            let cwd = package
110                .manifest_path()
111                .parent()
112                .unwrap_or_else(|| {
113                    panic!(
114                        "manifest path {} doesn't have a parent",
115                        package.manifest_path()
116                    )
117                })
118                .to_path_buf();
119
120            let binary_path = path_mapper.map_binary(binary.path.clone());
121            let cwd = path_mapper.map_cwd(cwd);
122
123            // Non-test binaries are only exposed to integration tests and benchmarks.
124            let non_test_binaries = if binary.kind == RustTestBinaryKind::TEST
125                || binary.kind == RustTestBinaryKind::BENCH
126            {
127                // Note we must use the TestListState rust_build_meta here to ensure we get remapped
128                // paths.
129                match rust_build_meta.non_test_binaries.get(package_id.repr()) {
130                    Some(binaries) => binaries
131                        .iter()
132                        .filter(|binary| {
133                            // Only expose BIN_EXE non-test files.
134                            binary.kind == RustNonTestBinaryKind::BIN_EXE
135                        })
136                        .map(|binary| {
137                            // Convert relative paths to absolute ones by joining with the target directory.
138                            let abs_path = rust_build_meta.target_directory.join(&binary.path);
139                            (binary.name.clone(), abs_path)
140                        })
141                        .collect(),
142                    None => BTreeSet::new(),
143                }
144            } else {
145                BTreeSet::new()
146            };
147
148            binaries.push(RustTestArtifact {
149                binary_id: binary.id.clone(),
150                package,
151                binary_path,
152                binary_name: binary.name.clone(),
153                kind: binary.kind.clone(),
154                cwd,
155                non_test_binaries,
156                build_platform: binary.build_platform,
157            })
158        }
159
160        Ok(binaries)
161    }
162
163    /// Returns a [`BinaryQuery`] corresponding to this test artifact.
164    pub fn to_binary_query(&self) -> BinaryQuery<'_> {
165        BinaryQuery {
166            package_id: self.package.id(),
167            binary_id: &self.binary_id,
168            kind: &self.kind,
169            binary_name: &self.binary_name,
170            platform: convert_build_platform(self.build_platform),
171        }
172    }
173
174    // ---
175    // Helper methods
176    // ---
177    fn into_test_suite(self, status: RustTestSuiteStatus) -> RustTestSuite<'g> {
178        let Self {
179            binary_id,
180            package,
181            binary_path,
182            binary_name,
183            kind,
184            non_test_binaries,
185            cwd,
186            build_platform,
187        } = self;
188
189        RustTestSuite {
190            binary_id,
191            binary_path,
192            package,
193            binary_name,
194            kind,
195            non_test_binaries,
196            cwd,
197            build_platform,
198            status,
199        }
200    }
201}
202
203/// Information about skipped tests and binaries.
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct SkipCounts {
206    /// The number of skipped tests.
207    pub skipped_tests: usize,
208
209    /// The number of tests skipped because they are not benchmarks.
210    ///
211    /// This is used when running in benchmark mode.
212    pub skipped_tests_non_benchmark: usize,
213
214    /// The number of tests skipped due to not being in the default set.
215    pub skipped_tests_default_filter: usize,
216
217    /// The number of skipped binaries.
218    pub skipped_binaries: usize,
219
220    /// The number of binaries skipped due to not being in the default set.
221    pub skipped_binaries_default_filter: usize,
222}
223
224/// List of test instances, obtained by querying the [`RustTestArtifact`] instances generated by Cargo.
225#[derive(Clone, Debug)]
226pub struct TestList<'g> {
227    test_count: usize,
228    mode: NextestRunMode,
229    rust_build_meta: RustBuildMeta<TestListState>,
230    rust_suites: IdOrdMap<RustTestSuite<'g>>,
231    workspace_root: Utf8PathBuf,
232    env: EnvironmentMap,
233    updated_dylib_path: OsString,
234    // Computed on first access.
235    skip_counts: OnceLock<SkipCounts>,
236}
237
238impl<'g> TestList<'g> {
239    /// Creates a new test list by running the given command and applying the specified filter.
240    #[expect(clippy::too_many_arguments)]
241    pub fn new<I>(
242        ctx: &TestExecuteContext<'_>,
243        test_artifacts: I,
244        rust_build_meta: RustBuildMeta<TestListState>,
245        filter: &TestFilterBuilder,
246        workspace_root: Utf8PathBuf,
247        env: EnvironmentMap,
248        profile: &impl ListProfile,
249        bound: FilterBound,
250        list_threads: usize,
251    ) -> Result<Self, CreateTestListError>
252    where
253        I: IntoIterator<Item = RustTestArtifact<'g>>,
254        I::IntoIter: Send,
255    {
256        let updated_dylib_path = Self::create_dylib_path(&rust_build_meta)?;
257        debug!(
258            "updated {}: {}",
259            dylib_path_envvar(),
260            updated_dylib_path.to_string_lossy(),
261        );
262        let lctx = LocalExecuteContext {
263            phase: TestCommandPhase::List,
264            // Note: this is the remapped workspace root, not the original one.
265            // (We really should have newtypes for this.)
266            workspace_root: &workspace_root,
267            rust_build_meta: &rust_build_meta,
268            double_spawn: ctx.double_spawn,
269            dylib_path: &updated_dylib_path,
270            profile_name: ctx.profile_name,
271            env: &env,
272        };
273
274        let ecx = profile.filterset_ecx();
275
276        let runtime = Runtime::new().map_err(CreateTestListError::TokioRuntimeCreate)?;
277
278        let stream = futures::stream::iter(test_artifacts).map(|test_binary| {
279            async {
280                let binary_query = test_binary.to_binary_query();
281                let binary_match = filter.filter_binary_match(&test_binary, &ecx, bound);
282                match binary_match {
283                    FilterBinaryMatch::Definite | FilterBinaryMatch::Possible => {
284                        debug!(
285                            "executing test binary to obtain test list \
286                            (match result is {binary_match:?}): {}",
287                            test_binary.binary_id,
288                        );
289                        // Run the binary to obtain the test list.
290                        let list_settings = profile.list_settings_for(&binary_query);
291                        let (non_ignored, ignored) = test_binary
292                            .exec(&lctx, &list_settings, ctx.target_runner)
293                            .await?;
294                        let info = Self::process_output(
295                            test_binary,
296                            filter,
297                            &ecx,
298                            bound,
299                            non_ignored.as_str(),
300                            ignored.as_str(),
301                        )?;
302                        Ok::<_, CreateTestListError>(info)
303                    }
304                    FilterBinaryMatch::Mismatch { reason } => {
305                        debug!("skipping test binary: {reason}: {}", test_binary.binary_id,);
306                        Ok(Self::process_skipped(test_binary, reason))
307                    }
308                }
309            }
310        });
311        let fut = stream.buffer_unordered(list_threads).try_collect();
312
313        let rust_suites: IdOrdMap<_> = runtime.block_on(fut)?;
314
315        // Ensure that the runtime doesn't stay hanging even if a custom test framework misbehaves
316        // (can be an issue on Windows).
317        runtime.shutdown_background();
318
319        let test_count = rust_suites
320            .iter()
321            .map(|suite| suite.status.test_count())
322            .sum();
323
324        Ok(Self {
325            rust_suites,
326            mode: filter.mode(),
327            workspace_root,
328            env,
329            rust_build_meta,
330            updated_dylib_path,
331            test_count,
332            skip_counts: OnceLock::new(),
333        })
334    }
335
336    /// Creates a new test list with the given binary names and outputs.
337    #[cfg(test)]
338    fn new_with_outputs(
339        test_bin_outputs: impl IntoIterator<
340            Item = (RustTestArtifact<'g>, impl AsRef<str>, impl AsRef<str>),
341        >,
342        workspace_root: Utf8PathBuf,
343        rust_build_meta: RustBuildMeta<TestListState>,
344        filter: &TestFilterBuilder,
345        env: EnvironmentMap,
346        ecx: &EvalContext<'_>,
347        bound: FilterBound,
348    ) -> Result<Self, CreateTestListError> {
349        let mut test_count = 0;
350
351        let updated_dylib_path = Self::create_dylib_path(&rust_build_meta)?;
352
353        let rust_suites = test_bin_outputs
354            .into_iter()
355            .map(|(test_binary, non_ignored, ignored)| {
356                let binary_match = filter.filter_binary_match(&test_binary, ecx, bound);
357                match binary_match {
358                    FilterBinaryMatch::Definite | FilterBinaryMatch::Possible => {
359                        debug!(
360                            "processing output for binary \
361                            (match result is {binary_match:?}): {}",
362                            test_binary.binary_id,
363                        );
364                        let info = Self::process_output(
365                            test_binary,
366                            filter,
367                            ecx,
368                            bound,
369                            non_ignored.as_ref(),
370                            ignored.as_ref(),
371                        )?;
372                        test_count += info.status.test_count();
373                        Ok(info)
374                    }
375                    FilterBinaryMatch::Mismatch { reason } => {
376                        debug!("skipping test binary: {reason}: {}", test_binary.binary_id,);
377                        Ok(Self::process_skipped(test_binary, reason))
378                    }
379                }
380            })
381            .collect::<Result<IdOrdMap<_>, _>>()?;
382
383        Ok(Self {
384            rust_suites,
385            mode: filter.mode(),
386            workspace_root,
387            env,
388            rust_build_meta,
389            updated_dylib_path,
390            test_count,
391            skip_counts: OnceLock::new(),
392        })
393    }
394
395    /// Reconstructs a TestList from archived summary data.
396    ///
397    /// This is used during replay to reconstruct a TestList without
398    /// executing test binaries. The reconstructed TestList provides the
399    /// data needed for display through the reporter infrastructure.
400    ///
401    /// # Arguments
402    ///
403    /// * `graph` - The package graph loaded from archived cargo metadata.
404    /// * `summary` - The test list summary loaded from the archive.
405    /// * `mode` - The run mode (normal test run or benchmark).
406    pub fn from_summary(
407        graph: &'g PackageGraph,
408        summary: &TestListSummary,
409        mode: NextestRunMode,
410    ) -> Result<Self, crate::errors::TestListFromSummaryError> {
411        use crate::errors::TestListFromSummaryError;
412
413        // Build RustBuildMeta from summary.
414        let rust_build_meta = RustBuildMeta::from_summary(summary.rust_build_meta.clone())
415            .map_err(TestListFromSummaryError::RustBuildMeta)?;
416
417        // Get the workspace root from the graph.
418        let workspace_root = graph.workspace().root().to_path_buf();
419
420        // Construct an empty environment map - we don't need it for replay.
421        let env = EnvironmentMap::empty();
422
423        // For replay, we don't need the actual dylib path since we're not executing tests.
424        let updated_dylib_path = OsString::new();
425
426        // Build test suites from the summary.
427        let mut rust_suites = IdOrdMap::new();
428        let mut test_count = 0;
429
430        for (binary_id, suite_summary) in &summary.rust_suites {
431            // Look up the package in the graph by package_id.
432            let package_id = PackageId::new(suite_summary.binary.package_id.clone());
433            let package = graph.metadata(&package_id).map_err(|_| {
434                TestListFromSummaryError::PackageNotFound {
435                    name: suite_summary.package_name.clone(),
436                    package_id: suite_summary.binary.package_id.clone(),
437                }
438            })?;
439
440            // Determine the status based on the summary.
441            let status = if suite_summary.status == RustTestSuiteStatusSummary::SKIPPED {
442                RustTestSuiteStatus::Skipped {
443                    reason: BinaryMismatchReason::Expression,
444                }
445            } else if suite_summary.status == RustTestSuiteStatusSummary::SKIPPED_DEFAULT_FILTER {
446                RustTestSuiteStatus::Skipped {
447                    reason: BinaryMismatchReason::DefaultSet,
448                }
449            } else {
450                // Build test cases from the summary (only for listed suites).
451                let test_cases: IdOrdMap<RustTestCase> = suite_summary
452                    .test_cases
453                    .iter()
454                    .map(|(name, info)| RustTestCase {
455                        name: name.clone(),
456                        test_info: info.clone(),
457                    })
458                    .collect();
459
460                test_count += test_cases.len();
461
462                // LISTED or any other status - treat as listed.
463                RustTestSuiteStatus::Listed {
464                    test_cases: DebugIgnore(test_cases),
465                }
466            };
467
468            let suite = RustTestSuite {
469                binary_id: binary_id.clone(),
470                binary_path: suite_summary.binary.binary_path.clone(),
471                package,
472                binary_name: suite_summary.binary.binary_name.clone(),
473                kind: suite_summary.binary.kind.clone(),
474                non_test_binaries: BTreeSet::new(), // Not stored in summary.
475                cwd: suite_summary.cwd.clone(),
476                build_platform: suite_summary.binary.build_platform,
477                status,
478            };
479
480            let _ = rust_suites.insert_unique(suite);
481        }
482
483        Ok(Self {
484            rust_suites,
485            mode,
486            workspace_root,
487            env,
488            rust_build_meta,
489            updated_dylib_path,
490            test_count,
491            skip_counts: OnceLock::new(),
492        })
493    }
494
495    /// Returns the total number of tests across all binaries.
496    pub fn test_count(&self) -> usize {
497        self.test_count
498    }
499
500    /// Returns the mode nextest is running in.
501    pub fn mode(&self) -> NextestRunMode {
502        self.mode
503    }
504
505    /// Returns the Rust build-related metadata for this test list.
506    pub fn rust_build_meta(&self) -> &RustBuildMeta<TestListState> {
507        &self.rust_build_meta
508    }
509
510    /// Returns the total number of skipped tests.
511    pub fn skip_counts(&self) -> &SkipCounts {
512        self.skip_counts.get_or_init(|| {
513            let mut skipped_tests_non_benchmark = 0;
514            let mut skipped_tests_default_filter = 0;
515            let skipped_tests = self
516                .iter_tests()
517                .filter(|instance| match instance.test_info.filter_match {
518                    FilterMatch::Mismatch {
519                        reason: MismatchReason::NotBenchmark,
520                    } => {
521                        skipped_tests_non_benchmark += 1;
522                        true
523                    }
524                    FilterMatch::Mismatch {
525                        reason: MismatchReason::DefaultFilter,
526                    } => {
527                        skipped_tests_default_filter += 1;
528                        true
529                    }
530                    FilterMatch::Mismatch { .. } => true,
531                    FilterMatch::Matches => false,
532                })
533                .count();
534
535            let mut skipped_binaries_default_filter = 0;
536            let skipped_binaries = self
537                .rust_suites
538                .iter()
539                .filter(|suite| match suite.status {
540                    RustTestSuiteStatus::Skipped {
541                        reason: BinaryMismatchReason::DefaultSet,
542                    } => {
543                        skipped_binaries_default_filter += 1;
544                        true
545                    }
546                    RustTestSuiteStatus::Skipped { .. } => true,
547                    RustTestSuiteStatus::Listed { .. } => false,
548                })
549                .count();
550
551            SkipCounts {
552                skipped_tests,
553                skipped_tests_non_benchmark,
554                skipped_tests_default_filter,
555                skipped_binaries,
556                skipped_binaries_default_filter,
557            }
558        })
559    }
560
561    /// Returns the total number of tests that aren't skipped.
562    ///
563    /// It is always the case that `run_count + skip_count == test_count`.
564    pub fn run_count(&self) -> usize {
565        self.test_count - self.skip_counts().skipped_tests
566    }
567
568    /// Returns the total number of binaries that contain tests.
569    pub fn binary_count(&self) -> usize {
570        self.rust_suites.len()
571    }
572
573    /// Returns the total number of binaries that were listed (not skipped).
574    pub fn listed_binary_count(&self) -> usize {
575        self.binary_count() - self.skip_counts().skipped_binaries
576    }
577
578    /// Returns the mapped workspace root.
579    pub fn workspace_root(&self) -> &Utf8Path {
580        &self.workspace_root
581    }
582
583    /// Returns the environment variables to be used when running tests.
584    pub fn cargo_env(&self) -> &EnvironmentMap {
585        &self.env
586    }
587
588    /// Returns the updated dynamic library path used for tests.
589    pub fn updated_dylib_path(&self) -> &OsStr {
590        &self.updated_dylib_path
591    }
592
593    /// Constructs a serializble summary for this test list.
594    pub fn to_summary(&self) -> TestListSummary {
595        let rust_suites = self
596            .rust_suites
597            .iter()
598            .map(|test_suite| {
599                let (status, test_cases) = test_suite.status.to_summary();
600                let testsuite = RustTestSuiteSummary {
601                    package_name: test_suite.package.name().to_owned(),
602                    binary: RustTestBinarySummary {
603                        binary_name: test_suite.binary_name.clone(),
604                        package_id: test_suite.package.id().repr().to_owned(),
605                        kind: test_suite.kind.clone(),
606                        binary_path: test_suite.binary_path.clone(),
607                        binary_id: test_suite.binary_id.clone(),
608                        build_platform: test_suite.build_platform,
609                    },
610                    cwd: test_suite.cwd.clone(),
611                    status,
612                    test_cases,
613                };
614                (test_suite.binary_id.clone(), testsuite)
615            })
616            .collect();
617        let mut summary = TestListSummary::new(self.rust_build_meta.to_summary());
618        summary.test_count = self.test_count;
619        summary.rust_suites = rust_suites;
620        summary
621    }
622
623    /// Outputs this list to the given writer.
624    pub fn write(
625        &self,
626        output_format: OutputFormat,
627        writer: &mut dyn WriteStr,
628        colorize: bool,
629    ) -> Result<(), WriteTestListError> {
630        match output_format {
631            OutputFormat::Human { verbose } => self
632                .write_human(writer, verbose, colorize)
633                .map_err(WriteTestListError::Io),
634            OutputFormat::Oneline { verbose } => self
635                .write_oneline(writer, verbose, colorize)
636                .map_err(WriteTestListError::Io),
637            OutputFormat::Serializable(format) => format.to_writer(&self.to_summary(), writer),
638        }
639    }
640
641    /// Iterates over all the test suites.
642    pub fn iter(&self) -> impl Iterator<Item = &RustTestSuite<'_>> + '_ {
643        self.rust_suites.iter()
644    }
645
646    /// Looks up a test suite by binary ID.
647    pub fn get_suite(&self, binary_id: &RustBinaryId) -> Option<&RustTestSuite<'_>> {
648        self.rust_suites.get(binary_id)
649    }
650
651    /// Iterates over the list of tests, returning the path and test name.
652    pub fn iter_tests(&self) -> impl Iterator<Item = TestInstance<'_>> + '_ {
653        self.rust_suites.iter().flat_map(|test_suite| {
654            test_suite
655                .status
656                .test_cases()
657                .map(move |case| TestInstance::new(case, test_suite))
658        })
659    }
660
661    /// Produces a priority queue of tests based on the given profile.
662    pub fn to_priority_queue(
663        &'g self,
664        profile: &'g EvaluatableProfile<'g>,
665    ) -> TestPriorityQueue<'g> {
666        TestPriorityQueue::new(self, profile)
667    }
668
669    /// Outputs this list as a string with the given format.
670    pub fn to_string(&self, output_format: OutputFormat) -> Result<String, WriteTestListError> {
671        let mut s = String::with_capacity(1024);
672        self.write(output_format, &mut s, false)?;
673        Ok(s)
674    }
675
676    // ---
677    // Helper methods
678    // ---
679
680    /// Creates an empty test list.
681    ///
682    /// This is primarily for use in tests where a placeholder test list is needed.
683    pub fn empty() -> Self {
684        Self {
685            test_count: 0,
686            mode: NextestRunMode::Test,
687            workspace_root: Utf8PathBuf::new(),
688            rust_build_meta: RustBuildMeta::empty(),
689            env: EnvironmentMap::empty(),
690            updated_dylib_path: OsString::new(),
691            rust_suites: IdOrdMap::new(),
692            skip_counts: OnceLock::new(),
693        }
694    }
695
696    pub(crate) fn create_dylib_path(
697        rust_build_meta: &RustBuildMeta<TestListState>,
698    ) -> Result<OsString, CreateTestListError> {
699        let dylib_path = dylib_path();
700        let dylib_path_is_empty = dylib_path.is_empty();
701        let new_paths = rust_build_meta.dylib_paths();
702
703        let mut updated_dylib_path: Vec<PathBuf> =
704            Vec::with_capacity(dylib_path.len() + new_paths.len());
705        updated_dylib_path.extend(
706            new_paths
707                .iter()
708                .map(|path| path.clone().into_std_path_buf()),
709        );
710        updated_dylib_path.extend(dylib_path);
711
712        // On macOS, these are the defaults when DYLD_FALLBACK_LIBRARY_PATH isn't set or set to an
713        // empty string. (This is relevant if nextest is invoked as its own process and not
714        // a Cargo subcommand.)
715        //
716        // This copies the logic from
717        // https://cs.github.com/rust-lang/cargo/blob/7d289b171183578d45dcabc56db6db44b9accbff/src/cargo/core/compiler/compilation.rs#L292.
718        if cfg!(target_os = "macos") && dylib_path_is_empty {
719            if let Some(home) = home::home_dir() {
720                updated_dylib_path.push(home.join("lib"));
721            }
722            updated_dylib_path.push("/usr/local/lib".into());
723            updated_dylib_path.push("/usr/lib".into());
724        }
725
726        std::env::join_paths(updated_dylib_path)
727            .map_err(move |error| CreateTestListError::dylib_join_paths(new_paths, error))
728    }
729
730    fn process_output(
731        test_binary: RustTestArtifact<'g>,
732        filter: &TestFilterBuilder,
733        ecx: &EvalContext<'_>,
734        bound: FilterBound,
735        non_ignored: impl AsRef<str>,
736        ignored: impl AsRef<str>,
737    ) -> Result<RustTestSuite<'g>, CreateTestListError> {
738        let mut test_cases = IdOrdMap::new();
739
740        // Treat ignored and non-ignored as separate sets of single filters, so that partitioning
741        // based on one doesn't affect the other.
742        let mut non_ignored_filter = filter.build();
743        for (test_name, kind) in Self::parse(&test_binary.binary_id, non_ignored.as_ref())? {
744            let name = TestCaseName::new(test_name);
745            let filter_match =
746                non_ignored_filter.filter_match(&test_binary, &name, &kind, ecx, bound, false);
747            test_cases.insert_overwrite(RustTestCase {
748                name,
749                test_info: RustTestCaseSummary {
750                    kind: Some(kind),
751                    ignored: false,
752                    filter_match,
753                },
754            });
755        }
756
757        let mut ignored_filter = filter.build();
758        for (test_name, kind) in Self::parse(&test_binary.binary_id, ignored.as_ref())? {
759            // Note that libtest prints out:
760            // * just ignored tests if --ignored is passed in
761            // * all tests, both ignored and non-ignored, if --ignored is not passed in
762            // Adding ignored tests after non-ignored ones makes everything resolve correctly.
763            let name = TestCaseName::new(test_name);
764            let filter_match =
765                ignored_filter.filter_match(&test_binary, &name, &kind, ecx, bound, true);
766            test_cases.insert_overwrite(RustTestCase {
767                name,
768                test_info: RustTestCaseSummary {
769                    kind: Some(kind),
770                    ignored: true,
771                    filter_match,
772                },
773            });
774        }
775
776        Ok(test_binary.into_test_suite(RustTestSuiteStatus::Listed {
777            test_cases: test_cases.into(),
778        }))
779    }
780
781    fn process_skipped(
782        test_binary: RustTestArtifact<'g>,
783        reason: BinaryMismatchReason,
784    ) -> RustTestSuite<'g> {
785        test_binary.into_test_suite(RustTestSuiteStatus::Skipped { reason })
786    }
787
788    /// Parses the output of --list --message-format terse and returns a sorted list.
789    fn parse<'a>(
790        binary_id: &'a RustBinaryId,
791        list_output: &'a str,
792    ) -> Result<Vec<(&'a str, RustTestKind)>, CreateTestListError> {
793        let mut list = parse_list_lines(binary_id, list_output).collect::<Result<Vec<_>, _>>()?;
794        list.sort_unstable();
795        Ok(list)
796    }
797
798    /// Writes this test list out in a human-friendly format.
799    pub fn write_human(
800        &self,
801        writer: &mut dyn WriteStr,
802        verbose: bool,
803        colorize: bool,
804    ) -> io::Result<()> {
805        self.write_human_impl(None, writer, verbose, colorize)
806    }
807
808    /// Writes this test list out in a human-friendly format with the given filter.
809    pub(crate) fn write_human_with_filter(
810        &self,
811        filter: &TestListDisplayFilter<'_>,
812        writer: &mut dyn WriteStr,
813        verbose: bool,
814        colorize: bool,
815    ) -> io::Result<()> {
816        self.write_human_impl(Some(filter), writer, verbose, colorize)
817    }
818
819    fn write_human_impl(
820        &self,
821        filter: Option<&TestListDisplayFilter<'_>>,
822        mut writer: &mut dyn WriteStr,
823        verbose: bool,
824        colorize: bool,
825    ) -> io::Result<()> {
826        let mut styles = Styles::default();
827        if colorize {
828            styles.colorize();
829        }
830
831        for info in &self.rust_suites {
832            let matcher = match filter {
833                Some(filter) => match filter.matcher_for(&info.binary_id) {
834                    Some(matcher) => matcher,
835                    None => continue,
836                },
837                None => DisplayFilterMatcher::All,
838            };
839
840            // Skip this binary if there are no tests within it that will be run, and this isn't
841            // verbose output.
842            if !verbose
843                && info
844                    .status
845                    .test_cases()
846                    .all(|case| !case.test_info.filter_match.is_match())
847            {
848                continue;
849            }
850
851            writeln!(writer, "{}:", info.binary_id.style(styles.binary_id))?;
852            if verbose {
853                writeln!(
854                    writer,
855                    "  {} {}",
856                    "bin:".style(styles.field),
857                    info.binary_path
858                )?;
859                writeln!(writer, "  {} {}", "cwd:".style(styles.field), info.cwd)?;
860                writeln!(
861                    writer,
862                    "  {} {}",
863                    "build platform:".style(styles.field),
864                    info.build_platform,
865                )?;
866            }
867
868            let mut indented = indented(writer).with_str("    ");
869
870            match &info.status {
871                RustTestSuiteStatus::Listed { test_cases } => {
872                    let matching_tests: Vec<_> = test_cases
873                        .iter()
874                        .filter(|case| matcher.is_match(&case.name))
875                        .collect();
876                    if matching_tests.is_empty() {
877                        writeln!(indented, "(no tests)")?;
878                    } else {
879                        for case in matching_tests {
880                            match (verbose, case.test_info.filter_match.is_match()) {
881                                (_, true) => {
882                                    write_test_name(&case.name, &styles, &mut indented)?;
883                                    writeln!(indented)?;
884                                }
885                                (true, false) => {
886                                    write_test_name(&case.name, &styles, &mut indented)?;
887                                    writeln!(indented, " (skipped)")?;
888                                }
889                                (false, false) => {
890                                    // Skip printing this test entirely if it isn't a match.
891                                }
892                            }
893                        }
894                    }
895                }
896                RustTestSuiteStatus::Skipped { reason } => {
897                    writeln!(indented, "(test binary {reason}, skipped)")?;
898                }
899            }
900
901            writer = indented.into_inner();
902        }
903        Ok(())
904    }
905
906    /// Writes this test list out in a one-line-per-test format.
907    pub fn write_oneline(
908        &self,
909        writer: &mut dyn WriteStr,
910        verbose: bool,
911        colorize: bool,
912    ) -> io::Result<()> {
913        let mut styles = Styles::default();
914        if colorize {
915            styles.colorize();
916        }
917
918        for info in &self.rust_suites {
919            match &info.status {
920                RustTestSuiteStatus::Listed { test_cases } => {
921                    for case in test_cases.iter() {
922                        let is_match = case.test_info.filter_match.is_match();
923                        // Skip tests that don't match the filter (unless verbose).
924                        if !verbose && !is_match {
925                            continue;
926                        }
927
928                        write!(writer, "{} ", info.binary_id.style(styles.binary_id))?;
929                        write_test_name(&case.name, &styles, writer)?;
930
931                        if verbose {
932                            write!(
933                                writer,
934                                " [{}{}] [{}{}] [{}{}]{}",
935                                "bin: ".style(styles.field),
936                                info.binary_path,
937                                "cwd: ".style(styles.field),
938                                info.cwd,
939                                "build platform: ".style(styles.field),
940                                info.build_platform,
941                                if is_match { "" } else { " (skipped)" },
942                            )?;
943                        }
944
945                        writeln!(writer)?;
946                    }
947                }
948                RustTestSuiteStatus::Skipped { .. } => {
949                    // Skip binaries that were not listed.
950                }
951            }
952        }
953
954        Ok(())
955    }
956}
957
958fn parse_list_lines<'a>(
959    binary_id: &'a RustBinaryId,
960    list_output: &'a str,
961) -> impl Iterator<Item = Result<(&'a str, RustTestKind), CreateTestListError>> + 'a + use<'a> {
962    // The output is in the form:
963    // <test name>: test
964    // <test name>: test
965    // ...
966
967    list_output
968        .lines()
969        .map(move |line| match line.strip_suffix(": test") {
970            Some(test_name) => Ok((test_name, RustTestKind::TEST)),
971            None => match line.strip_suffix(": benchmark") {
972                Some(test_name) => Ok((test_name, RustTestKind::BENCH)),
973                None => Err(CreateTestListError::parse_line(
974                    binary_id.clone(),
975                    format!(
976                        "line {line:?} did not end with the string \": test\" or \": benchmark\""
977                    ),
978                    list_output,
979                )),
980            },
981        })
982}
983
984/// Profile implementation for test lists.
985pub trait ListProfile {
986    /// Returns the evaluation context.
987    fn filterset_ecx(&self) -> EvalContext<'_>;
988
989    /// Returns list-time settings for a test binary.
990    fn list_settings_for(&self, query: &BinaryQuery<'_>) -> ListSettings<'_>;
991}
992
993impl<'g> ListProfile for EvaluatableProfile<'g> {
994    fn filterset_ecx(&self) -> EvalContext<'_> {
995        self.filterset_ecx()
996    }
997
998    fn list_settings_for(&self, query: &BinaryQuery<'_>) -> ListSettings<'_> {
999        self.list_settings_for(query)
1000    }
1001}
1002
1003/// A test list that has been sorted and has had priorities applied to it.
1004pub struct TestPriorityQueue<'a> {
1005    tests: Vec<TestInstanceWithSettings<'a>>,
1006}
1007
1008impl<'a> TestPriorityQueue<'a> {
1009    fn new(test_list: &'a TestList<'a>, profile: &'a EvaluatableProfile<'a>) -> Self {
1010        let mode = test_list.mode();
1011        let mut tests = test_list
1012            .iter_tests()
1013            .map(|instance| {
1014                let settings = profile.settings_for(mode, &instance.to_test_query());
1015                TestInstanceWithSettings { instance, settings }
1016            })
1017            .collect::<Vec<_>>();
1018        // Note: this is a stable sort so that tests with the same priority are
1019        // sorted by what `iter_tests` produced.
1020        tests.sort_by_key(|test| test.settings.priority());
1021
1022        Self { tests }
1023    }
1024}
1025
1026impl<'a> IntoIterator for TestPriorityQueue<'a> {
1027    type Item = TestInstanceWithSettings<'a>;
1028    type IntoIter = std::vec::IntoIter<Self::Item>;
1029
1030    fn into_iter(self) -> Self::IntoIter {
1031        self.tests.into_iter()
1032    }
1033}
1034
1035/// A test instance, along with computed settings from a profile.
1036///
1037/// Returned from [`TestPriorityQueue`].
1038#[derive(Debug)]
1039pub struct TestInstanceWithSettings<'a> {
1040    /// The test instance.
1041    pub instance: TestInstance<'a>,
1042
1043    /// The settings for this test.
1044    pub settings: TestSettings<'a>,
1045}
1046
1047/// A suite of tests within a single Rust test binary.
1048///
1049/// This is a representation of [`nextest_metadata::RustTestSuiteSummary`] used internally by the runner.
1050#[derive(Clone, Debug, Eq, PartialEq)]
1051pub struct RustTestSuite<'g> {
1052    /// A unique identifier for this binary.
1053    pub binary_id: RustBinaryId,
1054
1055    /// The path to the binary.
1056    pub binary_path: Utf8PathBuf,
1057
1058    /// Package metadata.
1059    pub package: PackageMetadata<'g>,
1060
1061    /// The unique binary name defined in `Cargo.toml` or inferred by the filename.
1062    pub binary_name: String,
1063
1064    /// The kind of Rust test binary this is.
1065    pub kind: RustTestBinaryKind,
1066
1067    /// The working directory that this test binary will be executed in. If None, the current directory
1068    /// will not be changed.
1069    pub cwd: Utf8PathBuf,
1070
1071    /// The platform the test suite is for (host or target).
1072    pub build_platform: BuildPlatform,
1073
1074    /// Non-test binaries corresponding to this test suite (name, path).
1075    pub non_test_binaries: BTreeSet<(String, Utf8PathBuf)>,
1076
1077    /// Test suite status and test case names.
1078    pub status: RustTestSuiteStatus,
1079}
1080
1081impl IdOrdItem for RustTestSuite<'_> {
1082    type Key<'a>
1083        = &'a RustBinaryId
1084    where
1085        Self: 'a;
1086
1087    fn key(&self) -> Self::Key<'_> {
1088        &self.binary_id
1089    }
1090
1091    id_upcast!();
1092}
1093
1094impl RustTestArtifact<'_> {
1095    /// Run this binary with and without --ignored and get the corresponding outputs.
1096    async fn exec(
1097        &self,
1098        lctx: &LocalExecuteContext<'_>,
1099        list_settings: &ListSettings<'_>,
1100        target_runner: &TargetRunner,
1101    ) -> Result<(String, String), CreateTestListError> {
1102        // This error situation has been known to happen with reused builds. It produces
1103        // a really terrible and confusing "file not found" message if allowed to prceed.
1104        if !self.cwd.is_dir() {
1105            return Err(CreateTestListError::CwdIsNotDir {
1106                binary_id: self.binary_id.clone(),
1107                cwd: self.cwd.clone(),
1108            });
1109        }
1110        let platform_runner = target_runner.for_build_platform(self.build_platform);
1111
1112        let non_ignored = self.exec_single(false, lctx, list_settings, platform_runner);
1113        let ignored = self.exec_single(true, lctx, list_settings, platform_runner);
1114
1115        let (non_ignored_out, ignored_out) = futures::future::join(non_ignored, ignored).await;
1116        Ok((non_ignored_out?, ignored_out?))
1117    }
1118
1119    async fn exec_single(
1120        &self,
1121        ignored: bool,
1122        lctx: &LocalExecuteContext<'_>,
1123        list_settings: &ListSettings<'_>,
1124        runner: Option<&PlatformRunner>,
1125    ) -> Result<String, CreateTestListError> {
1126        let mut cli = TestCommandCli::default();
1127        cli.apply_wrappers(
1128            list_settings.list_wrapper(),
1129            runner,
1130            lctx.workspace_root,
1131            &lctx.rust_build_meta.target_directory,
1132        );
1133        cli.push(self.binary_path.as_str());
1134
1135        cli.extend(["--list", "--format", "terse"]);
1136        if ignored {
1137            cli.push("--ignored");
1138        }
1139
1140        let cmd = TestCommand::new(
1141            lctx,
1142            cli.program
1143                .clone()
1144                .expect("at least one argument passed in")
1145                .into_owned(),
1146            &cli.args,
1147            &self.cwd,
1148            &self.package,
1149            &self.non_test_binaries,
1150            &Interceptor::None, // Interceptors are not used during the test list phase.
1151        );
1152
1153        let output =
1154            cmd.wait_with_output()
1155                .await
1156                .map_err(|error| CreateTestListError::CommandExecFail {
1157                    binary_id: self.binary_id.clone(),
1158                    command: cli.to_owned_cli(),
1159                    error,
1160                })?;
1161
1162        if output.status.success() {
1163            String::from_utf8(output.stdout).map_err(|err| CreateTestListError::CommandNonUtf8 {
1164                binary_id: self.binary_id.clone(),
1165                command: cli.to_owned_cli(),
1166                stdout: err.into_bytes(),
1167                stderr: output.stderr,
1168            })
1169        } else {
1170            Err(CreateTestListError::CommandFail {
1171                binary_id: self.binary_id.clone(),
1172                command: cli.to_owned_cli(),
1173                exit_status: output.status,
1174                stdout: output.stdout,
1175                stderr: output.stderr,
1176            })
1177        }
1178    }
1179}
1180
1181/// Serializable information about the status of and test cases within a test suite.
1182///
1183/// Part of a [`RustTestSuiteSummary`].
1184#[derive(Clone, Debug, Eq, PartialEq)]
1185pub enum RustTestSuiteStatus {
1186    /// The test suite was executed with `--list` and the list of test cases was obtained.
1187    Listed {
1188        /// The test cases contained within this test suite.
1189        test_cases: DebugIgnore<IdOrdMap<RustTestCase>>,
1190    },
1191
1192    /// The test suite was not executed.
1193    Skipped {
1194        /// The reason why the test suite was skipped.
1195        reason: BinaryMismatchReason,
1196    },
1197}
1198
1199static EMPTY_TEST_CASE_MAP: IdOrdMap<RustTestCase> = IdOrdMap::new();
1200
1201impl RustTestSuiteStatus {
1202    /// Returns the number of test cases within this suite.
1203    pub fn test_count(&self) -> usize {
1204        match self {
1205            RustTestSuiteStatus::Listed { test_cases } => test_cases.len(),
1206            RustTestSuiteStatus::Skipped { .. } => 0,
1207        }
1208    }
1209
1210    /// Returns the list of test cases within this suite.
1211    pub fn test_cases(&self) -> impl Iterator<Item = &RustTestCase> + '_ {
1212        match self {
1213            RustTestSuiteStatus::Listed { test_cases } => test_cases.iter(),
1214            RustTestSuiteStatus::Skipped { .. } => {
1215                // Return an empty test case.
1216                EMPTY_TEST_CASE_MAP.iter()
1217            }
1218        }
1219    }
1220
1221    /// Converts this status to its serializable form.
1222    pub fn to_summary(
1223        &self,
1224    ) -> (
1225        RustTestSuiteStatusSummary,
1226        BTreeMap<TestCaseName, RustTestCaseSummary>,
1227    ) {
1228        match self {
1229            Self::Listed { test_cases } => (
1230                RustTestSuiteStatusSummary::LISTED,
1231                test_cases
1232                    .iter()
1233                    .cloned()
1234                    .map(|case| (case.name, case.test_info))
1235                    .collect(),
1236            ),
1237            Self::Skipped {
1238                reason: BinaryMismatchReason::Expression,
1239            } => (RustTestSuiteStatusSummary::SKIPPED, BTreeMap::new()),
1240            Self::Skipped {
1241                reason: BinaryMismatchReason::DefaultSet,
1242            } => (
1243                RustTestSuiteStatusSummary::SKIPPED_DEFAULT_FILTER,
1244                BTreeMap::new(),
1245            ),
1246        }
1247    }
1248}
1249
1250/// A single test case within a test suite.
1251#[derive(Clone, Debug, Eq, PartialEq)]
1252pub struct RustTestCase {
1253    /// The name of the test.
1254    pub name: TestCaseName,
1255
1256    /// Information about the test.
1257    pub test_info: RustTestCaseSummary,
1258}
1259
1260impl IdOrdItem for RustTestCase {
1261    type Key<'a> = &'a TestCaseName;
1262    fn key(&self) -> Self::Key<'_> {
1263        &self.name
1264    }
1265    id_upcast!();
1266}
1267
1268/// Represents a single test with its associated binary.
1269#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1270pub struct TestInstance<'a> {
1271    /// The name of the test.
1272    pub name: &'a TestCaseName,
1273
1274    /// Information about the test suite.
1275    pub suite_info: &'a RustTestSuite<'a>,
1276
1277    /// Information about the test.
1278    pub test_info: &'a RustTestCaseSummary,
1279}
1280
1281impl<'a> TestInstance<'a> {
1282    /// Creates a new `TestInstance`.
1283    pub(crate) fn new(case: &'a RustTestCase, suite_info: &'a RustTestSuite) -> Self {
1284        Self {
1285            name: &case.name,
1286            suite_info,
1287            test_info: &case.test_info,
1288        }
1289    }
1290
1291    /// Return an identifier for test instances, including being able to sort
1292    /// them.
1293    #[inline]
1294    pub fn id(&self) -> TestInstanceId<'a> {
1295        TestInstanceId {
1296            binary_id: &self.suite_info.binary_id,
1297            test_name: self.name,
1298        }
1299    }
1300
1301    /// Returns the corresponding [`TestQuery`] for this `TestInstance`.
1302    pub fn to_test_query(&self) -> TestQuery<'a> {
1303        TestQuery {
1304            binary_query: BinaryQuery {
1305                package_id: self.suite_info.package.id(),
1306                binary_id: &self.suite_info.binary_id,
1307                kind: &self.suite_info.kind,
1308                binary_name: &self.suite_info.binary_name,
1309                platform: convert_build_platform(self.suite_info.build_platform),
1310            },
1311            test_name: self.name,
1312        }
1313    }
1314
1315    /// Creates the command for this test instance.
1316    pub(crate) fn make_command(
1317        &self,
1318        ctx: &TestExecuteContext<'_>,
1319        test_list: &TestList<'_>,
1320        wrapper_script: Option<&WrapperScriptConfig>,
1321        extra_args: &[String],
1322        interceptor: &Interceptor,
1323    ) -> TestCommand {
1324        // TODO: non-rust tests
1325        let cli = self.compute_cli(ctx, test_list, wrapper_script, extra_args);
1326
1327        let lctx = LocalExecuteContext {
1328            phase: TestCommandPhase::Run,
1329            workspace_root: test_list.workspace_root(),
1330            rust_build_meta: &test_list.rust_build_meta,
1331            double_spawn: ctx.double_spawn,
1332            dylib_path: test_list.updated_dylib_path(),
1333            profile_name: ctx.profile_name,
1334            env: &test_list.env,
1335        };
1336
1337        TestCommand::new(
1338            &lctx,
1339            cli.program
1340                .expect("at least one argument is guaranteed")
1341                .into_owned(),
1342            &cli.args,
1343            &self.suite_info.cwd,
1344            &self.suite_info.package,
1345            &self.suite_info.non_test_binaries,
1346            interceptor,
1347        )
1348    }
1349
1350    pub(crate) fn command_line(
1351        &self,
1352        ctx: &TestExecuteContext<'_>,
1353        test_list: &TestList<'_>,
1354        wrapper_script: Option<&WrapperScriptConfig>,
1355        extra_args: &[String],
1356    ) -> Vec<String> {
1357        self.compute_cli(ctx, test_list, wrapper_script, extra_args)
1358            .to_owned_cli()
1359    }
1360
1361    fn compute_cli(
1362        &self,
1363        ctx: &'a TestExecuteContext<'_>,
1364        test_list: &TestList<'_>,
1365        wrapper_script: Option<&'a WrapperScriptConfig>,
1366        extra_args: &'a [String],
1367    ) -> TestCommandCli<'a> {
1368        let platform_runner = ctx
1369            .target_runner
1370            .for_build_platform(self.suite_info.build_platform);
1371
1372        let mut cli = TestCommandCli::default();
1373        cli.apply_wrappers(
1374            wrapper_script,
1375            platform_runner,
1376            test_list.workspace_root(),
1377            &test_list.rust_build_meta().target_directory,
1378        );
1379        cli.push(self.suite_info.binary_path.as_str());
1380
1381        cli.extend(["--exact", self.name.as_str(), "--nocapture"]);
1382        if self.test_info.ignored {
1383            cli.push("--ignored");
1384        }
1385        match test_list.mode() {
1386            NextestRunMode::Test => {}
1387            NextestRunMode::Benchmark => {
1388                cli.push("--bench");
1389            }
1390        }
1391        cli.extend(extra_args.iter().map(String::as_str));
1392
1393        cli
1394    }
1395}
1396
1397#[derive(Clone, Debug, Default)]
1398struct TestCommandCli<'a> {
1399    program: Option<Cow<'a, str>>,
1400    args: Vec<Cow<'a, str>>,
1401}
1402
1403impl<'a> TestCommandCli<'a> {
1404    fn apply_wrappers(
1405        &mut self,
1406        wrapper_script: Option<&'a WrapperScriptConfig>,
1407        platform_runner: Option<&'a PlatformRunner>,
1408        workspace_root: &Utf8Path,
1409        target_dir: &Utf8Path,
1410    ) {
1411        // Apply the wrapper script if it's enabled.
1412        if let Some(wrapper) = wrapper_script {
1413            match wrapper.target_runner {
1414                WrapperScriptTargetRunner::Ignore => {
1415                    // Ignore the platform runner.
1416                    self.push(wrapper.command.program(workspace_root, target_dir));
1417                    self.extend(wrapper.command.args.iter().map(String::as_str));
1418                }
1419                WrapperScriptTargetRunner::AroundWrapper => {
1420                    // Platform runner goes first.
1421                    if let Some(runner) = platform_runner {
1422                        self.push(runner.binary());
1423                        self.extend(runner.args());
1424                    }
1425                    self.push(wrapper.command.program(workspace_root, target_dir));
1426                    self.extend(wrapper.command.args.iter().map(String::as_str));
1427                }
1428                WrapperScriptTargetRunner::WithinWrapper => {
1429                    // Wrapper script goes first.
1430                    self.push(wrapper.command.program(workspace_root, target_dir));
1431                    self.extend(wrapper.command.args.iter().map(String::as_str));
1432                    if let Some(runner) = platform_runner {
1433                        self.push(runner.binary());
1434                        self.extend(runner.args());
1435                    }
1436                }
1437                WrapperScriptTargetRunner::OverridesWrapper => {
1438                    // Target runner overrides wrapper.
1439                    if let Some(runner) = platform_runner {
1440                        self.push(runner.binary());
1441                        self.extend(runner.args());
1442                    }
1443                }
1444            }
1445        } else {
1446            // If no wrapper script is enabled, use the platform runner.
1447            if let Some(runner) = platform_runner {
1448                self.push(runner.binary());
1449                self.extend(runner.args());
1450            }
1451        }
1452    }
1453
1454    fn push(&mut self, arg: impl Into<Cow<'a, str>>) {
1455        if self.program.is_none() {
1456            self.program = Some(arg.into());
1457        } else {
1458            self.args.push(arg.into());
1459        }
1460    }
1461
1462    fn extend(&mut self, args: impl IntoIterator<Item = &'a str>) {
1463        for arg in args {
1464            self.push(arg);
1465        }
1466    }
1467
1468    fn to_owned_cli(&self) -> Vec<String> {
1469        let mut owned_cli = Vec::new();
1470        if let Some(program) = &self.program {
1471            owned_cli.push(program.to_string());
1472        }
1473        owned_cli.extend(self.args.iter().map(|arg| arg.clone().into_owned()));
1474        owned_cli
1475    }
1476}
1477
1478/// A key for identifying and sorting test instances.
1479///
1480/// Returned by [`TestInstance::id`].
1481#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize)]
1482pub struct TestInstanceId<'a> {
1483    /// The binary ID.
1484    pub binary_id: &'a RustBinaryId,
1485
1486    /// The name of the test.
1487    pub test_name: &'a TestCaseName,
1488}
1489
1490impl TestInstanceId<'_> {
1491    /// Return the attempt ID corresponding to this test instance.
1492    ///
1493    /// This string uniquely identifies a single test attempt.
1494    pub fn attempt_id(
1495        &self,
1496        run_id: ReportUuid,
1497        stress_index: Option<u32>,
1498        attempt: u32,
1499    ) -> String {
1500        let mut out = String::new();
1501        swrite!(out, "{run_id}:{}", self.binary_id);
1502        if let Some(stress_index) = stress_index {
1503            swrite!(out, "@stress-{}", stress_index);
1504        }
1505        swrite!(out, "${}", self.test_name);
1506        if attempt > 1 {
1507            swrite!(out, "#{attempt}");
1508        }
1509
1510        out
1511    }
1512}
1513
1514impl fmt::Display for TestInstanceId<'_> {
1515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1516        write!(f, "{} {}", self.binary_id, self.test_name)
1517    }
1518}
1519
1520/// An owned version of [`TestInstanceId`].
1521#[derive(Clone, Debug, PartialEq, Eq)]
1522pub struct OwnedTestInstanceId {
1523    /// The binary ID.
1524    pub binary_id: RustBinaryId,
1525
1526    /// The name of the test.
1527    pub test_name: TestCaseName,
1528}
1529
1530impl OwnedTestInstanceId {
1531    /// Borrow this as a [`TestInstanceId`].
1532    pub fn as_ref(&self) -> TestInstanceId<'_> {
1533        TestInstanceId {
1534            binary_id: &self.binary_id,
1535            test_name: &self.test_name,
1536        }
1537    }
1538}
1539
1540impl TestInstanceId<'_> {
1541    /// Convert this to an owned version.
1542    pub fn to_owned(&self) -> OwnedTestInstanceId {
1543        OwnedTestInstanceId {
1544            binary_id: self.binary_id.clone(),
1545            test_name: self.test_name.clone(),
1546        }
1547    }
1548}
1549
1550/// Context required for test execution.
1551#[derive(Clone, Debug)]
1552pub struct TestExecuteContext<'a> {
1553    /// The name of the profile.
1554    pub profile_name: &'a str,
1555
1556    /// Double-spawn info.
1557    pub double_spawn: &'a DoubleSpawnInfo,
1558
1559    /// Target runner.
1560    pub target_runner: &'a TargetRunner,
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::*;
1566    use crate::{
1567        cargo_config::{TargetDefinitionLocation, TargetTriple, TargetTripleSource},
1568        config::scripts::{ScriptCommand, ScriptCommandRelativeTo},
1569        list::SerializableFormat,
1570        platform::{BuildPlatforms, HostPlatform, PlatformLibdir, TargetPlatform},
1571        target_runner::PlatformRunnerSource,
1572        test_filter::{RunIgnored, TestFilterPatterns},
1573    };
1574    use guppy::CargoMetadata;
1575    use iddqd::id_ord_map;
1576    use indoc::indoc;
1577    use nextest_filtering::{CompiledExpr, Filterset, FiltersetKind, ParseContext};
1578    use nextest_metadata::{FilterMatch, MismatchReason, PlatformLibdirUnavailable, RustTestKind};
1579    use pretty_assertions::assert_eq;
1580    use std::sync::LazyLock;
1581    use target_spec::Platform;
1582
1583    #[test]
1584    fn test_parse_test_list() {
1585        // Lines ending in ': benchmark' (output by the default Rust bencher) should be skipped.
1586        let non_ignored_output = indoc! {"
1587            tests::foo::test_bar: test
1588            tests::baz::test_quux: test
1589            benches::bench_foo: benchmark
1590        "};
1591        let ignored_output = indoc! {"
1592            tests::ignored::test_bar: test
1593            tests::baz::test_ignored: test
1594            benches::ignored_bench_foo: benchmark
1595        "};
1596
1597        let cx = ParseContext::new(&PACKAGE_GRAPH_FIXTURE);
1598
1599        let test_filter = TestFilterBuilder::new(
1600            NextestRunMode::Test,
1601            RunIgnored::Default,
1602            None,
1603            TestFilterPatterns::default(),
1604            // Test against the platform() predicate because this is the most important one here.
1605            vec![
1606                Filterset::parse("platform(target)".to_owned(), &cx, FiltersetKind::Test).unwrap(),
1607            ],
1608        )
1609        .unwrap();
1610        let fake_cwd: Utf8PathBuf = "/fake/cwd".into();
1611        let fake_binary_name = "fake-binary".to_owned();
1612        let fake_binary_id = RustBinaryId::new("fake-package::fake-binary");
1613
1614        let test_binary = RustTestArtifact {
1615            binary_path: "/fake/binary".into(),
1616            cwd: fake_cwd.clone(),
1617            package: package_metadata(),
1618            binary_name: fake_binary_name.clone(),
1619            binary_id: fake_binary_id.clone(),
1620            kind: RustTestBinaryKind::LIB,
1621            non_test_binaries: BTreeSet::new(),
1622            build_platform: BuildPlatform::Target,
1623        };
1624
1625        let skipped_binary_name = "skipped-binary".to_owned();
1626        let skipped_binary_id = RustBinaryId::new("fake-package::skipped-binary");
1627        let skipped_binary = RustTestArtifact {
1628            binary_path: "/fake/skipped-binary".into(),
1629            cwd: fake_cwd.clone(),
1630            package: package_metadata(),
1631            binary_name: skipped_binary_name.clone(),
1632            binary_id: skipped_binary_id.clone(),
1633            kind: RustTestBinaryKind::PROC_MACRO,
1634            non_test_binaries: BTreeSet::new(),
1635            build_platform: BuildPlatform::Host,
1636        };
1637
1638        let fake_triple = TargetTriple {
1639            platform: Platform::new(
1640                "aarch64-unknown-linux-gnu",
1641                target_spec::TargetFeatures::Unknown,
1642            )
1643            .unwrap(),
1644            source: TargetTripleSource::CliOption,
1645            location: TargetDefinitionLocation::Builtin,
1646        };
1647        let fake_host_libdir = "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib";
1648        let build_platforms = BuildPlatforms {
1649            host: HostPlatform {
1650                platform: TargetTriple::x86_64_unknown_linux_gnu().platform,
1651                libdir: PlatformLibdir::Available(fake_host_libdir.into()),
1652            },
1653            target: Some(TargetPlatform {
1654                triple: fake_triple,
1655                // Test an unavailable libdir.
1656                libdir: PlatformLibdir::Unavailable(PlatformLibdirUnavailable::new_const("test")),
1657            }),
1658        };
1659
1660        let fake_env = EnvironmentMap::empty();
1661        let rust_build_meta =
1662            RustBuildMeta::new("/fake", build_platforms).map_paths(&PathMapper::noop());
1663        let ecx = EvalContext {
1664            default_filter: &CompiledExpr::ALL,
1665        };
1666        let test_list = TestList::new_with_outputs(
1667            [
1668                (test_binary, &non_ignored_output, &ignored_output),
1669                (
1670                    skipped_binary,
1671                    &"should-not-show-up-stdout",
1672                    &"should-not-show-up-stderr",
1673                ),
1674            ],
1675            Utf8PathBuf::from("/fake/path"),
1676            rust_build_meta,
1677            &test_filter,
1678            fake_env,
1679            &ecx,
1680            FilterBound::All,
1681        )
1682        .expect("valid output");
1683        assert_eq!(
1684            test_list.rust_suites,
1685            id_ord_map! {
1686                RustTestSuite {
1687                    status: RustTestSuiteStatus::Listed {
1688                        test_cases: id_ord_map! {
1689                            RustTestCase {
1690                                name: TestCaseName::new("tests::foo::test_bar"),
1691                                test_info: RustTestCaseSummary {
1692                                    kind: Some(RustTestKind::TEST),
1693                                    ignored: false,
1694                                    filter_match: FilterMatch::Matches,
1695                                },
1696                            },
1697                            RustTestCase {
1698                                name: TestCaseName::new("tests::baz::test_quux"),
1699                                test_info: RustTestCaseSummary {
1700                                    kind: Some(RustTestKind::TEST),
1701                                    ignored: false,
1702                                    filter_match: FilterMatch::Matches,
1703                                },
1704                            },
1705                            RustTestCase {
1706                                name: TestCaseName::new("benches::bench_foo"),
1707                                test_info: RustTestCaseSummary {
1708                                    kind: Some(RustTestKind::BENCH),
1709                                    ignored: false,
1710                                    filter_match: FilterMatch::Matches,
1711                                },
1712                            },
1713                            RustTestCase {
1714                                name: TestCaseName::new("tests::ignored::test_bar"),
1715                                test_info: RustTestCaseSummary {
1716                                    kind: Some(RustTestKind::TEST),
1717                                    ignored: true,
1718                                    filter_match: FilterMatch::Mismatch { reason: MismatchReason::Ignored },
1719                                },
1720                            },
1721                            RustTestCase {
1722                                name: TestCaseName::new("tests::baz::test_ignored"),
1723                                test_info: RustTestCaseSummary {
1724                                    kind: Some(RustTestKind::TEST),
1725                                    ignored: true,
1726                                    filter_match: FilterMatch::Mismatch { reason: MismatchReason::Ignored },
1727                                },
1728                            },
1729                            RustTestCase {
1730                                name: TestCaseName::new("benches::ignored_bench_foo"),
1731                                test_info: RustTestCaseSummary {
1732                                    kind: Some(RustTestKind::BENCH),
1733                                    ignored: true,
1734                                    filter_match: FilterMatch::Mismatch { reason: MismatchReason::Ignored },
1735                                },
1736                            },
1737                        }.into(),
1738                    },
1739                    cwd: fake_cwd.clone(),
1740                    build_platform: BuildPlatform::Target,
1741                    package: package_metadata(),
1742                    binary_name: fake_binary_name,
1743                    binary_id: fake_binary_id,
1744                    binary_path: "/fake/binary".into(),
1745                    kind: RustTestBinaryKind::LIB,
1746                    non_test_binaries: BTreeSet::new(),
1747                },
1748                RustTestSuite {
1749                    status: RustTestSuiteStatus::Skipped {
1750                        reason: BinaryMismatchReason::Expression,
1751                    },
1752                    cwd: fake_cwd,
1753                    build_platform: BuildPlatform::Host,
1754                    package: package_metadata(),
1755                    binary_name: skipped_binary_name,
1756                    binary_id: skipped_binary_id,
1757                    binary_path: "/fake/skipped-binary".into(),
1758                    kind: RustTestBinaryKind::PROC_MACRO,
1759                    non_test_binaries: BTreeSet::new(),
1760                },
1761            }
1762        );
1763
1764        // Check that the expected outputs are valid.
1765        static EXPECTED_HUMAN: &str = indoc! {"
1766        fake-package::fake-binary:
1767            benches::bench_foo
1768            tests::baz::test_quux
1769            tests::foo::test_bar
1770        "};
1771        static EXPECTED_HUMAN_VERBOSE: &str = indoc! {"
1772            fake-package::fake-binary:
1773              bin: /fake/binary
1774              cwd: /fake/cwd
1775              build platform: target
1776                benches::bench_foo
1777                benches::ignored_bench_foo (skipped)
1778                tests::baz::test_ignored (skipped)
1779                tests::baz::test_quux
1780                tests::foo::test_bar
1781                tests::ignored::test_bar (skipped)
1782            fake-package::skipped-binary:
1783              bin: /fake/skipped-binary
1784              cwd: /fake/cwd
1785              build platform: host
1786                (test binary didn't match filtersets, skipped)
1787        "};
1788        static EXPECTED_JSON_PRETTY: &str = indoc! {r#"
1789            {
1790              "rust-build-meta": {
1791                "target-directory": "/fake",
1792                "base-output-directories": [],
1793                "non-test-binaries": {},
1794                "build-script-out-dirs": {},
1795                "linked-paths": [],
1796                "platforms": {
1797                  "host": {
1798                    "platform": {
1799                      "triple": "x86_64-unknown-linux-gnu",
1800                      "target-features": "unknown"
1801                    },
1802                    "libdir": {
1803                      "status": "available",
1804                      "path": "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib"
1805                    }
1806                  },
1807                  "targets": [
1808                    {
1809                      "platform": {
1810                        "triple": "aarch64-unknown-linux-gnu",
1811                        "target-features": "unknown"
1812                      },
1813                      "libdir": {
1814                        "status": "unavailable",
1815                        "reason": "test"
1816                      }
1817                    }
1818                  ]
1819                },
1820                "target-platforms": [
1821                  {
1822                    "triple": "aarch64-unknown-linux-gnu",
1823                    "target-features": "unknown"
1824                  }
1825                ],
1826                "target-platform": "aarch64-unknown-linux-gnu"
1827              },
1828              "test-count": 6,
1829              "rust-suites": {
1830                "fake-package::fake-binary": {
1831                  "package-name": "metadata-helper",
1832                  "binary-id": "fake-package::fake-binary",
1833                  "binary-name": "fake-binary",
1834                  "package-id": "metadata-helper 0.1.0 (path+file:///Users/fakeuser/local/testcrates/metadata/metadata-helper)",
1835                  "kind": "lib",
1836                  "binary-path": "/fake/binary",
1837                  "build-platform": "target",
1838                  "cwd": "/fake/cwd",
1839                  "status": "listed",
1840                  "testcases": {
1841                    "benches::bench_foo": {
1842                      "kind": "bench",
1843                      "ignored": false,
1844                      "filter-match": {
1845                        "status": "matches"
1846                      }
1847                    },
1848                    "benches::ignored_bench_foo": {
1849                      "kind": "bench",
1850                      "ignored": true,
1851                      "filter-match": {
1852                        "status": "mismatch",
1853                        "reason": "ignored"
1854                      }
1855                    },
1856                    "tests::baz::test_ignored": {
1857                      "kind": "test",
1858                      "ignored": true,
1859                      "filter-match": {
1860                        "status": "mismatch",
1861                        "reason": "ignored"
1862                      }
1863                    },
1864                    "tests::baz::test_quux": {
1865                      "kind": "test",
1866                      "ignored": false,
1867                      "filter-match": {
1868                        "status": "matches"
1869                      }
1870                    },
1871                    "tests::foo::test_bar": {
1872                      "kind": "test",
1873                      "ignored": false,
1874                      "filter-match": {
1875                        "status": "matches"
1876                      }
1877                    },
1878                    "tests::ignored::test_bar": {
1879                      "kind": "test",
1880                      "ignored": true,
1881                      "filter-match": {
1882                        "status": "mismatch",
1883                        "reason": "ignored"
1884                      }
1885                    }
1886                  }
1887                },
1888                "fake-package::skipped-binary": {
1889                  "package-name": "metadata-helper",
1890                  "binary-id": "fake-package::skipped-binary",
1891                  "binary-name": "skipped-binary",
1892                  "package-id": "metadata-helper 0.1.0 (path+file:///Users/fakeuser/local/testcrates/metadata/metadata-helper)",
1893                  "kind": "proc-macro",
1894                  "binary-path": "/fake/skipped-binary",
1895                  "build-platform": "host",
1896                  "cwd": "/fake/cwd",
1897                  "status": "skipped",
1898                  "testcases": {}
1899                }
1900              }
1901            }"#};
1902        static EXPECTED_ONELINE: &str = indoc! {"
1903            fake-package::fake-binary benches::bench_foo
1904            fake-package::fake-binary tests::baz::test_quux
1905            fake-package::fake-binary tests::foo::test_bar
1906        "};
1907        static EXPECTED_ONELINE_VERBOSE: &str = indoc! {"
1908            fake-package::fake-binary benches::bench_foo [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target]
1909            fake-package::fake-binary benches::ignored_bench_foo [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target] (skipped)
1910            fake-package::fake-binary tests::baz::test_ignored [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target] (skipped)
1911            fake-package::fake-binary tests::baz::test_quux [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target]
1912            fake-package::fake-binary tests::foo::test_bar [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target]
1913            fake-package::fake-binary tests::ignored::test_bar [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target] (skipped)
1914        "};
1915
1916        assert_eq!(
1917            test_list
1918                .to_string(OutputFormat::Human { verbose: false })
1919                .expect("human succeeded"),
1920            EXPECTED_HUMAN
1921        );
1922        assert_eq!(
1923            test_list
1924                .to_string(OutputFormat::Human { verbose: true })
1925                .expect("human succeeded"),
1926            EXPECTED_HUMAN_VERBOSE
1927        );
1928        println!(
1929            "{}",
1930            test_list
1931                .to_string(OutputFormat::Serializable(SerializableFormat::JsonPretty))
1932                .expect("json-pretty succeeded")
1933        );
1934        assert_eq!(
1935            test_list
1936                .to_string(OutputFormat::Serializable(SerializableFormat::JsonPretty))
1937                .expect("json-pretty succeeded"),
1938            EXPECTED_JSON_PRETTY
1939        );
1940        assert_eq!(
1941            test_list
1942                .to_string(OutputFormat::Oneline { verbose: false })
1943                .expect("oneline succeeded"),
1944            EXPECTED_ONELINE
1945        );
1946        assert_eq!(
1947            test_list
1948                .to_string(OutputFormat::Oneline { verbose: true })
1949                .expect("oneline verbose succeeded"),
1950            EXPECTED_ONELINE_VERBOSE
1951        );
1952    }
1953
1954    #[test]
1955    fn apply_wrappers_examples() {
1956        cfg_if::cfg_if! {
1957            if #[cfg(windows)]
1958            {
1959                let workspace_root = Utf8Path::new("D:\\workspace\\root");
1960                let target_dir = Utf8Path::new("C:\\foo\\bar");
1961            } else {
1962                let workspace_root = Utf8Path::new("/workspace/root");
1963                let target_dir = Utf8Path::new("/foo/bar");
1964            }
1965        };
1966
1967        // Test with no wrappers
1968        {
1969            let mut cli_no_wrappers = TestCommandCli::default();
1970            cli_no_wrappers.apply_wrappers(None, None, workspace_root, target_dir);
1971            cli_no_wrappers.extend(["binary", "arg"]);
1972            assert_eq!(cli_no_wrappers.to_owned_cli(), vec!["binary", "arg"]);
1973        }
1974
1975        // Test with platform runner only
1976        {
1977            let runner = PlatformRunner::debug_new(
1978                "runner".into(),
1979                Vec::new(),
1980                PlatformRunnerSource::Env("fake".to_owned()),
1981            );
1982            let mut cli_runner_only = TestCommandCli::default();
1983            cli_runner_only.apply_wrappers(None, Some(&runner), workspace_root, target_dir);
1984            cli_runner_only.extend(["binary", "arg"]);
1985            assert_eq!(
1986                cli_runner_only.to_owned_cli(),
1987                vec!["runner", "binary", "arg"],
1988            );
1989        }
1990
1991        // Test wrapper with ignore target runner
1992        {
1993            let runner = PlatformRunner::debug_new(
1994                "runner".into(),
1995                Vec::new(),
1996                PlatformRunnerSource::Env("fake".to_owned()),
1997            );
1998            let wrapper_ignore = WrapperScriptConfig {
1999                command: ScriptCommand {
2000                    program: "wrapper".into(),
2001                    args: Vec::new(),
2002                    relative_to: ScriptCommandRelativeTo::None,
2003                },
2004                target_runner: WrapperScriptTargetRunner::Ignore,
2005            };
2006            let mut cli_wrapper_ignore = TestCommandCli::default();
2007            cli_wrapper_ignore.apply_wrappers(
2008                Some(&wrapper_ignore),
2009                Some(&runner),
2010                workspace_root,
2011                target_dir,
2012            );
2013            cli_wrapper_ignore.extend(["binary", "arg"]);
2014            assert_eq!(
2015                cli_wrapper_ignore.to_owned_cli(),
2016                vec!["wrapper", "binary", "arg"],
2017            );
2018        }
2019
2020        // Test wrapper with around wrapper (runner first)
2021        {
2022            let runner = PlatformRunner::debug_new(
2023                "runner".into(),
2024                Vec::new(),
2025                PlatformRunnerSource::Env("fake".to_owned()),
2026            );
2027            let wrapper_around = WrapperScriptConfig {
2028                command: ScriptCommand {
2029                    program: "wrapper".into(),
2030                    args: Vec::new(),
2031                    relative_to: ScriptCommandRelativeTo::None,
2032                },
2033                target_runner: WrapperScriptTargetRunner::AroundWrapper,
2034            };
2035            let mut cli_wrapper_around = TestCommandCli::default();
2036            cli_wrapper_around.apply_wrappers(
2037                Some(&wrapper_around),
2038                Some(&runner),
2039                workspace_root,
2040                target_dir,
2041            );
2042            cli_wrapper_around.extend(["binary", "arg"]);
2043            assert_eq!(
2044                cli_wrapper_around.to_owned_cli(),
2045                vec!["runner", "wrapper", "binary", "arg"],
2046            );
2047        }
2048
2049        // Test wrapper with within wrapper (wrapper first)
2050        {
2051            let runner = PlatformRunner::debug_new(
2052                "runner".into(),
2053                Vec::new(),
2054                PlatformRunnerSource::Env("fake".to_owned()),
2055            );
2056            let wrapper_within = WrapperScriptConfig {
2057                command: ScriptCommand {
2058                    program: "wrapper".into(),
2059                    args: Vec::new(),
2060                    relative_to: ScriptCommandRelativeTo::None,
2061                },
2062                target_runner: WrapperScriptTargetRunner::WithinWrapper,
2063            };
2064            let mut cli_wrapper_within = TestCommandCli::default();
2065            cli_wrapper_within.apply_wrappers(
2066                Some(&wrapper_within),
2067                Some(&runner),
2068                workspace_root,
2069                target_dir,
2070            );
2071            cli_wrapper_within.extend(["binary", "arg"]);
2072            assert_eq!(
2073                cli_wrapper_within.to_owned_cli(),
2074                vec!["wrapper", "runner", "binary", "arg"],
2075            );
2076        }
2077
2078        // Test wrapper with overrides wrapper (runner only)
2079        {
2080            let runner = PlatformRunner::debug_new(
2081                "runner".into(),
2082                Vec::new(),
2083                PlatformRunnerSource::Env("fake".to_owned()),
2084            );
2085            let wrapper_overrides = WrapperScriptConfig {
2086                command: ScriptCommand {
2087                    program: "wrapper".into(),
2088                    args: Vec::new(),
2089                    relative_to: ScriptCommandRelativeTo::None,
2090                },
2091                target_runner: WrapperScriptTargetRunner::OverridesWrapper,
2092            };
2093            let mut cli_wrapper_overrides = TestCommandCli::default();
2094            cli_wrapper_overrides.apply_wrappers(
2095                Some(&wrapper_overrides),
2096                Some(&runner),
2097                workspace_root,
2098                target_dir,
2099            );
2100            cli_wrapper_overrides.extend(["binary", "arg"]);
2101            assert_eq!(
2102                cli_wrapper_overrides.to_owned_cli(),
2103                vec!["runner", "binary", "arg"],
2104            );
2105        }
2106
2107        // Test wrapper with args
2108        {
2109            let wrapper_with_args = WrapperScriptConfig {
2110                command: ScriptCommand {
2111                    program: "wrapper".into(),
2112                    args: vec!["--flag".to_string(), "value".to_string()],
2113                    relative_to: ScriptCommandRelativeTo::None,
2114                },
2115                target_runner: WrapperScriptTargetRunner::Ignore,
2116            };
2117            let mut cli_wrapper_args = TestCommandCli::default();
2118            cli_wrapper_args.apply_wrappers(
2119                Some(&wrapper_with_args),
2120                None,
2121                workspace_root,
2122                target_dir,
2123            );
2124            cli_wrapper_args.extend(["binary", "arg"]);
2125            assert_eq!(
2126                cli_wrapper_args.to_owned_cli(),
2127                vec!["wrapper", "--flag", "value", "binary", "arg"],
2128            );
2129        }
2130
2131        // Test platform runner with args
2132        {
2133            let runner_with_args = PlatformRunner::debug_new(
2134                "runner".into(),
2135                vec!["--runner-flag".into(), "value".into()],
2136                PlatformRunnerSource::Env("fake".to_owned()),
2137            );
2138            let mut cli_runner_args = TestCommandCli::default();
2139            cli_runner_args.apply_wrappers(
2140                None,
2141                Some(&runner_with_args),
2142                workspace_root,
2143                target_dir,
2144            );
2145            cli_runner_args.extend(["binary", "arg"]);
2146            assert_eq!(
2147                cli_runner_args.to_owned_cli(),
2148                vec!["runner", "--runner-flag", "value", "binary", "arg"],
2149            );
2150        }
2151
2152        // Test wrapper with ScriptCommandRelativeTo::WorkspaceRoot
2153        {
2154            let wrapper_relative_to_workspace_root = WrapperScriptConfig {
2155                command: ScriptCommand {
2156                    program: "abc/def/my-wrapper".into(),
2157                    args: vec!["--verbose".to_string()],
2158                    relative_to: ScriptCommandRelativeTo::WorkspaceRoot,
2159                },
2160                target_runner: WrapperScriptTargetRunner::Ignore,
2161            };
2162            let mut cli_wrapper_relative = TestCommandCli::default();
2163            cli_wrapper_relative.apply_wrappers(
2164                Some(&wrapper_relative_to_workspace_root),
2165                None,
2166                workspace_root,
2167                target_dir,
2168            );
2169            cli_wrapper_relative.extend(["binary", "arg"]);
2170
2171            cfg_if::cfg_if! {
2172                if #[cfg(windows)] {
2173                    let wrapper_path = "D:\\workspace\\root\\abc\\def\\my-wrapper";
2174                } else {
2175                    let wrapper_path = "/workspace/root/abc/def/my-wrapper";
2176                }
2177            }
2178            assert_eq!(
2179                cli_wrapper_relative.to_owned_cli(),
2180                vec![wrapper_path, "--verbose", "binary", "arg"],
2181            );
2182        }
2183
2184        // Test wrapper with ScriptCommandRelativeTo::Target
2185        {
2186            let wrapper_relative_to_target = WrapperScriptConfig {
2187                command: ScriptCommand {
2188                    program: "abc/def/my-wrapper".into(),
2189                    args: vec!["--verbose".to_string()],
2190                    relative_to: ScriptCommandRelativeTo::Target,
2191                },
2192                target_runner: WrapperScriptTargetRunner::Ignore,
2193            };
2194            let mut cli_wrapper_relative = TestCommandCli::default();
2195            cli_wrapper_relative.apply_wrappers(
2196                Some(&wrapper_relative_to_target),
2197                None,
2198                workspace_root,
2199                target_dir,
2200            );
2201            cli_wrapper_relative.extend(["binary", "arg"]);
2202            cfg_if::cfg_if! {
2203                if #[cfg(windows)] {
2204                    let wrapper_path = "C:\\foo\\bar\\abc\\def\\my-wrapper";
2205                } else {
2206                    let wrapper_path = "/foo/bar/abc/def/my-wrapper";
2207                }
2208            }
2209            assert_eq!(
2210                cli_wrapper_relative.to_owned_cli(),
2211                vec![wrapper_path, "--verbose", "binary", "arg"],
2212            );
2213        }
2214    }
2215
2216    static PACKAGE_GRAPH_FIXTURE: LazyLock<PackageGraph> = LazyLock::new(|| {
2217        static FIXTURE_JSON: &str = include_str!("../../../fixtures/cargo-metadata.json");
2218        let metadata = CargoMetadata::parse_json(FIXTURE_JSON).expect("fixture is valid JSON");
2219        metadata
2220            .build_graph()
2221            .expect("fixture is valid PackageGraph")
2222    });
2223
2224    static PACKAGE_METADATA_ID: &str = "metadata-helper 0.1.0 (path+file:///Users/fakeuser/local/testcrates/metadata/metadata-helper)";
2225    fn package_metadata() -> PackageMetadata<'static> {
2226        PACKAGE_GRAPH_FIXTURE
2227            .metadata(&PackageId::new(PACKAGE_METADATA_ID))
2228            .expect("package ID is valid")
2229    }
2230
2231    #[test]
2232    fn test_parse_list_lines() {
2233        let binary_id = RustBinaryId::new("test-package::test-binary");
2234
2235        // Valid: tests only.
2236        let input = indoc! {"
2237            simple_test: test
2238            module::nested_test: test
2239            deeply::nested::module::test_name: test
2240        "};
2241        let results: Vec<_> = parse_list_lines(&binary_id, input)
2242            .collect::<Result<_, _>>()
2243            .expect("parsed valid test output");
2244        insta::assert_debug_snapshot!("valid_tests", results);
2245
2246        // Valid: benchmarks only.
2247        let input = indoc! {"
2248            simple_bench: benchmark
2249            benches::module::my_benchmark: benchmark
2250        "};
2251        let results: Vec<_> = parse_list_lines(&binary_id, input)
2252            .collect::<Result<_, _>>()
2253            .expect("parsed valid benchmark output");
2254        insta::assert_debug_snapshot!("valid_benchmarks", results);
2255
2256        // Valid: mixed tests and benchmarks.
2257        let input = indoc! {"
2258            test_one: test
2259            bench_one: benchmark
2260            test_two: test
2261            bench_two: benchmark
2262        "};
2263        let results: Vec<_> = parse_list_lines(&binary_id, input)
2264            .collect::<Result<_, _>>()
2265            .expect("parsed mixed output");
2266        insta::assert_debug_snapshot!("mixed_tests_and_benchmarks", results);
2267
2268        // Valid: special characters.
2269        let input = indoc! {r#"
2270            test_with_underscore_123: test
2271            test::with::colons: test
2272            test_with_numbers_42: test
2273        "#};
2274        let results: Vec<_> = parse_list_lines(&binary_id, input)
2275            .collect::<Result<_, _>>()
2276            .expect("parsed tests with special characters");
2277        insta::assert_debug_snapshot!("special_characters", results);
2278
2279        // Valid: empty input.
2280        let input = "";
2281        let results: Vec<_> = parse_list_lines(&binary_id, input)
2282            .collect::<Result<_, _>>()
2283            .expect("parsed empty output");
2284        insta::assert_debug_snapshot!("empty_input", results);
2285
2286        // Invalid: wrong suffix.
2287        let input = "invalid_test: wrong_suffix";
2288        let result = parse_list_lines(&binary_id, input).collect::<Result<Vec<_>, _>>();
2289        assert!(result.is_err());
2290        insta::assert_snapshot!("invalid_suffix_error", result.unwrap_err());
2291
2292        // Invalid: missing suffix.
2293        let input = "test_without_suffix";
2294        let result = parse_list_lines(&binary_id, input).collect::<Result<Vec<_>, _>>();
2295        assert!(result.is_err());
2296        insta::assert_snapshot!("missing_suffix_error", result.unwrap_err());
2297
2298        // Invalid: partial valid (stops at first error).
2299        let input = indoc! {"
2300            valid_test: test
2301            invalid_line
2302            another_valid: benchmark
2303        "};
2304        let result = parse_list_lines(&binary_id, input).collect::<Result<Vec<_>, _>>();
2305        assert!(result.is_err());
2306        insta::assert_snapshot!("partial_valid_error", result.unwrap_err());
2307
2308        // Invalid: control character.
2309        let input = indoc! {"
2310            valid_test: test
2311            \rinvalid_line
2312            another_valid: benchmark
2313        "};
2314        let result = parse_list_lines(&binary_id, input).collect::<Result<Vec<_>, _>>();
2315        assert!(result.is_err());
2316        insta::assert_snapshot!("control_character_error", result.unwrap_err());
2317    }
2318}