use console::Style;
use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verbosity {
Quiet,
Normal,
Verbose,
}
#[derive(Debug)]
pub struct Ui {
verbosity: Verbosity,
style_header: Style,
style_label: Style,
style_dim: Style,
style_green: Style,
style_yellow: Style,
style_red: Style,
style_bold: Style,
style_box: Style,
}
impl Ui {
pub fn new(verbosity: Verbosity) -> Self {
Self {
verbosity,
style_header: Style::new().bold(),
style_label: Style::new().cyan(),
style_dim: Style::new().dim(),
style_green: Style::new().green(),
style_yellow: Style::new().yellow(),
style_red: Style::new().red().bold(),
style_bold: Style::new().bold(),
style_box: Style::new().dim(),
}
}
fn is_quiet(&self) -> bool {
self.verbosity == Verbosity::Quiet
}
pub fn is_verbose(&self) -> bool {
self.verbosity == Verbosity::Verbose
}
pub fn header(&self, version: &str, commit: &str, build: &str, cpu_info: Option<&str>) {
if self.is_quiet() {
return;
}
eprintln!();
eprintln!(
" {} {}",
self.style_header.apply_to(format!("RustQC v{version}")),
self.style_dim
.apply_to(format!("({commit}, built {build})")),
);
if let Some(info) = cpu_info {
eprintln!(" {}", self.style_dim.apply_to(info));
}
eprintln!();
}
pub fn config(&self, key: &str, value: &str) {
if self.is_quiet() {
return;
}
eprintln!(
" {:<13}{}",
self.style_label.apply_to(format!("{key}:")),
value,
);
}
pub fn blank(&self) {
if self.is_quiet() {
return;
}
eprintln!();
}
pub fn section(&self, text: &str) {
if self.is_quiet() {
return;
}
eprintln!(" {}", self.style_header.apply_to(text));
}
pub fn step(&self, text: &str) {
if self.is_quiet() {
return;
}
eprintln!(" {text}");
}
pub fn detail(&self, text: &str) {
if self.verbosity != Verbosity::Verbose {
return;
}
eprintln!(" {}", self.style_dim.apply_to(text));
}
pub fn progress_bar(&self) -> ProgressBar {
if self.is_quiet() {
return ProgressBar::hidden();
}
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template(" {spinner:.cyan} {msg} {elapsed:.dim}")
.expect("valid template")
.tick_strings(&[
"\u{28fe}", "\u{28fd}", "\u{28fb}", "\u{28f7}", "\u{28ef}", "\u{28df}",
"\u{28bf}", "\u{287f}", "\u{28fe}",
]),
);
pb.enable_steady_tick(Duration::from_millis(100));
pb
}
pub fn finish_progress(&self, pb: &ProgressBar, reads: u64, duration: Duration) {
if self.is_quiet() {
pb.finish_and_clear();
return;
}
pb.finish_and_clear();
eprintln!(
" {} {} reads processed {}",
self.style_green.apply_to("\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}"),
self.style_bold.apply_to(format_count(reads)),
self.style_dim.apply_to(format_duration(duration)),
);
}
#[allow(clippy::too_many_arguments)]
pub fn summary_box(&self, title: &str, rows: &[(&str, String, String)]) {
if self.is_quiet() {
return;
}
let inner_width = console::Term::stderr()
.size()
.1
.saturating_sub(6) as usize;
let inner_width = inner_width.clamp(40, 80);
let border_h = "\u{2500}".repeat(inner_width + 2);
eprintln!();
eprintln!(
" {}",
self.style_box
.apply_to(format!("\u{250c}{border_h}\u{2510}"))
);
let title_padded = format!(" {title:<width$} ", width = inner_width);
eprintln!(
" {}{}{} ",
self.style_box.apply_to("\u{2502}"),
self.style_bold.apply_to(title_padded),
self.style_box.apply_to("\u{2502}"),
);
eprintln!(
" {}",
self.style_box.apply_to(format!(
"\u{2502}{:width$}\u{2502}",
"",
width = inner_width + 2
))
);
for (label, value, annotation) in rows {
eprintln!(
" {}{}{}",
self.style_box.apply_to("\u{2502}"),
format_summary_row(
&self.style_label,
&self.style_bold,
label,
value,
annotation,
inner_width,
),
self.style_box.apply_to("\u{2502}"),
);
}
eprintln!(
" {}",
self.style_box
.apply_to(format!("\u{2514}{border_h}\u{2518}"))
);
eprintln!();
}
pub fn output_item(&self, tool: &str, path: &str) {
if self.is_quiet() {
return;
}
eprintln!(
" {} {:<22}{}",
self.style_green.apply_to("\u{2713}"),
tool,
self.style_dim.apply_to(path),
);
}
pub fn output_detail(&self, text: &str) {
if self.verbosity != Verbosity::Verbose {
return;
}
eprintln!(" {}", self.style_dim.apply_to(text));
}
pub fn output_group(&self, name: &str) {
if self.is_quiet() {
return;
}
eprintln!(" {}", self.style_label.apply_to(format!("{name}:")));
}
pub fn bam_result_ok(&self, name: &str, duration: Duration) {
if self.is_quiet() {
return;
}
eprintln!(
" {} {:<40}{}",
self.style_green.apply_to("\u{2713}"),
name,
self.style_dim.apply_to(format_duration(duration)),
);
}
pub fn bam_result_err(&self, name: &str, error: &str) {
if self.is_quiet() {
return;
}
eprintln!(
" {} {:<40}{}",
self.style_red.apply_to("\u{2717}"),
name,
self.style_red.apply_to(format!("failed: {error}")),
);
}
pub fn finish(&self, label: &str, duration: Duration) {
if self.is_quiet() {
return;
}
eprintln!(
" {} {} {}",
self.style_green.apply_to("\u{2713}"),
self.style_header.apply_to(label),
self.style_dim
.apply_to(format!("finished in {}", format_duration(duration))),
);
eprintln!();
}
pub fn warn(&self, msg: &str) {
eprintln!(
" {} {}",
self.style_yellow.apply_to("\u{26a0}"),
self.style_yellow.apply_to(msg),
);
}
pub fn warn_box(&self, lines: &[&str]) {
let content_width = lines.iter().map(|l| l.len()).max().unwrap_or(0).max(40);
let y = &self.style_yellow;
let yb = Style::new().yellow().bold();
let top_label = " Warning ";
let remaining = content_width + 1 - top_label.len(); eprintln!(
" {}{}{}",
y.apply_to("╭─"),
y.apply_to(top_label),
y.apply_to(format!("{}╮", "─".repeat(remaining))),
);
for (i, line) in lines.iter().enumerate() {
let styled = if i == 0 {
format!("{}", yb.apply_to(line))
} else {
format!("{}", y.apply_to(line))
};
let pad = content_width.saturating_sub(line.len());
eprintln!(
" {} {}{} {}",
y.apply_to("│"),
styled,
" ".repeat(pad),
y.apply_to("│"),
);
}
eprintln!(
" {}",
y.apply_to(format!("╰{}╯", "─".repeat(content_width + 2))),
);
}
pub fn error(&self, msg: &str) {
eprintln!(
" {} {}",
self.style_red.apply_to("\u{2717} error:"),
self.style_red.apply_to(msg),
);
}
}
fn format_summary_row(
style_label: &Style,
style_bold: &Style,
label: &str,
value: &str,
annotation: &str,
inner_width: usize,
) -> String {
let styled_label = style_label.apply_to(format!("{label:<16}"));
let styled_value = style_bold.apply_to(format!("{value:>6}"));
let content = if annotation.is_empty() {
format!(" {styled_label}{styled_value}")
} else {
format!(" {styled_label}{styled_value} {annotation}")
};
let visible_width = console::measure_text_width(&content);
let target = inner_width + 2;
if visible_width < target {
format!("{content}{:width$}", "", width = target - visible_width)
} else {
content
}
}
pub fn format_count(n: u64) -> String {
use number_prefix::NumberPrefix;
match NumberPrefix::decimal(n as f64) {
NumberPrefix::Standalone(n) => format!("{n}"),
NumberPrefix::Prefixed(prefix, n) => {
let suffix = match prefix {
number_prefix::Prefix::Kilo => "K",
number_prefix::Prefix::Mega => "M",
number_prefix::Prefix::Giga => "G",
number_prefix::Prefix::Tera => "T",
_ => return format!("{:.1}{prefix:?}", n),
};
format!("{n:.1}{suffix}")
}
}
}
pub fn format_pct(n: u64, total: u64) -> String {
if total == 0 {
return "(0.0%)".to_string();
}
format!("({:.1}%)", n as f64 / total as f64 * 100.0)
}
pub fn format_duration(d: Duration) -> String {
let total_secs = d.as_secs_f64();
if total_secs < 60.0 {
return format!("{total_secs:.1}s");
}
let total_secs = d.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{hours}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes}:{seconds:02}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_count_small() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(42), "42");
assert_eq!(format_count(999), "999");
}
#[test]
fn test_format_count_thousands() {
assert_eq!(format_count(1000), "1.0K");
assert_eq!(format_count(1500), "1.5K");
assert_eq!(format_count(50000), "50.0K");
}
#[test]
fn test_format_count_millions() {
assert_eq!(format_count(1_000_000), "1.0M");
assert_eq!(format_count(48_200_000), "48.2M");
assert_eq!(format_count(50_000_000), "50.0M");
}
#[test]
fn test_format_count_billions() {
assert_eq!(format_count(1_000_000_000), "1.0G");
assert_eq!(format_count(5_000_000_000), "5.0G");
}
#[test]
fn test_format_pct() {
assert_eq!(format_pct(833, 1000), "(83.3%)");
assert_eq!(format_pct(0, 0), "(0.0%)");
assert_eq!(format_pct(1000, 1000), "(100.0%)");
}
#[test]
fn test_format_duration_seconds() {
assert_eq!(format_duration(Duration::from_secs_f64(0.5)), "0.5s");
assert_eq!(format_duration(Duration::from_secs_f64(45.2)), "45.2s");
assert_eq!(format_duration(Duration::from_secs_f64(59.9)), "59.9s");
}
#[test]
fn test_format_duration_minutes() {
assert_eq!(format_duration(Duration::from_secs(60)), "1:00");
assert_eq!(format_duration(Duration::from_secs(83)), "1:23");
assert_eq!(format_duration(Duration::from_secs(3599)), "59:59");
}
#[test]
fn test_format_duration_hours() {
assert_eq!(format_duration(Duration::from_secs(3600)), "1:00:00");
assert_eq!(format_duration(Duration::from_secs(3754)), "1:02:34");
}
}