use std::path::PathBuf;
use anyhow::Result;
use clap::{Args, ValueEnum};
use tldr_core::quality::coverage::{
parse_coverage, CoverageFormat as CoreCoverageFormat, CoverageOptions, CoverageReport,
};
use crate::output::{OutputFormat, OutputWriter};
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum CoverageFormat {
Cobertura,
Lcov,
#[value(name = "coveragepy")]
CoveragePy,
Auto,
}
impl From<CoverageFormat> for Option<CoreCoverageFormat> {
fn from(format: CoverageFormat) -> Self {
match format {
CoverageFormat::Cobertura => Some(CoreCoverageFormat::Cobertura),
CoverageFormat::Lcov => Some(CoreCoverageFormat::Lcov),
CoverageFormat::CoveragePy => Some(CoreCoverageFormat::CoveragePy),
CoverageFormat::Auto => None,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum SortOrder {
Asc,
Desc,
}
#[derive(Debug, Args)]
pub struct CoverageArgs {
pub report: PathBuf,
#[arg(
long = "report-format",
short = 'R',
value_enum,
default_value = "auto"
)]
pub report_format: CoverageFormat,
#[arg(long, default_value = "80.0")]
pub threshold: f64,
#[arg(long)]
pub by_file: bool,
#[arg(long)]
pub uncovered: bool,
#[arg(long)]
pub filter: Vec<String>,
#[arg(long, value_enum)]
pub sort: Option<SortOrder>,
#[arg(long)]
pub base_path: Option<PathBuf>,
#[arg(long)]
pub uncovered_only: bool,
}
impl CoverageArgs {
pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
let writer = OutputWriter::new(format, quiet);
writer.progress(&format!(
"Parsing coverage report: {}...",
self.report.display()
));
let options = CoverageOptions {
threshold: self.threshold,
by_file: self.by_file || self.uncovered_only,
include_uncovered: self.uncovered,
filter: self.filter.clone(),
base_path: self.base_path.clone(),
};
let mut report = parse_coverage(&self.report, self.report_format.into(), &options)?;
if let Some(sort_order) = self.sort {
report.files.sort_by(|a, b| {
let cmp = a.line_coverage.partial_cmp(&b.line_coverage).unwrap();
match sort_order {
SortOrder::Asc => cmp,
SortOrder::Desc => cmp.reverse(),
}
});
}
if self.uncovered_only {
report.files.retain(|f| f.line_coverage < self.threshold);
}
if writer.is_text() {
let text = format_coverage_text(&report, self.threshold);
writer.write_text(&text)?;
} else {
writer.write(&report)?;
}
Ok(())
}
}
fn format_coverage_text(report: &CoverageReport, threshold: f64) -> String {
use colored::Colorize;
use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
let mut output = String::new();
output.push_str(&format!(
"Coverage Report ({})\n",
report.format.to_string().cyan()
));
output.push_str("============================\n\n");
let summary = &report.summary;
output.push_str(&"Summary:\n".bold().to_string());
output.push_str(&format!(
" Line Coverage: {:.1}% ({}/{})\n",
summary.line_coverage,
summary.covered_lines.to_string().as_str().green(),
summary.total_lines
));
if let Some(branch_cov) = summary.branch_coverage {
output.push_str(&format!(" Branch Coverage: {:.1}%", branch_cov));
if let (Some(covered), Some(total)) = (summary.covered_branches, summary.total_branches) {
output.push_str(&format!(" ({}/{})", covered, total));
}
output.push('\n');
}
if let Some(func_cov) = summary.function_coverage {
output.push_str(&format!(" Function Coverage: {:.1}%", func_cov));
if let (Some(covered), Some(total)) = (summary.covered_functions, summary.total_functions) {
output.push_str(&format!(" ({}/{})", covered, total));
}
output.push('\n');
}
let threshold_status = if summary.threshold_met {
format!("PASS (>= {:.0}%)", threshold).green().to_string()
} else {
format!("FAIL (< {:.0}%)", threshold).red().to_string()
};
output.push_str(&format!(" Threshold: {}\n", threshold_status));
output.push('\n');
for warning in &report.warnings {
output.push_str(&format!("{} {}\n", "Warning:".yellow(), warning));
}
if !report.warnings.is_empty() {
output.push('\n');
}
if !report.files.is_empty() {
output.push_str(&"Per-File Coverage:\n".bold().to_string());
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("File").fg(Color::Cyan),
Cell::new("Line %").fg(Color::Cyan),
Cell::new("Lines").fg(Color::Cyan),
Cell::new("Branch %").fg(Color::Cyan),
Cell::new("Status").fg(Color::Cyan),
]);
for file in &report.files {
let cov_color = if file.line_coverage >= threshold {
Color::Green
} else if file.line_coverage >= threshold * 0.8 {
Color::Yellow
} else {
Color::Red
};
let status = if file.line_coverage >= threshold {
"OK".to_string()
} else {
"LOW".to_string()
};
let branch_str = file
.branch_coverage
.map(|b| format!("{:.1}%", b))
.unwrap_or_else(|| "-".to_string());
table.add_row(vec![
Cell::new(&file.path),
Cell::new(format!("{:.1}%", file.line_coverage)).fg(cov_color),
Cell::new(format!("{}/{}", file.covered_lines, file.total_lines)),
Cell::new(branch_str),
Cell::new(status).fg(cov_color),
]);
}
output.push_str(&table.to_string());
output.push_str("\n\n");
}
if let Some(uncovered) = &report.uncovered {
if !uncovered.functions.is_empty() {
output.push_str(&"Uncovered Functions:\n".bold().to_string());
for func in &uncovered.functions {
output.push_str(&format!(
" {}:{} - {}\n",
func.file.dimmed(),
func.line.to_string().cyan(),
func.name.red()
));
}
output.push('\n');
}
if !uncovered.line_ranges.is_empty() {
output.push_str(&"Uncovered Line Ranges:\n".bold().to_string());
let mut by_file: std::collections::HashMap<&str, Vec<(u32, u32)>> =
std::collections::HashMap::new();
for range in &uncovered.line_ranges {
by_file
.entry(&range.file)
.or_default()
.push((range.start, range.end));
}
for (file, ranges) in by_file {
let range_strs: Vec<String> = ranges
.iter()
.map(|(s, e)| {
if s == e {
format!("{}", s)
} else {
format!("{}-{}", s, e)
}
})
.collect();
output.push_str(&format!(
" {}: {}\n",
file.dimmed(),
range_strs.join(", ").red()
));
}
}
}
output
}