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::{Medal, Podium};
6use crate::observer::RunObserver;
7
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11
12fn bump_earned_medals(earned: &mut Vec<EarnedMedal>, track: TrackName, new_medal: Medal) {
16 match new_medal {
17 Medal::Gold => {
18 earned.retain(|m| !(m.track == track && m.medal == Medal::Bronze));
19 for m in earned.iter_mut() {
20 if m.track == track && m.medal == Medal::Silver {
21 m.medal = Medal::Bronze;
22 }
23 }
24 for m in earned.iter_mut() {
25 if m.track == track && m.medal == Medal::Gold {
26 m.medal = Medal::Silver;
27 }
28 }
29 }
30 Medal::Silver => {
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 }
38 Medal::Bronze => {
39 earned.retain(|m| !(m.track == track && m.medal == Medal::Bronze));
40 }
41 }
42}
43
44#[derive(Debug, Default, Serialize, Deserialize)]
45pub struct Highscores {
46 #[serde(default)]
47 pub single_cleanup: Podium,
48 #[serde(default)]
49 pub total_run: Podium,
50}
51
52pub struct HighscoreObserver {
53 highscores: Highscores,
54 earned_medals: Vec<EarnedMedal>,
55 file_path: PathBuf,
56}
57
58impl HighscoreObserver {
59 pub fn load() -> std::io::Result<Self> {
61 let file_path = Self::highscores_path()?;
62 let (highscores, is_first_run) = if file_path.exists() {
63 let content = fs::read_to_string(&file_path)?;
64 let highscores: Highscores = toml::from_str(&content)
65 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
66 (highscores, false)
67 } else {
68 (Highscores::default(), true)
69 };
70
71 if is_first_run {
72 println!("\u{1F3C6} A wild cleaner appears! Highscore board initialized.");
73 }
74
75 Ok(Self {
76 highscores,
77 earned_medals: Vec::new(),
78 file_path,
79 })
80 }
81
82 pub fn load_from(file_path: PathBuf) -> std::io::Result<Self> {
84 let highscores = if file_path.exists() {
85 let content = fs::read_to_string(&file_path)?;
86 toml::from_str(&content)
87 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?
88 } else {
89 Highscores::default()
90 };
91
92 Ok(Self {
93 highscores,
94 earned_medals: Vec::new(),
95 file_path,
96 })
97 }
98
99 fn highscores_path() -> std::io::Result<PathBuf> {
100 let config_dir = dirs_lite::config_dir().ok_or_else(|| {
101 std::io::Error::new(
102 std::io::ErrorKind::NotFound,
103 "Could not determine config directory",
104 )
105 })?;
106 Ok(config_dir.join("putzen").join("highscores.toml"))
107 }
108
109 fn today() -> String {
110 jiff::Zoned::now().date().to_string()
111 }
112
113 fn save(&self) -> std::io::Result<()> {
114 if let Some(parent) = self.file_path.parent() {
115 fs::create_dir_all(parent)?;
116 }
117 let content = toml::to_string_pretty(&self.highscores).map_err(std::io::Error::other)?;
118 fs::write(&self.file_path, content)
119 }
120}
121
122impl RunObserver for HighscoreObserver {
123 fn on_folder_cleaned(&mut self, size: u64) -> Option<String> {
124 let medal = self.highscores.single_cleanup.would_place(size)?;
125 let date = Self::today();
126 self.highscores.single_cleanup.place(size, &date);
127 bump_earned_medals(&mut self.earned_medals, TrackName::SingleCleanup, medal);
128 self.earned_medals.push(EarnedMedal {
129 medal,
130 track: TrackName::SingleCleanup,
131 size,
132 });
133 Some(inline_hint())
134 }
135
136 fn on_run_complete(&mut self, total: u64) -> Option<String> {
137 if total > 0 {
139 if let Some(medal) = self.highscores.total_run.would_place(total) {
140 let date = Self::today();
141 self.highscores.total_run.place(total, &date);
142 bump_earned_medals(&mut self.earned_medals, TrackName::TotalRun, medal);
143 self.earned_medals.push(EarnedMedal {
144 medal,
145 track: TrackName::TotalRun,
146 size: total,
147 });
148 }
149 }
150
151 let _ = self.save();
153
154 render_medals(&self.earned_medals)
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::observer::RunObserver;
162
163 #[test]
164 fn first_cleanup_returns_hint() {
165 let dir = tempfile::TempDir::new().unwrap();
166 let path = dir.path().join("highscores.toml");
167 let mut observer = HighscoreObserver::load_from(path).unwrap();
168
169 let hint = observer.on_folder_cleaned(1024);
170 assert!(hint.is_some());
171 assert!(hint.unwrap().contains("new highscore!"));
172 }
173
174 #[test]
175 fn small_cleanup_after_big_one_no_hint_when_podium_full() {
176 let dir = tempfile::TempDir::new().unwrap();
177 let path = dir.path().join("highscores.toml");
178 let mut observer = HighscoreObserver::load_from(path).unwrap();
179
180 observer.on_folder_cleaned(3000);
181 observer.on_folder_cleaned(2000);
182 observer.on_folder_cleaned(1000);
183
184 let hint = observer.on_folder_cleaned(500);
186 assert!(hint.is_none());
187 }
188
189 #[test]
190 fn on_run_complete_renders_medals() {
191 let dir = tempfile::TempDir::new().unwrap();
192 let path = dir.path().join("highscores.toml");
193 let mut observer = HighscoreObserver::load_from(path).unwrap();
194
195 observer.on_folder_cleaned(1_073_741_824); let output = observer.on_run_complete(1_073_741_824);
197 assert!(output.is_some());
198 let text = output.unwrap();
199 assert!(text.contains("NEW HIGHSCORE"));
200 assert!(text.contains("Gold"));
201 }
202
203 #[test]
204 fn saves_and_reloads_highscores() {
205 let dir = tempfile::TempDir::new().unwrap();
206 let path = dir.path().join("highscores.toml");
207
208 {
210 let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
211 observer.on_folder_cleaned(5000);
212 observer.on_run_complete(5000);
213 }
214
215 {
217 let mut observer = HighscoreObserver::load_from(path).unwrap();
218 let hint = observer.on_folder_cleaned(3000);
220 assert!(hint.is_some());
221 }
222 }
223
224 #[test]
225 fn many_increasing_cleanups_produce_at_most_three_medals_per_track() {
226 let dir = tempfile::TempDir::new().unwrap();
227 let path = dir.path().join("highscores.toml");
228 let mut observer = HighscoreObserver::load_from(path).unwrap();
229
230 for i in 1..=10 {
232 observer.on_folder_cleaned(i * 1000);
233 }
234
235 let output = observer.on_run_complete(55_000);
236 let text = output.unwrap();
237
238 assert_eq!(text.matches("Single cleanup").count(), 3);
240 assert_eq!(text.matches("Total run").count(), 1);
241
242 assert_eq!(text.matches("Gold \u{00B7} Single").count(), 1);
244 assert_eq!(text.matches("Silver \u{00B7} Single").count(), 1);
245 assert_eq!(text.matches("Bronze \u{00B7} Single").count(), 1);
246 }
247
248 #[test]
249 fn first_run_creates_file_on_save() {
250 let dir = tempfile::TempDir::new().unwrap();
251 let path = dir.path().join("highscores.toml");
252 assert!(!path.exists());
253 let mut observer = HighscoreObserver::load_from(path.clone()).unwrap();
254 observer.on_folder_cleaned(1000);
255 observer.on_run_complete(1000);
256 assert!(path.exists());
257 }
258}