use crate::errors::{CoverioError, Result};
use serde_json::{Map, Value};
const SPACE: &str = "%20";
const PERCENT: &str = "%25";
const BOX_DRAWINGS_LIGHT_VERTICAL: &str = "%E2%94%82";
const EM_DASH: &str = "%E2%80%94";
pub struct BadgeProperties {
badge_style: BadgeStyle,
badge_label: String,
separator_style: SeparatorStyle,
no_percent_sign: bool,
squash: bool,
}
impl BadgeProperties {
pub fn new() -> Self {
Self {
badge_style: Default::default(),
badge_label: "cov".to_string(),
separator_style: Default::default(),
no_percent_sign: false,
squash: false,
}
}
pub fn with_badge_style(mut self, badge_style: BadgeStyle) -> Self {
self.badge_style = badge_style;
self
}
pub fn with_badge_label(mut self, badge_label: String) -> Self {
self.badge_label = badge_label;
self
}
pub fn with_separator_style(mut self, separator_style: SeparatorStyle) -> Self {
self.separator_style = separator_style;
self
}
pub fn with_no_percent_sign(mut self, no_percent_sign: bool) -> Self {
self.no_percent_sign = no_percent_sign;
self
}
pub fn with_squash(mut self, squash: bool) -> Self {
self.squash = squash;
self
}
}
#[derive(Debug, Default)]
pub enum BadgeStyle {
#[default]
Flat,
FlatSquare,
Plastic,
ForTheBadge,
Social,
}
impl BadgeStyle {
fn query_parameter(&self) -> &'static str {
match self {
BadgeStyle::Flat => "",
BadgeStyle::FlatSquare => "flat-square",
BadgeStyle::Plastic => "plastic",
BadgeStyle::ForTheBadge => "for-the-badge",
BadgeStyle::Social => "social",
}
}
}
impl From<&str> for BadgeStyle {
fn from(value: &str) -> Self {
match value {
"flat" => Self::Flat,
"flat-square" => Self::FlatSquare,
"plastic" => Self::Plastic,
"for-the-badge" => Self::ForTheBadge,
"social" => Self::Social,
_ => Self::Flat,
}
}
}
#[derive(Debug, Default)]
pub enum SeparatorStyle {
Space,
Bar,
#[default]
SpacedBar,
}
impl From<&str> for SeparatorStyle {
fn from(value: &str) -> Self {
match value {
"space" => Self::Space,
"bar" => Self::Bar,
"spaced-bar" => Self::SpacedBar,
_ => Self::SpacedBar,
}
}
}
pub struct CoverageReport {
regions_count: u64,
regions_percent: f64,
functions_count: u64,
functions_percent: f64,
lines_count: u64,
lines_percent: f64,
threshold_high: f64,
threshold_moderate: f64,
color_high: String,
color_moderate: String,
color_low: String,
}
impl CoverageReport {
pub fn new() -> Self {
Self {
regions_count: 0,
regions_percent: 0.0,
functions_count: 0,
functions_percent: 0.0,
lines_count: 0,
lines_percent: 0.0,
threshold_high: 80.0,
threshold_moderate: 50.0,
color_high: "21b577".to_string(),
color_moderate: "f4b01b".to_string(),
color_low: "f52020".to_string(),
}
}
pub fn regions_percent(&self) -> f64 {
self.regions_percent
}
pub fn functions_percent(&self) -> f64 {
self.functions_percent
}
pub fn lines_percent(&self) -> f64 {
self.lines_percent
}
pub fn analyze(&mut self, value: &Value) -> Result<(), CoverioError> {
let Value::Object(map) = value else {
return Err(CoverioError::new("expected object"));
};
let Some(data) = map.get("data") else {
return Err(CoverioError::new("expected field 'data'"));
};
let Value::Array(array) = data else {
return Err(CoverioError::new("expected 'data' array"));
};
if array.len() != 1 {
return Err(CoverioError::new("expected single element in 'data' array"));
}
let Some(Value::Object(map)) = array.first() else {
return Err(CoverioError::new("expected single object in 'data' array"));
};
let Some(totals) = map.get("totals") else {
return Err(CoverioError::new("expected field 'totals' in 'data` object"));
};
let Value::Object(totals) = totals else {
return Err(CoverioError::new("expected 'totals' object"));
};
(self.regions_count, self.regions_percent) = self.read_values(totals, "regions")?;
(self.functions_count, self.functions_percent) = self.read_values(totals, "functions")?;
(self.lines_count, self.lines_percent) = self.read_values(totals, "lines")?;
Ok(())
}
fn read_values(&self, map: &Map<String, Value>, key: &str) -> Result<(u64, f64), CoverioError> {
let Some(Value::Object(map)) = map.get(key) else {
return Err(CoverioError::new(format!("expected '{key}' object")));
};
let Some(Value::Number(count_number)) = map.get("count") else {
return Err(CoverioError::new("expected 'count' number"));
};
let Some(count) = count_number.as_u64() else {
return Err(CoverioError::new("invalid 'count' number"));
};
let Some(Value::Number(percent_number)) = map.get("percent") else {
return Err(CoverioError::new("expected 'percent' number"));
};
let Some(percent) = percent_number.as_f64() else {
return Err(CoverioError::new("invalid 'percent' number"));
};
Ok((count, percent))
}
pub fn badge(&self, properties: BadgeProperties) -> String {
let regions_perc = self.regions_percent.trunc();
let functions_perc = self.functions_percent.trunc();
let lines_perc = self.lines_percent.trunc();
let equal_perc = self.regions_percent == self.functions_percent && self.functions_percent == self.lines_percent;
let mut min = f64::MAX;
for (percent, count) in [regions_perc, functions_perc, lines_perc]
.iter()
.zip([self.regions_count, self.functions_count, self.lines_count])
{
if *percent < min && count > 0 {
min = *percent;
}
}
let color = if min >= self.threshold_high {
&self.color_high
} else if min < self.threshold_moderate {
&self.color_low
} else {
&self.color_moderate
};
let separator = self.separator(properties.separator_style);
let prefix = format!("https://img.shields.io/badge/{}", properties.badge_label);
let query_parameter = properties.badge_style.query_parameter();
let style = if query_parameter.is_empty() {
"".to_string()
} else {
format!("?style={query_parameter}")
};
let regions = self.label_value(regions_perc, self.regions_count, properties.no_percent_sign);
let functions = self.label_value(functions_perc, self.functions_count, properties.no_percent_sign);
let lines = self.label_value(lines_perc, self.lines_count, properties.no_percent_sign);
if properties.squash && equal_perc {
format!("{prefix}-{lines}-{color}.svg{style}")
} else {
format!("{prefix}-{regions}{separator}{functions}{separator}{lines}-{color}.svg{style}")
}
}
fn separator(&self, separator_style: SeparatorStyle) -> String {
match separator_style {
SeparatorStyle::Space => SPACE.to_string(),
SeparatorStyle::Bar => BOX_DRAWINGS_LIGHT_VERTICAL.to_string(),
SeparatorStyle::SpacedBar => format!("{SPACE}{BOX_DRAWINGS_LIGHT_VERTICAL}{SPACE}"),
}
}
fn label_value(&self, value: f64, count: u64, no_percent_sign: bool) -> String {
if count > 0 {
if no_percent_sign { format!("{value}") } else { format!("{value}{PERCENT}") }
} else {
EM_DASH.to_string()
}
}
}