Skip to main content

cli_tutor/
progress.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct ModuleProgress {
9    pub completed: Vec<String>,
10    pub attempted: Vec<String>,
11    // timed_challenge.TIMER.3 — best solve time per exercise (milliseconds)
12    #[serde(default)]
13    pub best_times: HashMap<String, u64>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct Progress {
18    #[serde(flatten)]
19    pub modules: HashMap<String, ModuleProgress>,
20}
21
22impl Progress {
23    pub fn load() -> Self {
24        match Self::try_load() {
25            Ok(p) => p,
26            Err(e) => {
27                // progress_tracking.LOAD.2 — warn and start fresh on corrupt file
28                eprintln!("Warning: could not load progress file: {e}. Starting fresh.");
29                Self::default()
30            }
31        }
32    }
33
34    fn try_load() -> Result<Self> {
35        let path = progress_path()?;
36        if !path.exists() {
37            // progress_tracking.LOAD.1 — missing file → fresh start, no error
38            return Ok(Self::default());
39        }
40        let content = fs::read_to_string(&path)?;
41        let p: Self = serde_json::from_str(&content)?;
42        Ok(p)
43    }
44
45    pub fn save(&self) -> Result<()> {
46        let path = progress_path()?;
47        if let Some(parent) = path.parent() {
48            fs::create_dir_all(parent)?;
49        }
50        let json = serde_json::to_string_pretty(self)?;
51        fs::write(&path, json)?;
52        Ok(())
53    }
54
55    pub fn mark_completed(&mut self, module: &str, exercise_id: &str) {
56        let entry = self.modules.entry(module.to_string()).or_default();
57        if !entry.completed.contains(&exercise_id.to_string()) {
58            entry.completed.push(exercise_id.to_string());
59        }
60        entry.attempted.retain(|id| id != exercise_id);
61    }
62
63    pub fn mark_attempted(&mut self, module: &str, exercise_id: &str) {
64        let entry = self.modules.entry(module.to_string()).or_default();
65        if !entry.completed.contains(&exercise_id.to_string())
66            && !entry.attempted.contains(&exercise_id.to_string())
67        {
68            entry.attempted.push(exercise_id.to_string());
69        }
70    }
71
72    pub fn is_completed(&self, module: &str, exercise_id: &str) -> bool {
73        self.modules
74            .get(module)
75            .map(|p| p.completed.iter().any(|id| id == exercise_id))
76            .unwrap_or(false)
77    }
78
79    // timed_challenge.TIMER.3 — record best solve time; only keeps the fastest
80    pub fn record_time(&mut self, module: &str, exercise_id: &str, ms: u64) {
81        let entry = self.modules.entry(module.to_string()).or_default();
82        let best = entry.best_times.entry(exercise_id.to_string()).or_insert(u64::MAX);
83        if ms < *best {
84            *best = ms;
85        }
86    }
87
88    pub fn best_time(&self, module: &str, exercise_id: &str) -> Option<u64> {
89        self.modules
90            .get(module)
91            .and_then(|p| p.best_times.get(exercise_id).copied())
92            .filter(|&ms| ms < u64::MAX)
93    }
94}
95
96// gamification.PERSIST.1 — global stats stored separately from per-module progress
97#[derive(Debug, Clone, Serialize, Deserialize, Default)]
98pub struct Stats {
99    #[serde(default)]
100    pub total_xp: u64,
101    #[serde(default)]
102    pub streak_days: u32,
103    // gamification.STREAK.6 — Unix epoch day number (secs / 86400)
104    #[serde(default)]
105    pub last_active_day: Option<u64>,
106}
107
108impl Stats {
109    pub fn load() -> Self {
110        match Self::try_load() {
111            Ok(s) => s,
112            Err(e) => {
113                // gamification.PERSIST.3 — warn and use defaults on corrupt file
114                eprintln!("Warning: could not load stats file: {e}. Starting fresh.");
115                Self::default()
116            }
117        }
118    }
119
120    fn try_load() -> Result<Self> {
121        let path = stats_path()?;
122        if !path.exists() {
123            // gamification.PERSIST.2 — missing file → silent defaults
124            return Ok(Self::default());
125        }
126        let content = fs::read_to_string(&path)?;
127        let s: Self = serde_json::from_str(&content)?;
128        Ok(s)
129    }
130
131    pub fn save(&self) -> Result<()> {
132        let path = stats_path()?;
133        if let Some(parent) = path.parent() {
134            fs::create_dir_all(parent)?;
135        }
136        let json = serde_json::to_string_pretty(self)?;
137        fs::write(&path, json)?;
138        Ok(())
139    }
140
141    // gamification.XP.1 — caller passes the computed xp amount (no content-type dependency)
142    pub fn add_xp(&mut self, xp: u64) {
143        self.total_xp += xp;
144    }
145
146    // gamification.STREAK.1-4 — update streak based on today vs last active day
147    pub fn update_streak(&mut self) {
148        let today = today_day();
149        match self.last_active_day {
150            None => {
151                self.streak_days = 1;
152            }
153            Some(last) if last == today => {
154                // already active today — no change
155            }
156            Some(last) if today > 0 && last == today - 1 => {
157                // consecutive day — extend streak
158                self.streak_days += 1;
159            }
160            _ => {
161                // gap or future date — reset streak
162                self.streak_days = 1;
163            }
164        }
165        self.last_active_day = Some(today);
166    }
167}
168
169fn today_day() -> u64 {
170    use std::time::{SystemTime, UNIX_EPOCH};
171    SystemTime::now()
172        .duration_since(UNIX_EPOCH)
173        .unwrap_or_default()
174        .as_secs()
175        / 86400
176}
177
178fn stats_path() -> Result<PathBuf> {
179    let base = dirs_base()?;
180    Ok(base.join("cli-tutor").join("stats.json"))
181}
182
183fn progress_path() -> Result<PathBuf> {
184    let base = dirs_base()?;
185    Ok(base.join("cli-tutor").join("progress.json"))
186}
187
188fn dirs_base() -> Result<PathBuf> {
189    // XDG_DATA_HOME or ~/.local/share
190    if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
191        return Ok(PathBuf::from(xdg));
192    }
193    let home = std::env::var("HOME")?;
194    Ok(PathBuf::from(home).join(".local").join("share"))
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use std::sync::atomic::{AtomicU64, Ordering};
201
202    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
203
204    // Use XDG_DATA_HOME with a unique temp dir to avoid racing on HOME across parallel tests.
205    fn with_xdg_data<F: FnOnce(PathBuf)>(f: F) {
206        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
207        let tmp =
208            std::env::temp_dir().join(format!("cli-tutor-prog-test-{}-{}", std::process::id(), n));
209        std::fs::create_dir_all(&tmp).unwrap();
210        f(tmp.clone());
211        let _ = std::fs::remove_dir_all(&tmp);
212    }
213
214    fn load_from(xdg_data: &PathBuf) -> Progress {
215        let path = xdg_data.join("cli-tutor").join("progress.json");
216        if !path.exists() {
217            return Progress::default();
218        }
219        let content = std::fs::read_to_string(&path).unwrap_or_default();
220        serde_json::from_str(&content).unwrap_or_default()
221    }
222
223    fn save_to(p: &Progress, xdg_data: &PathBuf) {
224        let path = xdg_data.join("cli-tutor").join("progress.json");
225        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
226        let json = serde_json::to_string_pretty(p).unwrap();
227        std::fs::write(&path, json).unwrap();
228    }
229
230    #[test]
231    fn fresh_start_on_missing_file() {
232        with_xdg_data(|dir| {
233            let p = load_from(&dir);
234            assert!(p.modules.is_empty());
235        });
236    }
237
238    #[test]
239    fn save_and_round_trip() {
240        with_xdg_data(|dir| {
241            let mut p = Progress::default();
242            p.mark_completed("grep", "grep.1");
243            save_to(&p, &dir);
244
245            let loaded = load_from(&dir);
246            assert!(loaded.is_completed("grep", "grep.1"));
247        });
248    }
249
250    #[test]
251    fn fresh_start_on_corrupt_file() {
252        with_xdg_data(|dir| {
253            let path = dir.join("cli-tutor").join("progress.json");
254            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
255            std::fs::write(&path, "{{invalid json").unwrap();
256
257            let content = std::fs::read_to_string(&path).unwrap();
258            let result: Result<Progress, _> = serde_json::from_str(&content);
259            assert!(
260                result.is_err(),
261                "Expected corrupt file to fail deserialization"
262            );
263
264            // Progress::load() itself falls back to default on error
265            let p = Progress::default();
266            assert!(p.modules.is_empty());
267        });
268    }
269
270    #[test]
271    fn mark_completed_deduplicates() {
272        let mut p = Progress::default();
273        p.mark_completed("grep", "grep.1");
274        p.mark_completed("grep", "grep.1");
275        assert_eq!(p.modules["grep"].completed.len(), 1);
276    }
277}