nextest_runner/
helpers.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! General support code for nextest-runner.
5
6use crate::{
7    config::ScriptId,
8    list::{Styles, TestInstanceId},
9    reporter::events::AbortStatus,
10    write_str::WriteStr,
11};
12use camino::{Utf8Path, Utf8PathBuf};
13use owo_colors::{OwoColorize, Style};
14use std::{fmt, io, path::PathBuf, process::ExitStatus, time::Duration};
15
16/// Utilities for pluralizing various words based on count or plurality.
17pub mod plural {
18    /// Returns "were" if `plural` is true, otherwise "was".
19    pub fn were_plural_if(plural: bool) -> &'static str {
20        if plural { "were" } else { "was" }
21    }
22
23    /// Returns "setup script" if `count` is 1, otherwise "setup scripts".
24    pub fn setup_scripts_str(count: usize) -> &'static str {
25        if count == 1 {
26            "setup script"
27        } else {
28            "setup scripts"
29        }
30    }
31
32    /// Returns "test" if `count` is 1, otherwise "tests".
33    pub fn tests_str(count: usize) -> &'static str {
34        tests_plural_if(count != 1)
35    }
36
37    /// Returns "tests" if `plural` is true, otherwise "test".
38    pub fn tests_plural_if(plural: bool) -> &'static str {
39        if plural { "tests" } else { "test" }
40    }
41
42    /// Returns "binary" if `count` is 1, otherwise "binaries".
43    pub fn binaries_str(count: usize) -> &'static str {
44        if count == 1 { "binary" } else { "binaries" }
45    }
46
47    /// Returns "path" if `count` is 1, otherwise "paths".
48    pub fn paths_str(count: usize) -> &'static str {
49        if count == 1 { "path" } else { "paths" }
50    }
51
52    /// Returns "file" if `count` is 1, otherwise "files".
53    pub fn files_str(count: usize) -> &'static str {
54        if count == 1 { "file" } else { "files" }
55    }
56
57    /// Returns "directory" if `count` is 1, otherwise "directories".
58    pub fn directories_str(count: usize) -> &'static str {
59        if count == 1 {
60            "directory"
61        } else {
62            "directories"
63        }
64    }
65
66    /// Returns "this crate" if `count` is 1, otherwise "these crates".
67    pub fn this_crate_str(count: usize) -> &'static str {
68        if count == 1 {
69            "this crate"
70        } else {
71            "these crates"
72        }
73    }
74
75    /// Returns "library" if `count` is 1, otherwise "libraries".
76    pub fn libraries_str(count: usize) -> &'static str {
77        if count == 1 { "library" } else { "libraries" }
78    }
79
80    /// Returns "filter" if `count` is 1, otherwise "filters".
81    pub fn filters_str(count: usize) -> &'static str {
82        if count == 1 { "filter" } else { "filters" }
83    }
84
85    /// Returns "section" if `count` is 1, otherwise "sections".
86    pub fn sections_str(count: usize) -> &'static str {
87        if count == 1 { "section" } else { "sections" }
88    }
89}
90
91pub(crate) struct DisplayTestInstance<'a> {
92    instance: TestInstanceId<'a>,
93    styles: &'a Styles,
94}
95
96impl<'a> DisplayTestInstance<'a> {
97    pub(crate) fn new(instance: TestInstanceId<'a>, styles: &'a Styles) -> Self {
98        Self { instance, styles }
99    }
100}
101
102impl fmt::Display for DisplayTestInstance<'_> {
103    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
104        write!(
105            f,
106            "{} ",
107            self.instance.binary_id.style(self.styles.binary_id),
108        )?;
109        fmt_write_test_name(self.instance.test_name, self.styles, f)
110    }
111}
112
113pub(crate) struct DisplayScriptInstance {
114    script_id: ScriptId,
115    full_command: String,
116    script_id_style: Style,
117}
118
119impl DisplayScriptInstance {
120    pub(crate) fn new(
121        script_id: ScriptId,
122        command: &str,
123        args: &[String],
124        script_id_style: Style,
125    ) -> Self {
126        let full_command =
127            shell_words::join(std::iter::once(command).chain(args.iter().map(|arg| arg.as_ref())));
128
129        Self {
130            script_id,
131            full_command,
132            script_id_style,
133        }
134    }
135}
136
137impl fmt::Display for DisplayScriptInstance {
138    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
139        write!(
140            f,
141            "{}: {}",
142            self.script_id.style(self.script_id_style),
143            self.full_command,
144        )
145    }
146}
147
148/// Write out a test name.
149pub(crate) fn write_test_name(
150    name: &str,
151    style: &Styles,
152    writer: &mut dyn WriteStr,
153) -> io::Result<()> {
154    // Look for the part of the test after the last ::, if any.
155    let mut splits = name.rsplitn(2, "::");
156    let trailing = splits.next().expect("test should have at least 1 element");
157    if let Some(rest) = splits.next() {
158        write!(
159            writer,
160            "{}{}",
161            rest.style(style.module_path),
162            "::".style(style.module_path)
163        )?;
164    }
165    write!(writer, "{}", trailing.style(style.test_name))?;
166
167    Ok(())
168}
169
170/// Write out a test name, `std::fmt::Write` version.
171pub(crate) fn fmt_write_test_name(
172    name: &str,
173    style: &Styles,
174    writer: &mut dyn fmt::Write,
175) -> fmt::Result {
176    // Look for the part of the test after the last ::, if any.
177    let mut splits = name.rsplitn(2, "::");
178    let trailing = splits.next().expect("test should have at least 1 element");
179    if let Some(rest) = splits.next() {
180        write!(
181            writer,
182            "{}{}",
183            rest.style(style.module_path),
184            "::".style(style.module_path)
185        )?;
186    }
187    write!(writer, "{}", trailing.style(style.test_name))?;
188
189    Ok(())
190}
191
192pub(crate) fn convert_build_platform(
193    platform: nextest_metadata::BuildPlatform,
194) -> guppy::graph::cargo::BuildPlatform {
195    match platform {
196        nextest_metadata::BuildPlatform::Target => guppy::graph::cargo::BuildPlatform::Target,
197        nextest_metadata::BuildPlatform::Host => guppy::graph::cargo::BuildPlatform::Host,
198    }
199}
200
201// ---
202// Functions below copied from cargo-util to avoid pulling in a bunch of dependencies
203// ---
204
205/// Returns the name of the environment variable used for searching for
206/// dynamic libraries.
207pub(crate) fn dylib_path_envvar() -> &'static str {
208    if cfg!(windows) {
209        "PATH"
210    } else if cfg!(target_os = "macos") {
211        // When loading and linking a dynamic library or bundle, dlopen
212        // searches in LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, PWD, and
213        // DYLD_FALLBACK_LIBRARY_PATH.
214        // In the Mach-O format, a dynamic library has an "install path."
215        // Clients linking against the library record this path, and the
216        // dynamic linker, dyld, uses it to locate the library.
217        // dyld searches DYLD_LIBRARY_PATH *before* the install path.
218        // dyld searches DYLD_FALLBACK_LIBRARY_PATH only if it cannot
219        // find the library in the install path.
220        // Setting DYLD_LIBRARY_PATH can easily have unintended
221        // consequences.
222        //
223        // Also, DYLD_LIBRARY_PATH appears to have significant performance
224        // penalty starting in 10.13. Cargo's testsuite ran more than twice as
225        // slow with it on CI.
226        "DYLD_FALLBACK_LIBRARY_PATH"
227    } else {
228        "LD_LIBRARY_PATH"
229    }
230}
231
232/// Returns a list of directories that are searched for dynamic libraries.
233///
234/// Note that some operating systems will have defaults if this is empty that
235/// will need to be dealt with.
236pub(crate) fn dylib_path() -> Vec<PathBuf> {
237    match std::env::var_os(dylib_path_envvar()) {
238        Some(var) => std::env::split_paths(&var).collect(),
239        None => Vec::new(),
240    }
241}
242
243/// On Windows, convert relative paths to always use forward slashes.
244#[cfg(windows)]
245pub(crate) fn convert_rel_path_to_forward_slash(rel_path: &Utf8Path) -> Utf8PathBuf {
246    if !rel_path.is_relative() {
247        panic!(
248            "path for conversion to forward slash '{}' is not relative",
249            rel_path
250        );
251    }
252    rel_path.as_str().replace('\\', "/").into()
253}
254
255#[cfg(not(windows))]
256pub(crate) fn convert_rel_path_to_forward_slash(rel_path: &Utf8Path) -> Utf8PathBuf {
257    rel_path.to_path_buf()
258}
259
260/// On Windows, convert relative paths to use the main separator.
261#[cfg(windows)]
262pub(crate) fn convert_rel_path_to_main_sep(rel_path: &Utf8Path) -> Utf8PathBuf {
263    if !rel_path.is_relative() {
264        panic!(
265            "path for conversion to backslash '{}' is not relative",
266            rel_path
267        );
268    }
269    rel_path.as_str().replace('/', "\\").into()
270}
271
272#[cfg(not(windows))]
273pub(crate) fn convert_rel_path_to_main_sep(rel_path: &Utf8Path) -> Utf8PathBuf {
274    rel_path.to_path_buf()
275}
276
277/// Join relative paths using forward slashes.
278pub(crate) fn rel_path_join(rel_path: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
279    assert!(rel_path.is_relative(), "rel_path {rel_path} is relative");
280    assert!(path.is_relative(), "path {path} is relative",);
281    format!("{rel_path}/{path}").into()
282}
283
284#[derive(Debug)]
285pub(crate) struct FormattedDuration(pub(crate) Duration);
286
287impl fmt::Display for FormattedDuration {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        let duration = self.0.as_secs_f64();
290        if duration > 60.0 {
291            write!(f, "{}m {:.2}s", duration as u32 / 60, duration % 60.0)
292        } else {
293            write!(f, "{duration:.2}s")
294        }
295    }
296}
297
298// "exited with"/"terminated via"
299pub(crate) fn display_exited_with(exit_status: ExitStatus) -> String {
300    match AbortStatus::extract(exit_status) {
301        Some(abort_status) => display_abort_status(abort_status),
302        None => match exit_status.code() {
303            Some(code) => format!("exited with exit code {}", code),
304            None => "exited with an unknown error".to_owned(),
305        },
306    }
307}
308
309/// Displays the abort status.
310pub(crate) fn display_abort_status(abort_status: AbortStatus) -> String {
311    match abort_status {
312        #[cfg(unix)]
313        AbortStatus::UnixSignal(sig) => match crate::helpers::signal_str(sig) {
314            Some(s) => {
315                format!("aborted with signal {sig} (SIG{s})")
316            }
317            None => {
318                format!("aborted with signal {sig}")
319            }
320        },
321        #[cfg(windows)]
322        AbortStatus::WindowsNtStatus(nt_status) => {
323            format!(
324                "aborted with code {}",
325                // TODO: pass down a style here
326                crate::helpers::display_nt_status(nt_status, Style::new())
327            )
328        }
329        #[cfg(windows)]
330        AbortStatus::JobObject => "terminated via job object".to_string(),
331    }
332}
333
334#[cfg(unix)]
335pub(crate) fn signal_str(signal: i32) -> Option<&'static str> {
336    // These signal numbers are the same on at least Linux, macOS, FreeBSD and illumos.
337    //
338    // TODO: glibc has sigabbrev_np, and POSIX-1.2024 adds sig2str which has been available on
339    // illumos for many years:
340    // https://pubs.opengroup.org/onlinepubs/9799919799/functions/sig2str.html. We should use these
341    // if available.
342    match signal {
343        1 => Some("HUP"),
344        2 => Some("INT"),
345        3 => Some("QUIT"),
346        4 => Some("ILL"),
347        5 => Some("TRAP"),
348        6 => Some("ABRT"),
349        8 => Some("FPE"),
350        9 => Some("KILL"),
351        11 => Some("SEGV"),
352        13 => Some("PIPE"),
353        14 => Some("ALRM"),
354        15 => Some("TERM"),
355        _ => None,
356    }
357}
358
359#[cfg(windows)]
360pub(crate) fn display_nt_status(
361    nt_status: windows_sys::Win32::Foundation::NTSTATUS,
362    bold_style: Style,
363) -> String {
364    // 10 characters ("0x" + 8 hex digits) is how an NTSTATUS with the high bit
365    // set is going to be displayed anyway. This makes all possible displays
366    // uniform.
367    let bolded_status = format!("{:#010x}", nt_status.style(bold_style));
368    // Convert the NTSTATUS to a Win32 error code.
369    let win32_code = unsafe { windows_sys::Win32::Foundation::RtlNtStatusToDosError(nt_status) };
370
371    if win32_code == windows_sys::Win32::Foundation::ERROR_MR_MID_NOT_FOUND {
372        // The Win32 code was not found.
373        return bolded_status;
374    }
375
376    format!(
377        "{bolded_status}: {}",
378        io::Error::from_raw_os_error(win32_code as i32)
379    )
380}
381
382#[derive(Copy, Clone, Debug)]
383pub(crate) struct QuotedDisplay<'a, T: ?Sized>(pub(crate) &'a T);
384
385impl<T: ?Sized> fmt::Display for QuotedDisplay<'_, T>
386where
387    T: fmt::Display,
388{
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        write!(f, "'{}'", self.0)
391    }
392}
393
394// From https://twitter.com/8051Enthusiast/status/1571909110009921538
395unsafe extern "C" {
396    fn __nextest_external_symbol_that_does_not_exist();
397}
398
399#[inline]
400#[expect(dead_code)]
401pub(crate) fn statically_unreachable() -> ! {
402    unsafe {
403        __nextest_external_symbol_that_does_not_exist();
404    }
405    unreachable!("linker symbol above cannot be resolved")
406}