Skip to main content

putzen_cli/highscore/
mod.rs

1pub 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
12/// Mirror `Podium::place` bumping logic on earned medals for a given track.
13/// When a new medal is placed, existing earned medals shift down one tier
14/// and any that fall off the podium are removed.
15fn 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    /// Load highscores from disk or create a new empty set.
60    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    /// Load from an explicit path (for testing, no first-run message).
83    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        // Check total run highscore (skip if nothing was cleaned)
138        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        // Save to disk (best effort — don't fail the run)
152        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        // Podium full, 500 is too small
185        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); // 1 GiB
196        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        // First run
209        {
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        // Second run — should load saved data
216        {
217            let mut observer = HighscoreObserver::load_from(path).unwrap();
218            // 5000 is gold, so 3000 should place silver
219            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        // Simulate 10 folders of increasing size — each beats the previous gold
231        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        // Should have at most 3 single-cleanup medals + 1 total-run medal
239        assert_eq!(text.matches("Single cleanup").count(), 3);
240        assert_eq!(text.matches("Total run").count(), 1);
241
242        // Exactly 1 gold, 1 silver, 1 bronze for single cleanup
243        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}