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::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    /// Load highscores from disk or create a new empty set.
28    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    /// Load from an explicit path (for testing, no first-run message).
51    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        // Check total run highscore (skip if nothing was cleaned)
105        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        // Save to disk (best effort — don't fail the run)
118        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        // Podium full, 500 is too small
151        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); // 1 GiB
162        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        // First run
175        {
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        // Second run — should load saved data
182        {
183            let mut observer = HighscoreObserver::load_from(path).unwrap();
184            // 5000 is gold, so 3000 should place silver
185            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}