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