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}
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                // progress_tracking.LOAD.2 — warn and start fresh on corrupt file
25                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            // progress_tracking.LOAD.1 — missing file → fresh start, no error
35            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    // XDG_DATA_HOME or ~/.local/share
84    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    // Use XDG_DATA_HOME with a unique temp dir to avoid racing on HOME across parallel tests.
99    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            // Progress::load() itself falls back to default on error
159            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}