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 #[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 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 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 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#[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 #[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 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 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 pub fn add_xp(&mut self, xp: u64) {
143 self.total_xp += xp;
144 }
145
146 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 }
156 Some(last) if today > 0 && last == today - 1 => {
157 self.streak_days += 1;
159 }
160 _ => {
161 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 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 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 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}