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