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

4.1 KiB

Task 2 — CurriculumLoader (OnceLock-cached, no expect())

Index: README. Spec: design.

Goal

Provide a lazy, cached accessor over the embedded curriculum that returns Result<&Curriculum, BridgeError> instead of panicking. Required by every tutor tool. CLAUDE.md §10 forbids expect() in production.

Prerequisites

  • Task 1 merged.

Files

  • Modify: src/tutor.rs
  • Modify: src/error.rs

Steps

  • Step 1: Add a Curriculum variant to BridgeError in src/error.rs
#[error("curriculum data is malformed: {0}")]
Curriculum(String),
  • Step 2: Append failing tests to src/tutor.rs (inside the existing #[cfg(test)] block)
    #[test]
    fn loader_returns_curriculum_on_first_call() {
        let loader = CurriculumLoader::default();
        let c = loader.curriculum().expect("embedded curriculum must parse");
        assert_eq!(c.version, "1.0");
    }

    #[test]
    fn find_exercise_by_id_returns_correct_exercise() {
        let loader = CurriculumLoader::default();
        let exercise = loader.find_exercise("1-1-a").unwrap();
        assert_eq!(exercise.id, "1-1-a");
    }

    #[test]
    fn find_exercise_with_unknown_id_returns_none() {
        let loader = CurriculumLoader::default();
        assert!(loader.find_exercise("99-99-z").is_none());
    }

    #[test]
    fn get_lesson_unknown_module_returns_none() {
        let loader = CurriculumLoader::default();
        assert!(loader.get_lesson(99, 1).is_none());
    }

    #[test]
    fn loader_reports_curriculum_error_for_malformed_json() {
        let result = CurriculumLoader::from_str("{ not valid json");
        assert!(result.is_err());
    }
  • Step 3: Implement CurriculumLoader (above the #[cfg(test)] block)
use std::sync::OnceLock;
use crate::error::BridgeError;

const CURRICULUM_JSON: &str = include_str!("../curriculum/curriculum.json");

#[derive(Default)]
pub struct CurriculumLoader {
    cell: OnceLock<Result<Curriculum, String>>,
}

impl CurriculumLoader {
    pub fn curriculum(&self) -> Result<&Curriculum, BridgeError> {
        let result = self.cell.get_or_init(|| {
            serde_json::from_str::<Curriculum>(CURRICULUM_JSON).map_err(|e| e.to_string())
        });
        result
            .as_ref()
            .map_err(|msg| BridgeError::Curriculum(msg.clone()))
    }

    pub fn from_str(json: &str) -> Result<Self, BridgeError> {
        let curriculum: Curriculum =
            serde_json::from_str(json).map_err(|e| BridgeError::Curriculum(e.to_string()))?;
        let loader = Self::default();
        let _ = loader.cell.set(Ok(curriculum));
        Ok(loader)
    }

    pub fn get_module(&self, module_id: u32) -> Option<&Module> {
        self.curriculum().ok()?.modules.iter().find(|m| m.id == module_id)
    }

    pub fn get_lesson(&self, module_id: u32, lesson_id: u32) -> Option<&Lesson> {
        self.get_module(module_id)?
            .lessons
            .iter()
            .find(|l| l.id == lesson_id)
    }

    pub fn find_exercise(&self, exercise_id: &str) -> Option<&Exercise> {
        for module in &self.curriculum().ok()?.modules {
            for lesson in &module.lessons {
                if let Some(ex) = lesson.exercises.iter().find(|e| e.id == exercise_id) {
                    return Some(ex);
                }
            }
        }
        None
    }

    pub fn all_exercises(&self) -> Vec<&Exercise> {
        self.curriculum()
            .ok()
            .map(|c| {
                c.modules
                    .iter()
                    .flat_map(|m| m.lessons.iter())
                    .flat_map(|l| l.exercises.iter())
                    .collect()
            })
            .unwrap_or_default()
    }
}
  • Step 4: Run tests (Green)
cargo test tutor::tests 2>&1 | grep -E "test result|FAILED"

Expected: test result: ok. 6 passed.

  • Step 5: Commit
git add src/tutor.rs src/error.rs
git commit -m "feat: implement CurriculumLoader with OnceLock cache and structured error"