use std::collections::HashMap;
use rich::table::Table;
use rich::{Console, ConsoleOptions, Justify, Renderable, Segment, Style, Text};
use super::sizes::Sizes;
use super::timings::Timings;
use super::{DepsError, clean, stack, stack_measure, theme_style};
use crate::format;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Quantity {
Seconds,
Bytes,
}
impl Quantity {
pub fn format(self, value: f64) -> String {
match self {
Quantity::Seconds => format!("{value:.2}s"),
Quantity::Bytes => format::bytes_binary(value.max(0.0).round() as u64),
}
}
pub fn format_change(self, change: f64) -> String {
let size = self.format(change.abs());
if size == self.format(0.0) {
return size;
}
let sign = if change < 0.0 { "-" } else { "+" };
format!("{sign}{size}")
}
}
fn format_percent(percent: f64) -> String {
let text = format!("{percent:+.1}%");
if text == "+0.0%" || text == "-0.0%" {
"0.0%".into()
} else {
text
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Threshold {
Percent(f64),
Seconds(f64),
Bytes(u64),
}
impl Threshold {
pub fn parse(text: &str) -> Result<Self, DepsError> {
let invalid = || {
DepsError::new(format!(
"{:?} is not a threshold: give a percentage (10%), a time (0.5s) or a size \
(100KiB)",
clean(text)
))
};
let trimmed = text.trim();
let split = trimmed
.find(|c: char| !(c.is_ascii_digit() || c == '.'))
.unwrap_or(trimmed.len());
let (number, unit) = trimmed.split_at(split);
let value: f64 = number.parse().map_err(|_| invalid())?;
if !value.is_finite() || value < 0.0 {
return Err(invalid());
}
let unit = unit.trim().to_ascii_lowercase();
let bytes = |scale: f64| {
let bytes = value * scale;
if bytes > u64::MAX as f64 {
Err(invalid())
} else {
Ok(Threshold::Bytes(bytes.round() as u64))
}
};
match unit.as_str() {
"" | "%" => Ok(Threshold::Percent(value)),
"s" | "sec" | "secs" => Ok(Threshold::Seconds(value)),
"ms" => Ok(Threshold::Seconds(value / 1000.0)),
"min" => Ok(Threshold::Seconds(value * 60.0)),
"b" | "byte" | "bytes" => bytes(1.0),
"k" | "kib" => bytes(1024.0),
"kb" => bytes(1000.0),
"m" | "mib" => bytes(1024.0 * 1024.0),
"mb" => bytes(1e6),
"g" | "gib" => bytes(1024.0 * 1024.0 * 1024.0),
"gb" => bytes(1e9),
_ => Err(invalid()),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Thresholds {
pub percent: f64,
pub absolute: f64,
}
impl Thresholds {
pub const TIMINGS: Thresholds = Thresholds {
percent: 10.0,
absolute: 0.5,
};
pub const SIZES: Thresholds = Thresholds {
percent: 10.0,
absolute: 64.0 * 1024.0,
};
pub fn defaults(quantity: Quantity) -> Self {
match quantity {
Quantity::Seconds => Thresholds::TIMINGS,
Quantity::Bytes => Thresholds::SIZES,
}
}
pub fn with(mut self, threshold: Threshold, quantity: Quantity) -> Result<Self, DepsError> {
match (threshold, quantity) {
(Threshold::Percent(percent), _) => self.percent = percent,
(Threshold::Seconds(seconds), Quantity::Seconds) => self.absolute = seconds,
(Threshold::Bytes(bytes), Quantity::Bytes) => self.absolute = bytes as f64,
(Threshold::Seconds(_), Quantity::Bytes) => {
return Err(DepsError::new(
"a time threshold applies to build times, not sizes",
));
}
(Threshold::Bytes(_), Quantity::Seconds) => {
return Err(DepsError::new(
"a size threshold applies to sizes, not build times",
));
}
}
Ok(self)
}
pub fn verdict(&self, baseline: Option<f64>, current: Option<f64>) -> Verdict {
match (baseline, current) {
(None, Some(current)) if current > self.absolute => Verdict::Regression,
(None, Some(_)) => Verdict::New,
(Some(_), None) => Verdict::Removed,
(None, None) => Verdict::Unchanged,
(Some(baseline), Some(current)) => {
let change = current - baseline;
let percent = if baseline > 0.0 {
change / baseline * 100.0
} else if change > 0.0 {
f64::INFINITY
} else {
0.0
};
if change > self.absolute && percent > self.percent {
Verdict::Regression
} else if -change > self.absolute && -percent > self.percent {
Verdict::Improvement
} else {
Verdict::Unchanged
}
}
}
}
pub fn describe(&self, quantity: Quantity) -> String {
format!(
"over {}% and over {}",
trim_number(self.percent),
quantity.format(self.absolute)
)
}
}
fn trim_number(value: f64) -> String {
let text = format!("{value:.2}");
text.trim_end_matches('0').trim_end_matches('.').to_string()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Verdict {
Regression,
New,
Improvement,
Removed,
Unchanged,
}
impl Verdict {
pub fn name(self) -> &'static str {
match self {
Verdict::Regression => "regression",
Verdict::New => "new",
Verdict::Improvement => "improvement",
Verdict::Removed => "removed",
Verdict::Unchanged => "unchanged",
}
}
fn style(self, console: &Console) -> Style {
theme_style(
console,
match self {
Verdict::Regression => "deps.regression",
Verdict::Improvement => "deps.improvement",
Verdict::New | Verdict::Removed => "deps.new",
Verdict::Unchanged => "deps.off",
},
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
pub key: String,
pub baseline: Option<f64>,
pub current: Option<f64>,
pub verdict: Verdict,
}
impl Row {
pub fn change(&self) -> f64 {
self.current.unwrap_or(0.0) - self.baseline.unwrap_or(0.0)
}
pub fn percent(&self) -> Option<f64> {
let baseline = self.baseline.filter(|b| *b > 0.0)?;
Some((self.current? - baseline) / baseline * 100.0)
}
}
fn summed(values: impl IntoIterator<Item = (String, f64)>) -> Vec<(String, f64)> {
let mut out: Vec<(String, f64)> = Vec::new();
let mut index: HashMap<String, usize> = HashMap::new();
for (key, value) in values {
if !value.is_finite() {
continue;
}
match index.get(&key) {
Some(&at) => out[at].1 += value,
None => {
index.insert(key.clone(), out.len());
out.push((key, value));
}
}
}
out
}
#[derive(Clone, Debug)]
pub struct Comparison {
quantity: Quantity,
thresholds: Thresholds,
rows: Vec<Row>,
total: Row,
names: Option<(String, String)>,
limit: Option<usize>,
}
impl Comparison {
pub fn new(
quantity: Quantity,
baseline: impl IntoIterator<Item = (String, f64)>,
current: impl IntoIterator<Item = (String, f64)>,
thresholds: Thresholds,
) -> Self {
let baseline = summed(baseline);
let current = summed(current);
let old: HashMap<&str, f64> = baseline.iter().map(|(k, v)| (k.as_str(), *v)).collect();
let new: HashMap<&str, f64> = current.iter().map(|(k, v)| (k.as_str(), *v)).collect();
let mut rows: Vec<Row> = Vec::new();
for (key, value) in &baseline {
let now = new.get(key.as_str()).copied();
rows.push(Row {
key: key.clone(),
baseline: Some(*value),
current: now,
verdict: thresholds.verdict(Some(*value), now),
});
}
for (key, value) in ¤t {
if !old.contains_key(key.as_str()) {
rows.push(Row {
key: key.clone(),
baseline: None,
current: Some(*value),
verdict: thresholds.verdict(None, Some(*value)),
});
}
}
rows.sort_by(|a, b| {
a.verdict
.cmp(&b.verdict)
.then_with(|| b.change().abs().total_cmp(&a.change().abs()))
.then_with(|| a.key.cmp(&b.key))
});
let sum = |list: &[(String, f64)]| list.iter().map(|(_, v)| v).sum::<f64>();
let (old_total, new_total) = (sum(&baseline), sum(¤t));
let total = Row {
key: "total".into(),
baseline: Some(old_total),
current: Some(new_total),
verdict: thresholds.verdict(Some(old_total), Some(new_total)),
};
Comparison {
quantity,
thresholds,
rows,
total,
names: None,
limit: None,
}
}
pub fn timings(baseline: &Timings, current: &Timings, thresholds: Thresholds) -> Self {
let values = |timings: &Timings| -> Vec<(String, f64)> {
timings
.units()
.iter()
.map(|unit| (unit.label(), unit.duration))
.collect()
};
Comparison::new(
Quantity::Seconds,
values(baseline),
values(current),
thresholds,
)
}
pub fn sizes(baseline: &Sizes, current: &Sizes, thresholds: Thresholds) -> Self {
let values = |sizes: &Sizes| -> Vec<(String, f64)> {
sizes
.entries()
.iter()
.map(|entry| (entry.key(), entry.bytes as f64))
.collect()
};
Comparison::new(
Quantity::Bytes,
values(baseline),
values(current),
thresholds,
)
}
#[must_use]
pub fn names(mut self, baseline: impl Into<String>, current: impl Into<String>) -> Self {
self.names = Some((baseline.into(), current.into()));
self
}
#[must_use]
pub fn limit(mut self, limit: usize) -> Self {
self.limit = Some(limit.max(1));
self
}
pub fn quantity(&self) -> Quantity {
self.quantity
}
pub fn thresholds(&self) -> Thresholds {
self.thresholds
}
pub fn rows(&self) -> &[Row] {
&self.rows
}
pub fn total(&self) -> &Row {
&self.total
}
pub fn regressions(&self) -> Vec<&Row> {
self.rows
.iter()
.filter(|row| row.verdict == Verdict::Regression)
.collect()
}
pub fn count(&self, verdict: Verdict) -> usize {
self.rows
.iter()
.filter(|row| row.verdict == verdict)
.count()
}
pub fn failed(&self) -> bool {
self.total.verdict == Verdict::Regression || !self.regressions().is_empty()
}
fn heading(&self, console: &Console) -> Text {
let what = match self.quantity {
Quantity::Seconds => "Build times",
Quantity::Bytes => "Sizes",
};
let mut line = format!("{what} against the baseline");
if let Some((old, new)) = &self.names {
line.push_str(&format!(" ({} → {})", clean(old), clean(new)));
}
let mut text = Text::styled(line, theme_style(console, "deps.root"));
text.append("\n", None);
let counts = [
Verdict::Regression,
Verdict::Improvement,
Verdict::Unchanged,
Verdict::New,
Verdict::Removed,
];
for (index, verdict) in counts.into_iter().enumerate() {
let n = self.count(verdict);
if index > 0 {
text.append(", ", None);
}
let word = match (verdict, n) {
(Verdict::Regression | Verdict::Improvement, 1) => verdict.name().to_string(),
(Verdict::Regression | Verdict::Improvement, _) => format!("{}s", verdict.name()),
_ => verdict.name().to_string(),
};
let style = if n > 0 && verdict != Verdict::Unchanged {
Some(verdict.style(console).into())
} else {
None
};
text.append(&format!("{n} {word}"), style);
}
let total = &self.total;
text.append(
&format!(
"\ntotal {} → {}: {}",
self.quantity.format(total.baseline.unwrap_or(0.0)),
self.quantity.format(total.current.unwrap_or(0.0)),
self.quantity.format_change(total.change())
),
None,
);
if let Some(percent) = total.percent() {
text.append(&format!(" ({})", format_percent(percent)), None);
}
if matches!(total.verdict, Verdict::Regression | Verdict::Improvement) {
text.append(", ", None);
text.append(
&format!(
"a{} {}",
if total.verdict == Verdict::Improvement {
"n"
} else {
""
},
total.verdict.name()
),
Some(total.verdict.style(console).into()),
);
}
text.append(
&format!("\nregression: {}", self.thresholds.describe(self.quantity)),
Some(theme_style(console, "deps.section").into()),
);
text
}
fn table(&self, console: &Console) -> Table {
let mut table = Table::new();
table.add_column("Unit");
for header in ["Baseline", "Current", "Change", "%"] {
table.add_column_justify(header, Justify::Right);
}
table.add_column("Verdict");
let none = || Text::styled("–", theme_style(console, "deps.off"));
let value = |value: Option<f64>| {
value.map_or_else(none, |value| Text::new(self.quantity.format(value)))
};
let row_text = |row: &Row| {
let style = row.verdict.style(console);
let percent = match (row.percent(), row.verdict) {
(Some(percent), _) => format_percent(percent),
(None, Verdict::Removed) => "removed".into(),
(None, _) if row.baseline.is_none() => "new".into(),
(None, _) => "–".into(),
};
vec![
Text::new(clean(&row.key).into_owned()),
value(row.baseline),
value(row.current),
Text::styled(self.quantity.format_change(row.change()), style.clone()),
Text::styled(percent, style.clone()),
Text::styled(row.verdict.name(), style),
]
};
let limit = self.limit.unwrap_or(usize::MAX).min(self.rows.len());
let (shown, rest) = self.rows.split_at(limit);
for row in shown {
table.add_row_text(row_text(row));
}
if !rest.is_empty() {
table.add_section();
let mut cells = vec![Text::styled(
format!("and {} more", rest.len()),
theme_style(console, "deps.off"),
)];
cells.extend((0..5).map(|_| Text::new("")));
table.add_row_text(cells);
}
table.add_section();
let mut total = row_text(&self.total);
total[0] = Text::styled("total", theme_style(console, "deps.root"));
table.add_row_text(total);
table
}
}
impl Renderable for Comparison {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let (heading, table) = (self.heading(console), self.table(console));
stack(&[&heading, &table], console, options)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
let (heading, table) = (self.heading(console), self.table(console));
stack_measure(&[&heading, &table], console, options)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thresholds_parse_with_units() {
assert_eq!(Threshold::parse("10%").unwrap(), Threshold::Percent(10.0));
assert_eq!(Threshold::parse("2.5").unwrap(), Threshold::Percent(2.5));
assert_eq!(Threshold::parse("0.5s").unwrap(), Threshold::Seconds(0.5));
assert_eq!(Threshold::parse("250ms").unwrap(), Threshold::Seconds(0.25));
assert_eq!(
Threshold::parse("100KiB").unwrap(),
Threshold::Bytes(102_400)
);
assert_eq!(
Threshold::parse("100kb").unwrap(),
Threshold::Bytes(100_000)
);
assert_eq!(Threshold::parse("2M").unwrap(), Threshold::Bytes(2 << 20));
assert_eq!(
Threshold::parse("1 MB").unwrap(),
Threshold::Bytes(1_000_000)
);
assert_eq!(Threshold::parse("0B").unwrap(), Threshold::Bytes(0));
for bad in [
"",
"%",
"-1%",
"abc",
"10 parsecs",
"1e400",
"NaN",
"inf",
"1.2.3s",
] {
assert!(Threshold::parse(bad).is_err(), "{bad:?}");
}
}
#[test]
fn a_threshold_must_fit_the_quantity() {
let sizes = Thresholds::SIZES;
assert!(
sizes
.with(Threshold::Seconds(1.0), Quantity::Bytes)
.is_err()
);
assert!(
Thresholds::TIMINGS
.with(Threshold::Bytes(1), Quantity::Seconds)
.is_err()
);
let set = sizes
.with(Threshold::Percent(5.0), Quantity::Bytes)
.unwrap()
.with(Threshold::Bytes(1024), Quantity::Bytes)
.unwrap();
assert_eq!((set.percent, set.absolute), (5.0, 1024.0));
}
#[test]
fn verdicts_need_both_the_percentage_and_the_floor() {
let t = Thresholds {
percent: 10.0,
absolute: 1.0,
};
assert_eq!(t.verdict(Some(1.0), Some(1.5)), Verdict::Unchanged);
assert_eq!(t.verdict(Some(40.0), Some(42.0)), Verdict::Unchanged);
assert_eq!(t.verdict(Some(10.0), Some(12.0)), Verdict::Regression);
assert_eq!(t.verdict(Some(10.0), Some(8.0)), Verdict::Improvement);
assert_eq!(t.verdict(Some(0.0), Some(2.0)), Verdict::Regression);
assert_eq!(t.verdict(None, Some(2.0)), Verdict::Regression);
assert_eq!(t.verdict(None, Some(0.5)), Verdict::New);
assert_eq!(t.verdict(Some(3.0), None), Verdict::Removed);
assert_eq!(t.verdict(Some(10.0), Some(11.0)), Verdict::Unchanged);
}
#[test]
fn duplicate_names_add_up_and_rows_sort_regressions_first() {
let old = vec![
("syn".to_string(), 3.0),
("syn".to_string(), 2.0),
("gone".to_string(), 1.0),
("same".to_string(), 1.0),
];
let new = vec![
("syn".to_string(), 3.0),
("same".to_string(), 1.0),
("fresh".to_string(), 0.1),
("big".to_string(), 9.0),
];
let c = Comparison::new(Quantity::Seconds, old, new, Thresholds::TIMINGS);
let order: Vec<(&str, Verdict)> = c
.rows()
.iter()
.map(|r| (r.key.as_str(), r.verdict))
.collect();
assert_eq!(
order,
[
("big", Verdict::Regression),
("fresh", Verdict::New),
("syn", Verdict::Improvement),
("gone", Verdict::Removed),
("same", Verdict::Unchanged),
]
);
assert_eq!(c.total().baseline, Some(7.0));
assert_eq!(c.total().current, Some(13.1));
assert!(c.failed());
assert_eq!(c.rows()[2].percent().map(|p| p.round()), Some(-40.0));
}
}