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.then(|| {
130                        crate::_util::wslpath::linux_to_win(rt, working_dir_ref)
131                            .display()
132                            .to_string()
133                    });
134
135                    let tool_config_files: Vec<(String, PathBuf)> = tool_config_files
136                        .into_iter()
137                        .map(|(tool, var)| (tool, rt.read(var)))
138                        .collect();
139
140                    enum NextestInvocation {
141                        // when tests are already built and provided via archive
142                        Standalone { nextest_bin: PathBuf },
143                        // when tests need to be compiled first
144                        WithCargo { rust_toolchain: Option<String> },
145                    }
146
147                    // the invocation of `nextest run` is quite different
148                    // depending on whether this is an archived run or not, as
149                    // archives don't require passing build args (after all -
150                    // those were passed when the archive was built), nor do
151                    // they require having cargo installed.
152                    let (nextest_invocation, build_args, archive_file, build_env) =
153                        match run_kind_deps {
154                            RunKindDeps::BuildAndRun {
155                                params:
156                                    build_params::NextestBuildParams {
157                                        packages,
158                                        features,
159                                        no_default_features,
160                                        target,
161                                        profile,
162                                        extra_env,
163                                    },
164                                nextest_installed: _, // side-effect
165                                rust_toolchain,
166                                cargo_flags,
167                            } => {
168                                let (mut build_args, build_env) = cargo_nextest_build_args_and_env(
169                                    rt.read(cargo_flags),
170                                    profile,
171                                    target,
172                                    rt.read(packages),
173                                    features,
174                                    no_default_features,
175                                    rt.read(extra_env),
176                                );
177
178                                let nextest_invocation = NextestInvocation::WithCargo {
179                                    rust_toolchain: rt.read(rust_toolchain),
180                                };
181
182                                // nextest also requires explicitly specifying the
183                                // path to a cargo-metadata.json file when running
184                                // using --workspace-remap (which do we below).
185                                let cargo_metadata_path = std::env::current_dir()?
186                                    .absolute()?
187                                    .join("cargo_metadata.json");
188
189                                rt.sh.change_dir(&working_dir);
190                                let output =
191                                    flowey::shell_cmd!(rt, "cargo metadata --format-version 1")
192                                        .output()?;
193                                let cargo_metadata = String::from_utf8(output.stdout)?;
194                                fs_err::write(&cargo_metadata_path, cargo_metadata)?;
195
196                                build_args.push("--cargo-metadata".into());
197                                build_args.push(cargo_metadata_path.display().to_string());
198
199                                (nextest_invocation, build_args, None, build_env)
200                            }
201                            RunKindDeps::RunFromArchive {
202                                archive_file,
203                                nextest_bin,
204                                target: _,
205                            } => {
206                                let archive_file = rt.read(archive_file);
207                                let nextest_bin = rt.read(nextest_bin);
208
209                                (
210                                    NextestInvocation::Standalone { nextest_bin },
211                                    vec![],
212                                    Some(archive_file),
213                                    BTreeMap::default(),
214                                )
215                            }
216                        };
217
218                    // Convert a path via wslpath if running under WSL2,
219                    // otherwise just make it absolute.
220                    let wsl_convert_path = |path: PathBuf| -> anyhow::Result<PathBuf> {
221                        if windows_via_wsl2 {
222                            Ok(crate::_util::wslpath::linux_to_win(rt, path))
223                        } else {
224                            path.absolute()
225                                .with_context(|| format!("invalid path {}", path.display()))
226                        }
227                    };
228
229                    // Convert all known paths eagerly so the portable-path
230                    // closure below doesn't need the runtime.
231                    let config_file = wsl_convert_path(config_file)?;
232                    let converted_working_dir = wsl_convert_path(working_dir.clone())?;
233                    let tool_config_files: Vec<(String, PathBuf)> = tool_config_files
234                        .into_iter()
235                        .map(|(tool, path)| Ok((tool, wsl_convert_path(path)?)))
236                        .collect::<anyhow::Result<_>>()?;
237                    let archive_file = archive_file.map(&wsl_convert_path).transpose()?;
238
239                    // Make a converted path relative/portable if requested.
240                    let make_portable_path = |path: PathBuf| -> anyhow::Result<PathBuf> {
241                        let path = if portable {
242                            if windows_target {
243                                let working_dir_trimmed =
244                                    working_dir_win.as_ref().unwrap().trim_end_matches('\\');
245                                let path_win = path.display().to_string();
246                                let path_trimmed = path_win.trim_end_matches('\\');
247                                PathBuf::from(format!(
248                                    "$PSScriptRoot{}",
249                                    path_trimmed
250                                        .strip_prefix(working_dir_trimmed)
251                                        .with_context(|| format!(
252                                            "{} not in {}",
253                                            path_win, working_dir_trimmed
254                                        ),)?
255                                ))
256                            } else {
257                                path.strip_prefix(working_dir_ref)
258                                    .with_context(|| {
259                                        format!(
260                                            "{} not in {}",
261                                            path.display(),
262                                            working_dir_ref.display()
263                                        )
264                                    })?
265                                    .to_path_buf()
266                            }
267                        } else {
268                            path
269                        };
270                        Ok(path)
271                    };
272
273                    let mut args: Vec<OsString> = Vec::new();
274
275                    let argv0: OsString = match nextest_invocation {
276                        NextestInvocation::Standalone { nextest_bin } => if portable {
277                            make_portable_path(wsl_convert_path(nextest_bin)?)?
278                        } else {
279                            nextest_bin
280                        }
281                        .into(),
282                        NextestInvocation::WithCargo { rust_toolchain } => {
283                            if let Some(rust_toolchain) = rust_toolchain {
284                                args.extend(["run".into(), rust_toolchain.into(), "cargo".into()]);
285                                "rustup".into()
286                            } else {
287                                "cargo".into()
288                            }
289                        }
290                    };
291
292                    args.extend([
293                        "nextest".into(),
294                        "run".into(),
295                        "--profile".into(),
296                        (&nextest_profile).into(),
297                        "--config-file".into(),
298                        make_portable_path(config_file)?.into(),
299                        "--workspace-remap".into(),
300                        make_portable_path(converted_working_dir)?.into(),
301                    ]);
302
303                    for (tool, config_file) in tool_config_files {
304                        args.extend([
305                            "--tool-config-file".into(),
306                            format!("{}:{}", tool, make_portable_path(config_file)?.display())
307                                .into(),
308                        ]);
309                    }
310
311                    if let Some(archive_file) = archive_file {
312                        args.extend([
313                            "--archive-file".into(),
314                            make_portable_path(archive_file)?.into(),
315                        ]);
316                    }
317
318                    args.extend(build_args.into_iter().map(Into::into));
319
320                    if let Some(nextest_filter_expr) = nextest_filter_expr {
321                        args.push("--filter-expr".into());
322                        args.push(nextest_filter_expr.into());
323                    }
324
325                    if run_ignored {
326                        args.push("--run-ignored".into());
327                        args.push("all".into());
328                    }
329
330                    if let Some(fail_fast) = fail_fast {
331                        if fail_fast {
332                            args.push("--fail-fast".into());
333                        } else {
334                            args.push("--no-fail-fast".into());
335                        }
336                    }
337
338                    // useful default to have
339                    if !with_env.contains_key("RUST_BACKTRACE") {
340                        with_env.insert("RUST_BACKTRACE".into(), "1".into());
341                    }
342
343                    // also update WSLENV in cases where we're running windows tests via WSL2
344                    if !portable && crate::_util::running_in_wsl(rt) {
345                        let old_wslenv = std::env::var("WSLENV");
346                        let new_wslenv = with_env.keys().cloned().collect::<Vec<_>>().join(":");
347                        with_env.insert(
348                            "WSLENV".into(),
349                            format!(
350                                "{}{}",
351                                old_wslenv.map(|s| s + ":").unwrap_or_default(),
352                                new_wslenv
353                            ),
354                        );
355                    }
356
357                    // the build_env vars don't need to be mirrored to WSLENV,
358                    // and so they are only injected after the WSLENV code has
359                    // run.
360                    with_env.extend(build_env);
361
362                    commands.push((argv0, args));
363
364                    rt.write(
365                        command,
366                        &Script {
367                            env: with_env,
368                            commands,
369                            shell: if (portable || !windows_via_wsl2)
370                                && matches!(
371                                    target.operating_system,
372                                    target_lexicon::OperatingSystem::Windows
373                                ) {
374                                CommandShell::Powershell
375                            } else {
376                                CommandShell::Bash
377                            },
378                        },
379                    );
380
381                    Ok(())
382                }
383            });
384        }
385
386        Ok(())
387    }
388}
389
390// shared with `cargo_nextest_archive`
391pub(crate) fn cargo_nextest_build_args_and_env(
392    cargo_flags: crate::cfg_cargo_common_flags::Flags,
393    cargo_profile: CargoBuildProfile,
394    target: target_lexicon::Triple,
395    packages: build_params::TestPackages,
396    features: crate::run_cargo_build::CargoFeatureSet,
397    no_default_features: bool,
398    mut extra_env: BTreeMap<String, String>,
399) -> (Vec<String>, BTreeMap<String, String>) {
400    let no_incremental = cargo_flags.no_incremental;
401    let locked = cargo_flags.locked.then_some("--locked");
402    let verbose = cargo_flags.verbose.then_some("--verbose");
403    let cargo_profile = match &cargo_profile {
404        CargoBuildProfile::Debug => "dev",
405        CargoBuildProfile::Release => "release",
406        CargoBuildProfile::Custom(s) => s,
407    };
408    let target = target.to_string();
409
410    let packages: Vec<String> = {
411        // exclude benches
412        let mut v = vec!["--tests".into(), "--bins".into()];
413
414        match packages {
415            build_params::TestPackages::Workspace { exclude } => {
416                v.push("--workspace".into());
417                for crate_name in exclude {
418                    v.push("--exclude".into());
419                    v.push(crate_name);
420                }
421            }
422            build_params::TestPackages::Crates { crates } => {
423                for crate_name in crates {
424                    v.push("-p".into());
425                    v.push(crate_name);
426                }
427            }
428        }
429
430        v
431    };
432
433    let mut args = Vec::new();
434    args.extend(locked.map(Into::into));
435    args.extend(verbose.map(Into::into));
436    args.push("--cargo-profile".into());
437    args.push(cargo_profile.into());
438    args.push("--target".into());
439    args.push(target);
440    args.extend(packages);
441    if no_default_features {
442        args.push("--no-default-features".into())
443    }
444    args.extend(features.to_cargo_arg_strings());
445
446    let mut env = BTreeMap::new();
447
448    if no_incremental {
449        env.insert("CARGO_INCREMENTAL".into(), "0".into());
450    }
451
452    env.append(&mut extra_env);
453
454    (args, env)
455}
456
457// FUTURE: this seems like something a proc-macro can help with...
458impl RunKindDeps {
459    pub fn claim(self, ctx: &mut StepCtx<'_>) -> RunKindDeps<VarClaimed> {
460        match self {
461            RunKindDeps::BuildAndRun {
462                params,
463                nextest_installed,
464                rust_toolchain,
465                cargo_flags,
466            } => RunKindDeps::BuildAndRun {
467                params: params.claim(ctx),
468                nextest_installed: nextest_installed.claim(ctx),
469                rust_toolchain: rust_toolchain.claim(ctx),
470                cargo_flags: cargo_flags.claim(ctx),
471            },
472            RunKindDeps::RunFromArchive {
473                archive_file,
474                nextest_bin,
475                target,
476            } => RunKindDeps::RunFromArchive {
477                archive_file: archive_file.claim(ctx),
478                nextest_bin: nextest_bin.claim(ctx),
479                target,
480            },
481        }
482    }
483}
484
485impl CommandShell {
486    /// Quote a value as a literal for this shell. Single quotes suppress all
487    /// expansion, so `'` is the only character that needs escaping.
488    fn quote(&self, s: &OsStr) -> String {
489        match self {
490            CommandShell::Powershell => powershell_builder::quote_str(s)
491                .to_string_lossy()
492                .into_owned(),
493            CommandShell::Bash => format!("'{}'", s.to_string_lossy().replace('\'', r"'\''")),
494        }
495    }
496}
497
498impl std::fmt::Display for Script {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        let env_string = match self.shell {
501            CommandShell::Powershell => self
502                .env
503                .iter()
504                .map(|(k, v)| format!("$env:{k}={}", self.shell.quote(OsStr::new(v))))
505                .collect::<Vec<_>>()
506                .join("\n"),
507            CommandShell::Bash => self
508                .env
509                .iter()
510                .map(|(k, v)| format!("export {k}={}", self.shell.quote(OsStr::new(v))))
511                .collect::<Vec<_>>()
512                .join("\n"),
513        };
514        writeln!(f, "{env_string}")?;
515
516        for cmd in &self.commands {
517            let argv0_string = self.shell.quote(&cmd.0);
518            let argv0_string = match self.shell {
519                CommandShell::Powershell => format!("&{argv0_string}"),
520                CommandShell::Bash => argv0_string,
521            };
522
523            let arg_string = {
524                cmd.1
525                    .iter()
526                    .map(|v| self.shell.quote(v))
527                    .collect::<Vec<_>>()
528                    .join(" ")
529            };
530            writeln!(f, "{argv0_string} {arg_string}")?;
531        }
532
533        Ok(())
534    }
535}