use crate::highscore::podium::{Medal, Record};
use crate::highscore::Highscores;
use crate::HumanReadable;
const STROKE: &str = "─";
const STAR: &str = "★";
const MIDDLE_DOT: &str = "·"; const EM_DASH: &str = "—"; const TROPHY: &str = "🏆";
const MEDAL_GOLD: &str = "🥇";
const MEDAL_SILVER: &str = "🥈";
const MEDAL_BRONZE: &str = "🥉";
const BANNER_INDENT: &str = " ";
const HEADER_SIDE_STROKES: usize = 4;
const RULE_STROKES: usize = 28;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackName {
SingleCleanup,
TotalRun,
}
impl TrackName {
pub fn sort_key(self) -> u8 {
match self {
TrackName::SingleCleanup => 0,
TrackName::TotalRun => 1,
}
}
}
impl std::fmt::Display for TrackName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrackName::SingleCleanup => write!(f, "Single cleanup"),
TrackName::TotalRun => write!(f, "Total run"),
}
}
}
pub struct EarnedMedal {
pub medal: Medal,
pub track: TrackName,
pub size: u64,
}
impl Medal {
pub fn emoji(&self) -> &'static str {
match self {
Medal::Gold => MEDAL_GOLD,
Medal::Silver => MEDAL_SILVER,
Medal::Bronze => MEDAL_BRONZE,
}
}
pub fn label(&self) -> &'static str {
match self {
Medal::Gold => "Gold",
Medal::Silver => "Silver",
Medal::Bronze => "Bronze",
}
}
}
fn banner_header(title: &str) -> String {
let side = STROKE.repeat(HEADER_SIDE_STROKES);
format!(
"{}{} {} {} {} {}",
BANNER_INDENT, side, STAR, title, STAR, side
)
}
fn banner_rule() -> String {
format!("{}{}", BANNER_INDENT, STROKE.repeat(RULE_STROKES))
}
fn render_medal(earned: &EarnedMedal) -> String {
let size = (earned.size as usize).as_human_readable();
format!(
"\n{}\n {} {} {} {}\n {}\n{}",
banner_header("NEW HIGHSCORE"),
earned.medal.emoji(),
earned.medal.label(),
MIDDLE_DOT,
earned.track,
size,
banner_rule(),
)
}
pub fn render_medals(medals: &[EarnedMedal]) -> Option<String> {
if medals.is_empty() {
return None;
}
let mut sorted: Vec<&EarnedMedal> = medals.iter().collect();
sorted.sort_by_key(|m| (m.track.sort_key(), m.medal.sort_key()));
let output: String = sorted.iter().map(|m| render_medal(m)).collect();
Some(output)
}
pub fn inline_hint() -> String {
format!("{} new highscore!", TROPHY)
}
fn center_pad(s: &str, width: usize) -> String {
let len = s.chars().count();
if len >= width {
return s.to_string();
}
let diff = width - len;
let left = (diff + 1).div_ceil(2);
let right = diff / 2;
format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
}
fn render_board_slot(medal: Medal, record: Option<&Record>) -> String {
let detail = match record {
Some(r) => format!(
"{} {} {}",
(r.size as usize).as_human_readable(),
MIDDLE_DOT,
r.date
),
None => format!("(open {} be the first!)", EM_DASH),
};
format!(" {} {}\n{:>29}", medal.emoji(), medal.label(), detail,)
}
pub fn render_board(highscores: &Highscores) -> String {
let tracks = [
("SINGLE CLEANUP", &highscores.single_cleanup),
("TOTAL RUN", &highscores.total_run),
];
let title_width = tracks.iter().map(|(t, _)| t.chars().count()).max().unwrap();
let mut out = String::new();
for (title, podium) in tracks {
let padded = center_pad(title, title_width);
out.push('\n');
out.push_str(&banner_header(&padded));
out.push('\n');
out.push_str(&render_board_slot(Medal::Gold, podium.gold.as_ref()));
out.push('\n');
out.push_str(&banner_rule());
out.push('\n');
out.push_str(&render_board_slot(Medal::Silver, podium.silver.as_ref()));
out.push('\n');
out.push_str(&banner_rule());
out.push('\n');
out.push_str(&render_board_slot(Medal::Bronze, podium.bronze.as_ref()));
out.push('\n');
out.push_str(&banner_rule());
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_single_gold_medal() {
let medals = vec![EarnedMedal {
medal: Medal::Gold,
track: TrackName::SingleCleanup,
size: 2_684_354_560, }];
let output = render_medals(&medals).unwrap();
assert!(output.contains("NEW HIGHSCORE"));
assert!(output.contains("Gold"));
assert!(output.contains("Single cleanup"));
assert!(output.contains("2.5GiB"));
}
#[test]
fn render_multiple_medals() {
let medals = vec![
EarnedMedal {
medal: Medal::Gold,
track: TrackName::SingleCleanup,
size: 2_684_354_560,
},
EarnedMedal {
medal: Medal::Silver,
track: TrackName::TotalRun,
size: 1_073_741_824,
},
];
let output = render_medals(&medals).unwrap();
assert!(output.contains("Gold"));
assert!(output.contains("Silver"));
}
#[test]
fn render_medals_sorted_by_track_then_rank() {
let medals = vec![
EarnedMedal {
medal: Medal::Gold,
track: TrackName::SingleCleanup,
size: 3_000_000_000,
},
EarnedMedal {
medal: Medal::Bronze,
track: TrackName::SingleCleanup,
size: 500_000_000,
},
EarnedMedal {
medal: Medal::Silver,
track: TrackName::SingleCleanup,
size: 2_000_000_000,
},
EarnedMedal {
medal: Medal::Gold,
track: TrackName::TotalRun,
size: 5_500_000_000,
},
];
let output = render_medals(&medals).unwrap();
let gold_pos = output.find("Gold \u{00B7} Single").unwrap();
let silver_pos = output.find("Silver \u{00B7} Single").unwrap();
let bronze_pos = output.find("Bronze \u{00B7} Single").unwrap();
let total_pos = output.find("Gold \u{00B7} Total").unwrap();
assert!(gold_pos < silver_pos);
assert!(silver_pos < bronze_pos);
assert!(bronze_pos < total_pos);
}
#[test]
fn render_empty_returns_none() {
assert!(render_medals(&[]).is_none());
}
#[test]
fn inline_hint_contains_trophy() {
let hint = inline_hint();
assert!(hint.contains("new highscore!"));
}
#[test]
fn render_board_slot_populated_contains_size_and_date() {
let record = Record {
size: 1_073_741_824, date: "2026-03-15".to_string(),
};
let out = render_board_slot(Medal::Gold, Some(&record));
assert!(out.contains("Gold"));
assert!(out.contains("1.0GiB"));
assert!(out.contains("2026-03-15"));
assert!(!out.contains("open"));
}
#[test]
fn render_board_slot_open_contains_marker() {
let out = render_board_slot(Medal::Silver, None);
assert!(out.contains("Silver"));
assert!(out.contains("open"));
assert!(out.contains("be the first"));
}
#[test]
fn render_board_slot_right_aligns_detail_to_fixed_width() {
let short = Record {
size: 1_073_741_824,
date: "2026-03-15".to_string(),
};
let long = Record {
size: 42_949_672_960,
date: "2026-03-15".to_string(),
};
let out_short = render_board_slot(Medal::Gold, Some(&short));
let out_long = render_board_slot(Medal::Gold, Some(&long));
let detail_short = out_short.lines().nth(1).unwrap();
let detail_long = out_long.lines().nth(1).unwrap();
assert_eq!(
detail_short.chars().count(),
detail_long.chars().count(),
"detail lines must have equal char counts for right-alignment"
);
}
use crate::highscore::podium::Podium;
fn populated_record(size: u64, date: &str) -> Record {
Record {
size,
date: date.to_string(),
}
}
#[test]
fn render_board_empty_highscores_shows_all_open() {
let highscores = Highscores::default();
let out = render_board(&highscores);
assert!(out.contains("SINGLE CLEANUP"));
assert!(out.contains("TOTAL RUN"));
assert_eq!(out.matches("(open").count(), 6);
assert!(!out.contains("GiB"));
assert!(!out.contains("MiB"));
assert!(!out.contains("KiB"));
}
#[test]
fn render_board_fully_populated_shows_all_records() {
let highscores = Highscores {
single_cleanup: Podium {
gold: Some(populated_record(3_000_000_000, "2026-03-15")),
silver: Some(populated_record(2_000_000_000, "2026-02-01")),
bronze: Some(populated_record(1_000_000_000, "2026-01-20")),
},
total_run: Podium {
gold: Some(populated_record(5_500_000_000, "2026-03-15")),
silver: Some(populated_record(3_300_000_000, "2026-02-14")),
bronze: Some(populated_record(1_100_000_000, "2026-01-10")),
},
};
let out = render_board(&highscores);
assert!(out.contains("SINGLE CLEANUP"));
assert!(out.contains("TOTAL RUN"));
assert_eq!(out.matches("Gold").count(), 2);
assert_eq!(out.matches("Silver").count(), 2);
assert_eq!(out.matches("Bronze").count(), 2);
assert!(out.contains("2026-03-15"));
assert!(out.contains("2026-01-10"));
assert_eq!(out.matches("(open").count(), 0);
}
#[test]
fn render_board_banner_headers_have_equal_width() {
let highscores = Highscores::default();
let out = render_board(&highscores);
let header_lines: Vec<&str> = out.lines().filter(|l| l.contains(STAR)).collect();
assert_eq!(header_lines.len(), 2);
assert_eq!(
header_lines[0].chars().count(),
header_lines[1].chars().count(),
"banner headers for SINGLE CLEANUP and TOTAL RUN must match widths"
);
}
#[test]
fn render_board_slot_detail_mirrors_left_margin() {
let record = Record {
size: 1_073_741_824,
date: "2026-03-15".to_string(),
};
let out = render_board_slot(Medal::Gold, Some(&record));
let detail_line = out.lines().nth(1).unwrap();
assert_eq!(detail_line.chars().count(), 29);
}
#[test]
fn render_board_partial_track_mixes_populated_and_open() {
let highscores = Highscores {
single_cleanup: Podium {
gold: Some(populated_record(1_073_741_824, "2026-03-15")),
silver: None,
bronze: None,
},
..Default::default()
};
let out = render_board(&highscores);
assert!(out.contains("1.0GiB"));
assert!(out.contains("2026-03-15"));
assert_eq!(out.matches("(open").count(), 5);
}
}