Skip to main content

nextest_runner/list/
test_list.rs

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