cargo-nextest 0.9.140

A next-generation test runner for Rust.
Documentation
// Copyright (c) The nextest Contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Support for `cargo nextest help <command-path | topic>`.
//!
//! This forwards a command path to `cargo nextest <command> --help`, and renders
//! a reference doc for a help topic such as `filterset`.

use super::{EarlyArgs, app::CargoNextestApp, clap_error::handle_clap_error};
use crate::{
    Result,
    errors::ExpectedError,
    output::{OutputContext, terminal_width},
};
use clap::{CommandFactory, builder::styling::Style, error::ErrorKind};
use nextest_runner::{
    help_render::{self, HelpDoc, RenderOptions},
    pager::PagedOutput,
    user_config::{EarlyUserConfig, elements::PaginateSetting},
    write_str::WriteStr,
};
use std::io::Write as _;

/// A custom help topic.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum HelpTopic {
    Filterset,
    RepoConfig,
    UserConfig,
}

impl HelpTopic {
    const ALL: &[Self] = &[Self::Filterset, Self::RepoConfig, Self::UserConfig];

    /// Returns all names that match a topic.
    ///
    /// The first is the canonical name, and the rest are aliases.
    fn names(self) -> &'static [&'static str] {
        match self {
            Self::Filterset => &["filterset", "filtersets"],
            Self::RepoConfig => &["repo-config"],
            Self::UserConfig => &["user-config"],
        }
    }

    fn from_name(name: &str) -> Option<Self> {
        Self::ALL
            .iter()
            .copied()
            .find(|topic| topic.names().contains(&name))
    }

    fn name(self) -> &'static str {
        self.names()[0]
    }

    fn max_name_width() -> usize {
        Self::ALL
            .iter()
            .map(|topic| topic.name().len())
            .max()
            .unwrap_or(0)
    }

    fn description(self) -> &'static str {
        match self {
            Self::Filterset => "the filterset DSL: predicates, operators, and matchers",
            Self::RepoConfig => "repository configuration reference",
            Self::UserConfig => "user configuration reference",
        }
    }

    fn render(self, opts: RenderOptions) -> String {
        help_render::render(&self.doc(), opts)
    }

    /// Returns the [`HelpDoc`] for this topic.
    pub(super) fn doc(self) -> HelpDoc {
        match self {
            Self::Filterset => HelpDoc::filterset(),
            Self::RepoConfig => HelpDoc::repo_config(),
            Self::UserConfig => HelpDoc::user_config(),
        }
    }
}

/// Renders help output for the given command path, or a reference doc for a help topic.
pub(crate) fn exec_help(
    command_path: Vec<String>,
    early_args: &EarlyArgs,
    output: OutputContext,
) -> Result<i32> {
    // Try using the first segment as a help topic. (If it matches, drop the
    // remaining segments -- doing this is better than producing a weird error
    // if an extra argument is provided.)
    if let [name, ..] = command_path.as_slice()
        && let Some(topic) = HelpTopic::from_name(name)
    {
        return render_topic(&topic, early_args, output);
    }

    delegate_command_help(&command_path, early_args)
}

fn render_topic(topic: &HelpTopic, early_args: &EarlyArgs, output: OutputContext) -> Result<i32> {
    let early_config = EarlyUserConfig::load(early_args.user_config_location());
    let paginate = if early_args.no_pager {
        PaginateSetting::Never
    } else {
        early_config.paginate
    };

    let color = output.color.should_colorize(supports_color::Stream::Stdout);
    let mut paged =
        PagedOutput::request_pager(&early_config.pager, paginate, &early_config.streampager);

    let hyperlinks = force_hyperlink().unwrap_or_else(|| {
        // Check if standard out supports hyperlinks. Note that even when the
        // output is paged, Stream::Stdout is not a pipe to the pager! Rather,
        // it is the usual terminal.
        supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout)
            && paged.forwards_osc8_hyperlinks()
    });

    let rendered = topic.render(RenderOptions {
        color,
        hyperlinks,
        width: terminal_width(),
    });

    paged
        .write_str(&rendered)
        .map_err(|err| ExpectedError::WriteHelpError { err })?;
    paged
        .write_str_flush()
        .map_err(|err| ExpectedError::WriteHelpError { err })?;
    paged.finalize();
    Ok(0)
}

/// Returns `Some(_)` if the `FORCE_HYPERLINK` environment variable is set.
///
/// We duplicate the check in `supports_hyperlinks` to allow paged output to be
/// hyperlinked.
fn force_hyperlink() -> Option<bool> {
    std::env::var("FORCE_HYPERLINK")
        .ok()
        .map(|arg| arg.trim() != "0")
}

/// Forwards `[..path, --help]` to clap.
fn delegate_command_help(path: &[String], early_args: &EarlyArgs) -> Result<i32> {
    let mut argv = vec!["cargo".to_string(), "nextest".to_string()];
    argv.extend(path.iter().cloned());
    argv.push("--help".to_string());

    let err = match CargoNextestApp::command().try_get_matches_from(argv) {
        Ok(_) => return Ok(0),
        Err(err) => err,
    };

    // Only an unknown first segment (e.g. `cargo nextest help foo`) gets a
    // topic hint.
    let top_level_miss = !is_help_or_version(&err)
        && path
            .first()
            .is_some_and(|first| !is_nextest_subcommand(first));
    if !top_level_miss {
        return Ok(handle_clap_error(err, early_args));
    }

    // Append the topic hint to stderr.
    let colorize = early_args
        .color
        .should_colorize(supports_color::Stream::Stderr);
    let mut rendered = if colorize {
        err.render().ansi().to_string()
    } else {
        err.render().to_string()
    };
    append_topic_hint(&mut rendered, colorize);
    let _ = write!(std::io::stderr(), "{rendered}");

    Ok(err.exit_code())
}

fn is_help_or_version(err: &clap::Error) -> bool {
    matches!(
        err.kind(),
        ErrorKind::DisplayHelp
            | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
            | ErrorKind::DisplayVersion
    )
}

fn is_nextest_subcommand(name: &str) -> bool {
    CargoNextestApp::command()
        .find_subcommand("nextest")
        .is_some_and(|nextest| nextest.find_subcommand(name).is_some())
}

/// Returns a styled string with the "Help topics" section appended to the root
/// command's help.
///
/// This is written to be styled like clap's own sections.
pub(crate) fn topics_after_help() -> clap::builder::StyledStr {
    let styles = crate::output::clap_styles::style();
    let mut out = clap::builder::StyledStr::new();
    write_topics_section(&mut out, *styles.get_header(), *styles.get_literal());
    out
}

/// Appends the "Help topics" section to error output.
///
/// Unlike `topics_after_help`, this normalizes newlines and emits final
/// (colorized or not) text, since it's appended to an already-rendered clap
/// error and printed directly.
fn append_topic_hint(out: &mut String, colorize: bool) {
    if !out.ends_with('\n') {
        out.push('\n');
    }
    out.push('\n');

    let styles = crate::output::clap_styles::style();
    let (header, literal) = if colorize {
        (*styles.get_header(), *styles.get_literal())
    } else {
        (Style::new(), Style::new())
    };
    write_topics_section(out, header, literal);
    out.push('\n');
}

/// Writes the "Help topics" section.
fn write_topics_section(out: &mut impl std::fmt::Write, header: Style, literal: Style) {
    let width = HelpTopic::max_name_width();
    let _ = write!(
        out,
        "{header}Help topics{header:#} (try {literal}cargo nextest help <topic>{literal:#}):"
    );
    for topic in HelpTopic::ALL {
        let pad = " ".repeat(width - topic.name().len() + 2);
        let _ = write!(
            out,
            "\n  {literal}{}{literal:#}{pad}{}",
            topic.name(),
            topic.description(),
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn topic_names_resolve() {
        assert!(HelpTopic::from_name("filterset").is_some());
        assert!(HelpTopic::from_name("filtersets").is_some());
        assert_eq!(
            HelpTopic::from_name("repo-config"),
            Some(HelpTopic::RepoConfig)
        );
        assert_eq!(HelpTopic::RepoConfig.name(), "repo-config");
        assert_eq!(
            HelpTopic::from_name("user-config"),
            Some(HelpTopic::UserConfig)
        );
        assert_eq!(HelpTopic::UserConfig.name(), "user-config");
        assert!(HelpTopic::from_name("config").is_none());
        assert!(HelpTopic::from_name("run").is_none());
        assert!(HelpTopic::from_name("").is_none());
    }

    #[test]
    fn subcommand_detection() {
        assert!(is_nextest_subcommand("run"));
        assert!(is_nextest_subcommand("self"));
        assert!(is_nextest_subcommand("help"));
        assert!(!is_nextest_subcommand("bogus"));
        // Topics are not subcommands.
        assert!(!is_nextest_subcommand("filterset"));
        assert!(!is_nextest_subcommand("repo-config"));
        assert!(!is_nextest_subcommand("user-config"));
        assert!(!is_nextest_subcommand("filtersets"));
    }

    #[test]
    fn force_hyperlink_parses_env() {
        // SAFETY:
        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
        unsafe {
            std::env::remove_var("FORCE_HYPERLINK");
        }
        assert_eq!(force_hyperlink(), None);

        unsafe {
            std::env::set_var("FORCE_HYPERLINK", "1");
        }
        assert_eq!(force_hyperlink(), Some(true));

        unsafe {
            std::env::set_var("FORCE_HYPERLINK", "0");
        }
        assert_eq!(force_hyperlink(), Some(false));

        unsafe {
            std::env::set_var("FORCE_HYPERLINK", "");
        }
        assert_eq!(force_hyperlink(), Some(true));

        unsafe {
            std::env::set_var("FORCE_HYPERLINK", " 0 ");
        }
        assert_eq!(force_hyperlink(), Some(false));
    }
}