Skip to main content

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, group_membership::PrecomputedGroupMembership},
10        scripts::{ScriptCommandEnvMap, WrapperScriptConfig, WrapperScriptTargetRunner},
11    },
12    double_spawn::DoubleSpawnInfo,
13    errors::{
14        CreateTestListError, FromMessagesError, TestListFromSummaryError, WriteTestListError,
15    },
16    helpers::{convert_build_platform, dylib_path, dylib_path_envvar, write_test_name},
17    indenter::indented,
18    list::{BinaryList, OutputFormat, RustBuildMeta, Styles, TestListState},
19    partition::{Partitioner, PartitionerBuilder, PartitionerScope},
20    reuse_build::PathMapper,
21    run_mode::NextestRunMode,
22    runner::{Interceptor, VersionEnvVars},
23    target_runner::{PlatformRunner, TargetRunner},
24    test_command::{LocalExecuteContext, TestCommand, TestCommandPhase},
25    test_filter::{BinaryMismatchReason, FilterBinaryMatch, FilterBound, TestFilter},
26    write_str::WriteStr,
27};
28use camino::{Utf8Path, Utf8PathBuf};
29use debug_ignore::DebugIgnore;
30use futures::prelude::*;
31use guppy::{
32    PackageId,
33    graph::{PackageGraph, PackageMetadata},
34};
35use iddqd::{IdOrdItem, IdOrdMap, id_upcast};
36use nextest_filtering::{BinaryQuery, EvalContext, GroupLookup, TestQuery};
37use nextest_metadata::{
38    BuildPlatform, FilterMatch, MismatchReason, RustBinaryId, RustNonTestBinaryKind,
39    RustTestBinaryKind, RustTestBinarySummary, RustTestCaseSummary, RustTestKind,
40    RustTestSuiteStatusSummary, RustTestSuiteSummary, TestCaseName, TestListSummary,
41};
42use owo_colors::OwoColorize;
43use quick_junit::ReportUuid;
44use serde::{Deserialize, Serialize};
45use std::{
46    borrow::{Borrow, Cow},
47    collections::{BTreeMap, BTreeSet},
48    ffi::{OsStr, OsString},
49    fmt,
50    hash::{Hash, Hasher},
51    io,
52    path::PathBuf,
53    sync::{Arc, OnceLock},
54};
55use swrite::{SWrite, swrite};
56use tokio::runtime::Runtime;
57use tracing::debug;
58
59/// A Rust test binary built by Cargo. This artifact hasn't been run yet so there's no information
60/// about the tests within it.
61///
62/// Accepted as input to [`TestList::new`].
63#[derive(Clone, Debug)]
64pub struct RustTestArtifact<'g> {
65    /// A unique identifier for this test artifact.
66    pub binary_id: RustBinaryId,
67
68    /// Metadata for the package this artifact is a part of. This is used to set the correct
69    /// environment variables.
70    pub package: PackageMetadata<'g>,
71
72    /// The path to the binary artifact.
73    pub binary_path: Utf8PathBuf,
74
75    /// The unique binary name defined in `Cargo.toml` or inferred by the filename.
76    pub binary_name: String,
77
78    /// The kind of Rust test binary this is.
79    pub kind: RustTestBinaryKind,
80
81    /// Non-test binaries to be exposed to this artifact at runtime (name, path).
82    pub non_test_binaries: BTreeSet<(String, Utf8PathBuf)>,
83
84    /// The working directory that this test should be executed in.
85    pub cwd: Utf8PathBuf,
86
87    /// The platform for which this test artifact was built.
88    pub build_platform: BuildPlatform,
89}
90
91impl<'g> RustTestArtifact<'g> {
92    /// Constructs a list of test binaries from the list of built binaries.
93    pub fn from_binary_list(
94        graph: &'g PackageGraph,
95        binary_list: Arc<BinaryList>,
96        rust_build_meta: &RustBuildMeta<TestListState>,
97        path_mapper: &PathMapper,
98        platform_filter: Option<BuildPlatform>,
99    ) -> Result<Vec<Self>, FromMessagesError> {
100        let mut binaries = vec![];
101
102        for binary in &binary_list.rust_binaries {
103            if platform_filter.is_some() && platform_filter != Some(binary.build_platform) {
104                continue;
105            }
106
107            // Look up the executable by package ID.
108            let package_id = PackageId::new(binary.package_id.clone());
109            let package = graph
110                .metadata(&package_id)
111                .map_err(FromMessagesError::PackageGraph)?;
112
113            // Tests are run in the directory containing Cargo.toml
114            let cwd = package
115                .manifest_path()
116                .parent()
117                .unwrap_or_else(|| {
118                    panic!(
119                        "manifest path {} doesn't have a parent",
120                        package.manifest_path()
121                    )
122                })
123                .to_path_buf();
124
125            // Test binaries live under the build directory (never uplifted).
126            let binary_path = path_mapper.map_build_path(binary.path.clone());
127            let cwd = path_mapper.map_cwd(cwd);
128
129            // Non-test binaries are only exposed to integration tests and benchmarks.
130            let non_test_binaries = if binary.kind == RustTestBinaryKind::TEST
131                || binary.kind == RustTestBinaryKind::BENCH
132            {
133                // Note we must use the TestListState rust_build_meta here to ensure we get remapped
134                // paths.
135                match rust_build_meta.non_test_binaries.get(package_id.repr()) {
136                    Some(binaries) => binaries
137                        .iter()
138                        .filter(|binary| {
139                            // Only expose BIN_EXE non-test files.
140                            binary.kind == RustNonTestBinaryKind::BIN_EXE
141                        })
142                        .map(|binary| {
143                            // Convert relative paths to absolute ones by joining with the target directory.
144                            let abs_path = rust_build_meta.target_directory.join(&binary.path);
145                            (binary.name.clone(), abs_path)
146                        })
147                        .collect(),
148                    None => BTreeSet::new(),
149                }
150            } else {
151                BTreeSet::new()
152            };
153
154            binaries.push(RustTestArtifact {
155                binary_id: binary.id.clone(),
156                package,
157                binary_path,
158                binary_name: binary.name.clone(),
159                kind: binary.kind.clone(),
160                cwd,
161                non_test_binaries,
162                build_platform: binary.build_platform,
163            })
164        }
165
166        Ok(binaries)
167    }
168
169    /// Returns a [`BinaryQuery`] corresponding to this test artifact.
170    pub fn to_binary_query(&self) -> BinaryQuery<'_> {
171        BinaryQuery {
172            package_id: self.package.id(),
173            binary_id: &self.binary_id,
174            kind: &self.kind,
175            binary_name: &self.binary_name,
176            platform: convert_build_platform(self.build_platform),
177        }
178    }
179
180    // ---
181    // Helper methods
182    // ---
183    fn into_test_suite(self, status: RustTestSuiteStatus) -> RustTestSuite<'g> {
184        let Self {
185            binary_id,
186            package,
187            binary_path,
188            binary_name,
189            kind,
190            non_test_binaries,
191            cwd,
192            build_platform,
193        } = self;
194
195        RustTestSuite {
196            binary_id,
197            binary_path,
198            package,
199            binary_name,
200            kind,
201            non_test_binaries,
202            cwd,
203            build_platform,
204            status,
205        }
206    }
207}
208
209/// Information about skipped tests and binaries.
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct SkipCounts {
212    /// The number of skipped tests.
213    pub skipped_tests: usize,
214
215    /// The number of skipped tests due to this being a rerun and the test was
216    /// already passing.
217    pub skipped_tests_rerun: usize,
218
219    /// The number of tests skipped because they are not benchmarks.
220    ///
221    /// This is used when running in benchmark mode.
222    pub skipped_tests_non_benchmark: usize,
223
224    /// The number of tests skipped due to not being in the default set.
225    pub skipped_tests_default_filter: usize,
226
227    /// The number of skipped binaries.
228    pub skipped_binaries: usize,
229
230    /// The number of binaries skipped due to not being in the default set.
231    pub skipped_binaries_default_filter: usize,
232}
233
234/// List of test instances, obtained by querying the [`RustTestArtifact`] instances generated by Cargo.
235#[derive(Clone, Debug)]
236pub struct TestList<'g> {
237    test_count: usize,
238    mode: NextestRunMode,
239    rust_build_meta: RustBuildMeta<TestListState>,
240    rust_suites: IdOrdMap<RustTestSuite<'g>>,
241    workspace_root: Utf8PathBuf,
242    env: EnvironmentMap,
243    updated_dylib_path: OsString,
244    // Computed on first access.
245    skip_counts: OnceLock<SkipCounts>,
246}
247
248impl<'g> TestList<'g> {
249    /// Creates a new test list by running the given command and applying the specified filter.
250    #[expect(clippy::too_many_arguments)]
251    pub fn new<I>(
252        ctx: &TestExecuteContext<'_>,
253        test_artifacts: I,
254        rust_build_meta: RustBuildMeta<TestListState>,
255        filter: &TestFilter,
256        partitioner_builder: Option<&PartitionerBuilder>,
257        workspace_root: Utf8PathBuf,
258        env: EnvironmentMap,
259        profile: &impl ListProfile,
260        bound: FilterBound,
261        list_threads: usize,
262    ) -> Result<Self, CreateTestListError>
263    where
264        I: IntoIterator<Item = RustTestArtifact<'g>>,
265        I::IntoIter: Send,
266    {
267        let updated_dylib_path = Self::create_dylib_path(&rust_build_meta)?;
268        debug!(
269            "updated {}: {}",
270            dylib_path_envvar(),
271            updated_dylib_path.to_string_lossy(),
272        );
273        let lctx = LocalExecuteContext {
274            phase: TestCommandPhase::List,
275            run_id: ctx.run_id,
276            version_env_vars: ctx.version_env_vars,
277            // Note: this is the remapped workspace root, not the original one.
278            // (We really should have newtypes for this.)
279            workspace_root: &workspace_root,
280            rust_build_meta: &rust_build_meta,
281            double_spawn: ctx.double_spawn,
282            dylib_path: &updated_dylib_path,
283            profile_name: ctx.profile_name,
284            env: &env,
285        };
286
287        let ecx = profile.filterset_ecx();
288
289        let runtime = Runtime::new().map_err(CreateTestListError::TokioRuntimeCreate)?;
290
291        // Phase 1: run test binaries and parse their output. Binary-level
292        // filtering decides which binaries to execute; test-level filtering is
293        // deferred to a separate sequential phase below.
294        let stream = futures::stream::iter(test_artifacts).map(|test_binary| {
295            async {
296                let binary_query = test_binary.to_binary_query();
297                let binary_match = filter.filter_binary_match(&binary_query, &ecx, bound);
298                match binary_match {
299                    FilterBinaryMatch::Definite | FilterBinaryMatch::Possible => {
300                        debug!(
301                            "executing test binary to obtain test list \
302                            (match result is {binary_match:?}): {}",
303                            test_binary.binary_id,
304                        );
305                        // Run the binary to obtain the test list.
306                        let list_settings = profile.list_settings_for(&binary_query);
307                        let (non_ignored, ignored) = test_binary
308                            .exec(&lctx, &list_settings, ctx.target_runner)
309                            .await?;
310                        let parsed = Self::parse_output(
311                            test_binary,
312                            non_ignored.as_str(),
313                            ignored.as_str(),
314                        )?;
315                        Ok::<_, CreateTestListError>(parsed)
316                    }
317                    FilterBinaryMatch::Mismatch { reason } => {
318                        debug!("skipping test binary: {reason}: {}", test_binary.binary_id,);
319                        Ok(Self::make_skipped(test_binary, reason))
320                    }
321                }
322            }
323        });
324        let fut = stream
325            .buffer_unordered(list_threads)
326            .try_collect::<Vec<_>>();
327
328        let parsed_binaries: Vec<ParsedTestBinary<'g>> = runtime.block_on(fut)?;
329
330        // Ensure that the runtime doesn't stay hanging even if a custom test framework misbehaves
331        // (can be an issue on Windows).
332        runtime.shutdown_background();
333
334        // Phase 2: apply test-level filters and build suites.
335        //
336        // If the CLI filter uses group() predicates, precompute group
337        // memberships first so that group() evaluates correctly in a
338        // single pass (no re-evaluation needed).
339        let group_membership = if filter.has_group_predicates() {
340            let test_queries = Self::collect_test_queries_from_parsed(&parsed_binaries);
341            Some(profile.precompute_group_memberships(test_queries.into_iter()))
342        } else {
343            None
344        };
345        let groups = group_membership.as_ref().map(|g| g as &dyn GroupLookup);
346
347        let mut rust_suites = Self::build_suites(parsed_binaries, filter, &ecx, bound, groups);
348        Self::apply_partitioning(&mut rust_suites, partitioner_builder);
349
350        let test_count = rust_suites
351            .iter()
352            .map(|suite| suite.status.test_count())
353            .sum();
354
355        Ok(Self {
356            rust_suites,
357            mode: filter.mode(),
358            workspace_root,
359            env,
360            rust_build_meta,
361            updated_dylib_path,
362            test_count,
363            skip_counts: OnceLock::new(),
364        })
365    }
366
367    /// Creates a new test list with the given binary names and outputs.
368    #[cfg(test)]
369    #[expect(clippy::too_many_arguments)]
370    pub(crate) fn new_with_outputs(
371        test_bin_outputs: impl IntoIterator<
372            Item = (RustTestArtifact<'g>, impl AsRef<str>, impl AsRef<str>),
373        >,
374        workspace_root: Utf8PathBuf,
375        rust_build_meta: RustBuildMeta<TestListState>,
376        filter: &TestFilter,
377        partitioner_builder: Option<&PartitionerBuilder>,
378        env: EnvironmentMap,
379        ecx: &EvalContext<'_>,
380        bound: FilterBound,
381    ) -> Result<Self, CreateTestListError> {
382        let updated_dylib_path = Self::create_dylib_path(&rust_build_meta)?;
383
384        let parsed_binaries = test_bin_outputs
385            .into_iter()
386            .map(|(test_binary, non_ignored, ignored)| {
387                let binary_query = test_binary.to_binary_query();
388                let binary_match = filter.filter_binary_match(&binary_query, ecx, bound);
389                match binary_match {
390                    FilterBinaryMatch::Definite | FilterBinaryMatch::Possible => {
391                        debug!(
392                            "processing output for binary \
393                            (match result is {binary_match:?}): {}",
394                            test_binary.binary_id,
395                        );
396                        Self::parse_output(test_binary, non_ignored.as_ref(), ignored.as_ref())
397                    }
398                    FilterBinaryMatch::Mismatch { reason } => {
399                        debug!("skipping test binary: {reason}: {}", test_binary.binary_id,);
400                        Ok(Self::make_skipped(test_binary, reason))
401                    }
402                }
403            })
404            .collect::<Result<Vec<_>, _>>()?;
405
406        let mut rust_suites = Self::build_suites(parsed_binaries, filter, ecx, bound, None);
407
408        Self::apply_partitioning(&mut rust_suites, partitioner_builder);
409
410        let test_count = rust_suites
411            .iter()
412            .map(|suite| suite.status.test_count())
413            .sum();
414
415        Ok(Self {
416            rust_suites,
417            mode: filter.mode(),
418            workspace_root,
419            env,
420            rust_build_meta,
421            updated_dylib_path,
422            test_count,
423            skip_counts: OnceLock::new(),
424        })
425    }
426
427    /// Reconstructs a TestList from archived summary data.
428    ///
429    /// This is used during replay to reconstruct a TestList without
430    /// executing test binaries. The reconstructed TestList provides the
431    /// data needed for display through the reporter infrastructure.
432    pub fn from_summary(
433        graph: &'g PackageGraph,
434        summary: &TestListSummary,
435        mode: NextestRunMode,
436    ) -> Result<Self, TestListFromSummaryError> {
437        // Build RustBuildMeta from summary.
438        let rust_build_meta = RustBuildMeta::from_summary(summary.rust_build_meta.clone())
439            .map_err(TestListFromSummaryError::RustBuildMeta)?;
440
441        // Get the workspace root from the graph.
442        let workspace_root = graph.workspace().root().to_path_buf();
443
444        // Construct an empty environment map - we don't need it for replay.
445        let env = EnvironmentMap::empty();
446
447        // For replay, we don't need the actual dylib path since we're not executing tests.
448        let updated_dylib_path = OsString::new();
449
450        // Build test suites from the summary.
451        let mut rust_suites = IdOrdMap::new();
452        let mut test_count = 0;
453
454        for (binary_id, suite_summary) in &summary.rust_suites {
455            // Look up the package in the graph by package_id.
456            let package_id = PackageId::new(suite_summary.binary.package_id.clone());
457            let package = graph.metadata(&package_id).map_err(|_| {
458                TestListFromSummaryError::PackageNotFound {
459                    name: suite_summary.package_name.clone(),
460                    package_id: suite_summary.binary.package_id.clone(),
461                }
462            })?;
463
464            // Determine the status based on the summary.
465            let status = if suite_summary.status == RustTestSuiteStatusSummary::SKIPPED {
466                RustTestSuiteStatus::Skipped {
467                    reason: BinaryMismatchReason::Expression,
468                }
469            } else if suite_summary.status == RustTestSuiteStatusSummary::SKIPPED_DEFAULT_FILTER {
470                RustTestSuiteStatus::Skipped {
471                    reason: BinaryMismatchReason::DefaultSet,
472                }
473            } else {
474                // Build test cases from the summary (only for listed suites).
475                let test_cases: IdOrdMap<RustTestCase> = suite_summary
476                    .test_cases
477                    .iter()
478                    .map(|(name, info)| RustTestCase {
479                        name: name.clone(),
480                        test_info: info.clone(),
481                    })
482                    .collect();
483
484                test_count += test_cases.len();
485
486                // LISTED or any other status should be treated as listed.
487                RustTestSuiteStatus::Listed {
488                    test_cases: DebugIgnore(test_cases),
489                }
490            };
491
492            let suite = RustTestSuite {
493                binary_id: binary_id.clone(),
494                binary_path: suite_summary.binary.binary_path.clone(),
495                package,
496                binary_name: suite_summary.binary.binary_name.clone(),
497                kind: suite_summary.binary.kind.clone(),
498                non_test_binaries: BTreeSet::new(), // Not stored in summary.
499                cwd: suite_summary.cwd.clone(),
500                build_platform: suite_summary.binary.build_platform,
501                status,
502            };
503
504            let _ = rust_suites.insert_unique(suite);
505        }
506
507        Ok(Self {
508            rust_suites,
509            mode,
510            workspace_root,
511            env,
512            rust_build_meta,
513            updated_dylib_path,
514            test_count,
515            skip_counts: OnceLock::new(),
516        })
517    }
518
519    /// Returns the total number of tests across all binaries.
520    pub fn test_count(&self) -> usize {
521        self.test_count
522    }
523
524    /// Returns the mode nextest is running in.
525    pub fn mode(&self) -> NextestRunMode {
526        self.mode
527    }
528
529    /// Returns the Rust build-related metadata for this test list.
530    pub fn rust_build_meta(&self) -> &RustBuildMeta<TestListState> {
531        &self.rust_build_meta
532    }
533
534    /// Returns the total number of skipped tests.
535    pub fn skip_counts(&self) -> &SkipCounts {
536        self.skip_counts.get_or_init(|| {
537            let mut skipped_tests_rerun = 0;
538            let mut skipped_tests_non_benchmark = 0;
539            let mut skipped_tests_default_filter = 0;
540            let skipped_tests = self
541                .iter_tests()
542                .filter(|instance| match instance.test_info.filter_match {
543                    FilterMatch::Mismatch {
544                        reason: MismatchReason::RerunAlreadyPassed,
545                    } => {
546                        skipped_tests_rerun += 1;
547                        true
548                    }
549                    FilterMatch::Mismatch {
550                        reason: MismatchReason::NotBenchmark,
551                    } => {
552                        skipped_tests_non_benchmark += 1;
553                        true
554                    }
555                    FilterMatch::Mismatch {
556                        reason: MismatchReason::DefaultFilter,
557                    } => {
558                        skipped_tests_default_filter += 1;
559                        true
560                    }
561                    FilterMatch::Mismatch { .. } => true,
562                    FilterMatch::Matches => false,
563                })
564                .count();
565
566            let mut skipped_binaries_default_filter = 0;
567            let skipped_binaries = self
568                .rust_suites
569                .iter()
570                .filter(|suite| match suite.status {
571                    RustTestSuiteStatus::Skipped {
572                        reason: BinaryMismatchReason::DefaultSet,
573                    } => {
574                        skipped_binaries_default_filter += 1;
575                        true
576                    }
577                    RustTestSuiteStatus::Skipped { .. } => true,
578                    RustTestSuiteStatus::Listed { .. } => false,
579                })
580                .count();
581
582            SkipCounts {
583                skipped_tests,
584                skipped_tests_rerun,
585                skipped_tests_non_benchmark,
586                skipped_tests_default_filter,
587                skipped_binaries,
588                skipped_binaries_default_filter,
589            }
590        })
591    }
592
593    /// Returns the total number of tests that aren't skipped.
594    ///
595    /// It is always the case that `run_count + skip_count == test_count`.
596    pub fn run_count(&self) -> usize {
597        self.test_count - self.skip_counts().skipped_tests
598    }
599
600    /// Returns the total number of binaries that contain tests.
601    pub fn binary_count(&self) -> usize {
602        self.rust_suites.len()
603    }
604
605    /// Returns the total number of binaries that were listed (not skipped).
606    pub fn listed_binary_count(&self) -> usize {
607        self.binary_count() - self.skip_counts().skipped_binaries
608    }
609
610    /// Returns the mapped workspace root.
611    pub fn workspace_root(&self) -> &Utf8Path {
612        &self.workspace_root
613    }
614
615    /// Returns the environment variables to be used when running tests.
616    pub fn cargo_env(&self) -> &EnvironmentMap {
617        &self.env
618    }
619
620    /// Returns the updated dynamic library path used for tests.
621    pub fn updated_dylib_path(&self) -> &OsStr {
622        &self.updated_dylib_path
623    }
624
625    /// Constructs a serializble summary for this test list.
626    pub fn to_summary(&self) -> TestListSummary {
627        let rust_suites = self
628            .rust_suites
629            .iter()
630            .map(|test_suite| {
631                let (status, test_cases) = test_suite.status.to_summary();
632                let testsuite = RustTestSuiteSummary {
633                    package_name: test_suite.package.name().to_owned(),
634                    binary: RustTestBinarySummary {
635                        binary_name: test_suite.binary_name.clone(),
636                        package_id: test_suite.package.id().repr().to_owned(),
637                        kind: test_suite.kind.clone(),
638                        binary_path: test_suite.binary_path.clone(),
639                        binary_id: test_suite.binary_id.clone(),
640                        build_platform: test_suite.build_platform,
641                    },
642                    cwd: test_suite.cwd.clone(),
643                    status,
644                    test_cases,
645                };
646                (test_suite.binary_id.clone(), testsuite)
647            })
648            .collect();
649        let mut summary = TestListSummary::new(self.rust_build_meta.to_summary());
650        summary.test_count = self.test_count;
651        summary.rust_suites = rust_suites;
652        summary
653    }
654
655    /// Outputs this list to the given writer.
656    pub fn write(
657        &self,
658        output_format: OutputFormat,
659        writer: &mut dyn WriteStr,
660        colorize: bool,
661    ) -> Result<(), WriteTestListError> {
662        match output_format {
663            OutputFormat::Human { verbose } => self
664                .write_human(writer, verbose, colorize)
665                .map_err(WriteTestListError::Io),
666            OutputFormat::Oneline { verbose } => self
667                .write_oneline(writer, verbose, colorize)
668                .map_err(WriteTestListError::Io),
669            OutputFormat::Serializable(format) => format.to_writer(&self.to_summary(), writer),
670        }
671    }
672
673    /// Iterates over all the test suites.
674    pub fn iter(&self) -> impl Iterator<Item = &RustTestSuite<'_>> + '_ {
675        self.rust_suites.iter()
676    }
677
678    /// Looks up a test suite by binary ID.
679    pub fn get_suite(&self, binary_id: &RustBinaryId) -> Option<&RustTestSuite<'_>> {
680        self.rust_suites.get(binary_id)
681    }
682
683    /// Iterates over the list of tests, returning the path and test name.
684    pub fn iter_tests(&self) -> impl Iterator<Item = TestInstance<'_>> + '_ {
685        self.rust_suites.iter().flat_map(|test_suite| {
686            test_suite
687                .status
688                .test_cases()
689                .map(move |case| TestInstance::new(case, test_suite))
690        })
691    }
692
693    /// Produces a priority queue of tests based on the given profile.
694    pub fn to_priority_queue(
695        &'g self,
696        profile: &'g EvaluatableProfile<'g>,
697    ) -> TestPriorityQueue<'g> {
698        TestPriorityQueue::new(self, profile)
699    }
700
701    /// Outputs this list as a string with the given format.
702    pub fn to_string(&self, output_format: OutputFormat) -> Result<String, WriteTestListError> {
703        let mut s = String::with_capacity(1024);
704        self.write(output_format, &mut s, false)?;
705        Ok(s)
706    }
707
708    // ---
709    // Helper methods
710    // ---
711
712    /// Creates an empty test list.
713    ///
714    /// This is primarily for use in tests where a placeholder test list is needed.
715    pub fn empty() -> Self {
716        Self {
717            test_count: 0,
718            mode: NextestRunMode::Test,
719            workspace_root: Utf8PathBuf::new(),
720            rust_build_meta: RustBuildMeta::empty(),
721            env: EnvironmentMap::empty(),
722            updated_dylib_path: OsString::new(),
723            rust_suites: IdOrdMap::new(),
724            skip_counts: OnceLock::new(),
725        }
726    }
727
728    pub(crate) fn create_dylib_path(
729        rust_build_meta: &RustBuildMeta<TestListState>,
730    ) -> Result<OsString, CreateTestListError> {
731        let dylib_path = dylib_path();
732        let dylib_path_is_empty = dylib_path.is_empty();
733        let new_paths = rust_build_meta.dylib_paths();
734
735        let mut updated_dylib_path: Vec<PathBuf> =
736            Vec::with_capacity(dylib_path.len() + new_paths.len());
737        updated_dylib_path.extend(
738            new_paths
739                .iter()
740                .map(|path| path.clone().into_std_path_buf()),
741        );
742        updated_dylib_path.extend(dylib_path);
743
744        // On macOS, these are the defaults when DYLD_FALLBACK_LIBRARY_PATH isn't set or set to an
745        // empty string. (This is relevant if nextest is invoked as its own process and not
746        // a Cargo subcommand.)
747        //
748        // This copies the logic from
749        // https://cs.github.com/rust-lang/cargo/blob/7d289b171183578d45dcabc56db6db44b9accbff/src/cargo/core/compiler/compilation.rs#L292.
750        if cfg!(target_os = "macos") && dylib_path_is_empty {
751            if let Some(home) = home::home_dir() {
752                updated_dylib_path.push(home.join("lib"));
753            }
754            updated_dylib_path.push("/usr/local/lib".into());
755            updated_dylib_path.push("/usr/lib".into());
756        }
757
758        std::env::join_paths(updated_dylib_path)
759            .map_err(move |error| CreateTestListError::dylib_join_paths(new_paths, error))
760    }
761
762    /// Parses test binary output into a [`ParsedTestBinary`] without
763    /// applying filters.
764    fn parse_output(
765        test_binary: RustTestArtifact<'g>,
766        non_ignored: impl AsRef<str>,
767        ignored: impl AsRef<str>,
768    ) -> Result<ParsedTestBinary<'g>, CreateTestListError> {
769        let mut test_cases = Vec::new();
770
771        for (test_name, kind) in Self::parse(&test_binary.binary_id, non_ignored.as_ref())? {
772            test_cases.push(ParsedTestCase {
773                name: TestCaseName::new(test_name),
774                kind,
775                ignored: false,
776            });
777        }
778
779        for (test_name, kind) in Self::parse(&test_binary.binary_id, ignored.as_ref())? {
780            // Note that libtest prints out:
781            // * just ignored tests if --ignored is passed in
782            // * all tests, both ignored and non-ignored, if --ignored is not passed in
783            // Adding ignored tests after non-ignored ones makes everything resolve correctly.
784            test_cases.push(ParsedTestCase {
785                name: TestCaseName::new(test_name),
786                kind,
787                ignored: true,
788            });
789        }
790
791        Ok(ParsedTestBinary::Listed {
792            artifact: test_binary,
793            test_cases,
794        })
795    }
796
797    /// Converts parsed binaries into filtered test suites.
798    ///
799    /// This is separated from [`Self::parse_output`] so that callers can
800    /// insert processing between parsing and filtering (e.g. precomputing
801    /// group memberships).
802    ///
803    /// `groups` should be `Some` when the CLI filter contains `group()`
804    /// predicates (see [`TestFilter::has_group_predicates`]), and `None`
805    /// otherwise. When `None`, encountering a `group()` predicate in the
806    /// expression panics.
807    fn build_suites(
808        parsed: impl IntoIterator<Item = ParsedTestBinary<'g>>,
809        filter: &TestFilter,
810        ecx: &EvalContext<'_>,
811        bound: FilterBound,
812        groups: Option<&dyn GroupLookup>,
813    ) -> IdOrdMap<RustTestSuite<'g>> {
814        parsed
815            .into_iter()
816            .map(|binary| match binary {
817                ParsedTestBinary::Listed {
818                    artifact,
819                    test_cases,
820                } => {
821                    let filtered = {
822                        let query = artifact.to_binary_query();
823                        let mut map = IdOrdMap::new();
824                        for tc in test_cases {
825                            let filter_match = filter.filter_match(
826                                query, &tc.name, &tc.kind, ecx, bound, tc.ignored, groups,
827                            );
828                            // Use insert_overwrite so that ignored entries
829                            // (appended after non-ignored by parse_output)
830                            // take precedence when a test name appears in
831                            // both outputs.
832                            map.insert_overwrite(RustTestCase {
833                                name: tc.name,
834                                test_info: RustTestCaseSummary {
835                                    kind: Some(tc.kind),
836                                    ignored: tc.ignored,
837                                    filter_match,
838                                },
839                            });
840                        }
841                        map
842                    };
843                    artifact.into_test_suite(RustTestSuiteStatus::Listed {
844                        test_cases: filtered.into(),
845                    })
846                }
847                ParsedTestBinary::Skipped { artifact, reason } => {
848                    artifact.into_test_suite(RustTestSuiteStatus::Skipped { reason })
849                }
850            })
851            .collect()
852    }
853
854    fn make_skipped(
855        test_binary: RustTestArtifact<'g>,
856        reason: BinaryMismatchReason,
857    ) -> ParsedTestBinary<'g> {
858        ParsedTestBinary::Skipped {
859            artifact: test_binary,
860            reason,
861        }
862    }
863
864    /// Collects test queries from parsed (but not yet filtered) binaries.
865    ///
866    /// Used to precompute group memberships before building suites.
867    /// Override filters that assign `test-group` are group-free
868    /// (group() is banned in override filters), so this evaluation
869    /// only needs a base `EvalContext` without group lookup.
870    fn collect_test_queries_from_parsed<'a>(
871        parsed_binaries: &'a [ParsedTestBinary<'g>],
872    ) -> Vec<TestQuery<'a>> {
873        parsed_binaries
874            .iter()
875            .filter_map(|binary| match binary {
876                ParsedTestBinary::Listed {
877                    artifact,
878                    test_cases,
879                } => Some((artifact, test_cases)),
880                ParsedTestBinary::Skipped { .. } => None,
881            })
882            .flat_map(|(artifact, test_cases)| {
883                let binary_query = artifact.to_binary_query();
884                test_cases.iter().map(move |tc| TestQuery {
885                    binary_query,
886                    test_name: &tc.name,
887                })
888            })
889            .collect()
890    }
891
892    /// Applies partitioning to the test suites as a post-filtering step.
893    ///
894    /// This is called after all other filtering (name, expression, ignored) has
895    /// been applied. Partitioning operates on the set of tests that matched all
896    /// other filters, distributing them across shards.
897    fn apply_partitioning(
898        rust_suites: &mut IdOrdMap<RustTestSuite<'_>>,
899        partitioner_builder: Option<&PartitionerBuilder>,
900    ) {
901        let Some(partitioner_builder) = partitioner_builder else {
902            return;
903        };
904
905        match partitioner_builder.scope() {
906            PartitionerScope::PerBinary => {
907                Self::apply_per_binary_partitioning(rust_suites, partitioner_builder);
908            }
909            PartitionerScope::CrossBinary => {
910                Self::apply_cross_binary_partitioning(rust_suites, partitioner_builder);
911            }
912        }
913    }
914
915    /// Applies per-binary partitioning: each binary gets its own independent
916    /// partitioner instance, matching the old inline behavior.
917    fn apply_per_binary_partitioning(
918        rust_suites: &mut IdOrdMap<RustTestSuite<'_>>,
919        partitioner_builder: &PartitionerBuilder,
920    ) {
921        for mut suite in rust_suites.iter_mut() {
922            let RustTestSuiteStatus::Listed { test_cases } = &mut suite.status else {
923                continue;
924            };
925
926            // Non-ignored and ignored tests get independent partitioner state.
927            let mut non_ignored_partitioner = partitioner_builder.build();
928            apply_partitioner_to_tests(test_cases, &mut *non_ignored_partitioner, false);
929
930            let mut ignored_partitioner = partitioner_builder.build();
931            apply_partitioner_to_tests(test_cases, &mut *ignored_partitioner, true);
932        }
933    }
934
935    /// Applies cross-binary partitioning: a single partitioner instance spans
936    /// all binaries, producing even shard sizes regardless of how tests are
937    /// distributed across binaries.
938    fn apply_cross_binary_partitioning(
939        rust_suites: &mut IdOrdMap<RustTestSuite<'_>>,
940        partitioner_builder: &PartitionerBuilder,
941    ) {
942        // Pass 1: non-ignored tests across all binaries.
943        let mut non_ignored_partitioner = partitioner_builder.build();
944        for mut suite in rust_suites.iter_mut() {
945            let RustTestSuiteStatus::Listed { test_cases } = &mut suite.status else {
946                continue;
947            };
948            apply_partitioner_to_tests(test_cases, &mut *non_ignored_partitioner, false);
949        }
950
951        // Pass 2: ignored tests across all binaries.
952        let mut ignored_partitioner = partitioner_builder.build();
953        for mut suite in rust_suites.iter_mut() {
954            let RustTestSuiteStatus::Listed { test_cases } = &mut suite.status else {
955                continue;
956            };
957            apply_partitioner_to_tests(test_cases, &mut *ignored_partitioner, true);
958        }
959    }
960
961    /// Parses the output of --list --message-format terse and returns a sorted list.
962    fn parse<'a>(
963        binary_id: &'a RustBinaryId,
964        list_output: &'a str,
965    ) -> Result<Vec<(&'a str, RustTestKind)>, CreateTestListError> {
966        let mut list = parse_list_lines(binary_id, list_output).collect::<Result<Vec<_>, _>>()?;
967        list.sort_unstable();
968        Ok(list)
969    }
970
971    /// Writes this test list out in a human-friendly format.
972    pub fn write_human(
973        &self,
974        writer: &mut dyn WriteStr,
975        verbose: bool,
976        colorize: bool,
977    ) -> io::Result<()> {
978        self.write_human_impl(None, writer, verbose, colorize)
979    }
980
981    /// Writes this test list out in a human-friendly format with the given filter.
982    pub(crate) fn write_human_with_filter(
983        &self,
984        filter: &TestListDisplayFilter<'_>,
985        writer: &mut dyn WriteStr,
986        verbose: bool,
987        colorize: bool,
988    ) -> io::Result<()> {
989        self.write_human_impl(Some(filter), writer, verbose, colorize)
990    }
991
992    fn write_human_impl(
993        &self,
994        filter: Option<&TestListDisplayFilter<'_>>,
995        mut writer: &mut dyn WriteStr,
996        verbose: bool,
997        colorize: bool,
998    ) -> io::Result<()> {
999        let mut styles = Styles::default();
1000        if colorize {
1001            styles.colorize();
1002        }
1003
1004        for info in &self.rust_suites {
1005            let matcher = match filter {
1006                Some(filter) => match filter.matcher_for(&info.binary_id) {
1007                    Some(matcher) => matcher,
1008                    None => continue,
1009                },
1010                None => DisplayFilterMatcher::All,
1011            };
1012
1013            // Skip this binary if there are no tests within it that will be run, and this isn't
1014            // verbose output.
1015            if !verbose
1016                && info
1017                    .status
1018                    .test_cases()
1019                    .all(|case| !case.test_info.filter_match.is_match())
1020            {
1021                continue;
1022            }
1023
1024            writeln!(writer, "{}:", info.binary_id.style(styles.binary_id))?;
1025            if verbose {
1026                writeln!(
1027                    writer,
1028                    "  {} {}",
1029                    "bin:".style(styles.field),
1030                    info.binary_path
1031                )?;
1032                writeln!(writer, "  {} {}", "cwd:".style(styles.field), info.cwd)?;
1033                writeln!(
1034                    writer,
1035                    "  {} {}",
1036                    "build platform:".style(styles.field),
1037                    info.build_platform,
1038                )?;
1039            }
1040
1041            let mut indented = indented(writer).with_str("    ");
1042
1043            match &info.status {
1044                RustTestSuiteStatus::Listed { test_cases } => {
1045                    let matching_tests: Vec<_> = test_cases
1046                        .iter()
1047                        .filter(|case| matcher.is_match(&case.name))
1048                        .collect();
1049                    if matching_tests.is_empty() {
1050                        writeln!(indented, "(no tests)")?;
1051                    } else {
1052                        for case in matching_tests {
1053                            match (verbose, case.test_info.filter_match.is_match()) {
1054                                (_, true) => {
1055                                    write_test_name(&case.name, &styles, &mut indented)?;
1056                                    writeln!(indented)?;
1057                                }
1058                                (true, false) => {
1059                                    write_test_name(&case.name, &styles, &mut indented)?;
1060                                    writeln!(indented, " (skipped)")?;
1061                                }
1062                                (false, false) => {
1063                                    // Skip printing this test entirely if it isn't a match.
1064                                }
1065                            }
1066                        }
1067                    }
1068                }
1069                RustTestSuiteStatus::Skipped { reason } => {
1070                    writeln!(indented, "(test binary {reason}, skipped)")?;
1071                }
1072            }
1073
1074            writer = indented.into_inner();
1075        }
1076        Ok(())
1077    }
1078
1079    /// Writes this test list out in a one-line-per-test format.
1080    pub fn write_oneline(
1081        &self,
1082        writer: &mut dyn WriteStr,
1083        verbose: bool,
1084        colorize: bool,
1085    ) -> io::Result<()> {
1086        let mut styles = Styles::default();
1087        if colorize {
1088            styles.colorize();
1089        }
1090
1091        for info in &self.rust_suites {
1092            match &info.status {
1093                RustTestSuiteStatus::Listed { test_cases } => {
1094                    for case in test_cases.iter() {
1095                        let is_match = case.test_info.filter_match.is_match();
1096                        // Skip tests that don't match the filter (unless verbose).
1097                        if !verbose && !is_match {
1098                            continue;
1099                        }
1100
1101                        write!(writer, "{} ", info.binary_id.style(styles.binary_id))?;
1102                        write_test_name(&case.name, &styles, writer)?;
1103
1104                        if verbose {
1105                            write!(
1106                                writer,
1107                                " [{}{}] [{}{}] [{}{}]{}",
1108                                "bin: ".style(styles.field),
1109                                info.binary_path,
1110                                "cwd: ".style(styles.field),
1111                                info.cwd,
1112                                "build platform: ".style(styles.field),
1113                                info.build_platform,
1114                                if is_match { "" } else { " (skipped)" },
1115                            )?;
1116                        }
1117
1118                        writeln!(writer)?;
1119                    }
1120                }
1121                RustTestSuiteStatus::Skipped { .. } => {
1122                    // Skip binaries that were not listed.
1123                }
1124            }
1125        }
1126
1127        Ok(())
1128    }
1129}
1130
1131/// Applies a partitioner to all test cases with the given ignored status.
1132fn apply_partitioner_to_tests(
1133    test_cases: &mut IdOrdMap<RustTestCase>,
1134    partitioner: &mut dyn Partitioner,
1135    ignored: bool,
1136) {
1137    for mut test_case in test_cases.iter_mut() {
1138        if test_case.test_info.ignored == ignored {
1139            apply_partition_to_test(&mut test_case, partitioner);
1140        }
1141    }
1142}
1143
1144/// Applies a partitioner to a single test case.
1145///
1146/// - If the test currently matches, the partitioner decides whether to keep or exclude it.
1147/// - If the test is `RerunAlreadyPassed`, the partitioner counts it (to maintain stable bucketing)
1148///   but preserves its status.
1149/// - All other mismatch reasons mean the test was already filtered out and should not be counted by
1150///   the partitioner.
1151fn apply_partition_to_test(test_case: &mut RustTestCase, partitioner: &mut dyn Partitioner) {
1152    match test_case.test_info.filter_match {
1153        FilterMatch::Matches => {
1154            if !partitioner.test_matches(test_case.name.as_str()) {
1155                test_case.test_info.filter_match = FilterMatch::Mismatch {
1156                    reason: MismatchReason::Partition,
1157                };
1158            }
1159        }
1160        FilterMatch::Mismatch {
1161            reason: MismatchReason::RerunAlreadyPassed,
1162        } => {
1163            // Count the test to maintain consistent bucketing, but don't change its status.
1164            let _ = partitioner.test_matches(test_case.name.as_str());
1165        }
1166        FilterMatch::Mismatch { .. } => {
1167            // Already filtered out by another criterion; don't count it.
1168        }
1169    }
1170}
1171
1172fn parse_list_lines<'a>(
1173    binary_id: &'a RustBinaryId,
1174    list_output: &'a str,
1175) -> impl Iterator<Item = Result<(&'a str, RustTestKind), CreateTestListError>> + 'a + use<'a> {
1176    // The output is in the form:
1177    // <test name>: test
1178    // <test name>: test
1179    // ...
1180
1181    list_output
1182        .lines()
1183        .map(move |line| match line.strip_suffix(": test") {
1184            Some(test_name) => Ok((test_name, RustTestKind::TEST)),
1185            None => match line.strip_suffix(": benchmark") {
1186                Some(test_name) => Ok((test_name, RustTestKind::BENCH)),
1187                None => Err(CreateTestListError::parse_line(
1188                    binary_id.clone(),
1189                    format!(
1190                        "line {line:?} did not end with the string \": test\" or \": benchmark\""
1191                    ),
1192                    list_output,
1193                )),
1194            },
1195        })
1196}
1197
1198/// Profile implementation for test lists.
1199pub trait ListProfile {
1200    /// Returns the evaluation context.
1201    fn filterset_ecx(&self) -> EvalContext<'_>;
1202
1203    /// Returns list-time settings for a test binary.
1204    fn list_settings_for(&self, query: &BinaryQuery<'_>) -> ListSettings<'_>;
1205
1206    /// Precomputes group memberships for the given tests.
1207    fn precompute_group_memberships<'a>(
1208        &self,
1209        _tests: impl Iterator<Item = TestQuery<'a>>,
1210    ) -> PrecomputedGroupMembership;
1211}
1212
1213impl<'g> ListProfile for EvaluatableProfile<'g> {
1214    fn filterset_ecx(&self) -> EvalContext<'_> {
1215        self.filterset_ecx()
1216    }
1217
1218    fn list_settings_for(&self, query: &BinaryQuery<'_>) -> ListSettings<'_> {
1219        self.list_settings_for(query)
1220    }
1221
1222    fn precompute_group_memberships<'a>(
1223        &self,
1224        tests: impl Iterator<Item = TestQuery<'a>>,
1225    ) -> PrecomputedGroupMembership {
1226        EvaluatableProfile::precompute_group_memberships(self, tests)
1227    }
1228}
1229
1230/// A test list that has been sorted and has had priorities applied to it.
1231pub struct TestPriorityQueue<'a> {
1232    tests: Vec<TestInstanceWithSettings<'a>>,
1233}
1234
1235impl<'a> TestPriorityQueue<'a> {
1236    fn new(test_list: &'a TestList<'a>, profile: &'a EvaluatableProfile<'a>) -> Self {
1237        let mode = test_list.mode();
1238        let mut tests = test_list
1239            .iter_tests()
1240            .map(|instance| {
1241                let settings = profile.settings_for(mode, &instance.to_test_query());
1242                TestInstanceWithSettings { instance, settings }
1243            })
1244            .collect::<Vec<_>>();
1245        // Note: this is a stable sort so that tests with the same priority are
1246        // sorted by what `iter_tests` produced.
1247        tests.sort_by_key(|test| test.settings.priority());
1248
1249        Self { tests }
1250    }
1251}
1252
1253impl<'a> IntoIterator for TestPriorityQueue<'a> {
1254    type Item = TestInstanceWithSettings<'a>;
1255    type IntoIter = std::vec::IntoIter<Self::Item>;
1256
1257    fn into_iter(self) -> Self::IntoIter {
1258        self.tests.into_iter()
1259    }
1260}
1261
1262/// A test instance, along with computed settings from a profile.
1263///
1264/// Returned from [`TestPriorityQueue`].
1265#[derive(Debug)]
1266pub struct TestInstanceWithSettings<'a> {
1267    /// The test instance.
1268    pub instance: TestInstance<'a>,
1269
1270    /// The settings for this test.
1271    pub settings: TestSettings<'a>,
1272}
1273
1274/// A suite of tests within a single Rust test binary.
1275///
1276/// This is a representation of [`nextest_metadata::RustTestSuiteSummary`] used internally by the runner.
1277#[derive(Clone, Debug, Eq, PartialEq)]
1278pub struct RustTestSuite<'g> {
1279    /// A unique identifier for this binary.
1280    pub binary_id: RustBinaryId,
1281
1282    /// The path to the binary.
1283    pub binary_path: Utf8PathBuf,
1284
1285    /// Package metadata.
1286    pub package: PackageMetadata<'g>,
1287
1288    /// The unique binary name defined in `Cargo.toml` or inferred by the filename.
1289    pub binary_name: String,
1290
1291    /// The kind of Rust test binary this is.
1292    pub kind: RustTestBinaryKind,
1293
1294    /// The working directory that this test binary will be executed in. If None, the current directory
1295    /// will not be changed.
1296    pub cwd: Utf8PathBuf,
1297
1298    /// The platform the test suite is for (host or target).
1299    pub build_platform: BuildPlatform,
1300
1301    /// Non-test binaries corresponding to this test suite (name, path).
1302    pub non_test_binaries: BTreeSet<(String, Utf8PathBuf)>,
1303
1304    /// Test suite status and test case names.
1305    pub status: RustTestSuiteStatus,
1306}
1307
1308impl<'g> RustTestSuite<'g> {
1309    /// Returns a binary query for this suite.
1310    pub fn to_binary_query(&self) -> BinaryQuery<'_> {
1311        BinaryQuery {
1312            package_id: self.package.id(),
1313            binary_id: &self.binary_id,
1314            kind: &self.kind,
1315            binary_name: &self.binary_name,
1316            platform: convert_build_platform(self.build_platform),
1317        }
1318    }
1319}
1320
1321impl IdOrdItem for RustTestSuite<'_> {
1322    type Key<'a>
1323        = &'a RustBinaryId
1324    where
1325        Self: 'a;
1326
1327    fn key(&self) -> Self::Key<'_> {
1328        &self.binary_id
1329    }
1330
1331    id_upcast!();
1332}
1333
1334impl RustTestArtifact<'_> {
1335    /// Run this binary with and without --ignored and get the corresponding outputs.
1336    async fn exec(
1337        &self,
1338        lctx: &LocalExecuteContext<'_>,
1339        list_settings: &ListSettings<'_>,
1340        target_runner: &TargetRunner,
1341    ) -> Result<(String, String), CreateTestListError> {
1342        // This error situation has been known to happen with reused builds. It produces
1343        // a really terrible and confusing "file not found" message if allowed to prceed.
1344        if !self.cwd.is_dir() {
1345            return Err(CreateTestListError::CwdIsNotDir {
1346                binary_id: self.binary_id.clone(),
1347                cwd: self.cwd.clone(),
1348            });
1349        }
1350        let platform_runner = target_runner.for_build_platform(self.build_platform);
1351
1352        let non_ignored = self.exec_single(false, lctx, list_settings, platform_runner);
1353        let ignored = self.exec_single(true, lctx, list_settings, platform_runner);
1354
1355        let (non_ignored_out, ignored_out) = futures::future::join(non_ignored, ignored).await;
1356        Ok((non_ignored_out?, ignored_out?))
1357    }
1358
1359    async fn exec_single(
1360        &self,
1361        ignored: bool,
1362        lctx: &LocalExecuteContext<'_>,
1363        list_settings: &ListSettings<'_>,
1364        runner: Option<&PlatformRunner>,
1365    ) -> Result<String, CreateTestListError> {
1366        let mut cli = TestCommandCli::default();
1367        cli.apply_wrappers(
1368            list_settings.list_wrapper(),
1369            runner,
1370            lctx.workspace_root,
1371            &lctx.rust_build_meta.target_directory,
1372        );
1373        cli.push(self.binary_path.as_str());
1374
1375        cli.extend(["--list", "--format", "terse"]);
1376        if ignored {
1377            cli.push("--ignored");
1378        }
1379
1380        let mut cmd = TestCommand::new(
1381            lctx,
1382            cli.program
1383                .clone()
1384                .expect("at least one argument passed in")
1385                .into_owned(),
1386            &cli.args,
1387            cli.env,
1388            &self.cwd,
1389            &self.package,
1390            &self.non_test_binaries,
1391            &Interceptor::None, // Interceptors are not used during the test list phase.
1392        );
1393
1394        // Expose a subset of environment variables to the list phase.
1395        cmd.command_mut()
1396            .env("NEXTEST_RUN_ID", lctx.run_id.to_string())
1397            .env("NEXTEST_BINARY_ID", self.binary_id.as_str())
1398            .env("NEXTEST_WORKSPACE_ROOT", lctx.workspace_root.as_str());
1399        lctx.version_env_vars.apply_env(cmd.command_mut());
1400
1401        let output =
1402            cmd.wait_with_output()
1403                .await
1404                .map_err(|error| CreateTestListError::CommandExecFail {
1405                    binary_id: self.binary_id.clone(),
1406                    command: cli.to_owned_cli(),
1407                    error,
1408                })?;
1409
1410        if output.status.success() {
1411            String::from_utf8(output.stdout).map_err(|err| CreateTestListError::CommandNonUtf8 {
1412                binary_id: self.binary_id.clone(),
1413                command: cli.to_owned_cli(),
1414                stdout: err.into_bytes(),
1415                stderr: output.stderr,
1416            })
1417        } else {
1418            Err(CreateTestListError::CommandFail {
1419                binary_id: self.binary_id.clone(),
1420                command: cli.to_owned_cli(),
1421                exit_status: output.status,
1422                stdout: output.stdout,
1423                stderr: output.stderr,
1424            })
1425        }
1426    }
1427}
1428
1429/// A test binary whose output has been parsed but whose tests have not yet
1430/// been filtered.
1431///
1432/// This is the intermediate representation between the parsing and filtering
1433/// phases of test list construction.
1434enum ParsedTestBinary<'g> {
1435    /// The binary was executed and its test cases were parsed.
1436    Listed {
1437        /// The original test artifact.
1438        artifact: RustTestArtifact<'g>,
1439
1440        /// Parsed test cases without filter results.
1441        test_cases: Vec<ParsedTestCase>,
1442    },
1443
1444    /// The binary was skipped during binary-level filtering.
1445    Skipped {
1446        /// The original test artifact.
1447        artifact: RustTestArtifact<'g>,
1448
1449        /// Why the binary was skipped.
1450        reason: BinaryMismatchReason,
1451    },
1452}
1453
1454/// A test case parsed from binary output, before filtering has been applied.
1455///
1456/// Unlike [`RustTestCaseSummary`], this type has no `filter_match` field
1457/// because the filter result has not been computed yet.
1458struct ParsedTestCase {
1459    name: TestCaseName,
1460    kind: RustTestKind,
1461    ignored: bool,
1462}
1463
1464/// Serializable information about the status of and test cases within a test suite.
1465///
1466/// Part of a [`RustTestSuiteSummary`].
1467#[derive(Clone, Debug, Eq, PartialEq)]
1468pub enum RustTestSuiteStatus {
1469    /// The test suite was executed with `--list` and the list of test cases was obtained.
1470    Listed {
1471        /// The test cases contained within this test suite.
1472        test_cases: DebugIgnore<IdOrdMap<RustTestCase>>,
1473    },
1474
1475    /// The test suite was not executed.
1476    Skipped {
1477        /// The reason why the test suite was skipped.
1478        reason: BinaryMismatchReason,
1479    },
1480}
1481
1482static EMPTY_TEST_CASE_MAP: IdOrdMap<RustTestCase> = IdOrdMap::new();
1483
1484impl RustTestSuiteStatus {
1485    /// Returns the number of test cases within this suite.
1486    pub fn test_count(&self) -> usize {
1487        match self {
1488            RustTestSuiteStatus::Listed { test_cases } => test_cases.len(),
1489            RustTestSuiteStatus::Skipped { .. } => 0,
1490        }
1491    }
1492
1493    /// Returns a test case by name, or `None` if the suite was skipped or the test doesn't exist.
1494    pub fn get(&self, name: &TestCaseName) -> Option<&RustTestCase> {
1495        match self {
1496            RustTestSuiteStatus::Listed { test_cases } => test_cases.get(name),
1497            RustTestSuiteStatus::Skipped { .. } => None,
1498        }
1499    }
1500
1501    /// Returns the list of test cases within this suite.
1502    pub fn test_cases(&self) -> impl Iterator<Item = &RustTestCase> + '_ {
1503        match self {
1504            RustTestSuiteStatus::Listed { test_cases } => test_cases.iter(),
1505            RustTestSuiteStatus::Skipped { .. } => {
1506                // Return an empty test case.
1507                EMPTY_TEST_CASE_MAP.iter()
1508            }
1509        }
1510    }
1511
1512    /// Converts this status to its serializable form.
1513    pub fn to_summary(
1514        &self,
1515    ) -> (
1516        RustTestSuiteStatusSummary,
1517        BTreeMap<TestCaseName, RustTestCaseSummary>,
1518    ) {
1519        match self {
1520            Self::Listed { test_cases } => (
1521                RustTestSuiteStatusSummary::LISTED,
1522                test_cases
1523                    .iter()
1524                    .cloned()
1525                    .map(|case| (case.name, case.test_info))
1526                    .collect(),
1527            ),
1528            Self::Skipped {
1529                reason: BinaryMismatchReason::Expression,
1530            } => (RustTestSuiteStatusSummary::SKIPPED, BTreeMap::new()),
1531            Self::Skipped {
1532                reason: BinaryMismatchReason::DefaultSet,
1533            } => (
1534                RustTestSuiteStatusSummary::SKIPPED_DEFAULT_FILTER,
1535                BTreeMap::new(),
1536            ),
1537        }
1538    }
1539}
1540
1541/// A single test case within a test suite.
1542#[derive(Clone, Debug, Eq, PartialEq)]
1543pub struct RustTestCase {
1544    /// The name of the test.
1545    pub name: TestCaseName,
1546
1547    /// Information about the test.
1548    pub test_info: RustTestCaseSummary,
1549}
1550
1551impl IdOrdItem for RustTestCase {
1552    type Key<'a> = &'a TestCaseName;
1553    fn key(&self) -> Self::Key<'_> {
1554        &self.name
1555    }
1556    id_upcast!();
1557}
1558
1559/// Represents a single test with its associated binary.
1560#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1561pub struct TestInstance<'a> {
1562    /// The name of the test.
1563    pub name: &'a TestCaseName,
1564
1565    /// Information about the test suite.
1566    pub suite_info: &'a RustTestSuite<'a>,
1567
1568    /// Information about the test.
1569    pub test_info: &'a RustTestCaseSummary,
1570}
1571
1572impl<'a> TestInstance<'a> {
1573    /// Creates a new `TestInstance`.
1574    pub(crate) fn new(case: &'a RustTestCase, suite_info: &'a RustTestSuite) -> Self {
1575        Self {
1576            name: &case.name,
1577            suite_info,
1578            test_info: &case.test_info,
1579        }
1580    }
1581
1582    /// Return an identifier for test instances, including being able to sort
1583    /// them.
1584    #[inline]
1585    pub fn id(&self) -> TestInstanceId<'a> {
1586        TestInstanceId {
1587            binary_id: &self.suite_info.binary_id,
1588            test_name: self.name,
1589        }
1590    }
1591
1592    /// Returns the corresponding [`TestQuery`] for this `TestInstance`.
1593    pub fn to_test_query(&self) -> TestQuery<'a> {
1594        TestQuery {
1595            binary_query: BinaryQuery {
1596                package_id: self.suite_info.package.id(),
1597                binary_id: &self.suite_info.binary_id,
1598                kind: &self.suite_info.kind,
1599                binary_name: &self.suite_info.binary_name,
1600                platform: convert_build_platform(self.suite_info.build_platform),
1601            },
1602            test_name: self.name,
1603        }
1604    }
1605
1606    /// Creates the command for this test instance.
1607    pub(crate) fn make_command(
1608        &self,
1609        ctx: &TestExecuteContext<'_>,
1610        test_list: &TestList<'_>,
1611        wrapper_script: Option<&WrapperScriptConfig>,
1612        extra_args: &[String],
1613        interceptor: &Interceptor,
1614    ) -> TestCommand {
1615        // TODO: non-rust tests
1616        let cli = self.compute_cli(ctx, test_list, wrapper_script, extra_args);
1617
1618        let lctx = LocalExecuteContext {
1619            phase: TestCommandPhase::Run,
1620            run_id: ctx.run_id,
1621            version_env_vars: ctx.version_env_vars,
1622            workspace_root: test_list.workspace_root(),
1623            rust_build_meta: &test_list.rust_build_meta,
1624            double_spawn: ctx.double_spawn,
1625            dylib_path: test_list.updated_dylib_path(),
1626            profile_name: ctx.profile_name,
1627            env: &test_list.env,
1628        };
1629
1630        TestCommand::new(
1631            &lctx,
1632            cli.program
1633                .expect("at least one argument is guaranteed")
1634                .into_owned(),
1635            &cli.args,
1636            cli.env,
1637            &self.suite_info.cwd,
1638            &self.suite_info.package,
1639            &self.suite_info.non_test_binaries,
1640            interceptor,
1641        )
1642    }
1643
1644    pub(crate) fn command_line(
1645        &self,
1646        ctx: &TestExecuteContext<'_>,
1647        test_list: &TestList<'_>,
1648        wrapper_script: Option<&WrapperScriptConfig>,
1649        extra_args: &[String],
1650    ) -> Vec<String> {
1651        self.compute_cli(ctx, test_list, wrapper_script, extra_args)
1652            .to_owned_cli()
1653    }
1654
1655    fn compute_cli(
1656        &self,
1657        ctx: &'a TestExecuteContext<'_>,
1658        test_list: &TestList<'_>,
1659        wrapper_script: Option<&'a WrapperScriptConfig>,
1660        extra_args: &'a [String],
1661    ) -> TestCommandCli<'a> {
1662        let platform_runner = ctx
1663            .target_runner
1664            .for_build_platform(self.suite_info.build_platform);
1665
1666        let mut cli = TestCommandCli::default();
1667        cli.apply_wrappers(
1668            wrapper_script,
1669            platform_runner,
1670            test_list.workspace_root(),
1671            &test_list.rust_build_meta().target_directory,
1672        );
1673        cli.push(self.suite_info.binary_path.as_str());
1674
1675        cli.extend(["--exact", self.name.as_str(), "--nocapture"]);
1676        if self.test_info.ignored {
1677            cli.push("--ignored");
1678        }
1679        match test_list.mode() {
1680            NextestRunMode::Test => {}
1681            NextestRunMode::Benchmark => {
1682                cli.push("--bench");
1683            }
1684        }
1685        cli.extend(extra_args.iter().map(String::as_str));
1686
1687        cli
1688    }
1689}
1690
1691#[derive(Clone, Debug, Default)]
1692struct TestCommandCli<'a> {
1693    program: Option<Cow<'a, str>>,
1694    args: Vec<Cow<'a, str>>,
1695    env: Option<&'a ScriptCommandEnvMap>,
1696}
1697
1698impl<'a> TestCommandCli<'a> {
1699    fn apply_wrappers(
1700        &mut self,
1701        wrapper_script: Option<&'a WrapperScriptConfig>,
1702        platform_runner: Option<&'a PlatformRunner>,
1703        workspace_root: &Utf8Path,
1704        target_dir: &Utf8Path,
1705    ) {
1706        // Apply the wrapper script if it's enabled.
1707        if let Some(wrapper) = wrapper_script {
1708            match wrapper.target_runner {
1709                WrapperScriptTargetRunner::Ignore => {
1710                    // Ignore the platform runner.
1711                    self.env = Some(&wrapper.command.env);
1712                    self.push(wrapper.command.program(workspace_root, target_dir));
1713                    self.extend(wrapper.command.args.iter().map(String::as_str));
1714                }
1715                WrapperScriptTargetRunner::AroundWrapper => {
1716                    // Platform runner goes first.
1717                    self.env = Some(&wrapper.command.env);
1718                    if let Some(runner) = platform_runner {
1719                        self.push(runner.binary());
1720                        self.extend(runner.args());
1721                    }
1722                    self.push(wrapper.command.program(workspace_root, target_dir));
1723                    self.extend(wrapper.command.args.iter().map(String::as_str));
1724                }
1725                WrapperScriptTargetRunner::WithinWrapper => {
1726                    // Wrapper script goes first.
1727                    self.env = Some(&wrapper.command.env);
1728                    self.push(wrapper.command.program(workspace_root, target_dir));
1729                    self.extend(wrapper.command.args.iter().map(String::as_str));
1730                    if let Some(runner) = platform_runner {
1731                        self.push(runner.binary());
1732                        self.extend(runner.args());
1733                    }
1734                }
1735                WrapperScriptTargetRunner::OverridesWrapper => {
1736                    if let Some(runner) = platform_runner {
1737                        // Target runner overrides wrapper: wrapper's command
1738                        // and env are not used.
1739                        self.push(runner.binary());
1740                        self.extend(runner.args());
1741                    } else {
1742                        // No target runner: fall back to wrapper.
1743                        self.env = Some(&wrapper.command.env);
1744                        self.push(wrapper.command.program(workspace_root, target_dir));
1745                        self.extend(wrapper.command.args.iter().map(String::as_str));
1746                    }
1747                }
1748            }
1749        } else {
1750            // If no wrapper script is enabled, use the platform runner.
1751            if let Some(runner) = platform_runner {
1752                self.push(runner.binary());
1753                self.extend(runner.args());
1754            }
1755        }
1756    }
1757
1758    fn push(&mut self, arg: impl Into<Cow<'a, str>>) {
1759        if self.program.is_none() {
1760            self.program = Some(arg.into());
1761        } else {
1762            self.args.push(arg.into());
1763        }
1764    }
1765
1766    fn extend(&mut self, args: impl IntoIterator<Item = &'a str>) {
1767        for arg in args {
1768            self.push(arg);
1769        }
1770    }
1771
1772    fn to_owned_cli(&self) -> Vec<String> {
1773        let mut owned_cli = Vec::new();
1774        if let Some(program) = &self.program {
1775            owned_cli.push(program.to_string());
1776        }
1777        owned_cli.extend(self.args.iter().map(|arg| arg.to_string()));
1778        owned_cli
1779    }
1780}
1781
1782/// A key for identifying and sorting test instances.
1783///
1784/// Returned by [`TestInstance::id`].
1785#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize)]
1786pub struct TestInstanceId<'a> {
1787    /// The binary ID.
1788    pub binary_id: &'a RustBinaryId,
1789
1790    /// The name of the test.
1791    pub test_name: &'a TestCaseName,
1792}
1793
1794impl TestInstanceId<'_> {
1795    /// Return the attempt ID corresponding to this test instance.
1796    ///
1797    /// This string uniquely identifies a single test attempt.
1798    pub fn attempt_id(
1799        &self,
1800        run_id: ReportUuid,
1801        stress_index: Option<u32>,
1802        attempt: u32,
1803    ) -> String {
1804        let mut out = String::new();
1805        swrite!(out, "{run_id}:{}", self.binary_id);
1806        if let Some(stress_index) = stress_index {
1807            swrite!(out, "@stress-{}", stress_index);
1808        }
1809        swrite!(out, "${}", self.test_name);
1810        if attempt > 1 {
1811            swrite!(out, "#{attempt}");
1812        }
1813
1814        out
1815    }
1816}
1817
1818impl fmt::Display for TestInstanceId<'_> {
1819    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1820        write!(f, "{} {}", self.binary_id, self.test_name)
1821    }
1822}
1823
1824/// An owned version of [`TestInstanceId`].
1825#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
1826#[serde(rename_all = "kebab-case")]
1827#[cfg_attr(test, derive(test_strategy::Arbitrary))]
1828pub struct OwnedTestInstanceId {
1829    /// The binary ID.
1830    pub binary_id: RustBinaryId,
1831
1832    /// The name of the test.
1833    #[serde(rename = "name")]
1834    pub test_name: TestCaseName,
1835}
1836
1837impl OwnedTestInstanceId {
1838    /// Borrow this as a [`TestInstanceId`].
1839    pub fn as_ref(&self) -> TestInstanceId<'_> {
1840        TestInstanceId {
1841            binary_id: &self.binary_id,
1842            test_name: &self.test_name,
1843        }
1844    }
1845}
1846
1847impl TestInstanceId<'_> {
1848    /// Convert this to an owned version.
1849    pub fn to_owned(&self) -> OwnedTestInstanceId {
1850        OwnedTestInstanceId {
1851            binary_id: self.binary_id.clone(),
1852            test_name: self.test_name.clone(),
1853        }
1854    }
1855}
1856
1857/// Trait to allow retrieving data from a set of [`OwnedTestInstanceId`] using a
1858/// [`TestInstanceId`].
1859///
1860/// This is an implementation of the [borrow-complex-key-example
1861/// pattern](https://github.com/sunshowers-code/borrow-complex-key-example).
1862pub trait TestInstanceIdKey {
1863    /// Converts self to a [`TestInstanceId`].
1864    fn key<'k>(&'k self) -> TestInstanceId<'k>;
1865}
1866
1867impl TestInstanceIdKey for OwnedTestInstanceId {
1868    fn key<'k>(&'k self) -> TestInstanceId<'k> {
1869        TestInstanceId {
1870            binary_id: &self.binary_id,
1871            test_name: &self.test_name,
1872        }
1873    }
1874}
1875
1876impl<'a> TestInstanceIdKey for TestInstanceId<'a> {
1877    fn key<'k>(&'k self) -> TestInstanceId<'k> {
1878        *self
1879    }
1880}
1881
1882impl<'a> Borrow<dyn TestInstanceIdKey + 'a> for OwnedTestInstanceId {
1883    fn borrow(&self) -> &(dyn TestInstanceIdKey + 'a) {
1884        self
1885    }
1886}
1887
1888impl<'a> PartialEq for dyn TestInstanceIdKey + 'a {
1889    fn eq(&self, other: &(dyn TestInstanceIdKey + 'a)) -> bool {
1890        self.key() == other.key()
1891    }
1892}
1893
1894impl<'a> Eq for dyn TestInstanceIdKey + 'a {}
1895
1896impl<'a> PartialOrd for dyn TestInstanceIdKey + 'a {
1897    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1898        Some(self.cmp(other))
1899    }
1900}
1901
1902impl<'a> Ord for dyn TestInstanceIdKey + 'a {
1903    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1904        self.key().cmp(&other.key())
1905    }
1906}
1907
1908impl<'a> Hash for dyn TestInstanceIdKey + 'a {
1909    fn hash<H: Hasher>(&self, state: &mut H) {
1910        self.key().hash(state);
1911    }
1912}
1913
1914/// Context required for test execution.
1915#[derive(Clone, Debug)]
1916pub struct TestExecuteContext<'a> {
1917    /// The run ID for this invocation.
1918    pub run_id: ReportUuid,
1919
1920    /// Version-related environment variables.
1921    pub version_env_vars: &'a VersionEnvVars,
1922
1923    /// The name of the profile.
1924    pub profile_name: &'a str,
1925
1926    /// Double-spawn info.
1927    pub double_spawn: &'a DoubleSpawnInfo,
1928
1929    /// Target runner.
1930    pub target_runner: &'a TargetRunner,
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935    use super::*;
1936    use crate::{
1937        cargo_config::{TargetDefinitionLocation, TargetTriple, TargetTripleSource},
1938        config::scripts::{ScriptCommand, ScriptCommandEnvMap, ScriptCommandRelativeTo},
1939        list::{
1940            SerializableFormat,
1941            test_helpers::{PACKAGE_GRAPH_FIXTURE, package_metadata},
1942        },
1943        platform::{BuildPlatforms, HostPlatform, PlatformLibdir, TargetPlatform},
1944        target_runner::PlatformRunnerSource,
1945        test_filter::{RunIgnored, TestFilterPatterns},
1946    };
1947    use iddqd::id_ord_map;
1948    use indoc::indoc;
1949    use nextest_filtering::{CompiledExpr, Filterset, FiltersetKind, KnownGroups, ParseContext};
1950    use nextest_metadata::{FilterMatch, MismatchReason, PlatformLibdirUnavailable, RustTestKind};
1951    use pretty_assertions::assert_eq;
1952    use std::{
1953        collections::{BTreeMap, HashSet},
1954        hash::DefaultHasher,
1955    };
1956    use target_spec::Platform;
1957    use test_strategy::proptest;
1958
1959    #[test]
1960    fn test_parse_test_list() {
1961        // Lines ending in ': benchmark' (output by the default Rust bencher) should be skipped.
1962        let non_ignored_output = indoc! {"
1963            tests::foo::test_bar: test
1964            tests::baz::test_quux: test
1965            benches::bench_foo: benchmark
1966        "};
1967        let ignored_output = indoc! {"
1968            tests::ignored::test_bar: test
1969            tests::baz::test_ignored: test
1970            benches::ignored_bench_foo: benchmark
1971        "};
1972
1973        let cx = ParseContext::new(&PACKAGE_GRAPH_FIXTURE);
1974
1975        let test_filter = TestFilter::new(
1976            NextestRunMode::Test,
1977            RunIgnored::Default,
1978            TestFilterPatterns::default(),
1979            // Test against the platform() predicate because this is the most important one here.
1980            vec![
1981                Filterset::parse(
1982                    "platform(target)".to_owned(),
1983                    &cx,
1984                    FiltersetKind::Test,
1985                    &KnownGroups::Known {
1986                        custom_groups: HashSet::new(),
1987                    },
1988                )
1989                .unwrap(),
1990            ],
1991        )
1992        .unwrap();
1993        let fake_cwd: Utf8PathBuf = "/fake/cwd".into();
1994        let fake_binary_name = "fake-binary".to_owned();
1995        let fake_binary_id = RustBinaryId::new("fake-package::fake-binary");
1996
1997        let test_binary = RustTestArtifact {
1998            binary_path: "/fake/binary".into(),
1999            cwd: fake_cwd.clone(),
2000            package: package_metadata(),
2001            binary_name: fake_binary_name.clone(),
2002            binary_id: fake_binary_id.clone(),
2003            kind: RustTestBinaryKind::LIB,
2004            non_test_binaries: BTreeSet::new(),
2005            build_platform: BuildPlatform::Target,
2006        };
2007
2008        let skipped_binary_name = "skipped-binary".to_owned();
2009        let skipped_binary_id = RustBinaryId::new("fake-package::skipped-binary");
2010        let skipped_binary = RustTestArtifact {
2011            binary_path: "/fake/skipped-binary".into(),
2012            cwd: fake_cwd.clone(),
2013            package: package_metadata(),
2014            binary_name: skipped_binary_name.clone(),
2015            binary_id: skipped_binary_id.clone(),
2016            kind: RustTestBinaryKind::PROC_MACRO,
2017            non_test_binaries: BTreeSet::new(),
2018            build_platform: BuildPlatform::Host,
2019        };
2020
2021        let fake_triple = TargetTriple {
2022            platform: Platform::new(
2023                "aarch64-unknown-linux-gnu",
2024                target_spec::TargetFeatures::Unknown,
2025            )
2026            .unwrap(),
2027            source: TargetTripleSource::CliOption,
2028            location: TargetDefinitionLocation::Builtin,
2029        };
2030        let fake_host_libdir = "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib";
2031        let build_platforms = BuildPlatforms {
2032            host: HostPlatform {
2033                platform: TargetTriple::x86_64_unknown_linux_gnu().platform,
2034                libdir: PlatformLibdir::Available(fake_host_libdir.into()),
2035            },
2036            target: Some(TargetPlatform {
2037                triple: fake_triple,
2038                // Test an unavailable libdir.
2039                libdir: PlatformLibdir::Unavailable(PlatformLibdirUnavailable::new_const("test")),
2040            }),
2041        };
2042
2043        let fake_env = EnvironmentMap::empty();
2044        let rust_build_meta =
2045            RustBuildMeta::new("/fake", "/fake", build_platforms).map_paths(&PathMapper::noop());
2046        let ecx = EvalContext {
2047            default_filter: &CompiledExpr::ALL,
2048        };
2049        let test_list = TestList::new_with_outputs(
2050            [
2051                (test_binary, &non_ignored_output, &ignored_output),
2052                (
2053                    skipped_binary,
2054                    &"should-not-show-up-stdout",
2055                    &"should-not-show-up-stderr",
2056                ),
2057            ],
2058            Utf8PathBuf::from("/fake/path"),
2059            rust_build_meta,
2060            &test_filter,
2061            None,
2062            fake_env,
2063            &ecx,
2064            FilterBound::All,
2065        )
2066        .expect("valid output");
2067        assert_eq!(
2068            test_list.rust_suites,
2069            id_ord_map! {
2070                RustTestSuite {
2071                    status: RustTestSuiteStatus::Listed {
2072                        test_cases: id_ord_map! {
2073                            RustTestCase {
2074                                name: TestCaseName::new("tests::foo::test_bar"),
2075                                test_info: RustTestCaseSummary {
2076                                    kind: Some(RustTestKind::TEST),
2077                                    ignored: false,
2078                                    filter_match: FilterMatch::Matches,
2079                                },
2080                            },
2081                            RustTestCase {
2082                                name: TestCaseName::new("tests::baz::test_quux"),
2083                                test_info: RustTestCaseSummary {
2084                                    kind: Some(RustTestKind::TEST),
2085                                    ignored: false,
2086                                    filter_match: FilterMatch::Matches,
2087                                },
2088                            },
2089                            RustTestCase {
2090                                name: TestCaseName::new("benches::bench_foo"),
2091                                test_info: RustTestCaseSummary {
2092                                    kind: Some(RustTestKind::BENCH),
2093                                    ignored: false,
2094                                    filter_match: FilterMatch::Matches,
2095                                },
2096                            },
2097                            RustTestCase {
2098                                name: TestCaseName::new("tests::ignored::test_bar"),
2099                                test_info: RustTestCaseSummary {
2100                                    kind: Some(RustTestKind::TEST),
2101                                    ignored: true,
2102                                    filter_match: FilterMatch::Mismatch { reason: MismatchReason::Ignored },
2103                                },
2104                            },
2105                            RustTestCase {
2106                                name: TestCaseName::new("tests::baz::test_ignored"),
2107                                test_info: RustTestCaseSummary {
2108                                    kind: Some(RustTestKind::TEST),
2109                                    ignored: true,
2110                                    filter_match: FilterMatch::Mismatch { reason: MismatchReason::Ignored },
2111                                },
2112                            },
2113                            RustTestCase {
2114                                name: TestCaseName::new("benches::ignored_bench_foo"),
2115                                test_info: RustTestCaseSummary {
2116                                    kind: Some(RustTestKind::BENCH),
2117                                    ignored: true,
2118                                    filter_match: FilterMatch::Mismatch { reason: MismatchReason::Ignored },
2119                                },
2120                            },
2121                        }.into(),
2122                    },
2123                    cwd: fake_cwd.clone(),
2124                    build_platform: BuildPlatform::Target,
2125                    package: package_metadata(),
2126                    binary_name: fake_binary_name,
2127                    binary_id: fake_binary_id,
2128                    binary_path: "/fake/binary".into(),
2129                    kind: RustTestBinaryKind::LIB,
2130                    non_test_binaries: BTreeSet::new(),
2131                },
2132                RustTestSuite {
2133                    status: RustTestSuiteStatus::Skipped {
2134                        reason: BinaryMismatchReason::Expression,
2135                    },
2136                    cwd: fake_cwd,
2137                    build_platform: BuildPlatform::Host,
2138                    package: package_metadata(),
2139                    binary_name: skipped_binary_name,
2140                    binary_id: skipped_binary_id,
2141                    binary_path: "/fake/skipped-binary".into(),
2142                    kind: RustTestBinaryKind::PROC_MACRO,
2143                    non_test_binaries: BTreeSet::new(),
2144                },
2145            }
2146        );
2147
2148        // Check that the expected outputs are valid.
2149        static EXPECTED_HUMAN: &str = indoc! {"
2150        fake-package::fake-binary:
2151            benches::bench_foo
2152            tests::baz::test_quux
2153            tests::foo::test_bar
2154        "};
2155        static EXPECTED_HUMAN_VERBOSE: &str = indoc! {"
2156            fake-package::fake-binary:
2157              bin: /fake/binary
2158              cwd: /fake/cwd
2159              build platform: target
2160                benches::bench_foo
2161                benches::ignored_bench_foo (skipped)
2162                tests::baz::test_ignored (skipped)
2163                tests::baz::test_quux
2164                tests::foo::test_bar
2165                tests::ignored::test_bar (skipped)
2166            fake-package::skipped-binary:
2167              bin: /fake/skipped-binary
2168              cwd: /fake/cwd
2169              build platform: host
2170                (test binary didn't match filtersets, skipped)
2171        "};
2172        static EXPECTED_JSON_PRETTY: &str = indoc! {r#"
2173            {
2174              "rust-build-meta": {
2175                "target-directory": "/fake",
2176                "build-directory": "/fake",
2177                "base-output-directories": [],
2178                "non-test-binaries": {},
2179                "build-script-out-dirs": {},
2180                "build-script-info": {},
2181                "linked-paths": [],
2182                "platforms": {
2183                  "host": {
2184                    "platform": {
2185                      "triple": "x86_64-unknown-linux-gnu",
2186                      "target-features": "unknown"
2187                    },
2188                    "libdir": {
2189                      "status": "available",
2190                      "path": "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib"
2191                    }
2192                  },
2193                  "targets": [
2194                    {
2195                      "platform": {
2196                        "triple": "aarch64-unknown-linux-gnu",
2197                        "target-features": "unknown"
2198                      },
2199                      "libdir": {
2200                        "status": "unavailable",
2201                        "reason": "test"
2202                      }
2203                    }
2204                  ]
2205                },
2206                "target-platforms": [
2207                  {
2208                    "triple": "aarch64-unknown-linux-gnu",
2209                    "target-features": "unknown"
2210                  }
2211                ],
2212                "target-platform": "aarch64-unknown-linux-gnu"
2213              },
2214              "test-count": 6,
2215              "rust-suites": {
2216                "fake-package::fake-binary": {
2217                  "package-name": "metadata-helper",
2218                  "binary-id": "fake-package::fake-binary",
2219                  "binary-name": "fake-binary",
2220                  "package-id": "metadata-helper 0.1.0 (path+file:///Users/fakeuser/local/testcrates/metadata/metadata-helper)",
2221                  "kind": "lib",
2222                  "binary-path": "/fake/binary",
2223                  "build-platform": "target",
2224                  "cwd": "/fake/cwd",
2225                  "status": "listed",
2226                  "testcases": {
2227                    "benches::bench_foo": {
2228                      "kind": "bench",
2229                      "ignored": false,
2230                      "filter-match": {
2231                        "status": "matches"
2232                      }
2233                    },
2234                    "benches::ignored_bench_foo": {
2235                      "kind": "bench",
2236                      "ignored": true,
2237                      "filter-match": {
2238                        "status": "mismatch",
2239                        "reason": "ignored"
2240                      }
2241                    },
2242                    "tests::baz::test_ignored": {
2243                      "kind": "test",
2244                      "ignored": true,
2245                      "filter-match": {
2246                        "status": "mismatch",
2247                        "reason": "ignored"
2248                      }
2249                    },
2250                    "tests::baz::test_quux": {
2251                      "kind": "test",
2252                      "ignored": false,
2253                      "filter-match": {
2254                        "status": "matches"
2255                      }
2256                    },
2257                    "tests::foo::test_bar": {
2258                      "kind": "test",
2259                      "ignored": false,
2260                      "filter-match": {
2261                        "status": "matches"
2262                      }
2263                    },
2264                    "tests::ignored::test_bar": {
2265                      "kind": "test",
2266                      "ignored": true,
2267                      "filter-match": {
2268                        "status": "mismatch",
2269                        "reason": "ignored"
2270                      }
2271                    }
2272                  }
2273                },
2274                "fake-package::skipped-binary": {
2275                  "package-name": "metadata-helper",
2276                  "binary-id": "fake-package::skipped-binary",
2277                  "binary-name": "skipped-binary",
2278                  "package-id": "metadata-helper 0.1.0 (path+file:///Users/fakeuser/local/testcrates/metadata/metadata-helper)",
2279                  "kind": "proc-macro",
2280                  "binary-path": "/fake/skipped-binary",
2281                  "build-platform": "host",
2282                  "cwd": "/fake/cwd",
2283                  "status": "skipped",
2284                  "testcases": {}
2285                }
2286              }
2287            }"#};
2288        static EXPECTED_ONELINE: &str = indoc! {"
2289            fake-package::fake-binary benches::bench_foo
2290            fake-package::fake-binary tests::baz::test_quux
2291            fake-package::fake-binary tests::foo::test_bar
2292        "};
2293        static EXPECTED_ONELINE_VERBOSE: &str = indoc! {"
2294            fake-package::fake-binary benches::bench_foo [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target]
2295            fake-package::fake-binary benches::ignored_bench_foo [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target] (skipped)
2296            fake-package::fake-binary tests::baz::test_ignored [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target] (skipped)
2297            fake-package::fake-binary tests::baz::test_quux [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target]
2298            fake-package::fake-binary tests::foo::test_bar [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target]
2299            fake-package::fake-binary tests::ignored::test_bar [bin: /fake/binary] [cwd: /fake/cwd] [build platform: target] (skipped)
2300        "};
2301
2302        assert_eq!(
2303            test_list
2304                .to_string(OutputFormat::Human { verbose: false })
2305                .expect("human succeeded"),
2306            EXPECTED_HUMAN
2307        );
2308        assert_eq!(
2309            test_list
2310                .to_string(OutputFormat::Human { verbose: true })
2311                .expect("human succeeded"),
2312            EXPECTED_HUMAN_VERBOSE
2313        );
2314        println!(
2315            "{}",
2316            test_list
2317                .to_string(OutputFormat::Serializable(SerializableFormat::JsonPretty))
2318                .expect("json-pretty succeeded")
2319        );
2320        assert_eq!(
2321            test_list
2322                .to_string(OutputFormat::Serializable(SerializableFormat::JsonPretty))
2323                .expect("json-pretty succeeded"),
2324            EXPECTED_JSON_PRETTY
2325        );
2326        assert_eq!(
2327            test_list
2328                .to_string(OutputFormat::Oneline { verbose: false })
2329                .expect("oneline succeeded"),
2330            EXPECTED_ONELINE
2331        );
2332        assert_eq!(
2333            test_list
2334                .to_string(OutputFormat::Oneline { verbose: true })
2335                .expect("oneline verbose succeeded"),
2336            EXPECTED_ONELINE_VERBOSE
2337        );
2338    }
2339
2340    /// Regression test: when a test name appears in both the non-ignored and
2341    /// ignored outputs (which libtest does when `--ignored` is not passed),
2342    /// the ignored entry must win via `insert_overwrite`.
2343    #[test]
2344    fn test_ignored_overrides_non_ignored() {
2345        // "overlap_test" appears in both outputs. The ignored entry should
2346        // take precedence.
2347        let non_ignored_output = indoc! {"
2348            tests::unique_non_ignored: test
2349            tests::overlap_test: test
2350        "};
2351        let ignored_output = indoc! {"
2352            tests::unique_ignored: test
2353            tests::overlap_test: test
2354        "};
2355
2356        let test_filter = TestFilter::new(
2357            NextestRunMode::Test,
2358            RunIgnored::All,
2359            TestFilterPatterns::default(),
2360            Vec::new(),
2361        )
2362        .unwrap();
2363        let fake_cwd: Utf8PathBuf = "/fake/cwd".into();
2364        let fake_binary_id = RustBinaryId::new("fake-package::overlap-binary");
2365
2366        let test_binary = RustTestArtifact {
2367            binary_path: "/fake/binary".into(),
2368            cwd: fake_cwd.clone(),
2369            package: package_metadata(),
2370            binary_name: "overlap-binary".to_owned(),
2371            binary_id: fake_binary_id.clone(),
2372            kind: RustTestBinaryKind::LIB,
2373            non_test_binaries: BTreeSet::new(),
2374            build_platform: BuildPlatform::Target,
2375        };
2376
2377        let fake_host_libdir = "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib";
2378        let build_platforms = BuildPlatforms {
2379            host: HostPlatform {
2380                platform: TargetTriple::x86_64_unknown_linux_gnu().platform,
2381                libdir: PlatformLibdir::Available(fake_host_libdir.into()),
2382            },
2383            target: None,
2384        };
2385
2386        let fake_env = EnvironmentMap::empty();
2387        let rust_build_meta =
2388            RustBuildMeta::new("/fake", "/fake", build_platforms).map_paths(&PathMapper::noop());
2389        let ecx = EvalContext {
2390            default_filter: &CompiledExpr::ALL,
2391        };
2392        let test_list = TestList::new_with_outputs(
2393            [(test_binary, &non_ignored_output, &ignored_output)],
2394            Utf8PathBuf::from("/fake/path"),
2395            rust_build_meta,
2396            &test_filter,
2397            None,
2398            fake_env,
2399            &ecx,
2400            FilterBound::All,
2401        )
2402        .expect("valid output");
2403
2404        // The overlapping test must be marked as ignored.
2405        let suite = test_list
2406            .rust_suites
2407            .get(&fake_binary_id)
2408            .expect("suite exists");
2409        match &suite.status {
2410            RustTestSuiteStatus::Listed { test_cases } => {
2411                let overlap = test_cases
2412                    .get(&TestCaseName::new("tests::overlap_test"))
2413                    .expect("overlap_test exists");
2414                assert!(
2415                    overlap.test_info.ignored,
2416                    "overlapping test should be marked ignored"
2417                );
2418            }
2419            other => panic!("expected Listed status, got {other:?}"),
2420        }
2421    }
2422
2423    #[test]
2424    fn apply_wrappers_examples() {
2425        cfg_if::cfg_if! {
2426            if #[cfg(windows)]
2427            {
2428                let workspace_root = Utf8Path::new("D:\\workspace\\root");
2429                let target_dir = Utf8Path::new("C:\\foo\\bar");
2430            } else {
2431                let workspace_root = Utf8Path::new("/workspace/root");
2432                let target_dir = Utf8Path::new("/foo/bar");
2433            }
2434        };
2435
2436        // Test with no wrappers
2437        {
2438            let mut cli_no_wrappers = TestCommandCli::default();
2439            cli_no_wrappers.apply_wrappers(None, None, workspace_root, target_dir);
2440            cli_no_wrappers.extend(["binary", "arg"]);
2441            assert!(cli_no_wrappers.env.is_none());
2442            assert_eq!(cli_no_wrappers.to_owned_cli(), vec!["binary", "arg"]);
2443        }
2444
2445        // Test with platform runner only
2446        {
2447            let runner = PlatformRunner::debug_new(
2448                "runner".into(),
2449                Vec::new(),
2450                PlatformRunnerSource::Env("fake".to_owned()),
2451            );
2452            let mut cli_runner_only = TestCommandCli::default();
2453            cli_runner_only.apply_wrappers(None, Some(&runner), workspace_root, target_dir);
2454            cli_runner_only.extend(["binary", "arg"]);
2455            assert!(cli_runner_only.env.is_none());
2456            assert_eq!(
2457                cli_runner_only.to_owned_cli(),
2458                vec!["runner", "binary", "arg"],
2459            );
2460        }
2461
2462        // Test wrapper with ignore target runner
2463        {
2464            let runner = PlatformRunner::debug_new(
2465                "runner".into(),
2466                Vec::new(),
2467                PlatformRunnerSource::Env("fake".to_owned()),
2468            );
2469            let wrapper_ignore = WrapperScriptConfig {
2470                command: ScriptCommand {
2471                    program: "wrapper".into(),
2472                    args: Vec::new(),
2473                    env: ScriptCommandEnvMap::default(),
2474                    relative_to: ScriptCommandRelativeTo::None,
2475                },
2476                target_runner: WrapperScriptTargetRunner::Ignore,
2477            };
2478            let mut cli_wrapper_ignore = TestCommandCli::default();
2479            cli_wrapper_ignore.apply_wrappers(
2480                Some(&wrapper_ignore),
2481                Some(&runner),
2482                workspace_root,
2483                target_dir,
2484            );
2485            cli_wrapper_ignore.extend(["binary", "arg"]);
2486            assert_eq!(
2487                cli_wrapper_ignore.env,
2488                Some(&ScriptCommandEnvMap::default())
2489            );
2490            assert_eq!(
2491                cli_wrapper_ignore.to_owned_cli(),
2492                vec!["wrapper", "binary", "arg"],
2493            );
2494        }
2495
2496        // Test wrapper with around wrapper (runner first)
2497        {
2498            let runner = PlatformRunner::debug_new(
2499                "runner".into(),
2500                Vec::new(),
2501                PlatformRunnerSource::Env("fake".to_owned()),
2502            );
2503            let env = ScriptCommandEnvMap::new(BTreeMap::from([(
2504                String::from("MSG"),
2505                String::from("hello world"),
2506            )]))
2507            .expect("valid env var keys");
2508            let wrapper_around = WrapperScriptConfig {
2509                command: ScriptCommand {
2510                    program: "wrapper".into(),
2511                    args: Vec::new(),
2512                    env: env.clone(),
2513                    relative_to: ScriptCommandRelativeTo::None,
2514                },
2515                target_runner: WrapperScriptTargetRunner::AroundWrapper,
2516            };
2517            let mut cli_wrapper_around = TestCommandCli::default();
2518            cli_wrapper_around.apply_wrappers(
2519                Some(&wrapper_around),
2520                Some(&runner),
2521                workspace_root,
2522                target_dir,
2523            );
2524            cli_wrapper_around.extend(["binary", "arg"]);
2525            assert_eq!(cli_wrapper_around.env, Some(&env));
2526            assert_eq!(
2527                cli_wrapper_around.to_owned_cli(),
2528                vec!["runner", "wrapper", "binary", "arg"],
2529            );
2530        }
2531
2532        // Test wrapper with within wrapper (wrapper first)
2533        {
2534            let runner = PlatformRunner::debug_new(
2535                "runner".into(),
2536                Vec::new(),
2537                PlatformRunnerSource::Env("fake".to_owned()),
2538            );
2539            let wrapper_within = WrapperScriptConfig {
2540                command: ScriptCommand {
2541                    program: "wrapper".into(),
2542                    args: Vec::new(),
2543                    env: ScriptCommandEnvMap::default(),
2544                    relative_to: ScriptCommandRelativeTo::None,
2545                },
2546                target_runner: WrapperScriptTargetRunner::WithinWrapper,
2547            };
2548            let mut cli_wrapper_within = TestCommandCli::default();
2549            cli_wrapper_within.apply_wrappers(
2550                Some(&wrapper_within),
2551                Some(&runner),
2552                workspace_root,
2553                target_dir,
2554            );
2555            cli_wrapper_within.extend(["binary", "arg"]);
2556            assert_eq!(
2557                cli_wrapper_within.env,
2558                Some(&ScriptCommandEnvMap::default())
2559            );
2560            assert_eq!(
2561                cli_wrapper_within.to_owned_cli(),
2562                vec!["wrapper", "runner", "binary", "arg"],
2563            );
2564        }
2565
2566        // Test wrapper with overrides-wrapper + runner present: runner wins,
2567        // wrapper env is not applied.
2568        {
2569            let runner = PlatformRunner::debug_new(
2570                "runner".into(),
2571                Vec::new(),
2572                PlatformRunnerSource::Env("fake".to_owned()),
2573            );
2574            let wrapper_overrides = WrapperScriptConfig {
2575                command: ScriptCommand {
2576                    program: "wrapper".into(),
2577                    args: Vec::new(),
2578                    env: ScriptCommandEnvMap::default(),
2579                    relative_to: ScriptCommandRelativeTo::None,
2580                },
2581                target_runner: WrapperScriptTargetRunner::OverridesWrapper,
2582            };
2583            let mut cli_wrapper_overrides = TestCommandCli::default();
2584            cli_wrapper_overrides.apply_wrappers(
2585                Some(&wrapper_overrides),
2586                Some(&runner),
2587                workspace_root,
2588                target_dir,
2589            );
2590            cli_wrapper_overrides.extend(["binary", "arg"]);
2591            assert!(
2592                cli_wrapper_overrides.env.is_none(),
2593                "overrides-wrapper with runner should not apply wrapper env"
2594            );
2595            assert_eq!(
2596                cli_wrapper_overrides.to_owned_cli(),
2597                vec!["runner", "binary", "arg"],
2598            );
2599        }
2600
2601        // Test wrapper with overrides-wrapper + no runner: wrapper is used as
2602        // fallback, env is applied.
2603        {
2604            let wrapper_overrides = WrapperScriptConfig {
2605                command: ScriptCommand {
2606                    program: "wrapper".into(),
2607                    args: Vec::new(),
2608                    env: ScriptCommandEnvMap::default(),
2609                    relative_to: ScriptCommandRelativeTo::None,
2610                },
2611                target_runner: WrapperScriptTargetRunner::OverridesWrapper,
2612            };
2613            let mut cli_wrapper_overrides_no_runner = TestCommandCli::default();
2614            cli_wrapper_overrides_no_runner.apply_wrappers(
2615                Some(&wrapper_overrides),
2616                None,
2617                workspace_root,
2618                target_dir,
2619            );
2620            cli_wrapper_overrides_no_runner.extend(["binary", "arg"]);
2621            assert_eq!(
2622                cli_wrapper_overrides_no_runner.env,
2623                Some(&ScriptCommandEnvMap::default()),
2624                "overrides-wrapper without runner should apply wrapper env"
2625            );
2626            assert_eq!(
2627                cli_wrapper_overrides_no_runner.to_owned_cli(),
2628                vec!["wrapper", "binary", "arg"],
2629            );
2630        }
2631
2632        // Test wrapper with args
2633        {
2634            let wrapper_with_args = WrapperScriptConfig {
2635                command: ScriptCommand {
2636                    program: "wrapper".into(),
2637                    args: vec!["--flag".to_string(), "value".to_string()],
2638                    env: ScriptCommandEnvMap::default(),
2639                    relative_to: ScriptCommandRelativeTo::None,
2640                },
2641                target_runner: WrapperScriptTargetRunner::Ignore,
2642            };
2643            let mut cli_wrapper_args = TestCommandCli::default();
2644            cli_wrapper_args.apply_wrappers(
2645                Some(&wrapper_with_args),
2646                None,
2647                workspace_root,
2648                target_dir,
2649            );
2650            cli_wrapper_args.extend(["binary", "arg"]);
2651            assert_eq!(cli_wrapper_args.env, Some(&ScriptCommandEnvMap::default()));
2652            assert_eq!(
2653                cli_wrapper_args.to_owned_cli(),
2654                vec!["wrapper", "--flag", "value", "binary", "arg"],
2655            );
2656        }
2657
2658        // Test platform runner with args
2659        {
2660            let runner_with_args = PlatformRunner::debug_new(
2661                "runner".into(),
2662                vec!["--runner-flag".into(), "value".into()],
2663                PlatformRunnerSource::Env("fake".to_owned()),
2664            );
2665            let mut cli_runner_args = TestCommandCli::default();
2666            cli_runner_args.apply_wrappers(
2667                None,
2668                Some(&runner_with_args),
2669                workspace_root,
2670                target_dir,
2671            );
2672            cli_runner_args.extend(["binary", "arg"]);
2673            assert!(cli_runner_args.env.is_none());
2674            assert_eq!(
2675                cli_runner_args.to_owned_cli(),
2676                vec!["runner", "--runner-flag", "value", "binary", "arg"],
2677            );
2678        }
2679
2680        // Test wrapper with ScriptCommandRelativeTo::WorkspaceRoot
2681        {
2682            let wrapper_relative_to_workspace_root = WrapperScriptConfig {
2683                command: ScriptCommand {
2684                    program: "abc/def/my-wrapper".into(),
2685                    args: vec!["--verbose".to_string()],
2686                    env: ScriptCommandEnvMap::default(),
2687                    relative_to: ScriptCommandRelativeTo::WorkspaceRoot,
2688                },
2689                target_runner: WrapperScriptTargetRunner::Ignore,
2690            };
2691            let mut cli_wrapper_relative = TestCommandCli::default();
2692            cli_wrapper_relative.apply_wrappers(
2693                Some(&wrapper_relative_to_workspace_root),
2694                None,
2695                workspace_root,
2696                target_dir,
2697            );
2698            cli_wrapper_relative.extend(["binary", "arg"]);
2699
2700            cfg_if::cfg_if! {
2701                if #[cfg(windows)] {
2702                    let wrapper_path = "D:\\workspace\\root\\abc\\def\\my-wrapper";
2703                } else {
2704                    let wrapper_path = "/workspace/root/abc/def/my-wrapper";
2705                }
2706            }
2707            assert_eq!(
2708                cli_wrapper_relative.env,
2709                Some(&ScriptCommandEnvMap::default())
2710            );
2711            assert_eq!(
2712                cli_wrapper_relative.to_owned_cli(),
2713                vec![wrapper_path, "--verbose", "binary", "arg"],
2714            );
2715        }
2716
2717        // Test wrapper with ScriptCommandRelativeTo::Target
2718        {
2719            let wrapper_relative_to_target = WrapperScriptConfig {
2720                command: ScriptCommand {
2721                    program: "abc/def/my-wrapper".into(),
2722                    args: vec!["--verbose".to_string()],
2723                    env: ScriptCommandEnvMap::default(),
2724                    relative_to: ScriptCommandRelativeTo::Target,
2725                },
2726                target_runner: WrapperScriptTargetRunner::Ignore,
2727            };
2728            let mut cli_wrapper_relative = TestCommandCli::default();
2729            cli_wrapper_relative.apply_wrappers(
2730                Some(&wrapper_relative_to_target),
2731                None,
2732                workspace_root,
2733                target_dir,
2734            );
2735            cli_wrapper_relative.extend(["binary", "arg"]);
2736            cfg_if::cfg_if! {
2737                if #[cfg(windows)] {
2738                    let wrapper_path = "C:\\foo\\bar\\abc\\def\\my-wrapper";
2739                } else {
2740                    let wrapper_path = "/foo/bar/abc/def/my-wrapper";
2741                }
2742            }
2743            assert_eq!(
2744                cli_wrapper_relative.env,
2745                Some(&ScriptCommandEnvMap::default())
2746            );
2747            assert_eq!(
2748                cli_wrapper_relative.to_owned_cli(),
2749                vec![wrapper_path, "--verbose", "binary", "arg"],
2750            );
2751        }
2752    }
2753
2754    #[test]
2755    fn test_parse_list_lines() {
2756        let binary_id = RustBinaryId::new("test-package::test-binary");
2757
2758        // Valid: tests only.
2759        let input = indoc! {"
2760            simple_test: test
2761            module::nested_test: test
2762            deeply::nested::module::test_name: test
2763        "};
2764        let results: Vec<_> = parse_list_lines(&binary_id, input)
2765            .collect::<Result<_, _>>()
2766            .expect("parsed valid test output");
2767        insta::assert_debug_snapshot!("valid_tests", results);
2768
2769        // Valid: benchmarks only.
2770        let input = indoc! {"
2771            simple_bench: benchmark
2772            benches::module::my_benchmark: benchmark
2773        "};
2774        let results: Vec<_> = parse_list_lines(&binary_id, input)
2775            .collect::<Result<_, _>>()
2776            .expect("parsed valid benchmark output");
2777        insta::assert_debug_snapshot!("valid_benchmarks", results);
2778
2779        // Valid: mixed tests and benchmarks.
2780        let input = indoc! {"
2781            test_one: test
2782            bench_one: benchmark
2783            test_two: test
2784            bench_two: benchmark
2785        "};
2786        let results: Vec<_> = parse_list_lines(&binary_id, input)
2787            .collect::<Result<_, _>>()
2788            .expect("parsed mixed output");
2789        insta::assert_debug_snapshot!("mixed_tests_and_benchmarks", results);
2790
2791        // Valid: special characters.
2792        let input = indoc! {r#"
2793            test_with_underscore_123: test
2794            test::with::colons: test
2795            test_with_numbers_42: test
2796        "#};
2797        let results: Vec<_> = parse_list_lines(&binary_id, input)
2798            .collect::<Result<_, _>>()
2799            .expect("parsed tests with special characters");
2800        insta::assert_debug_snapshot!("special_characters", results);
2801
2802        // Valid: empty input.
2803        let input = "";
2804        let results: Vec<_> = parse_list_lines(&binary_id, input)
2805            .collect::<Result<_, _>>()
2806            .expect("parsed empty output");
2807        insta::assert_debug_snapshot!("empty_input", results);
2808
2809        // Invalid: wrong suffix.
2810        let input = "invalid_test: wrong_suffix";
2811        let result = parse_list_lines(&binary_id, input).collect::<Result<Vec<_>, _>>();
2812        assert!(result.is_err());
2813        insta::assert_snapshot!("invalid_suffix_error", result.unwrap_err());
2814
2815        // Invalid: missing suffix.
2816        let input = "test_without_suffix";
2817        let result = parse_list_lines(&binary_id, input).collect::<Result<Vec<_>, _>>();
2818        assert!(result.is_err());
2819        insta::assert_snapshot!("missing_suffix_error", result.unwrap_err());
2820
2821        // Invalid: partial valid (stops at first error).
2822        let input = indoc! {"
2823            valid_test: test
2824            invalid_line
2825            another_valid: benchmark
2826        "};
2827        let result = parse_list_lines(&binary_id, input).collect::<Result<Vec<_>, _>>();
2828        assert!(result.is_err());
2829        insta::assert_snapshot!("partial_valid_error", result.unwrap_err());
2830
2831        // Invalid: control character.
2832        let input = indoc! {"
2833            valid_test: test
2834            \rinvalid_line
2835            another_valid: benchmark
2836        "};
2837        let result = parse_list_lines(&binary_id, input).collect::<Result<Vec<_>, _>>();
2838        assert!(result.is_err());
2839        insta::assert_snapshot!("control_character_error", result.unwrap_err());
2840    }
2841
2842    // Proptest to verify that the `Borrow<dyn TestInstanceIdKey>` implementation for
2843    // `OwnedTestInstanceId` is consistent with Eq, Ord, and Hash.
2844    #[proptest]
2845    fn test_instance_id_key_borrow_consistency(
2846        owned1: OwnedTestInstanceId,
2847        owned2: OwnedTestInstanceId,
2848    ) {
2849        // Create borrowed trait object references.
2850        let borrowed1: &dyn TestInstanceIdKey = &owned1;
2851        let borrowed2: &dyn TestInstanceIdKey = &owned2;
2852
2853        // Verify Eq consistency: owned equality must match borrowed equality.
2854        assert_eq!(
2855            owned1 == owned2,
2856            borrowed1 == borrowed2,
2857            "Eq must be consistent between OwnedTestInstanceId and dyn TestInstanceIdKey"
2858        );
2859
2860        // Verify PartialOrd consistency.
2861        assert_eq!(
2862            owned1.partial_cmp(&owned2),
2863            borrowed1.partial_cmp(borrowed2),
2864            "PartialOrd must be consistent between OwnedTestInstanceId and dyn TestInstanceIdKey"
2865        );
2866
2867        // Verify Ord consistency.
2868        assert_eq!(
2869            owned1.cmp(&owned2),
2870            borrowed1.cmp(borrowed2),
2871            "Ord must be consistent between OwnedTestInstanceId and dyn TestInstanceIdKey"
2872        );
2873
2874        // Verify Hash consistency.
2875        fn hash_value(x: &impl Hash) -> u64 {
2876            let mut hasher = DefaultHasher::new();
2877            x.hash(&mut hasher);
2878            hasher.finish()
2879        }
2880
2881        assert_eq!(
2882            hash_value(&owned1),
2883            hash_value(&borrowed1),
2884            "Hash must be consistent for owned1 and its borrowed form"
2885        );
2886        assert_eq!(
2887            hash_value(&owned2),
2888            hash_value(&borrowed2),
2889            "Hash must be consistent for owned2 and its borrowed form"
2890        );
2891    }
2892
2893    /// A mock group lookup that reports all tests as members of a
2894    /// single named group.
2895    #[derive(Debug)]
2896    struct MockGroupLookup {
2897        group_name: String,
2898    }
2899
2900    impl GroupLookup for MockGroupLookup {
2901        fn is_member_test(
2902            &self,
2903            _test: &nextest_filtering::TestQuery<'_>,
2904            matcher: &nextest_filtering::NameMatcher,
2905        ) -> bool {
2906            matcher.is_match(&self.group_name)
2907        }
2908    }
2909
2910    /// Tests that `build_suites` correctly resolves `group()` predicates
2911    /// when a group lookup is provided.
2912    #[test]
2913    fn test_build_suites_with_group_filter() {
2914        let cx = ParseContext::new(&PACKAGE_GRAPH_FIXTURE);
2915
2916        // Create a filter with group(serial) — only tests in the
2917        // "serial" group should match.
2918        let test_filter = TestFilter::new(
2919            NextestRunMode::Test,
2920            RunIgnored::Default,
2921            TestFilterPatterns::default(),
2922            vec![
2923                Filterset::parse(
2924                    "group(serial)".to_owned(),
2925                    &cx,
2926                    FiltersetKind::Test,
2927                    &KnownGroups::Known {
2928                        custom_groups: HashSet::from(["serial".to_owned()]),
2929                    },
2930                )
2931                .unwrap(),
2932            ],
2933        )
2934        .unwrap();
2935
2936        assert!(
2937            test_filter.has_group_predicates(),
2938            "filter with group() must report has_group_predicates"
2939        );
2940
2941        let fake_binary_id = RustBinaryId::new("fake-package::fake-binary");
2942
2943        let make_parsed = || {
2944            vec![ParsedTestBinary::Listed {
2945                artifact: RustTestArtifact {
2946                    binary_path: "/fake/binary".into(),
2947                    cwd: "/fake/cwd".into(),
2948                    package: package_metadata(),
2949                    binary_name: "fake-binary".to_owned(),
2950                    binary_id: fake_binary_id.clone(),
2951                    kind: RustTestBinaryKind::LIB,
2952                    non_test_binaries: BTreeSet::new(),
2953                    build_platform: BuildPlatform::Target,
2954                },
2955                test_cases: vec![
2956                    ParsedTestCase {
2957                        name: TestCaseName::new("serial_test"),
2958                        kind: RustTestKind::TEST,
2959                        ignored: false,
2960                    },
2961                    ParsedTestCase {
2962                        name: TestCaseName::new("parallel_test"),
2963                        kind: RustTestKind::TEST,
2964                        ignored: false,
2965                    },
2966                ],
2967            }]
2968        };
2969
2970        let ecx = EvalContext {
2971            default_filter: &CompiledExpr::ALL,
2972        };
2973
2974        // Mock: all tests report as members of the "serial" group.
2975        let lookup = MockGroupLookup {
2976            group_name: "serial".to_owned(),
2977        };
2978        let suites = TestList::build_suites(
2979            make_parsed(),
2980            &test_filter,
2981            &ecx,
2982            FilterBound::All,
2983            Some(&lookup),
2984        );
2985        let suite = suites.get(&fake_binary_id).expect("suite exists");
2986        // Both tests should match because the mock says all are in "serial".
2987        for case in suite.status.test_cases() {
2988            assert_eq!(
2989                case.test_info.filter_match,
2990                FilterMatch::Matches,
2991                "{} should match with serial group lookup",
2992                case.name,
2993            );
2994        }
2995
2996        // Mock: all tests report as members of "batch", not "serial".
2997        let lookup_other = MockGroupLookup {
2998            group_name: "batch".to_owned(),
2999        };
3000        let suites = TestList::build_suites(
3001            make_parsed(),
3002            &test_filter,
3003            &ecx,
3004            FilterBound::All,
3005            Some(&lookup_other),
3006        );
3007        let suite = suites.get(&fake_binary_id).expect("suite exists");
3008        // No tests should match because the group is "batch", not "serial".
3009        for case in suite.status.test_cases() {
3010            assert_eq!(
3011                case.test_info.filter_match,
3012                FilterMatch::Mismatch {
3013                    reason: MismatchReason::Expression,
3014                },
3015                "{} should not match with batch group lookup",
3016                case.name,
3017            );
3018        }
3019    }
3020}