putzen_cli/highscore/
mod.rs1pub mod display;
2pub mod podium;
3
4use crate::highscore::display::{inline_hint, render_medals, EarnedMedal, TrackName};
5use crate::highscore::podium::Podium;
6use crate::observer::RunObserver;
7
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11
12#[derive(Debug, Default, Serialize, Deserialize)]
13pub struct Highscores {
14 #[serde(default)]
15 pub single_cleanup: Podium,
16 #[serde(default)]
17 pub total_run: Podium,
18}
19
20pub struct HighscoreObserver {
21 highscores: Highscores,
22 earned_medals: Vec<EarnedMedal>,
23 file_path: PathBuf,
24}
25
26impl HighscoreObserver {
27 pub fn load() -> std::io::Result<Self> {
29 let file_path = Self::highscores_path()?;
30 let (highscores, is_first_run) = if file_path.exists() {
31 let content = fs::read_to_string(&file_path)?;
32 let highscores: Highscores = toml::from_str(&content)
33 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
34 (highscores, false)
35 } else {
36 (Highscores::default(), true)
37 };
38
39 if is_first_run {
40 println!("\u{1F3C6} A wild cleaner appears! Highscore board initialized.");
41 }
42
43 Ok(Self {
44 highscores,
45 earned_medals: Vec::new(),
46 file_path,
47 })
48 }
49
50 pub fn load_from(file_path: PathBuf) -> std::io::Result<Self> {
52 let highscores = if file_path.exists() {
53 let content = fs::read_to_string(&file_path)?;
54 toml::from_str(&content)
55 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?
56 } else {
57 Highscores::default()
58 };
59
60 Ok(Self {
61 highscores,
62 earned_medals: Vec::new(),
63 file_path,
64 })
65 }
66
67 fn highscores_path() -> std::io::Result<PathBuf> {
68 let config_dir = dirs_lite::config_dir().ok_or_else(|| {
69 std::io::Error::new(
70 std::io::ErrorKind::NotFound,
71 "Could not determine config directory",
72 )
73 })?;
74 Ok(config_dir.join("putzen").join("highscores.toml"))
75 }
76
77 fn today() -> String {
78 jiff::Zoned::now().date().to_string()
79 }
80
81 fn save(&self) -> std::io::Result<()> {
82 if let Some(parent) = self.file_path.parent() {
83 fs::create_dir_all(parent)?;
84 }
85 let content = toml::to_string_pretty(&self.highscores).map_err(std::io::Error::other)?;
86 fs::write(&self.file_path, content)
87 }
88}
89
90impl RunObserver for HighscoreObserver {
91 fn on_folder_cleaned(&mut self, size: u64) -> Option<String> {
92 let medal = self.highscores.single_cleanup.would_place(size)?;
93 let date = Self::today();
94 self.highscores.single_cleanup.place(size, &date);
95 self.earned_medals.push(EarnedMedal {
96 medal,
97 track: TrackName::SingleCleanup,
98 size,
99 });
100 Some(inline_hint())
101 }
102
103 fn on_run_complete(&mut self, total: u64) -> Option<String> {
104 if total > 0 {
106 if let Some(medal) = self.highscores.total_run.would_place(total) {
107 let date = Self::today();
108 self.highscores.total_run.place(total, &date);
109 self.earned_medals.push(EarnedMedal {
110 medal,
111 track: TrackName::TotalRun,
112 size: total,
113 });
114 }
115 }
116
117 let _ = self.save();
119
120 render_medals(&self.earned_medals)
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::observer::RunObserver;
128
129 #[test]
130 fn first_cleanup_returns_hint() {
131 let dir = tempfile::TempDir::new().unwrap();
132 let path = dir.path().join("highscores.toml");
133 let mut observer = HighscoreObserver::load_from(path).unwrap();
134
135 let hint = observer.on_folder_cleaned(1024);
136 assert!(hint.is_some());
137 assert!(hint.unwrap().contains("new highscore!"));
138 }
139
140 #[test]
141 fn small_cleanup_after_big_one_no_hint_when_podium_full() {
142 let dir = tempfile::TempDir::new().unwrap();
143 let path = dir.path().join("highscores.toml");
144 let mut observer = HighscoreObserver::load_from(path).unwrap();
145
146 observer.on_folder_cleaned(3000);
147 observer.on_folder_cleaned(2000);
148 observer.on_folder_cleaned(1000);
149
150 let hint = observer.on_folder_cleaned(500);
152 assert!(hint.is_none());
153 }
154
155 #[test]
156 fn on_run_complete_renders_medals() {
157 let dir = tempfile::TempDir::new().unwrap();
158 let path = dir.path().join("highscores.toml");
159 let mut observer = HighscoreObserver::load_from(path).unwrap();
160
161 observer.on_folder_cleaned(1_073_741_824); let output = observer.on_run_complete(1_073_741_824);
163 assert!(output.is_some());
164 let text = output.unwrap();
165 assert!(text.contains("NEW HIGHSCORE"));
166 assert!(text.contains("Gold"));
167 }
168
169 #[test]
170 fn saves_and_reloads_highscores() {
171 let dir = tempfile::TempDir::new().unwrap();
172 let path = dir.path().join("highscores.toml");
173
174 {
176 let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
177 observer.on_folder_cleaned(5000);
178 observer.on_run_complete(5000);
179 }
180
181 {
183 let mut observer = HighscoreObserver::load_from(path).unwrap();
184 let hint = observer.on_folder_cleaned(3000);
186 assert!(hint.is_some());
187 }
188 }
189
190 #[test]
191 fn first_run_creates_file_on_save() {
192 let dir = tempfile::TempDir::new().unwrap();
193 let path = dir.path().join("highscores.toml");
194 assert!(!path.exists());
195 let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
196 observer.on_folder_cleaned(1000);
197 observer.on_run_complete(1000);
198 assert!(path.exists());
199 }
200}