use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use brogz::{Config, EncodingMeasurement, ProgressCallback, ProgressEvent, Report};
use clap::Parser;
use comfy_table::presets::UTF8_FULL;
use comfy_table::{Cell, Color, ContentArrangement, Table};
use tracing_subscriber::EnvFilter;
use url::Url;
#[derive(Parser, Debug)]
#[command(name = "brogz", version, about, long_about = None)]
struct Cli {
url: Url,
#[arg(short = 'r', long, default_value_t = brogz::DEFAULT_RUNS)]
runs: usize,
#[arg(short = 'c', long)]
concurrency: Option<usize>,
#[arg(short = 'k', long)]
insecure: bool,
#[arg(short = 'o', long, value_name = "FILE")]
out: Option<PathBuf>,
#[arg(short = 'p', long = "path", value_name = "PATH")]
paths: Vec<String>,
#[arg(long)]
no_color: bool,
#[arg(short = 'q', long)]
quiet: bool,
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.with_target(false)
.init();
let runs = cli.runs;
if runs == 0 {
eprintln!("error: --runs must be >= 1");
return ExitCode::from(2);
}
let concurrency = cli.concurrency.unwrap_or(runs * 3);
let base_display = cli.url.as_str().trim_end_matches('/');
println!("BASE_URL: {base_display}");
println!("Runs per URL × encoding: {runs}");
if cli.insecure {
println!("TLS verification: OFF (--insecure)");
}
println!();
let config = Config {
base_url: cli.url.clone(),
runs,
concurrency,
insecure: cli.insecure,
paths: (!cli.paths.is_empty()).then_some(cli.paths),
progress: (!cli.quiet).then(build_progress_reporter),
};
let report = match brogz::run(config).await {
Ok(r) => r,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::from(1);
}
};
print_table(&report, !cli.no_color);
if let Some(out) = cli.out {
if let Err(e) = write_report(&out, &report) {
eprintln!("error: failed to write report to {}: {e}", out.display());
return ExitCode::from(1);
}
println!("\nReport saved to {}", out.display());
}
ExitCode::SUCCESS
}
fn build_progress_reporter() -> ProgressCallback {
let counter = Arc::new(AtomicUsize::new(0));
let total = Arc::new(AtomicUsize::new(0));
Arc::new(move |event| match event {
ProgressEvent::Discovered {
url_count,
probes_per_url,
} => {
total.store(url_count, Ordering::Relaxed);
let probes = url_count * probes_per_url;
eprintln!(
"Discovered {url_count} URL{} ({probes} probe{} total)",
if url_count == 1 { "" } else { "s" },
if probes == 1 { "" } else { "s" },
);
}
ProgressEvent::UrlCompleted { path } => {
let n = counter.fetch_add(1, Ordering::Relaxed) + 1;
let t = total.load(Ordering::Relaxed);
eprintln!(" [{n}/{t}] {path}");
}
})
}
fn print_table(report: &Report, color: bool) {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
"path", "raw", "gzip", "gz CE", "br", "br CE", "br vs gz",
]);
for m in &report.measurements {
let is_better = m.br.bytes > 0 && m.br.bytes < m.gzip.bytes;
let row_color = color.then_some(if is_better { Color::Green } else { Color::Red });
table.add_row(colored_row(
row_color,
[
m.path.clone(),
format_bytes_cell(&m.identity),
format_bytes_cell(&m.gzip),
m.gzip.content_encoding.clone(),
format_bytes_cell(&m.br),
m.br.content_encoding.clone(),
format_delta(m.br.bytes, m.gzip.bytes),
],
));
}
let totals_color = color.then_some(Color::Cyan);
table.add_row(colored_row(
totals_color,
[
"Σ TOTAL".to_owned(),
format_totals_cell(report, |m| &m.identity),
format_totals_cell(report, |m| &m.gzip),
String::new(),
format_totals_cell(report, |m| &m.br),
String::new(),
format_delta(report.totals.br, report.totals.gzip),
],
));
println!("{table}");
}
fn colored_row(color: Option<Color>, cells: [String; 7]) -> Vec<Cell> {
cells
.into_iter()
.map(|s| match color {
Some(c) => Cell::new(s).fg(c),
None => Cell::new(s),
})
.collect()
}
fn format_kb(bytes: u64) -> String {
format!("{:.2}KB", bytes as f64 / 1024.0)
}
const VARIANCE_THRESHOLD_PCT: f64 = 1.0;
fn format_bytes_cell(m: &EncodingMeasurement) -> String {
let base = format_kb(m.bytes);
let spread_pct = variance_pct(m.bytes, m.bytes_min, m.bytes_max);
if spread_pct >= VARIANCE_THRESHOLD_PCT {
format!("{base} ±{spread_pct:.1}%")
} else {
base
}
}
fn format_totals_cell(
report: &Report,
pick: impl Fn(&brogz::UrlMeasurement) -> &EncodingMeasurement,
) -> String {
let median: u64 = report.measurements.iter().map(|m| pick(m).bytes).sum();
let min: u64 = report.measurements.iter().map(|m| pick(m).bytes_min).sum();
let max: u64 = report.measurements.iter().map(|m| pick(m).bytes_max).sum();
let base = format_kb(median);
let spread_pct = variance_pct(median, min, max);
if spread_pct >= VARIANCE_THRESHOLD_PCT {
format!("{base} ±{spread_pct:.1}%")
} else {
base
}
}
fn variance_pct(median: u64, min: u64, max: u64) -> f64 {
if median == 0 {
return 0.0;
}
max.saturating_sub(min) as f64 / median as f64 * 100.0
}
fn format_delta(current: u64, base: u64) -> String {
if base == 0 {
return "—".to_owned();
}
let pct = (current as f64 - base as f64) / base as f64 * 100.0;
let sign = if pct > 0.0 { "+" } else { "" };
format!("{sign}{pct:.1}%")
}
fn write_report(path: &std::path::Path, report: &Report) -> Result<(), Box<dyn std::error::Error>> {
let json = serde_json::to_string_pretty(report)?;
std::fs::write(path, format!("{json}\n"))?;
Ok(())
}