Skip to main content

putzen_cli/highscore/
mod.rs

1pub 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
14/// Resolve the on-disk path to `highscores.toml` under the user's config dir.
15pub(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
25/// Mirror `Podium::place` bumping logic on earned medals for a given track.
26/// When a new medal is placed, existing earned medals shift down one tier
27/// and any that fall off the podium are removed.
28fn 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    /// Load from the real user config directory.
67    /// Returns `Self::default()` if the file doesn't exist yet.
68    pub fn load() -> std::io::Result<Self> {
69        Self::load_from(highscores_path()?)
70    }
71
72    /// Load from an explicit path (used by tests and by `load`).
73    /// Returns `Self::default()` if the path doesn't exist.
74    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    /// Load highscores from disk or create a new empty set.
93    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    /// Load from an explicit path (for testing, no first-run message).
116    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        // Check total run highscore (skip if nothing was cleaned)
161        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        // Save to disk (best effort — don't fail the run)
175        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        // Podium full, 500 is too small
208        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); // 1 GiB
219        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        // First run
232        {
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        // Second run — should load saved data
239        {
240            let mut observer = HighscoreObserver::load_from(path).unwrap();
241            // 5000 is gold, so 3000 should place silver
242            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        // Simulate 10 folders of increasing size — each beats the previous gold
254        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        // Should have at most 3 single-cleanup medals + 1 total-run medal
262        assert_eq!(text.matches("Single cleanup").count(), 3);
263        assert_eq!(text.matches("Total run").count(), 1);
264
265        // Exactly 1 gold, 1 silver, 1 bronze for single cleanup
266        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        // Write a file via the observer so we know the format is correct.
294        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}