5.1 KiB
5.1 KiB
Task 3 — ProgressStore (sandboxable, deterministic tests)
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
tempfiledev-dep).
Files
- Create:
src/progress.rs - Modify:
src/error.rs - Modify:
src/lib.rs
Steps
- Step 1: Add a
Configurationvariant toBridgeErrorinsrc/error.rs
#[error("configuration error: {0}")]
Configuration(String),
- Step 2: Create
src/progress.rs
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<String>,
}
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<PathBuf, BridgeError> {
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
progressinsrc/lib.rs
pub mod progress;
- Step 4: Run tests
cargo test progress::tests 2>&1 | grep -E "test result|FAILED"
Expected: test result: ok. 5 passed.
- Step 5: Commit
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"