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