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