use std::fmt::Write;
use std::time::Duration;
pub mod chars {
pub const TOP_LEFT: char = '┌';
pub const TOP_RIGHT: char = '┐';
pub const BOTTOM_LEFT: char = '└';
pub const BOTTOM_RIGHT: char = '┘';
pub const HORIZONTAL: char = '─';
pub const VERTICAL: char = '│';
pub const T_DOWN: char = '┬';
pub const T_UP: char = '┴';
pub const T_RIGHT: char = '├';
pub const T_LEFT: char = '┤';
pub const CROSS: char = '┼';
pub const SPARK_EMPTY: char = ' ';
pub const SPARK_1_8: char = '▁';
pub const SPARK_2_8: char = '▂';
pub const SPARK_3_8: char = '▃';
pub const SPARK_4_8: char = '▄';
pub const SPARK_5_8: char = '▅';
pub const SPARK_6_8: char = '▆';
pub const SPARK_7_8: char = '▇';
pub const SPARK_FULL: char = '█';
pub const CHECK: &str = "✓";
pub const CROSS_MARK: &str = "✗";
pub const WARNING: &str = "⚠";
pub const INFO: &str = "ℹ";
pub const BULLET: &str = "•";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Grade {
F,
D,
CMinus,
C,
CPlus,
BMinus,
B,
BPlus,
AMinus,
A,
APlus,
}
impl Grade {
pub fn from_percentage(pct: f64) -> Self {
match pct as u32 {
95..=100 => Grade::APlus,
90..=94 => Grade::A,
85..=89 => Grade::AMinus,
80..=84 => Grade::BPlus,
75..=79 => Grade::B,
70..=74 => Grade::BMinus,
65..=69 => Grade::CPlus,
60..=64 => Grade::C,
55..=59 => Grade::CMinus,
50..=54 => Grade::D,
_ => Grade::F,
}
}
pub fn as_letter(&self) -> &'static str {
match self {
Grade::APlus => "A+",
Grade::A => "A",
Grade::AMinus => "A-",
Grade::BPlus => "B+",
Grade::B => "B",
Grade::BMinus => "B-",
Grade::CPlus => "C+",
Grade::C => "C",
Grade::CMinus => "C-",
Grade::D => "D",
Grade::F => "F",
}
}
pub fn is_passing(&self) -> bool {
*self >= Grade::CMinus
}
}
impl std::fmt::Display for Grade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_letter())
}
}
pub fn sparkline(values: &[f64]) -> String {
if values.is_empty() {
return String::new();
}
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let range = max - min;
let sparks = [
chars::SPARK_1_8,
chars::SPARK_2_8,
chars::SPARK_3_8,
chars::SPARK_4_8,
chars::SPARK_5_8,
chars::SPARK_6_8,
chars::SPARK_7_8,
chars::SPARK_FULL,
];
values
.iter()
.map(|&v| {
if range == 0.0 {
sparks.get(3).copied().unwrap_or(chars::SPARK_4_8) } else {
let normalized = (v - min) / range;
let index = ((normalized * 7.0).round() as usize).min(7);
sparks.get(index).copied().unwrap_or(chars::SPARK_FULL)
}
})
.collect()
}
pub fn progress_bar(current: f64, total: f64, width: usize) -> String {
let pct = if total > 0.0 { current / total } else { 0.0 };
let filled = ((pct * width as f64).round() as usize).min(width);
let empty = width.saturating_sub(filled);
format!(
"[{}{}] {:.1}%",
chars::SPARK_FULL.to_string().repeat(filled),
chars::SPARK_EMPTY.to_string().repeat(empty),
pct * 100.0
)
}
pub struct ReportBuilder {
sections: Vec<ReportSection>,
title: String,
width: usize,
}
#[derive(Debug, Clone)]
pub struct ReportSection {
pub title: String,
pub items: Vec<ReportItem>,
}
#[derive(Debug, Clone)]
pub enum ReportItem {
KeyValue { key: String, value: String },
Status { passed: bool, message: String },
Progress {
label: String,
current: f64,
total: f64,
},
Sparkline { label: String, values: Vec<f64> },
TableRow { cells: Vec<String> },
Text(String),
Metric {
name: String,
value: f64,
unit: String,
grade: Grade,
},
}
impl ReportBuilder {
pub fn new(title: &str) -> Self {
Self {
sections: Vec::new(),
title: title.to_string(),
width: 60,
}
}
pub fn width(mut self, width: usize) -> Self {
self.width = width;
self
}
pub fn section(mut self, title: &str) -> Self {
self.sections.push(ReportSection {
title: title.to_string(),
items: Vec::new(),
});
self
}
pub fn item(mut self, item: ReportItem) -> Self {
if let Some(section) = self.sections.last_mut() {
section.items.push(item);
}
self
}
pub fn kv(self, key: &str, value: &str) -> Self {
self.item(ReportItem::KeyValue {
key: key.to_string(),
value: value.to_string(),
})
}
pub fn status(self, passed: bool, message: &str) -> Self {
self.item(ReportItem::Status {
passed,
message: message.to_string(),
})
}
pub fn progress(self, label: &str, current: f64, total: f64) -> Self {
self.item(ReportItem::Progress {
label: label.to_string(),
current,
total,
})
}
pub fn sparkline(self, label: &str, values: Vec<f64>) -> Self {
self.item(ReportItem::Sparkline {
label: label.to_string(),
values,
})
}
pub fn metric(self, name: &str, value: f64, unit: &str) -> Self {
let grade = Grade::from_percentage(value);
self.item(ReportItem::Metric {
name: name.to_string(),
value,
unit: unit.to_string(),
grade,
})
}
pub fn build(&self) -> RichReport {
RichReport {
title: self.title.clone(),
sections: self.sections.clone(),
width: self.width,
}
}
}
#[derive(Debug, Clone)]
pub struct RichReport {
pub title: String,
pub sections: Vec<ReportSection>,
pub width: usize,
}
impl RichReport {
pub fn render(&self) -> String {
let mut out = String::new();
self.draw_title_box(&mut out);
for section in &self.sections {
self.draw_section(&mut out, section);
}
self.draw_footer(&mut out);
out
}
fn draw_title_box(&self, out: &mut String) {
let inner_width = self.width - 2;
let title_padding = (inner_width.saturating_sub(self.title.len())) / 2;
out.push(chars::TOP_LEFT);
for _ in 0..inner_width {
out.push(chars::HORIZONTAL);
}
out.push(chars::TOP_RIGHT);
out.push('\n');
out.push(chars::VERTICAL);
for _ in 0..title_padding {
out.push(' ');
}
out.push_str(&self.title);
for _ in 0..(inner_width - title_padding - self.title.len()) {
out.push(' ');
}
out.push(chars::VERTICAL);
out.push('\n');
out.push(chars::BOTTOM_LEFT);
for _ in 0..inner_width {
out.push(chars::HORIZONTAL);
}
out.push(chars::BOTTOM_RIGHT);
out.push('\n');
}
fn draw_section(&self, out: &mut String, section: &ReportSection) {
out.push('\n');
let _ = writeln!(
out,
"{} {} {}",
chars::T_RIGHT,
section.title,
chars::HORIZONTAL
.to_string()
.repeat(self.width.saturating_sub(section.title.len() + 4))
);
for item in §ion.items {
self.draw_item(out, item);
}
}
fn draw_item(&self, out: &mut String, item: &ReportItem) {
match item {
ReportItem::KeyValue { key, value } => {
let _ = writeln!(out, " {}: {}", key, value);
}
ReportItem::Status { passed, message } => {
let icon = if *passed {
chars::CHECK
} else {
chars::CROSS_MARK
};
let _ = writeln!(out, " {} {}", icon, message);
}
ReportItem::Progress {
label,
current,
total,
} => {
let bar = progress_bar(*current, *total, 20);
let _ = writeln!(out, " {}: {}", label, bar);
}
ReportItem::Sparkline { label, values } => {
let spark = sparkline(values);
let _ = writeln!(out, " {}: {}", label, spark);
}
ReportItem::TableRow { cells } => {
let _ = writeln!(out, " {}", cells.join(" │ "));
}
ReportItem::Text(text) => {
let _ = writeln!(out, " {}", text);
}
ReportItem::Metric {
name,
value,
unit,
grade,
} => {
let icon = if grade.is_passing() {
chars::CHECK
} else {
chars::CROSS_MARK
};
let _ = writeln!(out, " {} {} {:.1}{} ({})", icon, name, value, unit, grade);
}
}
}
fn draw_footer(&self, out: &mut String) {
out.push('\n');
for _ in 0..self.width {
out.push(chars::HORIZONTAL);
}
out.push('\n');
let _ = writeln!(out, "Generated by bashrs v{}", env!("CARGO_PKG_VERSION"));
}
}
pub fn format_duration(d: Duration) -> String {
let secs = d.as_secs();
let millis = d.subsec_millis();
if secs >= 3600 {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
} else if secs >= 60 {
format!("{}m {}s", secs / 60, secs % 60)
} else if secs > 0 {
format!("{}.{}s", secs, millis / 100)
} else {
format!("{}ms", millis)
}
}
pub fn gate_report(
tier: u8,
gates_passed: usize,
gates_total: usize,
duration: Duration,
details: &[(String, bool, Duration)],
) -> String {
let mut builder = ReportBuilder::new(&format!("Tier {} Quality Gate Report", tier))
.width(60)
.section("Summary");
let pct = if gates_total > 0 {
(gates_passed as f64 / gates_total as f64) * 100.0
} else {
100.0
};
builder = builder
.metric("Pass Rate", pct, "%")
.kv("Gates Passed", &format!("{}/{}", gates_passed, gates_total))
.kv("Total Duration", &format_duration(duration));
builder = builder.section("Gate Results");
for (name, passed, dur) in details {
builder = builder.status(*passed, &format!("{} ({})", name, format_duration(*dur)));
}
builder.build().render()
}
#[cfg(test)]
#[path = "report_tests_ml_011.rs"]
mod tests_extracted;