putzen_cli/highscore/
mod.rs1pub mod display;
2pub mod podium;
3
4pub use display::render_board;
5
6use crate::highscore::display::{inline_hint, render_medals, EarnedMedal, TrackName};
7use crate::highscore::podium::{Medal, Podium};
8use crate::observer::RunObserver;
9
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::PathBuf;
13
14pub(crate) fn highscores_path() -> std::io::Result<PathBuf> {
16 let config_dir = dirs_lite::config_dir().ok_or_else(|| {
17 std::io::Error::new(
18 std::io::ErrorKind::NotFound,
19 "Could not determine config directory",
20 )
21 })?;
22 Ok(config_dir.join("putzen").join("highscores.toml"))
23}
24
25fn bump_earned_medals(earned: &mut Vec<EarnedMedal>, track: TrackName, new_medal: Medal) {
29 match new_medal {
30 Medal::Gold => {
31 earned.retain(|m| !(m.track == track && m.medal == Medal::Bronze));
32 for m in earned.iter_mut() {
33 if m.track == track && m.medal == Medal::Silver {
34 m.medal = Medal::Bronze;
35 }
36 }
37 for m in earned.iter_mut() {
38 if m.track == track && m.medal == Medal::Gold {
39 m.medal = Medal::Silver;
40 }
41 }
42 }
43 Medal::Silver => {
44 earned.retain(|m| !(m.track == track && m.medal == Medal::Bronze));
45 for m in earned.iter_mut() {
46 if m.track == track && m.medal == Medal::Silver {
47 m.medal = Medal::Bronze;
48 }
49 }
50 }
51 Medal::Bronze => {
52 earned.retain(|m| !(m.track == track && m.medal == Medal::Bronze));
53 }
54 }
55}
56
57#[derive(Debug, Default, Serialize, Deserialize)]
58pub struct Highscores {
59 #[serde(default)]
60 pub single_cleanup: Podium,
61 #[serde(default)]
62 pub total_run: Podium,
63}
64
65impl Highscores {
66 pub fn load() -> std::io::Result<Self> {
69 Self::load_from(highscores_path()?)
70 }
71
72 pub fn load_from(file_path: PathBuf) -> std::io::Result<Self> {
75 if file_path.exists() {
76 let content = fs::read_to_string(&file_path)?;
77 toml::from_str(&content)
78 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
79 } else {
80 Ok(Self::default())
81 }
82 }
83}
84
85pub struct HighscoreObserver {
86 highscores: Highscores,
87 earned_medals: Vec<EarnedMedal>,
88 file_path: PathBuf,
89}
90
91impl HighscoreObserver {
92 pub fn load() -> std::io::Result<Self> {
94 let file_path = highscores_path()?;
95 let (highscores, is_first_run) = if file_path.exists() {
96 let content = fs::read_to_string(&file_path)?;
97 let highscores: Highscores = toml::from_str(&content)
98 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
99 (highscores, false)
100 } else {
101 (Highscores::default(), true)
102 };
103
104 if is_first_run {
105 println!("\u{1F3C6} A wild cleaner appears! Highscore board initialized.");
106 }
107
108 Ok(Self {
109 highscores,
110 earned_medals: Vec::new(),
111 file_path,
112 })
113 }
114
115 pub fn load_from(file_path: PathBuf) -> std::io::Result<Self> {
117 let highscores = if file_path.exists() {
118 let content = fs::read_to_string(&file_path)?;
119 toml::from_str(&content)
120 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?
121 } else {
122 Highscores::default()
123 };
124
125 Ok(Self {
126 highscores,
127 earned_medals: Vec::new(),
128 file_path,
129 })
130 }
131
132 fn today() -> String {
133 jiff::Zoned::now().date().to_string()
134 }
135
136 fn save(&self) -> std::io::Result<()> {
137 if let Some(parent) = self.file_path.parent() {
138 fs::create_dir_all(parent)?;
139 }
140 let content = toml::to_string_pretty(&self.highscores).map_err(std::io::Error::other)?;
141 fs::write(&self.file_path, content)
142 }
143}
144
145impl RunObserver for HighscoreObserver {
146 fn on_folder_cleaned(&mut self, size: u64) -> Option<String> {
147 let medal = self.highscores.single_cleanup.would_place(size)?;
148 let date = Self::today();
149 self.highscores.single_cleanup.place(size, &date);
150 bump_earned_medals(&mut self.earned_medals, TrackName::SingleCleanup, medal);
151 self.earned_medals.push(EarnedMedal {
152 medal,
153 track: TrackName::SingleCleanup,
154 size,
155 });
156 Some(inline_hint())
157 }
158
159 fn on_run_complete(&mut self, total: u64) -> Option<String> {
160 if total > 0 {
162 if let Some(medal) = self.highscores.total_run.would_place(total) {
163 let date = Self::today();
164 self.highscores.total_run.place(total, &date);
165 bump_earned_medals(&mut self.earned_medals, TrackName::TotalRun, medal);
166 self.earned_medals.push(EarnedMedal {
167 medal,
168 track: TrackName::TotalRun,
169 size: total,
170 });
171 }
172 }
173
174 let _ = self.save();
176
177 render_medals(&self.earned_medals)
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::observer::RunObserver;
185
186 #[test]
187 fn first_cleanup_returns_hint() {
188 let dir = tempfile::TempDir::new().unwrap();
189 let path = dir.path().join("highscores.toml");
190 let mut observer = HighscoreObserver::load_from(path).unwrap();
191
192 let hint = observer.on_folder_cleaned(1024);
193 assert!(hint.is_some());
194 assert!(hint.unwrap().contains("new highscore!"));
195 }
196
197 #[test]
198 fn small_cleanup_after_big_one_no_hint_when_podium_full() {
199 let dir = tempfile::TempDir::new().unwrap();
200 let path = dir.path().join("highscores.toml");
201 let mut observer = HighscoreObserver::load_from(path).unwrap();
202
203 observer.on_folder_cleaned(3000);
204 observer.on_folder_cleaned(2000);
205 observer.on_folder_cleaned(1000);
206
207 let hint = observer.on_folder_cleaned(500);
209 assert!(hint.is_none());
210 }
211
212 #[test]
213 fn on_run_complete_renders_medals() {
214 let dir = tempfile::TempDir::new().unwrap();
215 let path = dir.path().join("highscores.toml");
216 let mut observer = HighscoreObserver::load_from(path).unwrap();
217
218 observer.on_folder_cleaned(1_073_741_824); let output = observer.on_run_complete(1_073_741_824);
220 assert!(output.is_some());
221 let text = output.unwrap();
222 assert!(text.contains("NEW HIGHSCORE"));
223 assert!(text.contains("Gold"));
224 }
225
226 #[test]
227 fn saves_and_reloads_highscores() {
228 let dir = tempfile::TempDir::new().unwrap();
229 let path = dir.path().join("highscores.toml");
230
231 {
233 let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
234 observer.on_folder_cleaned(5000);
235 observer.on_run_complete(5000);
236 }
237
238 {
240 let mut observer = HighscoreObserver::load_from(path).unwrap();
241 let hint = observer.on_folder_cleaned(3000);
243 assert!(hint.is_some());
244 }
245 }
246
247 #[test]
248 fn many_increasing_cleanups_produce_at_most_three_medals_per_track() {
249 let dir = tempfile::TempDir::new().unwrap();
250 let path = dir.path().join("highscores.toml");
251 let mut observer = HighscoreObserver::load_from(path).unwrap();
252
253 for i in 1..=10 {
255 observer.on_folder_cleaned(i * 1000);
256 }
257
258 let output = observer.on_run_complete(55_000);
259 let text = output.unwrap();
260
261 assert_eq!(text.matches("Single cleanup").count(), 3);
263 assert_eq!(text.matches("Total run").count(), 1);
264
265 assert_eq!(text.matches("Gold \u{00B7} Single").count(), 1);
267 assert_eq!(text.matches("Silver \u{00B7} Single").count(), 1);
268 assert_eq!(text.matches("Bronze \u{00B7} Single").count(), 1);
269 }
270
271 #[test]
272 fn first_run_creates_file_on_save() {
273 let dir = tempfile::TempDir::new().unwrap();
274 let path = dir.path().join("highscores.toml");
275 assert!(!path.exists());
276 let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
277 observer.on_folder_cleaned(1000);
278 observer.on_run_complete(1000);
279 assert!(path.exists());
280 }
281
282 #[test]
283 fn highscores_load_from_returns_default_when_file_missing() {
284 let dir = tempfile::TempDir::new().unwrap();
285 let path = dir.path().join("does_not_exist.toml");
286 let highscores = Highscores::load_from(path).unwrap();
287 assert!(highscores.single_cleanup.gold.is_none());
288 assert!(highscores.total_run.gold.is_none());
289 }
290
291 #[test]
292 fn highscores_load_from_parses_existing_file() {
293 let dir = tempfile::TempDir::new().unwrap();
295 let path = dir.path().join("highscores.toml");
296 {
297 let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
298 observer.on_folder_cleaned(42_000);
299 observer.on_run_complete(42_000);
300 }
301
302 let highscores = Highscores::load_from(path).unwrap();
303 assert_eq!(
304 highscores.single_cleanup.gold.as_ref().unwrap().size,
305 42_000
306 );
307 assert_eq!(highscores.total_run.gold.as_ref().unwrap().size, 42_000);
308 }
309
310 #[test]
311 fn highscores_load_from_rejects_malformed_toml() {
312 let dir = tempfile::TempDir::new().unwrap();
313 let path = dir.path().join("highscores.toml");
314 fs::write(&path, "this is not toml = = {").unwrap();
315 let err = Highscores::load_from(path).unwrap_err();
316 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
317 }
318}