# Task 3 — `ProgressStore` (sandboxable, deterministic tests) > **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md). ## Goal Persist solved exercises to disk. The path resolves via `QB_PROGRESS_PATH` first, then `$HOME/.config/quantum-bridge-mcp/progress.json`. If neither is available, return an explicit error (never silently fall back to `.`). Tests use `tempfile::TempDir` so they are deterministic under cargo's parallel test runner. ## Prerequisites - Task 0 merged (for `tempfile` dev-dep). ## Files - Create: `src/progress.rs` - Modify: `src/error.rs` - Modify: `src/lib.rs` ## Steps - [ ] **Step 1: Add a `Configuration` variant to `BridgeError` in `src/error.rs`** ```rust #[error("configuration error: {0}")] Configuration(String), ``` - [ ] **Step 2: Create `src/progress.rs`** ```rust use std::collections::HashSet; use std::fs; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::error::BridgeError; pub const PROGRESS_PATH_ENV: &str = "QB_PROGRESS_PATH"; #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct UserProgress { pub solved_exercises: HashSet, } impl UserProgress { pub fn has_solved(&self, exercise_id: &str) -> bool { self.solved_exercises.contains(exercise_id) } pub fn mark_solved(&mut self, exercise_id: &str) { self.solved_exercises.insert(exercise_id.to_string()); } } pub struct ProgressStore { path: PathBuf, } impl ProgressStore { /// Env var override first, then `$HOME/.config/quantum-bridge-mcp/progress.json`. /// Errors out if neither is available — never falls back to `.`. pub fn default_path() -> Result { if let Ok(override_path) = std::env::var(PROGRESS_PATH_ENV) { return Ok(PathBuf::from(override_path)); } let home = std::env::var("HOME").map_err(|_| { BridgeError::Configuration( "neither QB_PROGRESS_PATH nor HOME is set; cannot resolve progress.json location" .into(), ) })?; Ok(PathBuf::from(home) .join(".config") .join("quantum-bridge-mcp") .join("progress.json")) } pub fn new(path: PathBuf) -> Self { Self { path } } pub fn load(&self) -> UserProgress { match fs::read_to_string(&self.path) { Ok(content) => serde_json::from_str(&content).unwrap_or_default(), Err(_) => UserProgress::default(), } } pub fn save(&self, progress: &UserProgress) -> Result<(), BridgeError> { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent) .map_err(|e| BridgeError::Configuration(format!("create_dir_all failed: {e}")))?; } let json = serde_json::to_string_pretty(progress) .map_err(|e| BridgeError::Configuration(format!("serialize failed: {e}")))?; fs::write(&self.path, json) .map_err(|e| BridgeError::Configuration(format!("write failed: {e}"))) } pub fn mark_solved(&self, exercise_id: &str) -> Result<(), BridgeError> { let mut progress = self.load(); progress.mark_solved(exercise_id); self.save(&progress) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn isolated_store() -> (ProgressStore, TempDir) { let dir = TempDir::new().expect("create tempdir"); let path = dir.path().join("progress.json"); (ProgressStore::new(path), dir) } #[test] fn load_returns_empty_progress_when_no_file_exists() { let (store, _dir) = isolated_store(); assert!(store.load().solved_exercises.is_empty()); } #[test] fn save_and_reload_preserves_solved_exercises() { let (store, _dir) = isolated_store(); store.mark_solved("1-1-a").unwrap(); assert!(store.load().has_solved("1-1-a")); } #[test] fn mark_solved_twice_does_not_duplicate() { let (store, _dir) = isolated_store(); store.mark_solved("1-1-a").unwrap(); store.mark_solved("1-1-a").unwrap(); assert_eq!(store.load().solved_exercises.len(), 1); } #[test] fn has_solved_returns_false_for_unknown_exercise() { assert!(!UserProgress::default().has_solved("99-99-z")); } #[test] fn default_path_uses_env_override_when_set() { let dir = TempDir::new().unwrap(); let target = dir.path().join("override.json"); std::env::set_var(PROGRESS_PATH_ENV, &target); let resolved = ProgressStore::default_path().unwrap(); std::env::remove_var(PROGRESS_PATH_ENV); assert_eq!(resolved, target); } } ``` - [ ] **Step 3: Register `progress` in `src/lib.rs`** ```rust pub mod progress; ``` - [ ] **Step 4: Run tests** ```bash cargo test progress::tests 2>&1 | grep -E "test result|FAILED" ``` Expected: `test result: ok. 5 passed`. - [ ] **Step 5: Commit** ```bash git add src/progress.rs src/error.rs src/lib.rs git commit -m "feat: implement ProgressStore with QB_PROGRESS_PATH sandbox and tempfile tests" ```