Files
Vincent Bourdon 9af114e391 Initial import
2026-06-09 16:14:55 +02:00

5.1 KiB

Task 3 — ProgressStore (sandboxable, deterministic tests)

Index: README. Spec: design.

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
#[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 progress in src/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"