pub mod display;
pub mod podium;
pub use display::render_board;
use crate::highscore::display::{inline_hint, render_medals, EarnedMedal, TrackName};
use crate::highscore::podium::{Medal, Podium};
use crate::observer::RunObserver;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
pub(crate) fn highscores_path() -> std::io::Result<PathBuf> {
let config_dir = dirs_lite::config_dir().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine config directory",
)
})?;
Ok(config_dir.join("putzen").join("highscores.toml"))
}
fn bump_earned_medals(earned: &mut Vec<EarnedMedal>, track: TrackName, new_medal: Medal) {
match new_medal {
Medal::Gold => {
earned.retain(|m| !(m.track == track && m.medal == Medal::Bronze));
for m in earned.iter_mut() {
if m.track == track && m.medal == Medal::Silver {
m.medal = Medal::Bronze;
}
}
for m in earned.iter_mut() {
if m.track == track && m.medal == Medal::Gold {
m.medal = Medal::Silver;
}
}
}
Medal::Silver => {
earned.retain(|m| !(m.track == track && m.medal == Medal::Bronze));
for m in earned.iter_mut() {
if m.track == track && m.medal == Medal::Silver {
m.medal = Medal::Bronze;
}
}
}
Medal::Bronze => {
earned.retain(|m| !(m.track == track && m.medal == Medal::Bronze));
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Highscores {
#[serde(default)]
pub single_cleanup: Podium,
#[serde(default)]
pub total_run: Podium,
}
impl Highscores {
pub fn load() -> std::io::Result<Self> {
Self::load_from(highscores_path()?)
}
pub fn load_from(file_path: PathBuf) -> std::io::Result<Self> {
if file_path.exists() {
let content = fs::read_to_string(&file_path)?;
toml::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
} else {
Ok(Self::default())
}
}
}
pub struct HighscoreObserver {
highscores: Highscores,
earned_medals: Vec<EarnedMedal>,
file_path: PathBuf,
}
impl HighscoreObserver {
pub fn load() -> std::io::Result<Self> {
let file_path = highscores_path()?;
let (highscores, is_first_run) = if file_path.exists() {
let content = fs::read_to_string(&file_path)?;
let highscores: Highscores = toml::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
(highscores, false)
} else {
(Highscores::default(), true)
};
if is_first_run {
println!("\u{1F3C6} A wild cleaner appears! Highscore board initialized.");
}
Ok(Self {
highscores,
earned_medals: Vec::new(),
file_path,
})
}
pub fn load_from(file_path: PathBuf) -> std::io::Result<Self> {
let highscores = if file_path.exists() {
let content = fs::read_to_string(&file_path)?;
toml::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?
} else {
Highscores::default()
};
Ok(Self {
highscores,
earned_medals: Vec::new(),
file_path,
})
}
fn today() -> String {
jiff::Zoned::now().date().to_string()
}
fn save(&self) -> std::io::Result<()> {
if let Some(parent) = self.file_path.parent() {
fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(&self.highscores).map_err(std::io::Error::other)?;
fs::write(&self.file_path, content)
}
}
impl RunObserver for HighscoreObserver {
fn on_folder_cleaned(&mut self, size: u64) -> Option<String> {
let medal = self.highscores.single_cleanup.would_place(size)?;
let date = Self::today();
self.highscores.single_cleanup.place(size, &date);
bump_earned_medals(&mut self.earned_medals, TrackName::SingleCleanup, medal);
self.earned_medals.push(EarnedMedal {
medal,
track: TrackName::SingleCleanup,
size,
});
Some(inline_hint())
}
fn on_run_complete(&mut self, total: u64) -> Option<String> {
if total > 0 {
if let Some(medal) = self.highscores.total_run.would_place(total) {
let date = Self::today();
self.highscores.total_run.place(total, &date);
bump_earned_medals(&mut self.earned_medals, TrackName::TotalRun, medal);
self.earned_medals.push(EarnedMedal {
medal,
track: TrackName::TotalRun,
size: total,
});
}
}
let _ = self.save();
render_medals(&self.earned_medals)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::observer::RunObserver;
#[test]
fn first_cleanup_returns_hint() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("highscores.toml");
let mut observer = HighscoreObserver::load_from(path).unwrap();
let hint = observer.on_folder_cleaned(1024);
assert!(hint.is_some());
assert!(hint.unwrap().contains("new highscore!"));
}
#[test]
fn small_cleanup_after_big_one_no_hint_when_podium_full() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("highscores.toml");
let mut observer = HighscoreObserver::load_from(path).unwrap();
observer.on_folder_cleaned(3000);
observer.on_folder_cleaned(2000);
observer.on_folder_cleaned(1000);
let hint = observer.on_folder_cleaned(500);
assert!(hint.is_none());
}
#[test]
fn on_run_complete_renders_medals() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("highscores.toml");
let mut observer = HighscoreObserver::load_from(path).unwrap();
observer.on_folder_cleaned(1_073_741_824); let output = observer.on_run_complete(1_073_741_824);
assert!(output.is_some());
let text = output.unwrap();
assert!(text.contains("NEW HIGHSCORE"));
assert!(text.contains("Gold"));
}
#[test]
fn saves_and_reloads_highscores() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("highscores.toml");
{
let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
observer.on_folder_cleaned(5000);
observer.on_run_complete(5000);
}
{
let mut observer = HighscoreObserver::load_from(path).unwrap();
let hint = observer.on_folder_cleaned(3000);
assert!(hint.is_some());
}
}
#[test]
fn many_increasing_cleanups_produce_at_most_three_medals_per_track() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("highscores.toml");
let mut observer = HighscoreObserver::load_from(path).unwrap();
for i in 1..=10 {
observer.on_folder_cleaned(i * 1000);
}
let output = observer.on_run_complete(55_000);
let text = output.unwrap();
assert_eq!(text.matches("Single cleanup").count(), 3);
assert_eq!(text.matches("Total run").count(), 1);
assert_eq!(text.matches("Gold \u{00B7} Single").count(), 1);
assert_eq!(text.matches("Silver \u{00B7} Single").count(), 1);
assert_eq!(text.matches("Bronze \u{00B7} Single").count(), 1);
}
#[test]
fn first_run_creates_file_on_save() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("highscores.toml");
assert!(!path.exists());
let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
observer.on_folder_cleaned(1000);
observer.on_run_complete(1000);
assert!(path.exists());
}
#[test]
fn highscores_load_from_returns_default_when_file_missing() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("does_not_exist.toml");
let highscores = Highscores::load_from(path).unwrap();
assert!(highscores.single_cleanup.gold.is_none());
assert!(highscores.total_run.gold.is_none());
}
#[test]
fn highscores_load_from_parses_existing_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("highscores.toml");
{
let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
observer.on_folder_cleaned(42_000);
observer.on_run_complete(42_000);
}
let highscores = Highscores::load_from(path).unwrap();
assert_eq!(
highscores.single_cleanup.gold.as_ref().unwrap().size,
42_000
);
assert_eq!(highscores.total_run.gold.as_ref().unwrap().size, 42_000);
}
#[test]
fn highscores_load_from_rejects_malformed_toml() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("highscores.toml");
fs::write(&path, "this is not toml = = {").unwrap();
let err = Highscores::load_from(path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
}