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