Repository: nextest-rs/nextest (language: rust)

Issue:
nextest run does not inherit `OUT_DIR` env var from Cargo
Hello! I'm using `cargo-nextest-nextest 0.9.63`. I'm testing on a package `my_package`, which has the structure:
```
my_package/
|-- Cargo.toml
|-- LICENSE
|-- README
|-- build.rs
|-- src
|   |-- bin
|   |   `-- test_executable.rs
|   |-- errors.rs
|   `-- lib.rs
`-- tests
    |-- test.rs
```

In the `test.rs` file, I need to use `OUT_DIR` env var. I ran the test with `cargo nextest run -p my_package` and it failed at this statement:
```
env::var("OUT_DIR").unwrap()
```
since the `OUT_DIR` env var is not set. 

The test can pass if I run it with `cargo test -p my_package`. I checked `OUT_DIR` was set to the package's path.  

The test can pass if I run `OUT_DIR=<dir> cargo nextest run -p my_package`. So in this case, I suspect that nextest does not inherit the [ OUT_DIR env vars](https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-reads) from Cargo. 

Please let me know if this is a bug thank you!


Discussion / hints:

Fix patch:
diff --git a/site/src/CHANGELOG.md b/site/src/CHANGELOG.md
index 83e6b50d0df..0cc21f36089 100644
--- a/site/src/CHANGELOG.md
+++ b/site/src/CHANGELOG.md
@@ -3,6 +3,29 @@
 This page documents new features and bugfixes for cargo-nextest. Please see the [stability
 policy](book/stability.md) for how versioning works with cargo-nextest.
 
+## Unreleased
+
+### Added
+
+Improvements to [build script `OUT_DIR`](https://doc.rust-lang.org/cargo/reference/build-scripts.html#outputs-of-the-build-script)
+support:
+
+- Matching the behavior of `cargo test`, nextest now sets the `OUT_DIR` environment variable at
+  runtime if there's a corresponding build script.
+
+- While [creating archives](https://nexte.st/book/reusing-builds), nextest now archives `OUT_DIR`s if:
+
+  - The build script is for a crate in the workspace, and
+  - There's at least one test binary for that crate.
+
+  This is so that the `OUT_DIR` environment variable continues to be relevant for test runs out of
+  archives.
+
+  Currently, `OUT_DIR`s are only archived one level deep to avoid bloating archives too much. In the
+  future, we may add configuration to archive more or less of the output directory. If you have a
+  use case that would benefit from this, please [file an
+  issue](https://github.com/nextest-rs/nextest/issues/new).
+
 ## [0.9.64] - 2023-12-03
 
 ### Added


Test patch:
diff --git a/fixtures/nextest-tests/Cargo.lock b/fixtures/nextest-tests/Cargo.lock
index 22876b180a2..789d4353e4c 100644
--- a/fixtures/nextest-tests/Cargo.lock
+++ b/fixtures/nextest-tests/Cargo.lock
@@ -31,3 +31,7 @@ name = "uuid"
 version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "feb41e78f93363bb2df8b0e86a2ca30eed7806ea16ea0c790d757cf93f79be83"
+
+[[package]]
+name = "with-build-script"
+version = "0.1.0"
diff --git a/fixtures/nextest-tests/Cargo.toml b/fixtures/nextest-tests/Cargo.toml
index 74f91d7be07..e20f05faa6c 100644
--- a/fixtures/nextest-tests/Cargo.toml
+++ b/fixtures/nextest-tests/Cargo.toml
@@ -33,6 +33,7 @@ members = [
     "cdylib/cdylib-link",
     "derive",
     "dylib-test",
+    "with-build-script",
 ]
 
 [dependencies]
diff --git a/fixtures/nextest-tests/cdylib/cdylib-link/build.rs b/fixtures/nextest-tests/cdylib/cdylib-link/build.rs
index 567ac4fc58c..70c52b88bb2 100644
--- a/fixtures/nextest-tests/cdylib/cdylib-link/build.rs
+++ b/fixtures/nextest-tests/cdylib/cdylib-link/build.rs
@@ -9,6 +9,7 @@ use std::{
 fn main() {
     // Set the target directory to be within the output directory.
     let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is valid"));
+
     std::env::set_var("CARGO_TARGET_DIR", out_dir.join("target"));
 
     let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned());
diff --git a/fixtures/nextest-tests/cdylib/cdylib-link/src/lib.rs b/fixtures/nextest-tests/cdylib/cdylib-link/src/lib.rs
index fdd75b79632..2e02892f0b3 100644
--- a/fixtures/nextest-tests/cdylib/cdylib-link/src/lib.rs
+++ b/fixtures/nextest-tests/cdylib/cdylib-link/src/lib.rs
@@ -8,4 +8,5 @@ extern "C" {
 #[test]
 fn test_multiply_two() {
     assert_eq!(unsafe { multiply_two(3, 3) }, 9);
+
 }
diff --git a/fixtures/nextest-tests/tests/basic.rs b/fixtures/nextest-tests/tests/basic.rs
index 3abf4c5863c..a46ff2b6e9c 100644
--- a/fixtures/nextest-tests/tests/basic.rs
+++ b/fixtures/nextest-tests/tests/basic.rs
@@ -128,6 +128,7 @@ fn test_cargo_env_vars() {
     for (k, v) in std::env::vars() {
         println!("{} = {}", k, v);
     }
+
     assert_eq!(
         std::env::var("NEXTEST").as_deref(),
         Ok("1"),
@@ -245,6 +246,19 @@ fn test_cargo_env_vars() {
         );
     }
 
+    // Since this test doesn't have a build script, assert that OUT_DIR isn't present at either
+    // compile time or runtime.
+    assert_eq!(
+        option_env!("OUT_DIR"),
+        None,
+        "OUT_DIR not present at compile time"
+    );
+    assert_eq!(
+        std::env::var("OUT_DIR"),
+        Err(std::env::VarError::NotPresent),
+        "OUT_DIR not present at runtime"
+    );
+
     assert_eq!(std::env::var("MY_ENV_VAR").as_deref(), Ok("my-env-var"));
 }
 
diff --git a/fixtures/nextest-tests/with-build-script/Cargo.toml b/fixtures/nextest-tests/with-build-script/Cargo.toml
new file mode 100644
index 00000000000..ac53681d541
--- /dev/null
+++ b/fixtures/nextest-tests/with-build-script/Cargo.toml
@@ -0,0 +1,6 @@
+[package]
+name = "with-build-script"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
diff --git a/fixtures/nextest-tests/with-build-script/build.rs b/fixtures/nextest-tests/with-build-script/build.rs
new file mode 100644
index 00000000000..da0a85bd0b4
--- /dev/null
+++ b/fixtures/nextest-tests/with-build-script/build.rs
@@ -0,0 +1,12 @@
+// Copyright (c) The nextest Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+use std::path::PathBuf;
+
+fn main() {
+    // Set the target directory to be within the output directory.
+    let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is valid"));
+
+    // The presence of this file is checked by a test.
+    std::fs::write(out_dir.join("this-is-a-test-file"), "test-contents").unwrap();
+}
diff --git a/fixtures/nextest-tests/with-build-script/src/lib.rs b/fixtures/nextest-tests/with-build-script/src/lib.rs
new file mode 100644
index 00000000000..d48d263953d
--- /dev/null
+++ b/fixtures/nextest-tests/with-build-script/src/lib.rs
@@ -0,0 +1,15 @@
+// Copyright (c) The nextest Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn test_out_dir_present() {
+        // Since this package has a build script, ensure that OUT_DIR is correct by the presence of
+        // the file that the script writes.
+        let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is valid");
+        let path = std::path::Path::new(&out_dir).join("this-is-a-test-file");
+        let contents = std::fs::read(&path).expect("test file exists in OUT_DIR");
+        assert_eq!(contents, b"test-contents");
+    }
+}
diff --git a/integration-tests/tests/integration/fixtures.rs b/integration-tests/tests/integration/fixtures.rs
index 6002397be60..dddb1161c45 100644
--- a/integration-tests/tests/integration/fixtures.rs
+++ b/integration-tests/tests/integration/fixtures.rs
@@ -14,6 +14,7 @@ use std::{borrow::Cow, collections::HashMap, ffi::OsString, fmt, process::Comman
 pub struct TestInfo {
     id: RustBinaryId,
     platform: BuildPlatform,
+    // The bool represents whether the test is ignored.
     test_cases: Vec<(&'static str, bool)>,
 }
 
@@ -116,6 +117,11 @@ pub static EXPECTED_LIST: Lazy<Vec<TestInfo>> = Lazy::new(|| {
             BuildPlatform::Target,
             vec![("tests::other_example_success", false)],
         ),
+        TestInfo::new(
+            "with-build-script",
+            BuildPlatform::Target,
+            vec![("tests::test_out_dir_present", false)],
+        ),
     ]
 });
 
@@ -246,6 +252,10 @@ pub(super) fn set_env_vars() {
     // Disable the tests which check for environment variables being set in `config.toml`, as they
     // won't be in the search path when running integration tests.
     std::env::set_var("__NEXTEST_NO_CHECK_CARGO_ENV_VARS", "1");
+
+    // Remove OUT_DIR from the environment, as it interferes with tests (some of them expect that
+    // OUT_DIR isn't set.)
+    std::env::remove_var("OUT_DIR");
 }
 
 #[track_caller]
@@ -433,10 +443,10 @@ pub fn check_run_output(stderr: &[u8], relocated: bool) {
     }
 
     let summary_reg = if relocated {
-        Regex::new(r"Summary \[.*\] *26 tests run: 18 passed \(1 leaky\), 8 failed, 5 skipped")
+        Regex::new(r"Summary \[.*\] *27 tests run: 19 passed \(1 leaky\), 8 failed, 5 skipped")
             .unwrap()
     } else {
-        Regex::new(r"Summary \[.*\] *26 tests run: 19 passed \(1 leaky\), 7 failed, 5 skipped")
+        Regex::new(r"Summary \[.*\] *27 tests run: 20 passed \(1 leaky\), 7 failed, 5 skipped")
             .unwrap()
     };
     assert!(
diff --git a/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-2.snap b/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-2.snap
index 90ffbe6614f..376cb2a57c1 100644
--- a/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-2.snap
+++ b/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-2.snap
@@ -46,3 +46,6 @@ group: @global
           tests::example_success
       nextest-tests::example/other:
           tests::other_example_success
+      with-build-script:
+          tests::test_out_dir_present
+
diff --git a/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-4.snap b/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-4.snap
index 12dd0d855bf..f532b164eee 100644
--- a/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-4.snap
+++ b/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-4.snap
@@ -47,3 +47,6 @@ group: @global
           tests::example_success
       nextest-tests::example/other:
           tests::other_example_success
+      with-build-script:
+          tests::test_out_dir_present
+
diff --git a/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-6.snap b/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-6.snap
index eeba532bf59..1ba27250a49 100644
--- a/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-6.snap
+++ b/integration-tests/tests/integration/snapshots/integration__show_config_test_groups-6.snap
@@ -48,3 +48,6 @@ group: @global
           tests::example_success
       nextest-tests::example/other:
           tests::other_example_success
+      with-build-script:
+          tests::test_out_dir_present
+
diff --git a/nextest-metadata/src/test_list.rs b/nextest-metadata/src/test_list.rs
index 24b3cd3cac4..909a9fd3696 100644
--- a/nextest-metadata/src/test_list.rs
+++ b/nextest-metadata/src/test_list.rs
@@ -473,6 +473,13 @@ pub struct RustBuildMetaSummary {
     /// Information about non-test binaries, keyed by package ID.
     pub non_test_binaries: BTreeMap<String, BTreeSet<RustNonTestBinarySummary>>,
 
+    /// Build script output directory, relative to the target directory and keyed by package ID.
+    /// Only present for workspace packages that have build scripts.
+    ///
+    /// Added in cargo-nextest 0.9.65.
+    #[serde(default)]
+    pub build_script_out_dirs: BTreeMap<String, Utf8PathBuf>,
+
     /// Linked paths, relative to the target directory.
     pub linked_paths: BTreeSet<Utf8PathBuf>,
 
@@ -694,6 +701,7 @@ mod tests {
         target_directory: "/foo".into(),
         base_output_directories: BTreeSet::new(),
         non_test_binaries: BTreeMap::new(),
+        build_script_out_dirs: BTreeMap::new(),
         linked_paths: BTreeSet::new(),
         target_platform: None,
         target_platforms: vec![],
@@ -708,6 +716,7 @@ mod tests {
         target_directory: "/foo".into(),
         base_output_directories: BTreeSet::new(),
         non_test_binaries: BTreeMap::new(),
+        build_script_out_dirs: BTreeMap::new(),
         linked_paths: BTreeSet::new(),
         target_platform: Some("x86_64-unknown-linux-gnu".to_owned()),
         target_platforms: vec![],
diff --git a/nextest-runner/src/config/scripts.rs b/nextest-runner/src/config/scripts.rs
index 9ac41e19a9f..6869b981a28 100644
--- a/nextest-runner/src/config/scripts.rs
+++ b/nextest-runner/src/config/scripts.rs
@@ -151,6 +151,7 @@ impl<'profile> SetupScript<'profile> {
         test_list: &TestList<'_>,
     ) -> Result<SetupScriptCommand, SetupScriptError> {
         let lctx = LocalExecuteContext {
+            rust_build_meta: test_list.rust_build_meta(),
             double_spawn,
             dylib_path: test_list.updated_dylib_path(),
             env: test_list.cargo_env(),
diff --git a/nextest-runner/src/helpers.rs b/nextest-runner/src/helpers.rs
index 727f9783e22..60268d0c602 100644
--- a/nextest-runner/src/helpers.rs
+++ b/nextest-runner/src/helpers.rs
@@ -12,6 +12,56 @@ use std::{
     time::Duration,
 };
 
+pub(crate) mod plural {
+    pub(crate) fn setup_scripts_str(count: usize) -> &'static str {
+        if count == 1 {
+            "setup script"
+        } else {
+            "setup scripts"
+        }
+    }
+
+    pub(crate) fn tests_str(count: usize) -> &'static str {
+        if count == 1 {
+            "test"
+        } else {
+            "tests"
+        }
+    }
+
+    pub(crate) fn binaries_str(count: usize) -> &'static str {
+        if count == 1 {
+            "binary"
+        } else {
+            "binaries"
+        }
+    }
+
+    pub(crate) fn paths_str(count: usize) -> &'static str {
+        if count == 1 {
+            "path"
+        } else {
+            "paths"
+        }
+    }
+
+    pub(crate) fn files_str(count: usize) -> &'static str {
+        if count == 1 {
+            "file"
+        } else {
+            "files"
+        }
+    }
+
+    pub(crate) fn directories_str(count: usize) -> &'static str {
+        if count == 1 {
+            "directory"
+        } else {
+            "directories"
+        }
+    }
+}
+
 /// Write out a test name.
 pub(crate) fn write_test_name(
     name: &str,
@@ -119,6 +169,13 @@ pub(crate) fn convert_rel_path_to_main_sep(rel_path: &Utf8Path) -> Utf8PathBuf {
     rel_path.to_path_buf()
 }
 
+/// Join relative paths using forward slashes.
+pub(crate) fn rel_path_join(rel_path: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
+    assert!(rel_path.is_relative(), "rel_path {rel_path} is relative");
+    assert!(path.is_relative(), "path {path} is relative",);
+    format!("{}/{}", rel_path, path).into()
+}
+
 pub(crate) fn format_duration(duration: Duration) -> String {
     let duration = duration.as_secs_f64();
     if duration > 60.0 {
diff --git a/nextest-runner/src/list/binary_list.rs b/nextest-runner/src/list/binary_list.rs
index 607b5e34cda..0ff059c4f6e 100644
--- a/nextest-runner/src/list/binary_list.rs
+++ b/nextest-runner/src/list/binary_list.rs
@@ -15,7 +15,7 @@ use nextest_metadata::{
     RustNonTestBinarySummary, RustTestBinaryKind, RustTestBinarySummary,
 };
 use owo_colors::OwoColorize;
-use std::{io, io::Write};
+use std::{collections::HashSet, io, io::Write};
 
 /// A Rust test binary built by Cargo.
 #[derive(Clone, Debug)]
@@ -313,6 +313,34 @@ impl<'g> BinaryListBuildState<'g> {
         for path in build_script.linked_paths {
             self.detect_linked_path(&build_script.package_id, &path);
         }
+
+        // We only care about build scripts for workspace packages.
+        let package_id = guppy::PackageId::new(build_script.package_id.repr);
+        let in_workspace = self.graph.metadata(&package_id).map_or_else(
+            |_| {
+                // Warn about processing a package that isn't in the package graph.
+                log::warn!(
+                    target: "nextest-runner::list",
+                    "warning: saw package ID `{}` which wasn't produced by cargo metadata",
+                    package_id
+                );
+                false
+            },
+            |p| p.in_workspace(),
+        );
+        if in_workspace {
+            // Ignore this build script if it's not in the target directory.
+            if let Ok(rel_out_dir) = build_script
+                .out_dir
+                .strip_prefix(&self.rust_build_meta.target_directory)
+            {
+                self.rust_build_meta.build_script_out_dirs.insert(
+                    package_id.repr().to_owned(),
+                    convert_rel_path_to_forward_slash(rel_out_dir),
+                );
+            }
+        }
+
         Ok(())
     }
 
@@ -338,6 +366,18 @@ impl<'g> BinaryListBuildState<'g> {
 
     fn finish(mut self) -> BinaryList {
         self.rust_binaries.sort_by(|b1, b2| b1.id.cmp(&b2.id));
+
+        // Clean out any build script output directories for which there's no corresponding binary.
+        let relevant_package_ids = self
+            .rust_binaries
+            .iter()
+            .map(|bin| bin.package_id.clone())
+            .collect::<HashSet<_>>();
+
+        self.rust_build_meta
+            .build_script_out_dirs
+            .retain(|package_id, _| relevant_package_ids.contains(package_id));
+
         BinaryList {
             rust_build_meta: self.rust_build_meta,
             rust_binaries: self.rust_binaries,
@@ -452,6 +492,7 @@ mod tests {
                 }
               ]
             },
+            "build-script-out-dirs": {},
             "linked-paths": [],
             "target-platforms": [
               {
diff --git a/nextest-runner/src/list/rust_build_meta.rs b/nextest-runner/src/list/rust_build_meta.rs
index f2a6a7b923a..22cfdd5a9e1 100644
--- a/nextest-runner/src/list/rust_build_meta.rs
+++ b/nextest-runner/src/list/rust_build_meta.rs
@@ -29,6 +29,10 @@ pub struct RustBuildMeta<State> {
     /// Information about non-test executables, keyed by package ID.
     pub non_test_binaries: BTreeMap<String, BTreeSet<RustNonTestBinarySummary>>,
 
+    /// Build script output directory, relative to the target directory and keyed by package ID.
+    /// Only present for workspace packages that have build scripts.
+    pub build_script_out_dirs: BTreeMap<String, Utf8PathBuf>,
+
     /// A list of linked paths, relative to the target directory. These directories are
     /// added to the dynamic library path.
     ///
@@ -54,6 +58,7 @@ impl RustBuildMeta<BinaryListState> {
             target_directory: target_directory.into(),
             base_output_directories: BTreeSet::new(),
             non_test_binaries: BTreeMap::new(),
+            build_script_out_dirs: BTreeMap::new(),
             linked_paths: BTreeMap::new(),
             state: PhantomData,
             target_triple,
@@ -70,6 +75,7 @@ impl RustBuildMeta<BinaryListState> {
             // Since these are relative paths, they don't need to be mapped.
             base_output_directories: self.base_output_directories.clone(),
             non_test_binaries: self.non_test_binaries.clone(),
+            build_script_out_dirs: self.build_script_out_dirs.clone(),
             linked_paths: self.linked_paths.clone(),
             state: PhantomData,
             target_triple: self.target_triple.clone(),
@@ -85,6 +91,7 @@ impl RustBuildMeta<TestListState> {
             target_directory: Utf8PathBuf::new(),
             base_output_directories: BTreeSet::new(),
             non_test_binaries: BTreeMap::new(),
+            build_script_out_dirs: BTreeMap::new(),
             linked_paths: BTreeMap::new(),
             state: PhantomData,
             target_triple: None,
@@ -142,6 +149,7 @@ impl<State> RustBuildMeta<State> {
         Ok(Self {
             target_directory: summary.target_directory,
             base_output_directories: summary.base_output_directories,
+            build_script_out_dirs: summary.build_script_out_dirs,
             non_test_binaries: summary.non_test_binaries,
             linked_paths: summary
                 .linked_paths
@@ -159,6 +167,7 @@ impl<State> RustBuildMeta<State> {
             target_directory: self.target_directory.clone(),
             base_output_directories: self.base_output_directories.clone(),
             non_test_binaries: self.non_test_binaries.clone(),
+            build_script_out_dirs: self.build_script_out_dirs.clone(),
             linked_paths: self.linked_paths.keys().cloned().collect(),
             target_platform: self
                 .target_triple
diff --git a/nextest-runner/src/list/test_list.rs b/nextest-runner/src/list/test_list.rs
index 66af271a5b8..8eb47e2ca35 100644
--- a/nextest-runner/src/list/test_list.rs
+++ b/nextest-runner/src/list/test_list.rs
@@ -222,6 +222,7 @@ impl<'g> TestList<'g> {
             updated_dylib_path.to_string_lossy(),
         );
         let lctx = LocalExecuteContext {
+            rust_build_meta: &rust_build_meta,
             double_spawn: ctx.double_spawn,
             dylib_path: &updated_dylib_path,
             env: &env,
@@ -934,6 +935,7 @@ impl<'a> TestInstance<'a> {
         }
 
         let lctx = LocalExecuteContext {
+            rust_build_meta: &test_list.rust_build_meta,
             double_spawn: ctx.double_spawn,
             dylib_path: test_list.updated_dylib_path(),
             env: &test_list.env,
@@ -1143,6 +1145,7 @@ mod tests {
                 "target-directory": "/fake",
                 "base-output-directories": [],
                 "non-test-binaries": {},
+                "build-script-out-dirs": {},
                 "linked-paths": [],
                 "target-platforms": [
                   {
diff --git a/nextest-runner/src/reporter.rs b/nextest-runner/src/reporter.rs
index fa8070f980e..c42318aa604 100644
--- a/nextest-runner/src/reporter.rs
+++ b/nextest-runner/src/reporter.rs
@@ -9,7 +9,7 @@ mod aggregator;
 use crate::{
     config::{NextestProfile, ScriptId},
     errors::WriteEventError,
-    helpers::write_test_name,
+    helpers::{plural, write_test_name},
     list::{TestInstance, TestList},
     reporter::aggregator::EventAggregator,
     runner::{
@@ -582,8 +582,8 @@ impl<'a> TestReporterImpl<'a> {
 
                 let count_style = self.styles.count;
 
-                let tests_str = tests_str(test_list.run_count());
-                let binaries_str = binaries_str(test_list.binary_count());
+                let tests_str = plural::tests_str(test_list.run_count());
+                let binaries_str = plural::binaries_str(test_list.binary_count());
 
                 write!(
                     writer,
@@ -850,14 +850,14 @@ impl<'a> TestReporterImpl<'a> {
 
                 // At the moment, we can have either setup scripts or tests running, but not both.
                 if *setup_scripts_running > 0 {
-                    let s = setup_scripts_str(*setup_scripts_running);
+                    let s = plural::setup_scripts_str(*setup_scripts_running);
                     write!(
                         writer,
                         ": {} {s} still running",
                         setup_scripts_running.style(self.styles.count),
                     )?;
                 } else if *running > 0 {
-                    let tests_str = tests_str(*running);
+                    let tests_str = plural::tests_str(*running);
                     write!(
                         writer,
                         ": {} {tests_str} still running",
@@ -879,14 +879,14 @@ impl<'a> TestReporterImpl<'a> {
 
                 // At the moment, we can have either setup scripts or tests running, but not both.
                 if *setup_scripts_running > 0 {
-                    let s = setup_scripts_str(*setup_scripts_running);
+                    let s = plural::setup_scripts_str(*setup_scripts_running);
                     write!(
                         writer,
                         ": {} {s} running",
                         setup_scripts_running.style(self.styles.count),
                     )?;
                 } else if *running > 0 {
-                    let tests_str = tests_str(*running);
+                    let tests_str = plural::tests_str(*running);
                     write!(
                         writer,
                         ": {} {tests_str} running",
@@ -908,14 +908,14 @@ impl<'a> TestReporterImpl<'a> {
 
                 // At the moment, we can have either setup scripts or tests running, but not both.
                 if *setup_scripts_running > 0 {
-                    let s = setup_scripts_str(*setup_scripts_running);
+                    let s = plural::setup_scripts_str(*setup_scripts_running);
                     write!(
                         writer,
                         ": {} {s} running",
                         setup_scripts_running.style(self.styles.count),
                     )?;
                 } else if *running > 0 {
-                    let tests_str = tests_str(*running);
+                    let tests_str = plural::tests_str(*running);
                     write!(
                         writer,
                         ": {} {tests_str} running",
@@ -1415,30 +1415,6 @@ impl<'a> TestReporterImpl<'a> {
     }
 }
 
-fn setup_scripts_str(count: usize) -> &'static str {
-    if count == 1 {
-        "setup script"
-    } else {
-        "setup scripts"
-    }
-}
-
-fn tests_str(count: usize) -> &'static str {
-    if count == 1 {
-        "test"
-    } else {
-        "tests"
-    }
-}
-
-fn binaries_str(count: usize) -> &'static str {
-    if count == 1 {
-        "binary"
-    } else {
-        "binaries"
-    }
-}
-
 impl<'a> fmt::Debug for TestReporter<'a> {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         f.debug_struct("TestReporter")
diff --git a/nextest-runner/src/reuse_build/archive_reporter.rs b/nextest-runner/src/reuse_build/archive_reporter.rs
index 4919397dd01..eb94d77f857 100644
--- a/nextest-runner/src/reuse_build/archive_reporter.rs
+++ b/nextest-runner/src/reuse_build/archive_reporter.rs
@@ -1,7 +1,7 @@
 // Copyright (c) The nextest Contributors
 // SPDX-License-Identifier: MIT OR Apache-2.0
 
-use crate::helpers::format_duration;
+use crate::helpers::{format_duration, plural};
 use camino::Utf8Path;
 use owo_colors::{OwoColorize, Style};
 use std::{
@@ -39,6 +39,7 @@ impl ArchiveReporter {
             ArchiveEvent::ArchiveStarted {
                 test_binary_count,
                 non_test_binary_count,
+                build_script_out_dir_count,
                 linked_path_count,
                 output_file,
             } => {
@@ -47,6 +48,7 @@ impl ArchiveReporter {
                 self.report_binary_counts(
                     test_binary_count,
                     non_test_binary_count,
+                    build_script_out_dir_count,
                     linked_path_count,
                     &mut writer,
                 )?;
@@ -70,6 +72,7 @@ impl ArchiveReporter {
             ArchiveEvent::ExtractStarted {
                 test_binary_count,
                 non_test_binary_count,
+                build_script_out_dir_count,
                 linked_path_count,
                 dest_dir: destination_dir,
             } => {
@@ -78,6 +81,7 @@ impl ArchiveReporter {
                 self.report_binary_counts(
                     test_binary_count,
                     non_test_binary_count,
+                    build_script_out_dir_count,
                     linked_path_count,
                     &mut writer,
                 )?;
@@ -92,8 +96,9 @@ impl ArchiveReporter {
                 write!(writer, "{:>12} ", "Extracted".style(self.styles.success))?;
                 writeln!(
                     writer,
-                    "{} files to {} in {}",
+                    "{} {} to {} in {}",
                     file_count.style(self.styles.bold),
+                    plural::files_str(file_count),
                     destination_dir.style(self.styles.bold),
                     format_duration(elapsed),
                 )?;
@@ -107,32 +112,57 @@ impl ArchiveReporter {
         &mut self,
         test_binary_count: usize,
         non_test_binary_count: usize,
+        build_script_out_dir_count: usize,
         linked_path_count: usize,
         mut writer: impl Write,
     ) -> io::Result<()> {
         let total_binary_count = test_binary_count + non_test_binary_count;
         let non_test_text = if non_test_binary_count > 0 {
             format!(
-                " (including {} non-test binaries)",
-                non_test_binary_count.style(self.styles.bold)
-            )
-        } else {
-            "".to_owned()
-        };
-        let linked_path_text = if linked_path_count > 0 {
-            format!(
-                " and {} linked paths",
-                linked_path_count.style(self.styles.bold)
+                " (including {} non-test {})",
+                non_test_binary_count.style(self.styles.bold),
+                plural::binaries_str(non_test_binary_count),
             )
         } else {
             "".to_owned()
         };
+        let mut more = Vec::new();
+        if build_script_out_dir_count > 0 {
+            more.push(format!(
+                "{} build script output {}",
+                build_script_out_dir_count.style(self.styles.bold),
+                plural::directories_str(build_script_out_dir_count),
+            ));
+        }
+        if linked_path_count > 0 {
+            more.push(format!(
+                "{} linked {}",
+                linked_path_count.style(self.styles.bold),
+                plural::paths_str(linked_path_count),
+            ));
+        }
 
         write!(
             writer,
-            "{} binaries{non_test_text}{linked_path_text}",
+            "{} {}{non_test_text}",
             total_binary_count.style(self.styles.bold),
-        )
+            plural::binaries_str(total_binary_count),
+        )?;
+
+        match more.len() {
+            0 => Ok(()),
+            1 => {
+                write!(writer, " and {}", more[0])
+            }
+            _ => {
+                write!(
+                    writer,
+                    ", {}, and {}",
+                    more[..more.len() - 1].join(", "),
+                    more.last().unwrap(),
+                )
+            }
+        }
     }
 }
 
@@ -163,6 +193,9 @@ pub enum ArchiveEvent<'a> {
         /// The number of non-test binaries to archive.
         non_test_binary_count: usize,
 
+        /// The number of build script output directories to archive.
+        build_script_out_dir_count: usize,
+
         /// The number of linked paths to archive.
         linked_path_count: usize,
 
@@ -190,6 +223,9 @@ pub enum ArchiveEvent<'a> {
         /// The number of non-test binaries to extract.
         non_test_binary_count: usize,
 
+        /// The number of build script output directories to archive.
+        build_script_out_dir_count: usize,
+
         /// The number of linked paths to extract.
         linked_path_count: usize,
 
diff --git a/nextest-runner/src/reuse_build/archiver.rs b/nextest-runner/src/reuse_build/archiver.rs
index 8dc5c5afe31..f46ca5f91d0 100644
--- a/nextest-runner/src/reuse_build/archiver.rs
+++ b/nextest-runner/src/reuse_build/archiver.rs
@@ -5,7 +5,7 @@ use super::{ArchiveEvent, BINARIES_METADATA_FILE_NAME, CARGO_METADATA_FILE_NAME}
 use crate::{
     config::get_num_cpus,
     errors::{ArchiveCreateError, UnknownArchiveFormat},
-    helpers::convert_rel_path_to_forward_slash,
+    helpers::{convert_rel_path_to_forward_slash, rel_path_join},
     list::{BinaryList, OutputFormat, SerializableFormat},
     reuse_build::PathMapper,
 };
@@ -64,6 +64,7 @@ where
     let file = AtomicFile::new(output_file, OverwriteBehavior::AllowOverwrite);
     let test_binary_count = binary_list.rust_binaries.len();
     let non_test_binary_count = binary_list.rust_build_meta.non_test_binaries.len();
+    let build_script_out_dir_count = binary_list.rust_build_meta.build_script_out_dirs.len();
     let linked_path_count = binary_list.rust_build_meta.linked_paths.len();
     let start_time = Instant::now();
 
@@ -72,6 +73,7 @@ where
             callback(ArchiveEvent::ArchiveStarted {
                 test_binary_count,
                 non_test_binary_count,
+                build_script_out_dir_count,
                 linked_path_count,
                 output_file,
             })
@@ -199,9 +201,33 @@ impl<'a, W: Write> Archiver<'a, W> {
             self.append_path(&src_path, &rel_path)?;
         }
 
+        // Write build script output directories to the archive.
+        for build_script_out_dir in self
+            .binary_list
+            .rust_build_meta
+            .build_script_out_dirs
+            .values()
+        {
+            let src_path = self
+                .binary_list
+                .rust_build_meta
+                .target_directory
+                .join(build_script_out_dir);
+            let src_path = self.path_mapper.map_binary(src_path);
+
+            let rel_path = Utf8Path::new("target").join(build_script_out_dir);
+            let rel_path = convert_rel_path_to_forward_slash(&rel_path);
+
+            // XXX: For now, we only archive one level of build script output directories as a
+            // conservative solution. If necessary, we may have to either broaden this by default or
+            // add configuration for this. Archiving too much can cause unnecessary slowdowns.
+            self.append_dir_one_level(&rel_path, &src_path)?;
+        }
+
         // Write linked paths to the archive.
         for (linked_path, requested_by) in &self.binary_list.rust_build_meta.linked_paths {
-            // linked paths are e.g. debug/foo/bar. We need to prepend the target directory.
+            // Linked paths are relative, e.g. debug/foo/bar. We need to prepend the target
+            // directory.
             let src_path = self
                 .binary_list
                 .rust_build_meta
@@ -274,20 +300,21 @@ impl<'a, W: Write> Archiver<'a, W> {
         rel_path: &Utf8Path,
         src_path: &Utf8Path,
     ) -> Result<(), ArchiveCreateError> {
-        // In case of a symlink pointing to a directory, is_dir is false, but src.is_dir() will return true
-        for entry in
+        let entries =
             src_path
                 .read_dir_utf8()
                 .map_err(|error| ArchiveCreateError::InputFileRead {
                     path: src_path.to_owned(),
                     is_dir: Some(true),
                     error,
-                })?
-        {
+                })?;
+        for entry in entries {
             let entry = entry.map_err(|error| ArchiveCreateError::DirEntryRead {
                 path: src_path.to_owned(),
                 error,
             })?;
+            // In case of a symlink pointing to a directory, entry.file_type.is_dir() is false, but
+            // src.is_dir() will return true. We want to use entry.file_type.is_dir().
             let src = entry.path();
             let file_type =
                 entry
@@ -298,7 +325,7 @@ impl<'a, W: Write> Archiver<'a, W> {
                         error,
                     })?;
             if !file_type.is_dir() {
-                let dest = rel_path.join(src.file_name().expect("entries should have a file name"));
+                let dest = rel_path_join(rel_path, entry.file_name().as_ref());
                 self.append_path(src, &dest)?;
             }
         }
diff --git a/nextest-runner/src/reuse_build/unarchiver.rs b/nextest-runner/src/reuse_build/unarchiver.rs
index 6a3bf246757..9e7230b4169 100644
--- a/nextest-runner/src/reuse_build/unarchiver.rs
+++ b/nextest-runner/src/reuse_build/unarchiver.rs
@@ -126,12 +126,15 @@ impl<'a> Unarchiver<'a> {
                 let test_binary_count = this_binary_list.rust_binaries.len();
                 let non_test_binary_count =
                     this_binary_list.rust_build_meta.non_test_binaries.len();
+                let build_script_out_dir_count =
+                    this_binary_list.rust_build_meta.build_script_out_dirs.len();
                 let linked_path_count = this_binary_list.rust_build_meta.linked_paths.len();
 
                 // Report begin extraction.
                 callback(ArchiveEvent::ExtractStarted {
                     test_binary_count,
                     non_test_binary_count,
+                    build_script_out_dir_count,
                     linked_path_count,
                     dest_dir: &dest_dir,
                 })
diff --git a/nextest-runner/src/test_command.rs b/nextest-runner/src/test_command.rs
index 90331a12a71..48d8abafeb3 100644
--- a/nextest-runner/src/test_command.rs
+++ b/nextest-runner/src/test_command.rs
@@ -5,6 +5,7 @@ use crate::{
     cargo_config::EnvironmentMap,
     double_spawn::{DoubleSpawnContext, DoubleSpawnInfo},
     helpers::dylib_path_envvar,
+    list::{RustBuildMeta, TestListState},
 };
 use camino::{Utf8Path, Utf8PathBuf};
 use guppy::graph::PackageMetadata;
@@ -16,6 +17,8 @@ use std::{
 
 #[derive(Clone, Debug)]
 pub(crate) struct LocalExecuteContext<'a> {
+    // Note: Must use TestListState here to get remapped paths.
+    pub(crate) rust_build_meta: &'a RustBuildMeta<TestListState>,
     pub(crate) double_spawn: &'a DoubleSpawnInfo,
     pub(crate) dylib_path: &'a OsStr,
     pub(crate) env: &'a EnvironmentMap,
@@ -57,8 +60,19 @@ impl TestCommand {
             );
 
         apply_package_env(&mut cmd, package);
+
         apply_ld_dyld_env(&mut cmd, lctx.dylib_path);
 
+        if let Some(out_dir) = lctx
+            .rust_build_meta
+            .build_script_out_dirs
+            .get(package.id().repr())
+        {
+            // Convert the output directory to an absolute path.
+            let out_dir = lctx.rust_build_meta.target_directory.join(out_dir);
+            cmd.env("OUT_DIR", out_dir);
+        }
+
         // Expose paths to non-test binaries at runtime so that relocated paths work.
         // These paths aren't exposed by Cargo at runtime, so use a NEXTEST_BIN_EXE prefix.
         for (name, path) in non_test_binaries {
diff --git a/nextest-runner/tests/integration/fixtures.rs b/nextest-runner/tests/integration/fixtures.rs
index 8c2d0a90756..26b7b22ca32 100644
--- a/nextest-runner/tests/integration/fixtures.rs
+++ b/nextest-runner/tests/integration/fixtures.rs
@@ -167,7 +167,11 @@ pub(crate) static EXPECTED_TESTS: Lazy<BTreeMap<RustBinaryId, Vec<TestFixture>>>
             "dylib-test".into() => vec![],
             "cdylib-example".into() => vec![
                 TestFixture { name: "tests::test_multiply_two_cdylib", status: FixtureStatus::Pass },
-            ]
+            ],
+            // Build script tests
+            "with-build-script".into() => vec![
+                TestFixture { name: "tests::test_out_dir_present", status: FixtureStatus::Pass },
+            ],
         }
     },
 );
@@ -238,6 +242,10 @@ pub(crate) fn set_env_vars() {
         "__NEXTEST_TESTING_EXTRA_CONFIG_OVERRIDE_FORCE_FALSE",
         "test-PASSED-value-set-by-environment",
     );
+
+    // Remove OUT_DIR from the environment, as it interferes with tests (some of them expect that
+    // OUT_DIR isn't set.)
+    std::env::remove_var("OUT_DIR");
 }
 
 pub(crate) fn workspace_root() -> Utf8PathBuf {