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}
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct Progress {
15 #[serde(flatten)]
16 pub modules: HashMap<String, ModuleProgress>,
17}
18
19impl Progress {
20 pub fn load() -> Self {
21 match Self::try_load() {
22 Ok(p) => p,
23 Err(e) => {
24 eprintln!("Warning: could not load progress file: {e}. Starting fresh.");
26 Self::default()
27 }
28 }
29 }
30
31 fn try_load() -> Result<Self> {
32 let path = progress_path()?;
33 if !path.exists() {
34 return Ok(Self::default());
36 }
37 let content = fs::read_to_string(&path)?;
38 let p: Self = serde_json::from_str(&content)?;
39 Ok(p)
40 }
41
42 pub fn save(&self) -> Result<()> {
43 let path = progress_path()?;
44 if let Some(parent) = path.parent() {
45 fs::create_dir_all(parent)?;
46 }
47 let json = serde_json::to_string_pretty(self)?;
48 fs::write(&path, json)?;
49 Ok(())
50 }
51
52 pub fn mark_completed(&mut self, module: &str, exercise_id: &str) {
53 let entry = self.modules.entry(module.to_string()).or_default();
54 if !entry.completed.contains(&exercise_id.to_string()) {
55 entry.completed.push(exercise_id.to_string());
56 }
57 entry.attempted.retain(|id| id != exercise_id);
58 }
59
60 pub fn mark_attempted(&mut self, module: &str, exercise_id: &str) {
61 let entry = self.modules.entry(module.to_string()).or_default();
62 if !entry.completed.contains(&exercise_id.to_string())
63 && !entry.attempted.contains(&exercise_id.to_string())
64 {
65 entry.attempted.push(exercise_id.to_string());
66 }
67 }
68
69 pub fn is_completed(&self, module: &str, exercise_id: &str) -> bool {
70 self.modules
71 .get(module)
72 .map(|p| p.completed.iter().any(|id| id == exercise_id))
73 .unwrap_or(false)
74 }
75}
76
77fn progress_path() -> Result<PathBuf> {
78 let base = dirs_base()?;
79 Ok(base.join("cli-tutor").join("progress.json"))
80}
81
82fn dirs_base() -> Result<PathBuf> {
83 if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
85 return Ok(PathBuf::from(xdg));
86 }
87 let home = std::env::var("HOME")?;
88 Ok(PathBuf::from(home).join(".local").join("share"))
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use std::sync::atomic::{AtomicU64, Ordering};
95
96 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
97
98 fn with_xdg_data<F: FnOnce(PathBuf)>(f: F) {
100 let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
101 let tmp =
102 std::env::temp_dir().join(format!("cli-tutor-prog-test-{}-{}", std::process::id(), n));
103 std::fs::create_dir_all(&tmp).unwrap();
104 f(tmp.clone());
105 let _ = std::fs::remove_dir_all(&tmp);
106 }
107
108 fn load_from(xdg_data: &PathBuf) -> Progress {
109 let path = xdg_data.join("cli-tutor").join("progress.json");
110 if !path.exists() {
111 return Progress::default();
112 }
113 let content = std::fs::read_to_string(&path).unwrap_or_default();
114 serde_json::from_str(&content).unwrap_or_default()
115 }
116
117 fn save_to(p: &Progress, xdg_data: &PathBuf) {
118 let path = xdg_data.join("cli-tutor").join("progress.json");
119 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
120 let json = serde_json::to_string_pretty(p).unwrap();
121 std::fs::write(&path, json).unwrap();
122 }
123
124 #[test]
125 fn fresh_start_on_missing_file() {
126 with_xdg_data(|dir| {
127 let p = load_from(&dir);
128 assert!(p.modules.is_empty());
129 });
130 }
131
132 #[test]
133 fn save_and_round_trip() {
134 with_xdg_data(|dir| {
135 let mut p = Progress::default();
136 p.mark_completed("grep", "grep.1");
137 save_to(&p, &dir);
138
139 let loaded = load_from(&dir);
140 assert!(loaded.is_completed("grep", "grep.1"));
141 });
142 }
143
144 #[test]
145 fn fresh_start_on_corrupt_file() {
146 with_xdg_data(|dir| {
147 let path = dir.join("cli-tutor").join("progress.json");
148 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
149 std::fs::write(&path, "{{invalid json").unwrap();
150
151 let content = std::fs::read_to_string(&path).unwrap();
152 let result: Result<Progress, _> = serde_json::from_str(&content);
153 assert!(
154 result.is_err(),
155 "Expected corrupt file to fail deserialization"
156 );
157
158 let p = Progress::default();
160 assert!(p.modules.is_empty());
161 });
162 }
163
164 #[test]
165 fn mark_completed_deduplicates() {
166 let mut p = Progress::default();
167 p.mark_completed("grep", "grep.1");
168 p.mark_completed("grep", "grep.1");
169 assert_eq!(p.modules["grep"].completed.len(), 1);
170 }
171}