Skip to main content

nextest_runner/list/
test_list.rs

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