pub(crate) mod diff;
#[cfg(test)]
mod doc_fields;
pub mod exit;
pub(crate) mod lint_markers;
pub(crate) mod merge;
pub mod warn;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
use self::exit::ErrorReport;
pub const ERROR_FORMAT_ENV: &str = "PATCHCOV_ERROR_FORMAT";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum ErrorFormat {
#[default]
Text,
Json,
}
#[derive(Parser)]
#[command(name = "patchcov", version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(long = "repo", short = 'C', global = true, value_name = "PATH")]
pub repo: Option<PathBuf>,
#[arg(
long = "error-format",
global = true,
value_enum,
value_name = "FORMAT"
)]
pub error_format: Option<ErrorFormat>,
}
#[derive(Subcommand)]
pub enum Commands {
Diff(Box<diff::DiffCommand>),
LintMarkers(lint_markers::LintMarkersCommand),
Merge(merge::MergeCommand),
}
impl Cli {
pub fn try_parse_args(args: &[OsString]) -> std::result::Result<Self, clap::Error> {
let mut normalized = Vec::with_capacity(args.len());
let mut args = args.iter().peekable();
normalized.extend(args.next().cloned());
while let Some(arg) = args.next() {
if arg == "--" {
normalized.push(arg.clone());
normalized.extend(args.cloned());
break;
}
if matches!(
arg.to_str(),
Some("--fail-under-patch" | "--fail-under-lines")
) && args.peek().is_some_and(|value| {
value
.to_str()
.is_some_and(|value| value.starts_with('-') && value.parse::<f64>().is_ok())
}) {
let mut joined = arg.clone();
joined.push("=");
if let Some(value) = args.next() {
joined.push(value);
}
normalized.push(joined);
} else {
normalized.push(arg.clone());
}
}
Self::try_parse_from(normalized)
}
pub fn resolve_error_format(&self, env: Option<&OsStr>) -> (ErrorFormat, Option<String>) {
if let Some(format) = self.error_format {
return (format, None);
}
let Some(env) = env else {
return (ErrorFormat::Text, None);
};
match env.to_str() {
Some("" | "text") => (ErrorFormat::Text, None),
Some("json") => (ErrorFormat::Json, None),
_ => (
ErrorFormat::Text,
Some(format!(
"ignoring {ERROR_FORMAT_ENV}={}: expected 'text' or 'json'",
env.to_string_lossy()
)),
),
}
}
pub fn execute(self) -> Result<()> {
let repo: Option<&Path> = self.repo.as_deref();
match self.command {
Commands::Diff(cmd) => cmd.execute(repo),
Commands::LintMarkers(cmd) => cmd.execute(repo),
Commands::Merge(cmd) => cmd.execute(repo),
}
}
}
pub fn usage_report(
err: &clap::Error,
args: &[OsString],
env: Option<&OsString>,
) -> Option<ErrorReport> {
if !err.use_stderr() || !json_requested(args, env) {
return None;
}
let rendered = err.render().to_string();
let end = ["\n\nUsage:", "\n\nFor more information"]
.iter()
.filter_map(|trailer| rendered.find(trailer))
.min()
.unwrap_or(rendered.len());
let message = rendered[..end].trim();
Some(ErrorReport::usage(
message.strip_prefix("error: ").unwrap_or(message),
))
}
fn json_requested(args: &[OsString], env: Option<&OsString>) -> bool {
let mut value = env.map(OsString::as_os_str);
let mut args = args.iter().skip(1).map(OsString::as_os_str);
while let Some(arg) = args.next() {
let Some(arg) = arg.to_str() else { continue };
if arg == "--" {
break;
}
if let Some(inline) = arg.strip_prefix("--error-format=") {
value = Some(inline.as_ref());
} else if arg == "--error-format" {
value = args.next().or(value);
}
}
value == Some("json".as_ref())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn dispatches_to_diff() {
let cmd = Cli {
command: Commands::Diff(Box::new(diff::DiffCommand {
report: vec![std::path::PathBuf::from("/nonexistent/report.lcov")],
report_format: diff::ReportFormat::Auto,
branch_coverage: false,
base_ref: Some("HEAD".to_string()),
head_ref: None,
baseline_report: None,
baseline_report_format: diff::ReportFormat::Auto,
output: diff::OutputFormatArg::Markdown,
no_explanation: false,
format: None,
fail_under_patch: None,
fail_on_unmeasured: Vec::new(),
fail_under_lines: None,
allow_path_mismatch: false,
fail_on_path_mismatch: false,
strip_prefix: None,
ignore_filename_regex: Vec::new(),
config_dir: None,
collapse_ranges: false,
all_files: false,
artifact_url: None,
run_url: None,
base_sha: None,
head_sha: None,
commit_url: None,
})),
repo: None,
error_format: None,
};
assert!(cmd.execute().is_err());
}
#[test]
fn dispatches_to_merge() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("merged.lcov");
let cmd = Cli {
command: Commands::Merge(merge::MergeCommand {
report: vec![dir.path().join("missing.lcov")],
report_format: diff::ReportFormat::Auto,
output: output.clone(),
strip_prefix: None,
}),
repo: None,
error_format: None,
};
assert!(cmd.execute().is_err());
assert!(!output.exists());
}
fn args(list: &[&str]) -> Vec<OsString> {
std::iter::once("patchcov")
.chain(list.iter().copied())
.map(OsString::from)
.collect()
}
#[test]
fn negative_float_thresholds_have_explanations_in_both_spellings() {
for flag in ["--fail-under-patch", "--fail-under-lines"] {
for (value, reason) in [
("-inf", "must be a finite number"),
("-infinity", "must be a finite number"),
("-NaN", "must be a finite number"),
("-nan", "must be a finite number"),
("-1e999", "must be a finite number"),
("-.5", "must not be negative"),
("-5", "must not be negative"),
] {
let joined = format!("{flag}={value}");
for argv in [
args(&["diff", "--report", "r.lcov", flag, value]),
args(&["diff", "--report", "r.lcov", &joined]),
] {
let err = Cli::try_parse_args(&argv).err().unwrap();
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
let message = err.to_string();
assert!(
message.contains(&format!("invalid value '{value}'")),
"{message}"
);
assert!(message.contains(flag), "{message}");
assert!(message.contains(reason), "{message}");
}
}
for value in ["-0", "-.0", "-0e5"] {
let cli = Cli::try_parse_args(&args(&["diff", "--report", "r.lcov", flag, value]))
.unwrap();
let Commands::Diff(cmd) = cli.command else {
panic!("expected diff")
};
let got = if flag == "--fail-under-patch" {
cmd.fail_under_patch
} else {
cmd.fail_under_lines
};
assert!(got.is_some_and(|v| v == 0.0 && v.is_sign_positive()));
}
}
}
#[test]
fn normalization_preserves_missing_values_and_unrelated_arguments() {
for flag in ["--fail-under-patch", "--fail-under-lines"] {
for rest in [
vec!["--fail-on-unmeasured", "src/**"],
vec!["--head-ref", "main"],
vec!["--no-explanation"],
vec!["--fail-under-patch", "80"],
vec!["--fail-under-lines", "80"],
vec!["-o", "json"],
vec![],
] {
let mut argv = args(&["diff", "--report", "r.lcov", flag]);
argv.extend(rest.into_iter().map(OsString::from));
let message = Cli::try_parse_args(&argv).err().unwrap().to_string();
assert!(
message.contains(&format!("a value is required for '{flag} <PCT>'")),
"{message}"
);
}
}
for argv in [
args(&[
"diff",
"--report",
"r.lcov",
"--",
"--fail-under-patch",
"-inf",
]),
args(&[
"diff",
"--report",
"r.lcov",
"--fail-under-patch",
"--unknown",
]),
args(&["merge", "--fail-under-patch", "-inf"]),
] {
let expected = Cli::try_parse_from(&argv).err().unwrap();
let got = Cli::try_parse_args(&argv).err().unwrap();
assert_eq!(got.kind(), expected.kind());
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let report = OsString::from_vec(b"report-\xff.lcov".to_vec());
let mut argv = args(&["diff", "--report"]);
argv.push(report.clone());
argv.extend([OsString::from("--fail-under-patch"), OsString::from("-.0")]);
let cli = Cli::try_parse_args(&argv).unwrap();
let Commands::Diff(cmd) = cli.command else {
panic!("expected diff")
};
assert_eq!(cmd.report, vec![PathBuf::from(report)]);
}
}
fn cli_with(error_format: Option<ErrorFormat>) -> Cli {
Cli {
command: Commands::Merge(merge::MergeCommand {
report: Vec::new(),
report_format: diff::ReportFormat::Auto,
output: PathBuf::from("out.lcov"),
strip_prefix: None,
}),
repo: None,
error_format,
}
}
#[test]
fn the_flag_wins_over_the_environment_and_text_is_the_default() {
let env = |value: &'static str| Some(OsStr::new(value));
let resolve = |flag, value| cli_with(flag).resolve_error_format(value);
assert_eq!(resolve(None, None), (ErrorFormat::Text, None));
assert_eq!(resolve(None, env("json")), (ErrorFormat::Json, None));
assert_eq!(resolve(None, env("text")), (ErrorFormat::Text, None));
assert_eq!(resolve(None, env("")), (ErrorFormat::Text, None));
let flag = Some(ErrorFormat::Text);
assert_eq!(resolve(flag, env("json")), (ErrorFormat::Text, None));
let flag = Some(ErrorFormat::Json);
assert_eq!(resolve(flag, env("text")), (ErrorFormat::Json, None));
let (format, warning) = resolve(None, env("xml"));
assert_eq!(format, ErrorFormat::Text);
assert!(warning.unwrap().contains("PATCHCOV_ERROR_FORMAT=xml"));
assert_eq!(resolve(flag, env("xml")), (ErrorFormat::Json, None));
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let (format, warning) = resolve(None, Some(OsStr::from_bytes(b"\xff")));
assert_eq!(format, ErrorFormat::Text);
assert!(warning.is_some());
}
}
#[test]
fn the_flag_parses_before_or_after_the_subcommand() {
for argv in [
["--error-format", "json", "lint-markers"],
["lint-markers", "--error-format", "json"],
] {
let cli = Cli::try_parse_from(std::iter::once("patchcov").chain(argv)).unwrap();
assert_eq!(cli.error_format, Some(ErrorFormat::Json));
}
let cli = Cli::try_parse_from(["patchcov", "lint-markers"]).unwrap();
assert_eq!(cli.error_format, None);
}
#[test]
fn json_is_found_in_the_arguments_or_the_environment() {
let json = OsString::from("json");
let text = OsString::from("text");
assert!(!json_requested(&args(&["diff"]), None));
assert!(json_requested(&args(&["--error-format", "json"]), None));
assert!(json_requested(
&args(&["diff", "--error-format=json"]),
None
));
assert!(json_requested(&args(&["diff"]), Some(&json)));
assert!(!json_requested(
&args(&["--error-format=text"]),
Some(&json)
));
assert!(json_requested(
&args(&["--error-format=text", "--error-format", "json"]),
None
));
assert!(!json_requested(
&args(&["--error-format=json", "--error-format=text"]),
None
));
assert!(!json_requested(&args(&["diff"]), Some(&text)));
assert!(json_requested(&args(&["--error-format"]), Some(&json)));
assert!(!json_requested(&args(&["--", "--error-format=json"]), None));
}
#[test]
fn a_parse_failure_is_reported_when_json_is_asked_for() {
let argv = args(&["diff", "--error-format=json"]);
let err = Cli::try_parse_from(&argv).err().unwrap();
let report = usage_report(&err, &argv, None).unwrap();
assert_eq!((report.code, report.kind), (2, "usage"));
assert!(report.message.contains("--report"), "{}", report.message);
assert!(!report.message.contains("Usage:"), "{}", report.message);
assert!(!report.message.starts_with("error:"), "{}", report.message);
assert!(report.chain.is_empty());
assert!(usage_report(&err, &args(&["diff"]), None).is_none());
}
#[test]
fn a_parse_failure_keeps_clap_s_tips() {
let argv = args(&["lint-marker", "--error-format=json"]);
let err = Cli::try_parse_from(&argv).err().unwrap();
let report = usage_report(&err, &argv, None).unwrap();
assert!(
report.message.contains("lint-markers"),
"{}",
report.message
);
assert!(report.message.contains("tip:"), "{}", report.message);
assert!(!report.message.contains("Usage:"), "{}", report.message);
assert!(!report.message.contains("--help"), "{}", report.message);
}
#[test]
fn help_and_version_are_not_failures() {
for flag in ["--help", "--version"] {
let argv = args(&[flag, "--error-format=json"]);
let err = Cli::try_parse_from(&argv).err().unwrap();
assert!(usage_report(&err, &argv, None).is_none(), "{flag}");
}
}
}