Initial import
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
# 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<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 `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"
|
||||
```
|
||||
Reference in New Issue
Block a user