Skip to main content

flowey_lib_common/
gen_cargo_nextest_run_cmd.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Generate a cargo-nextest run command.
5
6use crate::run_cargo_build::CargoBuildProfile;
7use crate::run_cargo_nextest_run::build_params;
8use flowey::node::prelude::*;
9use std::collections::BTreeMap;
10use std::ffi::OsStr;
11use std::ffi::OsString;
12
13flowey_request! {
14    pub struct Request {
15        /// What kind of test run this is (inline build vs. from nextest archive).
16        pub run_kind_deps: RunKindDeps,
17        /// Working directory the test archive was created from.
18        pub working_dir: ReadVar<PathBuf>,
19        /// Path to `.config/nextest.toml`
20        pub config_file: ReadVar<PathBuf>,
21        /// Path to any tool-specific config files
22        pub tool_config_files: Vec<(String, ReadVar<PathBuf>)>,
23        /// Nextest profile to use when running the source code (as defined in the
24        /// `.config.nextest.toml`).
25        pub nextest_profile: String,
26        /// Nextest test filter expression
27        pub nextest_filter_expr: Option<String>,
28        /// Whether to run ignored tests
29        pub run_ignored: bool,
30        /// Override fail fast setting
31        pub fail_fast: Option<bool>,
32        /// Additional env vars set when executing the tests.
33        pub extra_env: Option<ReadVar<BTreeMap<String, String>>>,
34        /// Additional command to run before the tests
35        pub extra_commands: Option<ReadVar<Vec<(OsString, Vec<OsString>)>>>,
36        /// Generate a portable command with paths relative to `test_content_dir`
37        pub portable: bool,
38        /// Command for running the tests
39        pub command: WriteVar<Script>,
40    }
41}
42
43#[derive(Serialize, Deserialize)]
44pub enum RunKindDeps<C = VarNotClaimed> {
45    BuildAndRun {
46        params: build_params::NextestBuildParams<C>,
47        nextest_installed: ReadVar<SideEffect, C>,
48        rust_toolchain: ReadVar<Option<String>, C>,
49        cargo_flags: ReadVar<crate::cfg_cargo_common_flags::Flags, C>,
50    },
51    RunFromArchive {
52        archive_file: ReadVar<PathBuf, C>,
53        nextest_bin: ReadVar<PathBuf, C>,
54        target: target_lexicon::Triple,
55    },
56}
57
58#[derive(Serialize, Deserialize)]
59pub enum CommandShell {
60    Powershell,
61    Bash,
62}
63
64#[derive(Serialize, Deserialize)]
65pub struct Script {
66    pub env: BTreeMap<String, String>,
67    pub commands: Vec<(OsString, Vec<OsString>)>,
68    pub shell: CommandShell,
69}
70
71new_flow_node!(struct Node);
72
73impl FlowNode for Node {
74    type Request = Request;
75
76    fn imports(ctx: &mut ImportCtx<'_>) {
77        ctx.import::<crate::cfg_cargo_common_flags::Node>();
78    }
79
80    fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
81        for Request {
82            run_kind_deps,
83            working_dir,
84            config_file,
85            tool_config_files,
86            nextest_profile,
87            extra_env,
88            extra_commands,
89            nextest_filter_expr,
90            run_ignored,
91            fail_fast,
92            portable,
93            command,
94        } in requests
95        {
96            ctx.emit_rust_step("generate nextest command", |ctx| {
97                let run_kind_deps = run_kind_deps.claim(ctx);
98                let working_dir = working_dir.claim(ctx);
99                let config_file = config_file.claim(ctx);
100                let tool_config_files = tool_config_files
101                    .into_iter()
102                    .map(|(a, b)| (a, b.claim(ctx)))
103                    .collect::<Vec<_>>();
104                let extra_env = extra_env.claim(ctx);
105                let extra_commands = extra_commands.claim(ctx);
106                let command = command.claim(ctx);
107
108                move |rt| {
109                    let working_dir = rt.read(working_dir);
110                    let config_file = rt.read(config_file);
111                    let mut with_env = rt.read(extra_env).unwrap_or_default();
112                    let mut commands = rt.read(extra_commands).unwrap_or_default();
113
114                    let target = match &run_kind_deps {
115                        RunKindDeps::BuildAndRun {
116                            params: build_params::NextestBuildParams { target, .. },
117                            ..
118                        } => target.clone(),
119                        RunKindDeps::RunFromArchive { target, .. } => target.clone(),
120                    };
121
122                    let windows_target = matches!(
123                        target.operating_system,
124                        target_lexicon::OperatingSystem::Windows
125                    );
126                    let windows_via_wsl2 = windows_target && crate::_util::running_in_wsl(rt);
127
128                    let working_dir_ref = working_dir.as_path();
129                    let working_dir_win = windows_via_wsl2
130                        .then(|| crate::_util::wslpath::linux_to_win(rt, working_dir_ref))
131                        .transpose()?
132                        .map(|path| path.display().to_string());
133
134                    let tool_config_files: Vec<(String, PathBuf)> = tool_config_files
135                        .into_iter()
136                        .map(|(tool, var)| (tool, rt.read(var)))
137                        .collect();
138
139                    enum NextestInvocation {
140                        // when tests are already built and provided via archive
141                        Standalone { nextest_bin: PathBuf },
142                        // when tests need to be compiled first
143                        WithCargo { rust_toolchain: Option<String> },
144                    }
145
146                    // the invocation of `nextest run` is quite different
147                    // depending on whether this is an archived run or not, as
148                    // archives don't require passing build args (after all -
149                    // those were passed when the archive was built), nor do
150                    // they require having cargo installed.
151                    let (nextest_invocation, build_args, archive_file, build_env) =
152                        match run_kind_deps {
153                            RunKindDeps::BuildAndRun {
154                                params:
155                                    build_params::NextestBuildParams {
156                                        packages,
157                                        features,
158                                        no_default_features,
159                                        target,
160                                        profile,
161                                        extra_env,
162                                    },
163                                nextest_installed: _, // side-effect
164                                rust_toolchain,
165                                cargo_flags,
166                            } => {
167                                let (mut build_args, build_env) = cargo_nextest_build_args_and_env(
168                                    rt.read(cargo_flags),
169                                    profile,
170                                    target,
171                                    rt.read(packages),
172                                    features,
173                                    no_default_features,
174                                    rt.read(extra_env),
175                                );
176
177                                let nextest_invocation = NextestInvocation::WithCargo {
178                                    rust_toolchain: rt.read(rust_toolchain),
179                                };
180
181                                // nextest also requires explicitly specifying the
182                                // path to a cargo-metadata.json file when running
183                                // using --workspace-remap (which do we below).
184                                let cargo_metadata_path = std::env::current_dir()?
185                                    .absolute()?
186                                    .join("cargo_metadata.json");
187
188                                rt.sh.change_dir(&working_dir);
189                                let output =
190                                    flowey::shell_cmd!(rt, "cargo metadata --format-version 1")
191                                        .output()?;
192                                let cargo_metadata = String::from_utf8(output.stdout)?;
193                                fs_err::write(&cargo_metadata_path, cargo_metadata)?;
194
195                                build_args.push("--cargo-metadata".into());
196                                build_args.push(cargo_metadata_path.display().to_string());
197
198                                (nextest_invocation, build_args, None, build_env)
199                            }
200                            RunKindDeps::RunFromArchive {
201                                archive_file,
202                                nextest_bin,
203                                target: _,
204                            } => {
205                                let archive_file = rt.read(archive_file);
206                                let nextest_bin = rt.read(nextest_bin);
207
208                                (
209                                    NextestInvocation::Standalone { nextest_bin },
210                                    vec![],
211                                    Some(archive_file),
212                                    BTreeMap::default(),
213                                )
214                            }
215                        };
216
217                    // Convert a path via wslpath if running under WSL2,
218                    // otherwise just make it absolute.
219                    let wsl_convert_path = |path: PathBuf| -> anyhow::Result<PathBuf> {
220                        if windows_via_wsl2 {
221                            crate::_util::wslpath::linux_to_win(rt, path)
222                        } else {
223                            path.absolute()
224                                .with_context(|| format!("invalid path {}", path.display()))
225                        }
226                    };
227
228                    // Convert all known paths eagerly so the portable-path
229                    // closure below doesn't need the runtime.
230                    let config_file = wsl_convert_path(config_file)?;
231                    let converted_working_dir = wsl_convert_path(working_dir.clone())?;
232                    let tool_config_files: Vec<(String, PathBuf)> = tool_config_files
233                        .into_iter()
234                        .map(|(tool, path)| Ok((tool, wsl_convert_path(path)?)))
235                        .collect::<anyhow::Result<_>>()?;
236                    let archive_file = archive_file.map(&wsl_convert_path).transpose()?;
237
238                    // Make a converted path relative/portable if requested.
239                    let make_portable_path = |path: PathBuf| -> anyhow::Result<PathBuf> {
240                        let path = if portable {
241                            if windows_target {
242                                let working_dir_trimmed =
243                                    working_dir_win.as_ref().unwrap().trim_end_matches('\\');
244                                let path_win = path.display().to_string();
245                                let path_trimmed = path_win.trim_end_matches('\\');
246                                PathBuf::from(format!(
247                                    "$PSScriptRoot{}",
248                                    path_trimmed
249                                        .strip_prefix(working_dir_trimmed)
250                                        .with_context(|| format!(
251                                            "{} not in {}",
252                                            path_win, working_dir_trimmed
253                                        ),)?
254                                ))
255                            } else {
256                                path.strip_prefix(working_dir_ref)
257                                    .with_context(|| {
258                                        format!(
259                                            "{} not in {}",
260                                            path.display(),
261                                            working_dir_ref.display()
262                                        )
263                                    })?
264                                    .to_path_buf()
265                            }
266                        } else {
267                            path
268                        };
269                        Ok(path)
270                    };
271
272                    let mut args: Vec<OsString> = Vec::new();
273
274                    let argv0: OsString = match nextest_invocation {
275                        NextestInvocation::Standalone { nextest_bin } => if portable {
276                            make_portable_path(wsl_convert_path(nextest_bin)?)?
277                        } else {
278                            nextest_bin
279                        }
280                        .into(),
281                        NextestInvocation::WithCargo { rust_toolchain } => {
282                            if let Some(rust_toolchain) = rust_toolchain {
283                                args.extend(["run".into(), rust_toolchain.into(), "cargo".into()]);
284                                "rustup".into()
285                            } else {
286                                "cargo".into()
287                            }
288                        }
289                    };
290
291                    args.extend([
292                        "nextest".into(),
293                        "run".into(),
294                        "--profile".into(),
295                        (&nextest_profile).into(),
296                        "--config-file".into(),
297                        make_portable_path(config_file)?.into(),
298                        "--workspace-remap".into(),
299                        make_portable_path(converted_working_dir)?.into(),
300                    ]);
301
302                    for (tool, config_file) in tool_config_files {
303                        args.extend([
304                            "--tool-config-file".into(),
305                            format!("{}:{}", tool, make_portable_path(config_file)?.display())
306                                .into(),
307                        ]);
308                    }
309
310                    if let Some(archive_file) = archive_file {
311                        args.extend([
312                            "--archive-file".into(),
313                            make_portable_path(archive_file)?.into(),
314                        ]);
315                    }
316
317                    args.extend(build_args.into_iter().map(Into::into));
318
319                    if let Some(nextest_filter_expr) = nextest_filter_expr {
320                        args.push("--filter-expr".into());
321                        args.push(nextest_filter_expr.into());
322                    }
323
324                    if run_ignored {
325                        args.push("--run-ignored".into());
326                        args.push("all".into());
327                    }
328
329                    if let Some(fail_fast) = fail_fast {
330                        if fail_fast {
331                            args.push("--fail-fast".into());
332                        } else {
333                            args.push("--no-fail-fast".into());
334                        }
335                    }
336
337                    // useful default to have
338                    if !with_env.contains_key("RUST_BACKTRACE") {
339                        with_env.insert("RUST_BACKTRACE".into(), "1".into());
340                    }
341
342                    // also update WSLENV in cases where we're running windows tests via WSL2
343                    if !portable && crate::_util::running_in_wsl(rt) {
344                        let old_wslenv = std::env::var("WSLENV");
345                        let new_wslenv = with_env.keys().cloned().collect::<Vec<_>>().join(":");
346                        with_env.insert(
347                            "WSLENV".into(),
348                            format!(
349                                "{}{}",
350                                old_wslenv.map(|s| s + ":").unwrap_or_default(),
351                                new_wslenv
352                            ),
353                        );
354                    }
355
356                    // the build_env vars don't need to be mirrored to WSLENV,
357                    // and so they are only injected after the WSLENV code has
358                    // run.
359                    with_env.extend(build_env);
360
361                    commands.push((argv0, args));
362
363                    rt.write(
364                        command,
365                        &Script {
366                            env: with_env,
367                            commands,
368                            shell: if (portable || !windows_via_wsl2)
369                                && matches!(
370                                    target.operating_system,
371                                    target_lexicon::OperatingSystem::Windows
372                                ) {
373                                CommandShell::Powershell
374                            } else {
375                                CommandShell::Bash
376                            },
377                        },
378                    );
379
380                    Ok(())
381                }
382            });
383        }
384
385        Ok(())
386    }
387}
388
389// shared with `cargo_nextest_archive`
390pub(crate) fn cargo_nextest_build_args_and_env(
391    cargo_flags: crate::cfg_cargo_common_flags::Flags,
392    cargo_profile: CargoBuildProfile,
393    target: target_lexicon::Triple,
394    packages: build_params::TestPackages,
395    features: crate::run_cargo_build::CargoFeatureSet,
396    no_default_features: bool,
397    mut extra_env: BTreeMap<String, String>,
398) -> (Vec<String>, BTreeMap<String, String>) {
399    let no_incremental = cargo_flags.no_incremental;
400    let locked = cargo_flags.locked.then_some("--locked");
401    let verbose = cargo_flags.verbose.then_some("--verbose");
402    let cargo_profile = cargo_profile.as_cargo_profile();
403    let target = target.to_string();
404
405    let packages: Vec<String> = {
406        // exclude benches
407        let mut v = vec!["--tests".into(), "--bins".into()];
408
409        match packages {
410            build_params::TestPackages::Workspace { exclude } => {
411                v.push("--workspace".into());
412                for crate_name in exclude {
413                    v.push("--exclude".into());
414                    v.push(crate_name);
415                }
416            }
417            build_params::TestPackages::Crates { crates } => {
418                for crate_name in crates {
419                    v.push("-p".into());
420                    v.push(crate_name);
421                }
422            }
423        }
424
425        v
426    };
427
428    let mut args = Vec::new();
429    args.extend(locked.map(Into::into));
430    args.extend(verbose.map(Into::into));
431    args.push("--cargo-profile".into());
432    args.push(cargo_profile.into());
433    args.push("--target".into());
434    args.push(target);
435    args.extend(packages);
436    if no_default_features {
437        args.push("--no-default-features".into())
438    }
439    args.extend(features.to_cargo_arg_strings());
440
441    let mut env = BTreeMap::new();
442
443    if no_incremental {
444        env.insert("CARGO_INCREMENTAL".into(), "0".into());
445    }
446
447    env.append(&mut extra_env);
448
449    (args, env)
450}
451
452// FUTURE: this seems like something a proc-macro can help with...
453impl RunKindDeps {
454    pub fn claim(self, ctx: &mut StepCtx<'_>) -> RunKindDeps<VarClaimed> {
455        match self {
456            RunKindDeps::BuildAndRun {
457                params,
458                nextest_installed,
459                rust_toolchain,
460                cargo_flags,
461            } => RunKindDeps::BuildAndRun {
462                params: params.claim(ctx),
463                nextest_installed: nextest_installed.claim(ctx),
464                rust_toolchain: rust_toolchain.claim(ctx),
465                cargo_flags: cargo_flags.claim(ctx),
466            },
467            RunKindDeps::RunFromArchive {
468                archive_file,
469                nextest_bin,
470                target,
471            } => RunKindDeps::RunFromArchive {
472                archive_file: archive_file.claim(ctx),
473                nextest_bin: nextest_bin.claim(ctx),
474                target,
475            },
476        }
477    }
478}
479
480impl CommandShell {
481    /// Quote a value as a literal for this shell. Single quotes suppress all
482    /// expansion, so `'` is the only character that needs escaping.
483    fn quote(&self, s: &OsStr) -> String {
484        match self {
485            CommandShell::Powershell => powershell_builder::quote_str(s)
486                .to_string_lossy()
487                .into_owned(),
488            CommandShell::Bash => format!("'{}'", s.to_string_lossy().replace('\'', r"'\''")),
489        }
490    }
491}
492
493impl std::fmt::Display for Script {
494    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
495        let env_string = match self.shell {
496            CommandShell::Powershell => self
497                .env
498                .iter()
499                .map(|(k, v)| format!("$env:{k}={}", self.shell.quote(OsStr::new(v))))
500                .collect::<Vec<_>>()
501                .join("\n"),
502            CommandShell::Bash => self
503                .env
504                .iter()
505                .map(|(k, v)| format!("export {k}={}", self.shell.quote(OsStr::new(v))))
506                .collect::<Vec<_>>()
507                .join("\n"),
508        };
509        writeln!(f, "{env_string}")?;
510
511        for cmd in &self.commands {
512            let argv0_string = self.shell.quote(&cmd.0);
513            let argv0_string = match self.shell {
514                CommandShell::Powershell => format!("&{argv0_string}"),
515                CommandShell::Bash => argv0_string,
516            };
517
518            let arg_string = {
519                cmd.1
520                    .iter()
521                    .map(|v| self.shell.quote(v))
522                    .collect::<Vec<_>>()
523                    .join(" ")
524            };
525            writeln!(f, "{argv0_string} {arg_string}")?;
526        }
527
528        Ok(())
529    }
530}