Skip to main content

putzen_cli/highscore/
display.rs

1use crate::highscore::podium::{Medal, Record};
2use crate::highscore::Highscores;
3use crate::HumanReadable;
4
5const STROKE: &str = "─";
6const STAR: &str = "★";
7const MIDDLE_DOT: &str = "·"; // U+00B7 — not • U+2022 bullet, not ∙ U+2219
8const EM_DASH: &str = "—"; // U+2014 — not – U+2013 en dash, not - hyphen
9const TROPHY: &str = "🏆";
10const MEDAL_GOLD: &str = "🥇";
11const MEDAL_SILVER: &str = "🥈";
12const MEDAL_BRONZE: &str = "🥉";
13
14const BANNER_INDENT: &str = "   ";
15const HEADER_SIDE_STROKES: usize = 4;
16const RULE_STROKES: usize = 28;
17
18/// The name of a highscore track, used in display output.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum TrackName {
21    SingleCleanup,
22    TotalRun,
23}
24
25impl TrackName {
26    pub fn sort_key(self) -> u8 {
27        match self {
28            TrackName::SingleCleanup => 0,
29            TrackName::TotalRun => 1,
30        }
31    }
32}
33
34impl std::fmt::Display for TrackName {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            TrackName::SingleCleanup => write!(f, "Single cleanup"),
38            TrackName::TotalRun => write!(f, "Total run"),
39        }
40    }
41}
42
43/// A record of a new medal earned during the current run.
44pub struct EarnedMedal {
45    pub medal: Medal,
46    pub track: TrackName,
47    pub size: u64,
48}
49
50impl Medal {
51    pub fn emoji(&self) -> &'static str {
52        match self {
53            Medal::Gold => MEDAL_GOLD,
54            Medal::Silver => MEDAL_SILVER,
55            Medal::Bronze => MEDAL_BRONZE,
56        }
57    }
58
59    pub fn label(&self) -> &'static str {
60        match self {
61            Medal::Gold => "Gold",
62            Medal::Silver => "Silver",
63            Medal::Bronze => "Bronze",
64        }
65    }
66}
67
68/// Banner header line, e.g. `   ──── ★ NEW HIGHSCORE ★ ────`.
69fn banner_header(title: &str) -> String {
70    let side = STROKE.repeat(HEADER_SIDE_STROKES);
71    format!(
72        "{}{} {} {} {} {}",
73        BANNER_INDENT, side, STAR, title, STAR, side
74    )
75}
76
77/// Horizontal rule used as the bottom of a banner / separator between slots.
78fn banner_rule() -> String {
79    format!("{}{}", BANNER_INDENT, STROKE.repeat(RULE_STROKES))
80}
81
82/// Render a single medal banner.
83fn render_medal(earned: &EarnedMedal) -> String {
84    let size = (earned.size as usize).as_human_readable();
85    format!(
86        "\n{}\n     {} {} {} {}\n          {}\n{}",
87        banner_header("NEW HIGHSCORE"),
88        earned.medal.emoji(),
89        earned.medal.label(),
90        MIDDLE_DOT,
91        earned.track,
92        size,
93        banner_rule(),
94    )
95}
96
97/// Render all earned medals into a single display string.
98/// Medals are sorted by track (Single cleanup first, then Total run),
99/// then by medal rank (Gold, Silver, Bronze) within each track.
100pub fn render_medals(medals: &[EarnedMedal]) -> Option<String> {
101    if medals.is_empty() {
102        return None;
103    }
104    let mut sorted: Vec<&EarnedMedal> = medals.iter().collect();
105    sorted.sort_by_key(|m| (m.track.sort_key(), m.medal.sort_key()));
106    let output: String = sorted.iter().map(|m| render_medal(m)).collect();
107    Some(output)
108}
109
110/// Return the inline hint string for a new highscore.
111pub fn inline_hint() -> String {
112    format!("{} new highscore!", TROPHY)
113}
114
115/// Center-pad `s` to `width` chars. Odd overflow goes to the left side.
116fn center_pad(s: &str, width: usize) -> String {
117    let len = s.chars().count();
118    if len >= width {
119        return s.to_string();
120    }
121    let diff = width - len;
122    let left = (diff + 1).div_ceil(2);
123    let right = diff / 2;
124    format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
125}
126
127/// Render one medal slot of the highscore board.
128/// `record: None` renders an "open" placeholder.
129/// The caller is responsible for any surrounding banner_header / banner_rule.
130fn render_board_slot(medal: Medal, record: Option<&Record>) -> String {
131    let detail = match record {
132        Some(r) => format!(
133            "{} {} {}",
134            (r.size as usize).as_human_readable(),
135            MIDDLE_DOT,
136            r.date
137        ),
138        None => format!("(open {} be the first!)", EM_DASH),
139    };
140    // Rule is 31 cols wide; medal emoji sits 2 cols in from the stroke
141    // start, so right-align detail to 29 cols to mirror that margin.
142    format!("     {} {}\n{:>29}", medal.emoji(), medal.label(), detail,)
143}
144
145/// Render the full two-track highscore board.
146/// Format: per-track banner header, three slots (gold/silver/bronze) each
147/// separated by a banner_rule, blank line between tracks.
148pub fn render_board(highscores: &Highscores) -> String {
149    let tracks = [
150        ("SINGLE CLEANUP", &highscores.single_cleanup),
151        ("TOTAL RUN", &highscores.total_run),
152    ];
153    let title_width = tracks.iter().map(|(t, _)| t.chars().count()).max().unwrap();
154
155    let mut out = String::new();
156    for (title, podium) in tracks {
157        let padded = center_pad(title, title_width);
158        out.push('\n');
159        out.push_str(&banner_header(&padded));
160        out.push('\n');
161        out.push_str(&render_board_slot(Medal::Gold, podium.gold.as_ref()));
162        out.push('\n');
163        out.push_str(&banner_rule());
164        out.push('\n');
165        out.push_str(&render_board_slot(Medal::Silver, podium.silver.as_ref()));
166        out.push('\n');
167        out.push_str(&banner_rule());
168        out.push('\n');
169        out.push_str(&render_board_slot(Medal::Bronze, podium.bronze.as_ref()));
170        out.push('\n');
171        out.push_str(&banner_rule());
172        out.push('\n');
173    }
174    out
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn render_single_gold_medal() {
183        let medals = vec![EarnedMedal {
184            medal: Medal::Gold,
185            track: TrackName::SingleCleanup,
186            size: 2_684_354_560, // 2.5 GiB
187        }];
188        let output = render_medals(&medals).unwrap();
189        assert!(output.contains("NEW HIGHSCORE"));
190        assert!(output.contains("Gold"));
191        assert!(output.contains("Single cleanup"));
192        assert!(output.contains("2.5GiB"));
193    }
194
195    #[test]
196    fn render_multiple_medals() {
197        let medals = vec![
198            EarnedMedal {
199                medal: Medal::Gold,
200                track: TrackName::SingleCleanup,
201                size: 2_684_354_560,
202            },
203            EarnedMedal {
204                medal: Medal::Silver,
205                track: TrackName::TotalRun,
206                size: 1_073_741_824,
207            },
208        ];
209        let output = render_medals(&medals).unwrap();
210        // Should contain both medals
211        assert!(output.contains("Gold"));
212        assert!(output.contains("Silver"));
213    }
214
215    #[test]
216    fn render_medals_sorted_by_track_then_rank() {
217        let medals = vec![
218            EarnedMedal {
219                medal: Medal::Gold,
220                track: TrackName::SingleCleanup,
221                size: 3_000_000_000,
222            },
223            EarnedMedal {
224                medal: Medal::Bronze,
225                track: TrackName::SingleCleanup,
226                size: 500_000_000,
227            },
228            EarnedMedal {
229                medal: Medal::Silver,
230                track: TrackName::SingleCleanup,
231                size: 2_000_000_000,
232            },
233            EarnedMedal {
234                medal: Medal::Gold,
235                track: TrackName::TotalRun,
236                size: 5_500_000_000,
237            },
238        ];
239        let output = render_medals(&medals).unwrap();
240        let gold_pos = output.find("Gold \u{00B7} Single").unwrap();
241        let silver_pos = output.find("Silver \u{00B7} Single").unwrap();
242        let bronze_pos = output.find("Bronze \u{00B7} Single").unwrap();
243        let total_pos = output.find("Gold \u{00B7} Total").unwrap();
244        assert!(gold_pos < silver_pos);
245        assert!(silver_pos < bronze_pos);
246        assert!(bronze_pos < total_pos);
247    }
248
249    #[test]
250    fn render_empty_returns_none() {
251        assert!(render_medals(&[]).is_none());
252    }
253
254    #[test]
255    fn inline_hint_contains_trophy() {
256        let hint = inline_hint();
257        assert!(hint.contains("new highscore!"));
258    }
259
260    #[test]
261    fn render_board_slot_populated_contains_size_and_date() {
262        let record = Record {
263            size: 1_073_741_824, // 1 GiB
264            date: "2026-03-15".to_string(),
265        };
266        let out = render_board_slot(Medal::Gold, Some(&record));
267        assert!(out.contains("Gold"));
268        assert!(out.contains("1.0GiB"));
269        assert!(out.contains("2026-03-15"));
270        assert!(!out.contains("open"));
271    }
272
273    #[test]
274    fn render_board_slot_open_contains_marker() {
275        let out = render_board_slot(Medal::Silver, None);
276        assert!(out.contains("Silver"));
277        assert!(out.contains("open"));
278        assert!(out.contains("be the first"));
279    }
280
281    #[test]
282    fn render_board_slot_right_aligns_detail_to_fixed_width() {
283        let short = Record {
284            size: 1_073_741_824,
285            date: "2026-03-15".to_string(),
286        };
287        let long = Record {
288            size: 42_949_672_960,
289            date: "2026-03-15".to_string(),
290        };
291        let out_short = render_board_slot(Medal::Gold, Some(&short));
292        let out_long = render_board_slot(Medal::Gold, Some(&long));
293        let detail_short = out_short.lines().nth(1).unwrap();
294        let detail_long = out_long.lines().nth(1).unwrap();
295        assert_eq!(
296            detail_short.chars().count(),
297            detail_long.chars().count(),
298            "detail lines must have equal char counts for right-alignment"
299        );
300    }
301
302    use crate::highscore::podium::Podium;
303
304    fn populated_record(size: u64, date: &str) -> Record {
305        Record {
306            size,
307            date: date.to_string(),
308        }
309    }
310
311    #[test]
312    fn render_board_empty_highscores_shows_all_open() {
313        let highscores = Highscores::default();
314        let out = render_board(&highscores);
315        assert!(out.contains("SINGLE CLEANUP"));
316        assert!(out.contains("TOTAL RUN"));
317        // Six "open" markers — three per track
318        assert_eq!(out.matches("(open").count(), 6);
319        // No size units should appear when nothing is populated
320        assert!(!out.contains("GiB"));
321        assert!(!out.contains("MiB"));
322        assert!(!out.contains("KiB"));
323    }
324
325    #[test]
326    fn render_board_fully_populated_shows_all_records() {
327        let highscores = Highscores {
328            single_cleanup: Podium {
329                gold: Some(populated_record(3_000_000_000, "2026-03-15")),
330                silver: Some(populated_record(2_000_000_000, "2026-02-01")),
331                bronze: Some(populated_record(1_000_000_000, "2026-01-20")),
332            },
333            total_run: Podium {
334                gold: Some(populated_record(5_500_000_000, "2026-03-15")),
335                silver: Some(populated_record(3_300_000_000, "2026-02-14")),
336                bronze: Some(populated_record(1_100_000_000, "2026-01-10")),
337            },
338        };
339        let out = render_board(&highscores);
340        assert!(out.contains("SINGLE CLEANUP"));
341        assert!(out.contains("TOTAL RUN"));
342        assert_eq!(out.matches("Gold").count(), 2);
343        assert_eq!(out.matches("Silver").count(), 2);
344        assert_eq!(out.matches("Bronze").count(), 2);
345        // Dates appear somewhere in the output
346        assert!(out.contains("2026-03-15"));
347        assert!(out.contains("2026-01-10"));
348        // No open markers when everything is populated
349        assert_eq!(out.matches("(open").count(), 0);
350    }
351
352    #[test]
353    fn render_board_banner_headers_have_equal_width() {
354        let highscores = Highscores::default();
355        let out = render_board(&highscores);
356        let header_lines: Vec<&str> = out.lines().filter(|l| l.contains(STAR)).collect();
357        assert_eq!(header_lines.len(), 2);
358        assert_eq!(
359            header_lines[0].chars().count(),
360            header_lines[1].chars().count(),
361            "banner headers for SINGLE CLEANUP and TOTAL RUN must match widths"
362        );
363    }
364
365    #[test]
366    fn render_board_slot_detail_mirrors_left_margin() {
367        // Rule is 31 chars; medal emoji sits 2 chars in from the stroke
368        // start, so the detail line must end 2 chars before the stroke end.
369        let record = Record {
370            size: 1_073_741_824,
371            date: "2026-03-15".to_string(),
372        };
373        let out = render_board_slot(Medal::Gold, Some(&record));
374        let detail_line = out.lines().nth(1).unwrap();
375        assert_eq!(detail_line.chars().count(), 29);
376    }
377
378    #[test]
379    fn render_board_partial_track_mixes_populated_and_open() {
380        let highscores = Highscores {
381            single_cleanup: Podium {
382                gold: Some(populated_record(1_073_741_824, "2026-03-15")),
383                silver: None,
384                bronze: None,
385            },
386            ..Default::default()
387        };
388        let out = render_board(&highscores);
389        // Gold is populated → size + date appear
390        assert!(out.contains("1.0GiB"));
391        assert!(out.contains("2026-03-15"));
392        // 5 open markers: silver+bronze of single-cleanup, all 3 of total-run
393        assert_eq!(out.matches("(open").count(), 5);
394    }
395}