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::scripts::ScriptId,
8    list::{Styles, TestInstanceId},
9    reporter::events::{AbortStatus, StressIndex},
10    write_str::WriteStr,
11};
12use camino::{Utf8Path, Utf8PathBuf};
13use console::AnsiCodeIterator;
14use owo_colors::{OwoColorize, Style};
15use std::{fmt, io, path::PathBuf, process::ExitStatus, time::Duration};
16use unicode_width::UnicodeWidthChar;
17
18/// Utilities for pluralizing various words based on count or plurality.
19pub mod plural {
20    /// Returns "were" if `plural` is true, otherwise "was".
21    pub fn were_plural_if(plural: bool) -> &'static str {
22        if plural { "were" } else { "was" }
23    }
24
25    /// Returns "setup script" if `count` is 1, otherwise "setup scripts".
26    pub fn setup_scripts_str(count: usize) -> &'static str {
27        if count == 1 {
28            "setup script"
29        } else {
30            "setup scripts"
31        }
32    }
33
34    /// Returns "test" if `count` is 1, otherwise "tests".
35    pub fn tests_str(count: usize) -> &'static str {
36        tests_plural_if(count != 1)
37    }
38
39    /// Returns "tests" if `plural` is true, otherwise "test".
40    pub fn tests_plural_if(plural: bool) -> &'static str {
41        if plural { "tests" } else { "test" }
42    }
43
44    /// Returns "binary" if `count` is 1, otherwise "binaries".
45    pub fn binaries_str(count: usize) -> &'static str {
46        if count == 1 { "binary" } else { "binaries" }
47    }
48
49    /// Returns "path" if `count` is 1, otherwise "paths".
50    pub fn paths_str(count: usize) -> &'static str {
51        if count == 1 { "path" } else { "paths" }
52    }
53
54    /// Returns "file" if `count` is 1, otherwise "files".
55    pub fn files_str(count: usize) -> &'static str {
56        if count == 1 { "file" } else { "files" }
57    }
58
59    /// Returns "directory" if `count` is 1, otherwise "directories".
60    pub fn directories_str(count: usize) -> &'static str {
61        if count == 1 {
62            "directory"
63        } else {
64            "directories"
65        }
66    }
67
68    /// Returns "this crate" if `count` is 1, otherwise "these crates".
69    pub fn this_crate_str(count: usize) -> &'static str {
70        if count == 1 {
71            "this crate"
72        } else {
73            "these crates"
74        }
75    }
76
77    /// Returns "library" if `count` is 1, otherwise "libraries".
78    pub fn libraries_str(count: usize) -> &'static str {
79        if count == 1 { "library" } else { "libraries" }
80    }
81
82    /// Returns "filter" if `count` is 1, otherwise "filters".
83    pub fn filters_str(count: usize) -> &'static str {
84        if count == 1 { "filter" } else { "filters" }
85    }
86
87    /// Returns "section" if `count` is 1, otherwise "sections".
88    pub fn sections_str(count: usize) -> &'static str {
89        if count == 1 { "section" } else { "sections" }
90    }
91
92    /// Returns "iteration" if `count` is 1, otherwise "iterations".
93    pub fn iterations_str(count: u32) -> &'static str {
94        if count == 1 {
95            "iteration"
96        } else {
97            "iterations"
98        }
99    }
100}
101
102pub(crate) struct DisplayTestInstance<'a> {
103    stress_index: Option<StressIndex>,
104    display_counter_index: Option<DisplayCounterIndex>,
105    instance: TestInstanceId<'a>,
106    styles: &'a Styles,
107    max_width: Option<usize>,
108}
109
110impl<'a> DisplayTestInstance<'a> {
111    pub(crate) fn new(
112        stress_index: Option<StressIndex>,
113        display_counter_index: Option<DisplayCounterIndex>,
114        instance: TestInstanceId<'a>,
115        styles: &'a Styles,
116    ) -> Self {
117        Self {
118            stress_index,
119            display_counter_index,
120            instance,
121            styles,
122            max_width: None,
123        }
124    }
125
126    pub(crate) fn with_max_width(mut self, max_width: usize) -> Self {
127        self.max_width = Some(max_width);
128        self
129    }
130}
131
132impl fmt::Display for DisplayTestInstance<'_> {
133    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
134        // Figure out the widths for each component.
135        let stress_index_str = if let Some(stress_index) = self.stress_index {
136            format!(
137                "[{}] ",
138                DisplayStressIndex {
139                    stress_index,
140                    count_style: self.styles.count,
141                }
142            )
143        } else {
144            String::new()
145        };
146        let counter_index_str = if let Some(display_counter_index) = &self.display_counter_index {
147            format!("{display_counter_index} ")
148        } else {
149            String::new()
150        };
151        let binary_id_str = format!("{} ", self.instance.binary_id.style(self.styles.binary_id));
152        let test_name_str = format!(
153            "{}",
154            DisplayTestName::new(self.instance.test_name, self.styles)
155        );
156
157        // If a max width is defined, trim strings until they fit into it.
158        if let Some(max_width) = self.max_width {
159            // We have to be careful while computing string width -- the strings
160            // above include ANSI escape codes which have a display width of
161            // zero.
162            let stress_index_width = text_width(&stress_index_str);
163            let counter_index_width = text_width(&counter_index_str);
164            let binary_id_width = text_width(&binary_id_str);
165            let test_name_width = text_width(&test_name_str);
166
167            // Truncate components in order, from most important to keep to least:
168            //
169            // * stress-index (left-aligned)
170            // * counter index (left-aligned)
171            // * binary ID (left-aligned)
172            // * test name (right-aligned)
173            let mut stress_index_resolved_width = stress_index_width;
174            let mut counter_index_resolved_width = counter_index_width;
175            let mut binary_id_resolved_width = binary_id_width;
176            let mut test_name_resolved_width = test_name_width;
177
178            // Truncate stress-index first.
179            if stress_index_resolved_width > max_width {
180                stress_index_resolved_width = max_width;
181            }
182
183            // Truncate counter index next.
184            let remaining_width = max_width.saturating_sub(stress_index_resolved_width);
185            if counter_index_resolved_width > remaining_width {
186                counter_index_resolved_width = remaining_width;
187            }
188
189            // Truncate binary ID next.
190            let remaining_width = max_width
191                .saturating_sub(stress_index_resolved_width)
192                .saturating_sub(counter_index_resolved_width);
193            if binary_id_resolved_width > remaining_width {
194                binary_id_resolved_width = remaining_width;
195            }
196
197            // Truncate test name last.
198            let remaining_width = max_width
199                .saturating_sub(stress_index_resolved_width)
200                .saturating_sub(counter_index_resolved_width)
201                .saturating_sub(binary_id_resolved_width);
202            if test_name_resolved_width > remaining_width {
203                test_name_resolved_width = remaining_width;
204            }
205
206            // Now truncate the strings if applicable.
207            let test_name_truncated_str = if test_name_resolved_width == test_name_width {
208                test_name_str
209            } else {
210                // Right-align the test name.
211                truncate_ansi_aware(
212                    &test_name_str,
213                    test_name_width.saturating_sub(test_name_resolved_width),
214                    test_name_width,
215                )
216            };
217            let binary_id_truncated_str = if binary_id_resolved_width == binary_id_width {
218                binary_id_str
219            } else {
220                // Left-align the binary ID.
221                truncate_ansi_aware(&binary_id_str, 0, binary_id_resolved_width)
222            };
223            let counter_index_truncated_str = if counter_index_resolved_width == counter_index_width
224            {
225                counter_index_str
226            } else {
227                // Left-align the counter index.
228                truncate_ansi_aware(&counter_index_str, 0, counter_index_resolved_width)
229            };
230            let stress_index_truncated_str = if stress_index_resolved_width == stress_index_width {
231                stress_index_str
232            } else {
233                // Left-align the stress index.
234                truncate_ansi_aware(&stress_index_str, 0, stress_index_resolved_width)
235            };
236
237            write!(
238                f,
239                "{}{}{}{}",
240                stress_index_truncated_str,
241                counter_index_truncated_str,
242                binary_id_truncated_str,
243                test_name_truncated_str,
244            )
245        } else {
246            write!(
247                f,
248                "{}{}{}{}",
249                stress_index_str, counter_index_str, binary_id_str, test_name_str
250            )
251        }
252    }
253}
254
255fn text_width(text: &str) -> usize {
256    // Technically, the width of a string may not be the same as the sum of the
257    // widths of its characters. But managing truncation is pretty difficult. See
258    // https://docs.rs/unicode-width/latest/unicode_width/#rules-for-determining-width.
259    //
260    // This is quite difficult to manage truncation for, so we just use the sum
261    // of the widths of the string's characters (both here and in
262    // truncate_ansi_aware below).
263    strip_ansi_escapes::strip_str(text)
264        .chars()
265        .map(|c| c.width().unwrap_or(0))
266        .sum()
267}
268
269fn truncate_ansi_aware(text: &str, start: usize, end: usize) -> String {
270    let mut pos = 0;
271    let mut res = String::new();
272    for (s, is_ansi) in AnsiCodeIterator::new(text) {
273        if is_ansi {
274            res.push_str(s);
275            continue;
276        } else if pos >= end {
277            // We retain ANSI escape codes, so this is `continue` rather than
278            // `break`.
279            continue;
280        }
281
282        for c in s.chars() {
283            let c_width = c.width().unwrap_or(0);
284            if start <= pos && pos + c_width <= end {
285                res.push(c);
286            }
287            pos += c_width;
288            if pos > end {
289                // no need to iterate over the rest of s
290                break;
291            }
292        }
293    }
294
295    res
296}
297
298pub(crate) struct DisplayScriptInstance {
299    stress_index: Option<StressIndex>,
300    script_id: ScriptId,
301    full_command: String,
302    script_id_style: Style,
303    count_style: Style,
304}
305
306impl DisplayScriptInstance {
307    pub(crate) fn new(
308        stress_index: Option<StressIndex>,
309        script_id: ScriptId,
310        command: &str,
311        args: &[String],
312        script_id_style: Style,
313        count_style: Style,
314    ) -> Self {
315        let full_command =
316            shell_words::join(std::iter::once(command).chain(args.iter().map(|arg| arg.as_ref())));
317
318        Self {
319            stress_index,
320            script_id,
321            full_command,
322            script_id_style,
323            count_style,
324        }
325    }
326}
327
328impl fmt::Display for DisplayScriptInstance {
329    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
330        if let Some(stress_index) = self.stress_index {
331            write!(
332                f,
333                "[{}] ",
334                DisplayStressIndex {
335                    stress_index,
336                    count_style: self.count_style,
337                }
338            )?;
339        }
340        write!(
341            f,
342            "{}: {}",
343            self.script_id.style(self.script_id_style),
344            self.full_command,
345        )
346    }
347}
348
349struct DisplayStressIndex {
350    stress_index: StressIndex,
351    count_style: Style,
352}
353
354impl fmt::Display for DisplayStressIndex {
355    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356        match self.stress_index.total {
357            Some(total) => {
358                write!(
359                    f,
360                    "{:>width$}/{}",
361                    (self.stress_index.current + 1).style(self.count_style),
362                    total.style(self.count_style),
363                    width = u32_decimal_char_width(total.get()),
364                )
365            }
366            None => {
367                write!(
368                    f,
369                    "{}",
370                    (self.stress_index.current + 1).style(self.count_style)
371                )
372            }
373        }
374    }
375}
376
377pub(super) enum DisplayCounterIndex {
378    Counter { current: usize, total: usize },
379    Padded { character: char, width: usize },
380}
381
382impl DisplayCounterIndex {
383    pub fn new_counter(current: usize, total: usize) -> Self {
384        Self::Counter { current, total }
385    }
386
387    pub fn new_padded(character: char, width: usize) -> Self {
388        Self::Padded { character, width }
389    }
390}
391
392impl fmt::Display for DisplayCounterIndex {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        match self {
395            Self::Counter { current, total } => {
396                write!(
397                    f,
398                    "({:>width$}/{})",
399                    current,
400                    total,
401                    width = usize_decimal_char_width(*total)
402                )
403            }
404            Self::Padded { character, width } => {
405                // Rendered as:
406                //
407                // (  20/5000)
408                // (---------)
409                let s: String = std::iter::repeat_n(*character, 2 * *width + 1).collect();
410                write!(f, "({s})")
411            }
412        }
413    }
414}
415
416pub(crate) fn usize_decimal_char_width(n: usize) -> usize {
417    // checked_ilog10 returns 0 for 1-9, 1 for 10-99, 2 for 100-999, etc. (And
418    // None for 0 which we unwrap to the same as 1). Add 1 to it to get the
419    // actual number of digits.
420    (n.checked_ilog10().unwrap_or(0) + 1).try_into().unwrap()
421}
422
423pub(crate) fn u32_decimal_char_width(n: u32) -> usize {
424    // checked_ilog10 returns 0 for 1-9, 1 for 10-99, 2 for 100-999, etc. (And
425    // None for 0 which we unwrap to the same as 1). Add 1 to it to get the
426    // actual number of digits.
427    (n.checked_ilog10().unwrap_or(0) + 1).try_into().unwrap()
428}
429
430/// Write out a test name.
431pub(crate) fn write_test_name(
432    name: &str,
433    style: &Styles,
434    writer: &mut dyn WriteStr,
435) -> io::Result<()> {
436    // Look for the part of the test after the last ::, if any.
437    let mut splits = name.rsplitn(2, "::");
438    let trailing = splits.next().expect("test should have at least 1 element");
439    if let Some(rest) = splits.next() {
440        write!(
441            writer,
442            "{}{}",
443            rest.style(style.module_path),
444            "::".style(style.module_path)
445        )?;
446    }
447    write!(writer, "{}", trailing.style(style.test_name))?;
448
449    Ok(())
450}
451
452/// Wrapper for displaying a test name with styling.
453pub(crate) struct DisplayTestName<'a> {
454    name: &'a str,
455    styles: &'a Styles,
456}
457
458impl<'a> DisplayTestName<'a> {
459    pub(crate) fn new(name: &'a str, styles: &'a Styles) -> Self {
460        Self { name, styles }
461    }
462}
463
464impl fmt::Display for DisplayTestName<'_> {
465    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
466        // Look for the part of the test after the last ::, if any.
467        let mut splits = self.name.rsplitn(2, "::");
468        let trailing = splits.next().expect("test should have at least 1 element");
469        if let Some(rest) = splits.next() {
470            write!(
471                f,
472                "{}{}",
473                rest.style(self.styles.module_path),
474                "::".style(self.styles.module_path)
475            )?;
476        }
477        write!(f, "{}", trailing.style(self.styles.test_name))?;
478
479        Ok(())
480    }
481}
482
483pub(crate) fn convert_build_platform(
484    platform: nextest_metadata::BuildPlatform,
485) -> guppy::graph::cargo::BuildPlatform {
486    match platform {
487        nextest_metadata::BuildPlatform::Target => guppy::graph::cargo::BuildPlatform::Target,
488        nextest_metadata::BuildPlatform::Host => guppy::graph::cargo::BuildPlatform::Host,
489    }
490}
491
492// ---
493// Functions below copied from cargo-util to avoid pulling in a bunch of dependencies
494// ---
495
496/// Returns the name of the environment variable used for searching for
497/// dynamic libraries.
498pub(crate) fn dylib_path_envvar() -> &'static str {
499    if cfg!(windows) {
500        "PATH"
501    } else if cfg!(target_os = "macos") {
502        // When loading and linking a dynamic library or bundle, dlopen
503        // searches in LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, PWD, and
504        // DYLD_FALLBACK_LIBRARY_PATH.
505        // In the Mach-O format, a dynamic library has an "install path."
506        // Clients linking against the library record this path, and the
507        // dynamic linker, dyld, uses it to locate the library.
508        // dyld searches DYLD_LIBRARY_PATH *before* the install path.
509        // dyld searches DYLD_FALLBACK_LIBRARY_PATH only if it cannot
510        // find the library in the install path.
511        // Setting DYLD_LIBRARY_PATH can easily have unintended
512        // consequences.
513        //
514        // Also, DYLD_LIBRARY_PATH appears to have significant performance
515        // penalty starting in 10.13. Cargo's testsuite ran more than twice as
516        // slow with it on CI.
517        "DYLD_FALLBACK_LIBRARY_PATH"
518    } else {
519        "LD_LIBRARY_PATH"
520    }
521}
522
523/// Returns a list of directories that are searched for dynamic libraries.
524///
525/// Note that some operating systems will have defaults if this is empty that
526/// will need to be dealt with.
527pub(crate) fn dylib_path() -> Vec<PathBuf> {
528    match std::env::var_os(dylib_path_envvar()) {
529        Some(var) => std::env::split_paths(&var).collect(),
530        None => Vec::new(),
531    }
532}
533
534/// On Windows, convert relative paths to always use forward slashes.
535#[cfg(windows)]
536pub(crate) fn convert_rel_path_to_forward_slash(rel_path: &Utf8Path) -> Utf8PathBuf {
537    if !rel_path.is_relative() {
538        panic!("path for conversion to forward slash '{rel_path}' is not relative");
539    }
540    rel_path.as_str().replace('\\', "/").into()
541}
542
543#[cfg(not(windows))]
544pub(crate) fn convert_rel_path_to_forward_slash(rel_path: &Utf8Path) -> Utf8PathBuf {
545    rel_path.to_path_buf()
546}
547
548/// On Windows, convert relative paths to use the main separator.
549#[cfg(windows)]
550pub(crate) fn convert_rel_path_to_main_sep(rel_path: &Utf8Path) -> Utf8PathBuf {
551    if !rel_path.is_relative() {
552        panic!("path for conversion to backslash '{rel_path}' is not relative");
553    }
554    rel_path.as_str().replace('/', "\\").into()
555}
556
557#[cfg(not(windows))]
558pub(crate) fn convert_rel_path_to_main_sep(rel_path: &Utf8Path) -> Utf8PathBuf {
559    rel_path.to_path_buf()
560}
561
562/// Join relative paths using forward slashes.
563pub(crate) fn rel_path_join(rel_path: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
564    assert!(rel_path.is_relative(), "rel_path {rel_path} is relative");
565    assert!(path.is_relative(), "path {path} is relative",);
566    format!("{rel_path}/{path}").into()
567}
568
569#[derive(Debug)]
570pub(crate) struct FormattedDuration(pub(crate) Duration);
571
572impl fmt::Display for FormattedDuration {
573    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
574        let duration = self.0.as_secs_f64();
575        if duration > 60.0 {
576            write!(f, "{}m {:.2}s", duration as u32 / 60, duration % 60.0)
577        } else {
578            write!(f, "{duration:.2}s")
579        }
580    }
581}
582
583// "exited with"/"terminated via"
584pub(crate) fn display_exited_with(exit_status: ExitStatus) -> String {
585    match AbortStatus::extract(exit_status) {
586        Some(abort_status) => display_abort_status(abort_status),
587        None => match exit_status.code() {
588            Some(code) => format!("exited with exit code {code}"),
589            None => "exited with an unknown error".to_owned(),
590        },
591    }
592}
593
594/// Displays the abort status.
595pub(crate) fn display_abort_status(abort_status: AbortStatus) -> String {
596    match abort_status {
597        #[cfg(unix)]
598        AbortStatus::UnixSignal(sig) => match crate::helpers::signal_str(sig) {
599            Some(s) => {
600                format!("aborted with signal {sig} (SIG{s})")
601            }
602            None => {
603                format!("aborted with signal {sig}")
604            }
605        },
606        #[cfg(windows)]
607        AbortStatus::WindowsNtStatus(nt_status) => {
608            format!(
609                "aborted with code {}",
610                // TODO: pass down a style here
611                crate::helpers::display_nt_status(nt_status, Style::new())
612            )
613        }
614        #[cfg(windows)]
615        AbortStatus::JobObject => "terminated via job object".to_string(),
616    }
617}
618
619#[cfg(unix)]
620pub(crate) fn signal_str(signal: i32) -> Option<&'static str> {
621    // These signal numbers are the same on at least Linux, macOS, FreeBSD and illumos.
622    //
623    // TODO: glibc has sigabbrev_np, and POSIX-1.2024 adds sig2str which has been available on
624    // illumos for many years:
625    // https://pubs.opengroup.org/onlinepubs/9799919799/functions/sig2str.html. We should use these
626    // if available.
627    match signal {
628        1 => Some("HUP"),
629        2 => Some("INT"),
630        3 => Some("QUIT"),
631        4 => Some("ILL"),
632        5 => Some("TRAP"),
633        6 => Some("ABRT"),
634        8 => Some("FPE"),
635        9 => Some("KILL"),
636        11 => Some("SEGV"),
637        13 => Some("PIPE"),
638        14 => Some("ALRM"),
639        15 => Some("TERM"),
640        _ => None,
641    }
642}
643
644#[cfg(windows)]
645pub(crate) fn display_nt_status(
646    nt_status: windows_sys::Win32::Foundation::NTSTATUS,
647    bold_style: Style,
648) -> String {
649    // 10 characters ("0x" + 8 hex digits) is how an NTSTATUS with the high bit
650    // set is going to be displayed anyway. This makes all possible displays
651    // uniform.
652    let bolded_status = format!("{:#010x}", nt_status.style(bold_style));
653    // Convert the NTSTATUS to a Win32 error code.
654    let win32_code = unsafe { windows_sys::Win32::Foundation::RtlNtStatusToDosError(nt_status) };
655
656    if win32_code == windows_sys::Win32::Foundation::ERROR_MR_MID_NOT_FOUND {
657        // The Win32 code was not found.
658        return bolded_status;
659    }
660
661    format!(
662        "{bolded_status}: {}",
663        io::Error::from_raw_os_error(win32_code as i32)
664    )
665}
666
667#[derive(Copy, Clone, Debug)]
668pub(crate) struct QuotedDisplay<'a, T: ?Sized>(pub(crate) &'a T);
669
670impl<T: ?Sized> fmt::Display for QuotedDisplay<'_, T>
671where
672    T: fmt::Display,
673{
674    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
675        write!(f, "'{}'", self.0)
676    }
677}
678
679// From https://twitter.com/8051Enthusiast/status/1571909110009921538
680unsafe extern "C" {
681    fn __nextest_external_symbol_that_does_not_exist();
682}
683
684#[inline]
685#[expect(dead_code)]
686pub(crate) fn statically_unreachable() -> ! {
687    unsafe {
688        __nextest_external_symbol_that_does_not_exist();
689    }
690    unreachable!("linker symbol above cannot be resolved")
691}
692
693#[cfg(test)]
694mod test {
695    use super::*;
696
697    #[test]
698    fn test_decimal_char_width() {
699        assert_eq!(1, usize_decimal_char_width(0));
700        assert_eq!(1, usize_decimal_char_width(1));
701        assert_eq!(1, usize_decimal_char_width(5));
702        assert_eq!(1, usize_decimal_char_width(9));
703        assert_eq!(2, usize_decimal_char_width(10));
704        assert_eq!(2, usize_decimal_char_width(11));
705        assert_eq!(2, usize_decimal_char_width(99));
706        assert_eq!(3, usize_decimal_char_width(100));
707        assert_eq!(3, usize_decimal_char_width(999));
708    }
709}