use owo_colors::OwoColorize;
use terminal_size::{terminal_size, Width};
use crate::analyzer::ProjectStats;
use crate::config::LanguageIndex;
use crate::logger::{Level, Log, Verbosity};
pub enum Mode {
Stats {
stats: ProjectStats,
},
Diff {
base: ProjectStats,
target: ProjectStats,
},
}
struct LangLines {
name: String,
color: owo_colors::Rgb,
lines: u32,
}
struct Remainder {
frac: f64,
index: usize,
}
fn terminal_width() -> Option<u16> {
terminal_size().map(|(Width(w), _)| w)
}
fn format_thousands(n: u32) -> String {
if n == 0 {
return "0".to_string();
}
let mut n = n;
let mut out = String::new();
let mut count: u32 = 0;
while n > 0 {
if count > 0 && count % 3 == 0 {
out.push(',');
}
out.push(char::from_digit(n % 10, 10).unwrap());
n /= 10;
count += 1;
}
out.chars().rev().collect()
}
fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1000.0 && unit < UNITS.len() - 1 {
value /= 1000.0;
unit += 1;
}
if unit == 0 {
format!("{} B", bytes) } else {
format!("{:.1} {}", value, UNITS[unit])
}
}
fn build_lang_lines(stats: &ProjectStats, index: &LanguageIndex) -> Vec<LangLines> {
let mut result = Vec::new();
for (id, &lines) in &stats.language_line_map {
if lines == 0 {
continue;
}
if let Some(entry) = index.get(id) {
result.push(LangLines {
name: entry.name.clone(),
color: entry.to_rgb(),
lines,
});
}
}
result.sort_by(|a, b| b.lines.cmp(&a.lines));
result
}
fn render_bar(langs: &[LangLines], half_width: usize, total_lines: u32) -> String {
let n = langs.len();
if n == 0 || half_width == 0 || total_lines == 0 {
return String::new();
}
let total_units = half_width.max(n);
let mut units = Vec::with_capacity(n);
let mut required = 0usize;
let mut remainders: Vec<Remainder> = Vec::with_capacity(n);
for (i, ll) in langs.iter().enumerate() {
let quota = ll.lines as f64 / total_lines as f64 * total_units as f64;
let mut u = quota.floor() as usize;
if u == 0 {
u = 1;
}
units.push(u);
required += u;
remainders.push(Remainder {
frac: quota - quota.floor(),
index: i,
});
}
let total = total_units.max(required);
let mut remaining = total - required;
remainders.sort_by(|a, b| b.frac.total_cmp(&a.frac));
while remaining > 0 {
for r in &remainders {
if remaining == 0 {
break;
}
units[r.index] += 1;
remaining -= 1;
}
}
let mut bar = String::new();
for (i, ll) in langs.iter().enumerate() {
bar.push_str(&format!("{}", &"━".repeat(units[i]).color(ll.color)));
}
bar
}
pub fn render(mode: Mode, index: &LanguageIndex) -> Result<(), Log> {
let Some(terminal_w) = terminal_width() else {
return Err(Log::new(
Level::Error,
"Unable to get terminal size, please make sure to run this program in a terminal"
.to_string(),
Some(Verbosity::Normal),
));
};
let half_width = (terminal_w as usize) / 2;
match mode {
Mode::Stats { stats } => {
let lang_lines = build_lang_lines(&stats, index);
if stats.total_lines > 0 {
println!("Language Line Distribution:");
println!("{}", render_bar(&lang_lines, half_width, stats.total_lines));
for ll in &lang_lines {
let share = ll.lines as f64 / stats.total_lines as f64;
let pct = share * 100.0;
let pct_str = if pct < 0.1 {
"< 0.1%".to_string()
} else {
format!("{:.1}%", pct)
};
println!(
"{} {} {} ({} Lines)",
"●".color(ll.color),
ll.name,
pct_str,
format_thousands(ll.lines)
);
}
println!();
}
println!(
"Total: {} lines | Files: {} | Size: {}",
format_thousands(stats.total_lines),
stats.total_files,
format_size(stats.total_bytes)
);
Ok(())
}
Mode::Diff { base, target } => {
let mut deltas: Vec<(String, i64)> = Vec::new();
for entry in index.all_entries() {
let id = &entry.id;
let base_lines = base.language_line_map.get(id).copied().unwrap_or(0) as i64;
let target_lines = target.language_line_map.get(id).copied().unwrap_or(0) as i64;
let delta = target_lines - base_lines;
if delta != 0 {
deltas.push((id.clone(), delta));
}
}
deltas.sort_unstable_by_key(|(_, d)| d.abs());
deltas.reverse();
println!("Line Diff:");
if !deltas.is_empty() {
let mut lang_lines: Vec<LangLines> = deltas
.iter()
.filter_map(|(id, d)| {
index.get(id).map(|entry| LangLines {
name: entry.name.clone(),
color: entry.to_rgb(),
lines: d.unsigned_abs() as u32,
})
})
.collect();
lang_lines.sort_by(|a, b| b.lines.cmp(&a.lines));
let total_abs: u32 = lang_lines.iter().map(|ll| ll.lines).sum();
println!("{}", render_bar(&lang_lines, half_width, total_abs));
for (id, d) in &deltas {
if let Some(entry) = index.get(id) {
let sign = if *d >= 0 { "+" } else { "-" };
println!(
"{} {} {}{} Lines",
"●".color(entry.to_rgb()),
entry.name,
sign,
format_thousands(d.unsigned_abs() as u32)
);
}
}
println!();
}
let line_delta = target.total_lines as i64 - base.total_lines as i64;
let file_delta = target.total_files as i64 - base.total_files as i64;
let byte_delta = target.total_bytes as i64 - base.total_bytes as i64;
println!(
"Total: {}{} Lines | Files: {}{} | Size: {}{}",
signed_prefix(line_delta),
format_thousands(line_delta.unsigned_abs() as u32),
signed_prefix(file_delta),
file_delta.unsigned_abs(),
signed_prefix(byte_delta),
format_size(byte_delta.unsigned_abs())
);
Ok(())
}
}
}
fn signed_prefix(n: i64) -> &'static str {
if n >= 0 {
"+"
} else {
"-"
}
}