nextest_runner/
target_runner.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Support for [target runners](https://doc.rust-lang.org/cargo/reference/config.html#targettriplerunner)
5
6use crate::{
7    cargo_config::{CargoConfig, CargoConfigSource, CargoConfigs, DiscoveredConfig, Runner},
8    errors::TargetRunnerError,
9    platform::BuildPlatforms,
10};
11use camino::{Utf8Path, Utf8PathBuf};
12use nextest_metadata::BuildPlatform;
13use std::fmt;
14use target_spec::Platform;
15
16/// A [target runner](https://doc.rust-lang.org/cargo/reference/config.html#targettriplerunner)
17/// used to execute a test binary rather than the default of executing natively.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct TargetRunner {
20    host: Option<PlatformRunner>,
21    target: Option<PlatformRunner>,
22}
23
24impl TargetRunner {
25    /// Acquires the [target runner](https://doc.rust-lang.org/cargo/reference/config.html#targettriplerunner)
26    /// which can be set in a [.cargo/config.toml](https://doc.rust-lang.org/cargo/reference/config.html#hierarchical-structure)
27    /// or via a `CARGO_TARGET_{TRIPLE}_RUNNER` environment variable
28    pub fn new(
29        configs: &CargoConfigs,
30        build_platforms: &BuildPlatforms,
31    ) -> Result<Self, TargetRunnerError> {
32        let host = PlatformRunner::by_precedence(configs, &build_platforms.host.platform)?;
33        let target = match &build_platforms.target {
34            Some(target) => PlatformRunner::by_precedence(configs, &target.triple.platform)?,
35            None => host.clone(),
36        };
37
38        Ok(Self { host, target })
39    }
40
41    /// Creates an empty target runner that does not delegate to any runner binaries.
42    pub fn empty() -> Self {
43        Self {
44            host: None,
45            target: None,
46        }
47    }
48
49    /// Returns the target [`PlatformRunner`].
50    #[inline]
51    pub fn target(&self) -> Option<&PlatformRunner> {
52        self.target.as_ref()
53    }
54
55    /// Returns the host [`PlatformRunner`].
56    #[inline]
57    pub fn host(&self) -> Option<&PlatformRunner> {
58        self.host.as_ref()
59    }
60
61    /// Returns the [`PlatformRunner`] for the given build platform (host or target).
62    #[inline]
63    pub fn for_build_platform(&self, build_platform: BuildPlatform) -> Option<&PlatformRunner> {
64        match build_platform {
65            BuildPlatform::Target => self.target(),
66            BuildPlatform::Host => self.host(),
67        }
68    }
69
70    /// Returns the platform runners for all build platforms.
71    #[inline]
72    pub fn all_build_platforms(&self) -> [(BuildPlatform, Option<&PlatformRunner>); 2] {
73        [
74            (BuildPlatform::Target, self.target()),
75            (BuildPlatform::Host, self.host()),
76        ]
77    }
78}
79
80/// A target runner scoped to a specific platform (host or target).
81///
82/// This forms part of [`TargetRunner`].
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct PlatformRunner {
85    runner_binary: Utf8PathBuf,
86    args: Vec<String>,
87    source: PlatformRunnerSource,
88}
89
90impl PlatformRunner {
91    fn by_precedence(
92        configs: &CargoConfigs,
93        platform: &Platform,
94    ) -> Result<Option<Self>, TargetRunnerError> {
95        Self::find_config(configs, platform)
96    }
97
98    /// Attempts to find a target runner for the specified target from a
99    /// [cargo config](https://doc.rust-lang.org/cargo/reference/config.html#hierarchical-structure)
100    ///
101    /// Not part of the public API. For testing only.
102    #[doc(hidden)]
103    pub fn find_config(
104        configs: &CargoConfigs,
105        target: &Platform,
106    ) -> Result<Option<Self>, TargetRunnerError> {
107        // Now that we've found all of the config files that could declare
108        // a runner that matches our target triple, we need to actually find
109        // all the matches, but in reverse order as the closer the config is
110        // to our current working directory, the higher precedence it has
111        for discovered_config in configs.discovered_configs() {
112            match discovered_config {
113                DiscoveredConfig::CliOption { config, source }
114                | DiscoveredConfig::File { config, source } => {
115                    if let Some(runner) =
116                        Self::from_cli_option_or_file(target, config, source, configs.cwd())?
117                    {
118                        return Ok(Some(runner));
119                    }
120                }
121                DiscoveredConfig::Env => {
122                    // Check if we have a CARGO_TARGET_{TRIPLE}_RUNNER environment variable
123                    // set, and if so use that.
124                    if let Some(tr) = Self::from_env(Self::runner_env_var(target), configs.cwd())? {
125                        return Ok(Some(tr));
126                    }
127                }
128            }
129        }
130
131        Ok(None)
132    }
133
134    fn from_cli_option_or_file(
135        target: &target_spec::Platform,
136        config: &CargoConfig,
137        source: &CargoConfigSource,
138        cwd: &Utf8Path,
139    ) -> Result<Option<Self>, TargetRunnerError> {
140        if let Some(targets) = &config.target {
141            // First lookup by the exact triple, as that one always takes precedence
142            if let Some(parent) = targets.get(target.triple_str()) {
143                if let Some(runner) = &parent.runner {
144                    return Ok(Some(Self::parse_runner(
145                        PlatformRunnerSource::CargoConfig {
146                            source: source.clone(),
147                            target_table: target.triple_str().into(),
148                        },
149                        runner.clone(),
150                        cwd,
151                    )?));
152                }
153            }
154
155            // Next check if there are target.'cfg(..)' expressions that match
156            // the target. cargo states that it is not allowed for more than
157            // 1 cfg runner to match the target, but we let cargo handle that
158            // error itself, we just use the first one that matches
159            for (cfg, runner) in targets.iter().filter_map(|(k, v)| match &v.runner {
160                Some(runner) if k.starts_with("cfg(") => Some((k, runner)),
161                _ => None,
162            }) {
163                // Treat these as non-fatal, but would be good to log maybe
164                let expr = match target_spec::TargetSpecExpression::new(cfg) {
165                    Ok(expr) => expr,
166                    Err(_err) => continue,
167                };
168
169                if expr.eval(target) == Some(true) {
170                    return Ok(Some(Self::parse_runner(
171                        PlatformRunnerSource::CargoConfig {
172                            source: source.clone(),
173                            target_table: cfg.clone(),
174                        },
175                        runner.clone(),
176                        cwd,
177                    )?));
178                }
179            }
180        }
181
182        Ok(None)
183    }
184
185    fn from_env(env_key: String, cwd: &Utf8Path) -> Result<Option<Self>, TargetRunnerError> {
186        if let Some(runner_var) = std::env::var_os(&env_key) {
187            let runner = runner_var
188                .into_string()
189                .map_err(|_osstr| TargetRunnerError::InvalidEnvironmentVar(env_key.clone()))?;
190            Self::parse_runner(
191                PlatformRunnerSource::Env(env_key),
192                Runner::Simple(runner),
193                cwd,
194            )
195            .map(Some)
196        } else {
197            Ok(None)
198        }
199    }
200
201    // Not part of the public API. Exposed for testing only.
202    #[doc(hidden)]
203    pub fn runner_env_var(target: &Platform) -> String {
204        let triple_str = target.triple_str().to_ascii_uppercase().replace('-', "_");
205        format!("CARGO_TARGET_{triple_str}_RUNNER")
206    }
207
208    fn parse_runner(
209        source: PlatformRunnerSource,
210        runner: Runner,
211        cwd: &Utf8Path,
212    ) -> Result<Self, TargetRunnerError> {
213        let (runner_binary, args) = match runner {
214            Runner::Simple(value) => {
215                // We only split on whitespace, which doesn't take quoting into account,
216                // but I believe that cargo doesn't do that either
217                let mut runner_iter = value.split_whitespace();
218
219                let runner_binary =
220                    runner_iter
221                        .next()
222                        .ok_or_else(|| TargetRunnerError::BinaryNotSpecified {
223                            key: source.clone(),
224                            value: value.clone(),
225                        })?;
226                let args = runner_iter.map(String::from).collect();
227                (
228                    Self::normalize_runner(runner_binary, source.resolve_dir(cwd)),
229                    args,
230                )
231            }
232            Runner::List(mut values) => {
233                if values.is_empty() {
234                    return Err(TargetRunnerError::BinaryNotSpecified {
235                        key: source,
236                        value: String::new(),
237                    });
238                } else {
239                    let runner_binary = values.remove(0);
240                    (
241                        Self::normalize_runner(&runner_binary, source.resolve_dir(cwd)),
242                        values,
243                    )
244                }
245            }
246        };
247
248        Ok(Self {
249            runner_binary,
250            args,
251            source,
252        })
253    }
254
255    // https://github.com/rust-lang/cargo/blob/40b674cd1115299034fafa34e7db3a9140b48a49/src/cargo/util/config/mod.rs#L735-L743
256    fn normalize_runner(runner_binary: &str, resolve_dir: &Utf8Path) -> Utf8PathBuf {
257        let is_path =
258            runner_binary.contains('/') || (cfg!(windows) && runner_binary.contains('\\'));
259        if is_path {
260            resolve_dir.join(runner_binary)
261        } else {
262            // A pathless name.
263            runner_binary.into()
264        }
265    }
266
267    /// Gets the runner binary path.
268    ///
269    /// Note that this is returned as a `str` specifically to avoid duct's
270    /// behavior of prepending `.` to paths it thinks are relative, the path
271    /// specified for a runner can be a full path, but it is most commonly a
272    /// binary found in `PATH`
273    #[inline]
274    pub fn binary(&self) -> &str {
275        self.runner_binary.as_str()
276    }
277
278    /// Gets the (optional) runner binary arguments
279    #[inline]
280    pub fn args(&self) -> impl Iterator<Item = &str> {
281        self.args.iter().map(AsRef::as_ref)
282    }
283
284    /// Returns the location where the platform runner is defined.
285    #[inline]
286    pub fn source(&self) -> &PlatformRunnerSource {
287        &self.source
288    }
289}
290
291/// The place where a platform runner's configuration was picked up from.
292///
293/// Returned by [`PlatformRunner::source`].
294#[derive(Clone, Debug, PartialEq, Eq)]
295pub enum PlatformRunnerSource {
296    /// The platform runner was defined by this environment variable.
297    Env(String),
298
299    /// The platform runner was defined through a `.cargo/config.toml` or `.cargo/config` file, or
300    /// via `--config` (unstable).
301    CargoConfig {
302        /// The configuration source.
303        source: CargoConfigSource,
304
305        /// The table name within `target` that was used.
306        ///
307        /// # Examples
308        ///
309        /// If `target.'cfg(target_os = "linux")'.runner` is used, this is `cfg(target_os = "linux")`.
310        target_table: String,
311    },
312}
313
314impl PlatformRunnerSource {
315    // https://github.com/rust-lang/cargo/blob/3959f87158ea4f8733e2fcbe032b8a50ae0b6834/src/cargo/util/config/value.rs#L66-L75
316    fn resolve_dir<'a>(&'a self, cwd: &'a Utf8Path) -> &'a Utf8Path {
317        match self {
318            Self::Env(_) => cwd,
319            Self::CargoConfig { source, .. } => source.resolve_dir(cwd),
320        }
321    }
322}
323
324impl fmt::Display for PlatformRunnerSource {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        match self {
327            Self::Env(var) => {
328                write!(f, "environment variable `{var}`")
329            }
330            Self::CargoConfig {
331                source: CargoConfigSource::CliOption,
332                target_table,
333            } => {
334                write!(f, "`target.{target_table}.runner` specified by `--config`")
335            }
336            Self::CargoConfig {
337                source: CargoConfigSource::File(path),
338                target_table,
339            } => {
340                write!(f, "`target.{target_table}.runner` within `{path}`")
341            }
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use camino_tempfile::Utf8TempDir;
350    use color_eyre::eyre::{Context, Result};
351    use target_spec::TargetFeatures;
352
353    #[test]
354    fn test_find_config() {
355        let dir = setup_temp_dir().unwrap();
356        let dir_path = dir.path().canonicalize_utf8().unwrap();
357        let dir_foo_path = dir_path.join("foo");
358        let dir_foo_bar_path = dir_foo_path.join("bar");
359
360        // ---
361        // Searches through the full directory tree
362        // ---
363        assert_eq!(
364            find_config(
365                Platform::new("x86_64-pc-windows-msvc", TargetFeatures::Unknown).unwrap(),
366                &[],
367                &dir_foo_bar_path,
368                &dir_path,
369            ),
370            Some(PlatformRunner {
371                runner_binary: "wine".into(),
372                args: vec!["--test-arg".into()],
373                source: PlatformRunnerSource::CargoConfig {
374                    source: CargoConfigSource::File(dir_path.join("foo/bar/.cargo/config.toml")),
375                    target_table: "x86_64-pc-windows-msvc".into()
376                },
377            }),
378        );
379
380        assert_eq!(
381            find_config(
382                Platform::new("x86_64-pc-windows-gnu", TargetFeatures::Unknown).unwrap(),
383                &[],
384                &dir_foo_bar_path,
385                &dir_path,
386            ),
387            Some(PlatformRunner {
388                runner_binary: "wine2".into(),
389                args: vec![],
390                source: PlatformRunnerSource::CargoConfig {
391                    source: CargoConfigSource::File(dir_path.join("foo/bar/.cargo/config.toml")),
392                    target_table: "cfg(windows)".into()
393                },
394            }),
395        );
396
397        assert_eq!(
398            find_config(
399                Platform::new("x86_64-unknown-linux-gnu", TargetFeatures::Unknown).unwrap(),
400                &[],
401                &dir_foo_bar_path,
402                &dir_path,
403            ),
404            Some(PlatformRunner {
405                runner_binary: dir_path.join("unix-runner"),
406                args: vec![],
407                source: PlatformRunnerSource::CargoConfig {
408                    source: CargoConfigSource::File(dir_path.join(".cargo/config")),
409                    target_table: "cfg(unix)".into()
410                },
411            }),
412        );
413
414        // ---
415        // Searches starting from the "foo" directory which has no .cargo/config in it
416        // ---
417        assert_eq!(
418            find_config(
419                Platform::new("x86_64-pc-windows-msvc", TargetFeatures::Unknown).unwrap(),
420                &[],
421                &dir_foo_path,
422                &dir_path,
423            ),
424            Some(PlatformRunner {
425                runner_binary: dir_path.join("../parent-wine"),
426                args: vec![],
427                source: PlatformRunnerSource::CargoConfig {
428                    source: CargoConfigSource::File(dir_path.join(".cargo/config")),
429                    target_table: "x86_64-pc-windows-msvc".into()
430                },
431            }),
432        );
433
434        assert_eq!(
435            find_config(
436                Platform::new("x86_64-pc-windows-gnu", TargetFeatures::Unknown).unwrap(),
437                &[],
438                &dir_foo_path,
439                &dir_path,
440            ),
441            None,
442        );
443
444        // ---
445        // Searches starting and ending at the root directory.
446        // ---
447        assert_eq!(
448            find_config(
449                Platform::new("x86_64-pc-windows-msvc", TargetFeatures::Unknown).unwrap(),
450                &[],
451                &dir_path,
452                &dir_path,
453            ),
454            Some(PlatformRunner {
455                runner_binary: dir_path.join("../parent-wine"),
456                args: vec![],
457                source: PlatformRunnerSource::CargoConfig {
458                    source: CargoConfigSource::File(dir_path.join(".cargo/config")),
459                    target_table: "x86_64-pc-windows-msvc".into()
460                },
461            }),
462        );
463
464        assert_eq!(
465            find_config(
466                Platform::new("x86_64-pc-windows-gnu", TargetFeatures::Unknown).unwrap(),
467                &[],
468                &dir_path,
469                &dir_path,
470            ),
471            None,
472        );
473
474        // ---
475        // CLI configs
476        // ---
477        assert_eq!(
478            find_config(
479                Platform::new("x86_64-pc-windows-msvc", TargetFeatures::Unknown).unwrap(),
480                &["target.'cfg(windows)'.runner='windows-runner'"],
481                &dir_path,
482                &dir_path,
483            ),
484            Some(PlatformRunner {
485                runner_binary: "windows-runner".into(),
486                args: vec![],
487                source: PlatformRunnerSource::CargoConfig {
488                    source: CargoConfigSource::CliOption,
489                    target_table: "cfg(windows)".into()
490                },
491            }),
492        );
493
494        assert_eq!(
495            find_config(
496                Platform::new("x86_64-pc-windows-gnu", TargetFeatures::Unknown).unwrap(),
497                &["target.'cfg(windows)'.runner='windows-runner'"],
498                &dir_path,
499                &dir_path,
500            ),
501            Some(PlatformRunner {
502                runner_binary: "windows-runner".into(),
503                args: vec![],
504                source: PlatformRunnerSource::CargoConfig {
505                    source: CargoConfigSource::CliOption,
506                    target_table: "cfg(windows)".into()
507                },
508            }),
509        );
510
511        // cfg(unix) doesn't match this platform.
512        assert_eq!(
513            find_config(
514                Platform::new("x86_64-pc-windows-msvc", TargetFeatures::Unknown).unwrap(),
515                &["target.'cfg(unix)'.runner='unix-runner'"],
516                &dir_path,
517                &dir_path,
518            ),
519            Some(PlatformRunner {
520                runner_binary: dir_path.join("../parent-wine"),
521                args: vec![],
522                source: PlatformRunnerSource::CargoConfig {
523                    source: CargoConfigSource::File(dir_path.join(".cargo/config")),
524                    target_table: "x86_64-pc-windows-msvc".into()
525                },
526            }),
527        );
528
529        assert_eq!(
530            find_config(
531                Platform::new("x86_64-pc-windows-gnu", TargetFeatures::Unknown).unwrap(),
532                &["target.'cfg(unix)'.runner='unix-runner'"],
533                &dir_path,
534                &dir_path,
535            ),
536            None,
537        );
538
539        // Config is followed from left to right.
540        assert_eq!(
541            find_config(
542                Platform::new("x86_64-pc-windows-msvc", TargetFeatures::Unknown).unwrap(),
543                &[
544                    "target.'cfg(windows)'.runner='windows-runner'",
545                    "target.'cfg(all())'.runner='all-runner'"
546                ],
547                &dir_path,
548                &dir_path,
549            ),
550            Some(PlatformRunner {
551                runner_binary: "windows-runner".into(),
552                args: vec![],
553                source: PlatformRunnerSource::CargoConfig {
554                    source: CargoConfigSource::CliOption,
555                    target_table: "cfg(windows)".into()
556                },
557            }),
558        );
559
560        assert_eq!(
561            find_config(
562                Platform::new("x86_64-pc-windows-msvc", TargetFeatures::Unknown).unwrap(),
563                &[
564                    "target.'cfg(all())'.runner='./all-runner'",
565                    "target.'cfg(windows)'.runner='windows-runner'",
566                ],
567                &dir_path,
568                &dir_path,
569            ),
570            Some(PlatformRunner {
571                runner_binary: dir_path.join("all-runner"),
572                args: vec![],
573                source: PlatformRunnerSource::CargoConfig {
574                    source: CargoConfigSource::CliOption,
575                    target_table: "cfg(all())".into()
576                },
577            }),
578        );
579    }
580
581    fn setup_temp_dir() -> Result<Utf8TempDir> {
582        let dir = camino_tempfile::Builder::new()
583            .tempdir()
584            .wrap_err("error creating tempdir")?;
585
586        std::fs::create_dir_all(dir.path().join(".cargo"))
587            .wrap_err("error creating .cargo subdir")?;
588        std::fs::create_dir_all(dir.path().join("foo/bar/.cargo"))
589            .wrap_err("error creating foo/bar/.cargo subdir")?;
590
591        std::fs::write(dir.path().join(".cargo/config"), CARGO_CONFIG_CONTENTS)
592            .wrap_err("error writing .cargo/config")?;
593        std::fs::write(
594            dir.path().join("foo/bar/.cargo/config.toml"),
595            FOO_BAR_CARGO_CONFIG_CONTENTS,
596        )
597        .wrap_err("error writing foo/bar/.cargo/config.toml")?;
598
599        Ok(dir)
600    }
601
602    fn find_config(
603        platform: Platform,
604        cli_configs: &[&str],
605        cwd: &Utf8Path,
606        terminate_search_at: &Utf8Path,
607    ) -> Option<PlatformRunner> {
608        let configs =
609            CargoConfigs::new_with_isolation(cli_configs, cwd, terminate_search_at, Vec::new())
610                .unwrap();
611        PlatformRunner::find_config(&configs, &platform).unwrap()
612    }
613
614    static CARGO_CONFIG_CONTENTS: &str = r#"
615    [target.x86_64-pc-windows-msvc]
616    runner = "../parent-wine"
617
618    [target.'cfg(unix)']
619    runner = "./unix-runner"
620    "#;
621
622    static FOO_BAR_CARGO_CONFIG_CONTENTS: &str = r#"
623    [target.x86_64-pc-windows-msvc]
624    runner = ["wine", "--test-arg"]
625
626    [target.'cfg(windows)']
627    runner = "wine2"
628    "#;
629}