diff-coverage 0.6.1

Diff-coverage, supercharged in Rust. Fast, memory-efficient coverage on changed lines for CI.
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::OnceLock;

use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum, ValueHint};
use regex::Regex;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
#[value(rename_all = "kebab_case")]
pub enum OutputFormat {
    Cli,
    Summary,
    Gitlab,
    Json,
}

pub const DEFAULT_OUTPUT_FORMAT: OutputFormat = OutputFormat::Cli;

impl OutputFormat {
    pub fn label(self) -> String {
        self.to_possible_value()
            .expect("output format possible value")
            .get_name()
            .to_string()
    }

    pub fn requires_path(self) -> bool {
        !matches!(self, OutputFormat::Cli | OutputFormat::Summary)
    }

    fn parse(raw: &str) -> Option<Self> {
        let raw = raw.trim();
        OutputFormat::value_variants()
            .iter()
            .copied()
            .find(|format| format.label().eq_ignore_ascii_case(raw))
    }

    fn labels() -> Vec<String> {
        OutputFormat::value_variants()
            .iter()
            .map(|format| format.label())
            .collect()
    }

    fn labels_without_path() -> Vec<String> {
        OutputFormat::value_variants()
            .iter()
            .copied()
            .filter(|format| !format.requires_path())
            .map(|format| format.label())
            .collect()
    }

    fn labels_requiring_path() -> Vec<String> {
        OutputFormat::value_variants()
            .iter()
            .copied()
            .filter(|format| format.requires_path())
            .map(|format| format.label())
            .collect()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
#[value(rename_all = "kebab_case")]
pub enum MissingCoverageMode {
    Uncovered,
    Ignore,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputTarget {
    pub format: OutputFormat,
    pub path: Option<PathBuf>,
}

#[derive(Debug, Parser)]
#[command(
    name = "diff-coverage",
    version,
    about = "Scan diffs for coverage changes.",
    arg_required_else_help = true
)]
pub struct CliOptions {
    #[arg(long, value_name = "PATH")]
    pub diff_file: Option<PathBuf>,
    #[arg(
        value_name = "COVERAGE",
        help = "Coverage file or directory; can be repeated or comma-separated",
        action = clap::ArgAction::Append,
        value_delimiter = ',',
        value_hint = ValueHint::AnyPath
    )]
    pub coverage_paths: Vec<PathBuf>,
    #[arg(long, value_name = "PERCENT")]
    pub fail_under: Option<f64>,
    #[arg(
        long = "missing-coverage",
        value_name = "MODE",
        default_value = "ignore",
        help = "How to handle files missing from coverage: uncovered or ignore"
    )]
    pub missing_coverage: MissingCoverageMode,
    #[arg(
        long = "skip-coverage-path",
        value_name = "REGEX",
        help = "Regex of file paths to exclude from uncovered line calculations; repeatable or comma-separated",
        action = clap::ArgAction::Append,
        value_delimiter = ','
    )]
    pub skip_coverage_paths: Vec<Regex>,
    #[arg(
        long = "output",
        id = "output",
        value_name = "FORMAT=PATH",
        help = "Output target(s); can be repeated or comma-separated",
        action = clap::ArgAction::Append,
        value_delimiter = ',',
        value_parser = parse_output_target
    )]
    pub outputs: Vec<OutputTarget>,
}

fn parse_output_target(raw: &str) -> Result<OutputTarget, String> {
    let (format_raw, path_raw) = match raw.split_once('=') {
        Some((format_raw, path_raw)) => (format_raw, Some(path_raw)),
        None => (raw, None),
    };
    let format = OutputFormat::parse(format_raw).ok_or_else(|| {
        format!(
            "output target format must be one of: {}",
            OutputFormat::labels().join(", ")
        )
    })?;
    match (format.requires_path(), path_raw) {
        (false, None) => Ok(OutputTarget { format, path: None }),
        (false, Some(_)) => Err(format!("{} output does not take a path", format.label())),
        (true, None) => Err(format!(
            "output target must be FORMAT=PATH for formats that require a path: {}",
            OutputFormat::labels_requiring_path().join(", ")
        )),
        (true, Some(path_raw)) if path_raw.is_empty() => {
            Err("output target path cannot be empty".to_string())
        }
        (true, Some(path_raw)) => Ok(OutputTarget {
            format,
            path: Some(PathBuf::from(path_raw)),
        }),
    }
}

fn output_help() -> &'static str {
    static OUTPUT_HELP: OnceLock<String> = OnceLock::new();
    OUTPUT_HELP.get_or_init(|| {
        let formats = OutputFormat::labels().join(", ");
        let no_path_formats = OutputFormat::labels_without_path().join(", ");
        let default_format = DEFAULT_OUTPUT_FORMAT.label();
        format!(
            "Output target(s); can be repeated or comma-separated [default: {default_format}] [possible values: {formats}] [formats that do not take a path: {no_path_formats}]"
        )
    })
    .as_str()
}

fn cli_command() -> clap::Command {
    let output_help = output_help();
    CliOptions::command().mut_arg("output", |arg| arg.help(output_help).long_help(output_help))
}

pub fn parse_args<I>(args: I) -> Result<CliOptions, String>
where
    I: IntoIterator<Item = OsString>,
{
    let matches = match cli_command().try_get_matches_from(args) {
        Ok(matches) => matches,
        Err(err) => {
            if matches!(
                err.kind(),
                clap::error::ErrorKind::DisplayHelp
                    | clap::error::ErrorKind::DisplayVersion
                    | clap::error::ErrorKind::MissingRequiredArgument
            ) {
                print!("{err}");
                std::process::exit(0);
            }
            return Err(err.to_string());
        }
    };

    CliOptions::from_arg_matches(&matches).map_err(|err| err.to_string())
}

pub fn print_help() {
    let mut cmd = cli_command();
    cmd.print_help().expect("print help");
    println!();
}

#[cfg(test)]
mod tests {
    use std::ffi::OsString;

    use super::{parse_args, MissingCoverageMode, OutputFormat};

    #[test]
    fn parses_diff_file_flag() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--diff-file"),
            OsString::from("diff.txt"),
        ])
        .expect("parse");
        assert_eq!(options.diff_file.unwrap().to_string_lossy(), "diff.txt");
    }

    #[test]
    fn parses_diff_file_equals() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--diff-file=diff.txt"),
        ])
        .expect("parse");
        assert_eq!(options.diff_file.unwrap().to_string_lossy(), "diff.txt");
    }

    #[test]
    fn parses_coverage_paths_single() {
        let options =
            parse_args([OsString::from("bin"), OsString::from("cov.xml")]).expect("parse");
        assert_eq!(options.coverage_paths.len(), 1);
        assert_eq!(options.coverage_paths[0].to_string_lossy(), "cov.xml");
    }

    #[test]
    fn parses_coverage_paths_multiple() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("a.xml"),
            OsString::from("b"),
        ])
        .expect("parse");
        assert_eq!(options.coverage_paths.len(), 2);
        assert_eq!(options.coverage_paths[0].to_string_lossy(), "a.xml");
        assert_eq!(options.coverage_paths[1].to_string_lossy(), "b");
    }

    #[test]
    fn parses_coverage_paths_comma_separated() {
        let options =
            parse_args([OsString::from("bin"), OsString::from("a.xml,b.xml")]).expect("parse");
        assert_eq!(options.coverage_paths.len(), 2);
        assert_eq!(options.coverage_paths[0].to_string_lossy(), "a.xml");
        assert_eq!(options.coverage_paths[1].to_string_lossy(), "b.xml");
    }

    #[test]
    fn parses_fail_under_flag() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--fail-under"),
            OsString::from("82.5"),
        ])
        .expect("parse");
        assert_eq!(options.fail_under, Some(82.5));
    }

    #[test]
    fn parses_output_target() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--output"),
            OsString::from("gitlab=code-quality.json"),
        ])
        .expect("parse");
        assert_eq!(options.outputs.len(), 1);
        assert_eq!(options.outputs[0].format, OutputFormat::Gitlab);
        assert_eq!(
            options.outputs[0].path.as_ref().unwrap().to_string_lossy(),
            "code-quality.json"
        );
    }

    #[test]
    fn parses_output_target_cli_without_path() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--output"),
            OsString::from("cli"),
        ])
        .expect("parse");
        assert_eq!(options.outputs.len(), 1);
        assert_eq!(options.outputs[0].format, OutputFormat::Cli);
        assert!(options.outputs[0].path.is_none());
    }

    #[test]
    fn parses_output_target_summary_without_path() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--output"),
            OsString::from("summary"),
        ])
        .expect("parse");
        assert_eq!(options.outputs.len(), 1);
        assert_eq!(options.outputs[0].format, OutputFormat::Summary);
        assert!(options.outputs[0].path.is_none());
    }

    #[test]
    fn parses_output_target_json() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--output"),
            OsString::from("json=report.json"),
        ])
        .expect("parse");
        assert_eq!(options.outputs.len(), 1);
        assert_eq!(options.outputs[0].format, OutputFormat::Json);
        assert_eq!(
            options.outputs[0].path.as_ref().unwrap().to_string_lossy(),
            "report.json"
        );
    }

    #[test]
    fn parses_missing_coverage_mode() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--diff-file"),
            OsString::from("diff.txt"),
            OsString::from("--missing-coverage"),
            OsString::from("ignore"),
        ])
        .expect("parse");
        assert_eq!(options.missing_coverage, MissingCoverageMode::Ignore);
    }

    #[test]
    fn parses_skip_coverage_paths() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--skip-coverage-path"),
            OsString::from("^pkg/mongodb"),
            OsString::from("--skip-coverage-path"),
            OsString::from("vendor/.*"),
        ])
        .expect("parse");
        assert_eq!(options.skip_coverage_paths.len(), 2);
        assert_eq!(options.skip_coverage_paths[0].as_str(), "^pkg/mongodb");
        assert_eq!(options.skip_coverage_paths[1].as_str(), "vendor/.*");
    }

    #[test]
    fn parses_skip_coverage_paths_comma_separated() {
        let options = parse_args([
            OsString::from("bin"),
            OsString::from("--skip-coverage-path"),
            OsString::from("^pkg/mongodb,vendor/.*"),
        ])
        .expect("parse");
        assert_eq!(options.skip_coverage_paths.len(), 2);
        assert_eq!(options.skip_coverage_paths[0].as_str(), "^pkg/mongodb");
        assert_eq!(options.skip_coverage_paths[1].as_str(), "vendor/.*");
    }
}