Skip to main content

logicaffeine_web/
content.rs

1use include_dir::{include_dir, Dir};
2use serde::Deserialize;
3use std::collections::HashMap;
4
5// Curriculum content lives under assets/curriculum/ (01_first-steps/, 02_building-blocks/, etc.)
6// — scoped so images and other assets never ride into the wasm binary.
7static CURRICULUM_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/assets/curriculum");
8
9#[derive(Debug, Clone, Deserialize)]
10pub struct EraMeta {
11    pub id: String,
12    pub title: String,
13    pub description: String,
14    pub order: u32,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct ModuleMeta {
19    pub id: String,
20    pub title: String,
21    pub pedagogy: String,
22    pub order: u32,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26pub struct ExerciseConfig {
27    pub id: String,
28    #[serde(rename = "type")]
29    pub exercise_type: ExerciseType,
30    pub difficulty: u32,
31    pub prompt: String,
32    #[serde(default)]
33    pub template: Option<String>,
34    #[serde(default)]
35    pub constraints: HashMap<String, Vec<String>>,
36    #[serde(default)]
37    pub hint: Option<String>,
38    #[serde(default)]
39    pub explanation: Option<String>,
40    #[serde(default)]
41    pub options: Option<Vec<String>>,
42    #[serde(default)]
43    pub correct: Option<usize>,
44}
45
46#[derive(Debug, Clone, Deserialize, PartialEq)]
47#[serde(rename_all = "snake_case")]
48pub enum ExerciseType {
49    Translation,
50    MultipleChoice,
51    Ambiguity,
52}
53
54/// A symbol definition for the glossary
55#[derive(Debug, Clone, Deserialize)]
56pub struct SymbolDef {
57    pub symbol: String,
58    pub name: String,
59    pub meaning: String,
60    #[serde(default)]
61    pub example: Option<String>,
62}
63
64/// A content block within a section (paragraph, definition, example, etc.)
65#[derive(Debug, Clone, Deserialize)]
66#[serde(tag = "type", rename_all = "snake_case")]
67pub enum ContentBlock {
68    Paragraph {
69        text: String,
70    },
71    Definition {
72        term: String,
73        definition: String,
74    },
75    Example {
76        title: String,
77        #[serde(default)]
78        premises: Vec<String>,
79        #[serde(default)]
80        conclusion: Option<String>,
81        #[serde(default)]
82        note: Option<String>,
83    },
84    /// Symbol glossary block - shows relevant symbols for this section
85    Symbols {
86        title: String,
87        symbols: Vec<SymbolDef>,
88    },
89    /// Quiz question embedded in the lesson
90    Quiz {
91        question: String,
92        options: Vec<String>,
93        correct: usize,
94        #[serde(default)]
95        explanation: Option<String>,
96    },
97}
98
99/// A lesson section with structured content
100#[derive(Debug, Clone, Deserialize)]
101pub struct Section {
102    pub id: String,
103    pub title: String,
104    pub order: u32,
105    #[serde(default)]
106    pub content: Vec<ContentBlock>,
107    #[serde(default)]
108    pub key_symbols: Vec<String>,
109}
110
111#[derive(Debug, Clone)]
112pub struct Module {
113    pub meta: ModuleMeta,
114    pub exercises: Vec<ExerciseConfig>,
115    pub sections: Vec<Section>,
116}
117
118#[derive(Debug, Clone)]
119pub struct Era {
120    pub meta: EraMeta,
121    pub modules: Vec<Module>,
122}
123
124#[derive(Debug, Clone)]
125pub struct Curriculum {
126    pub eras: Vec<Era>,
127}
128
129pub struct ContentEngine {
130    pub curriculum: Curriculum,
131}
132
133impl ContentEngine {
134    pub fn new() -> Self {
135        let curriculum = Self::load_curriculum();
136        Self { curriculum }
137    }
138
139    fn load_curriculum() -> Curriculum {
140        let mut eras = Vec::new();
141
142        for era_entry in CURRICULUM_DIR.dirs() {
143            if let Some(era) = Self::load_era(era_entry) {
144                eras.push(era);
145            }
146        }
147
148        eras.sort_by_key(|e| e.meta.order);
149        Curriculum { eras }
150    }
151
152    fn load_era(era_dir: &Dir) -> Option<Era> {
153        // era_dir.path() returns "01_trivium", file paths are "01_trivium/meta.json"
154        let era_path = era_dir.path().to_string_lossy();
155        let meta_path = format!("{}/meta.json", era_path);
156        let meta_file = era_dir.get_file(&meta_path)?;
157        let meta_content = meta_file.contents_utf8()?;
158        let meta: EraMeta = serde_json::from_str(meta_content).ok()?;
159
160        let mut modules = Vec::new();
161        for module_entry in era_dir.dirs() {
162            if let Some(module) = Self::load_module(module_entry) {
163                modules.push(module);
164            }
165        }
166
167        modules.sort_by_key(|m| m.meta.order);
168        Some(Era { meta, modules })
169    }
170
171    fn load_module(module_dir: &Dir) -> Option<Module> {
172        // module_dir.path() returns "01_trivium/01_atomic"
173        let module_path = module_dir.path().to_string_lossy();
174        let meta_path = format!("{}/meta.json", module_path);
175        let meta_file = module_dir.get_file(&meta_path)?;
176        let meta_content = meta_file.contents_utf8()?;
177        let meta: ModuleMeta = serde_json::from_str(meta_content).ok()?;
178
179        let mut exercises = Vec::new();
180        let mut sections = Vec::new();
181
182        for file in module_dir.files() {
183            if let Some(name) = file.path().file_name() {
184                let name_str = name.to_string_lossy();
185                if name_str.starts_with("ex_") && name_str.ends_with(".json") {
186                    // Load exercise
187                    if let Some(content) = file.contents_utf8() {
188                        if let Ok(exercise) = serde_json::from_str::<ExerciseConfig>(content) {
189                            exercises.push(exercise);
190                        }
191                    }
192                } else if name_str.starts_with("sec_") && name_str.ends_with(".json") {
193                    // Load section
194                    if let Some(content) = file.contents_utf8() {
195                        if let Ok(section) = serde_json::from_str::<Section>(content) {
196                            sections.push(section);
197                        }
198                    }
199                }
200            }
201        }
202
203        exercises.sort_by(|a, b| a.id.cmp(&b.id));
204        sections.sort_by_key(|s| s.order);
205        Some(Module { meta, exercises, sections })
206    }
207
208    pub fn get_era(&self, era_id: &str) -> Option<&Era> {
209        self.curriculum.eras.iter().find(|e| e.meta.id == era_id)
210    }
211
212    pub fn get_module(&self, era_id: &str, module_id: &str) -> Option<&Module> {
213        self.get_era(era_id)?
214            .modules
215            .iter()
216            .find(|m| m.meta.id == module_id)
217    }
218
219    pub fn get_exercise(&self, era_id: &str, module_id: &str, exercise_id: &str) -> Option<&ExerciseConfig> {
220        self.get_module(era_id, module_id)?
221            .exercises
222            .iter()
223            .find(|e| e.id == exercise_id)
224    }
225
226    pub fn eras(&self) -> &[Era] {
227        &self.curriculum.eras
228    }
229
230    pub fn era_count(&self) -> usize {
231        self.curriculum.eras.len()
232    }
233
234    pub fn module_count(&self, era_id: &str) -> usize {
235        self.get_era(era_id).map(|e| e.modules.len()).unwrap_or(0)
236    }
237
238    pub fn exercise_count(&self, era_id: &str, module_id: &str) -> usize {
239        self.get_module(era_id, module_id)
240            .map(|m| m.exercises.len())
241            .unwrap_or(0)
242    }
243}
244
245impl Default for ContentEngine {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_dir_contents() {
257        // Debug: print what's in the embedded directory
258        println!("Files in CURRICULUM_DIR:");
259        for f in CURRICULUM_DIR.files() {
260            println!("  file: {:?}", f.path());
261        }
262        println!("Dirs in CURRICULUM_DIR:");
263        for d in CURRICULUM_DIR.dirs() {
264            println!("  era dir: {:?}", d.path());
265            for f in d.files() {
266                println!("    era file: {:?}", f.path());
267            }
268            for module_d in d.dirs() {
269                println!("    module dir: {:?}", module_d.path());
270                for f in module_d.files() {
271                    println!("      module file: {:?}", f.path());
272                }
273            }
274        }
275        assert!(!CURRICULUM_DIR.dirs().collect::<Vec<_>>().is_empty(), "Should have embedded directories");
276    }
277
278    #[test]
279    fn test_curriculum_loads() {
280        let engine = ContentEngine::new();
281        assert!(engine.era_count() >= 4, "Should have at least 4 eras (got {})", engine.era_count());
282    }
283
284    #[test]
285    fn test_first_steps_era_exists() {
286        let engine = ContentEngine::new();
287        let era = engine.get_era("first-steps");
288        assert!(era.is_some(), "First Steps era should exist");
289        assert_eq!(era.unwrap().meta.title, "First Steps");
290    }
291
292    #[test]
293    fn test_building_blocks_era_exists() {
294        let engine = ContentEngine::new();
295        let era = engine.get_era("building-blocks");
296        assert!(era.is_some(), "Building Blocks era should exist");
297        assert_eq!(era.unwrap().meta.title, "Building Blocks");
298    }
299
300    #[test]
301    fn test_expanding_horizons_era_exists() {
302        let engine = ContentEngine::new();
303        let era = engine.get_era("expanding-horizons");
304        assert!(era.is_some(), "Expanding Horizons era should exist");
305        assert_eq!(era.unwrap().meta.title, "Expanding Horizons");
306    }
307
308    #[test]
309    fn test_mastery_era_exists() {
310        let engine = ContentEngine::new();
311        let era = engine.get_era("mastery");
312        assert!(era.is_some(), "Mastery era should exist");
313        assert_eq!(era.unwrap().meta.title, "Mastery");
314    }
315
316    #[test]
317    fn test_introduction_module_exists() {
318        let engine = ContentEngine::new();
319        let module = engine.get_module("first-steps", "introduction");
320        assert!(module.is_some(), "Introduction module should exist");
321        assert_eq!(module.unwrap().meta.title, "Introduction");
322    }
323
324    #[test]
325    fn test_syllogistic_module_exists() {
326        let engine = ContentEngine::new();
327        let module = engine.get_module("first-steps", "syllogistic");
328        assert!(module.is_some(), "Syllogistic module should exist");
329        let m = module.unwrap();
330        assert_eq!(m.meta.title, "Syllogistic Logic");
331        assert!(m.exercises.len() >= 90, "Should have at least 90 exercises (got {})", m.exercises.len());
332    }
333
334    #[test]
335    fn test_propositional_module_exists() {
336        let engine = ContentEngine::new();
337        let module = engine.get_module("building-blocks", "propositional");
338        assert!(module.is_some(), "Propositional module should exist");
339        let m = module.unwrap();
340        assert_eq!(m.meta.title, "Basic Propositional Logic");
341        assert!(m.exercises.len() >= 100, "Should have at least 100 exercises (got {})", m.exercises.len());
342    }
343
344    #[test]
345    fn test_exercises_load() {
346        let engine = ContentEngine::new();
347        let count = engine.exercise_count("first-steps", "syllogistic");
348        assert!(count >= 90, "Syllogistic module should have at least 90 exercises");
349    }
350
351    #[test]
352    fn test_exercise_has_explanation() {
353        let engine = ContentEngine::new();
354        let ex = engine.get_exercise("first-steps", "syllogistic", "A_1.1");
355        assert!(ex.is_some(), "Exercise A_1.1 should exist");
356        let exercise = ex.unwrap();
357        assert!(exercise.explanation.is_some(), "Exercise should have explanation");
358        assert!(exercise.options.is_some(), "Exercise should have options");
359        assert_eq!(exercise.exercise_type, ExerciseType::MultipleChoice);
360    }
361
362    #[test]
363    fn test_all_eras_have_modules() {
364        let engine = ContentEngine::new();
365
366        // First Steps: 5 modules
367        let first_steps_modules = ["introduction", "syllogistic", "definitions", "fallacies", "inductive"];
368        for module in first_steps_modules {
369            assert!(engine.get_module("first-steps", module).is_some(), "first-steps/{} should exist", module);
370        }
371
372        // Building Blocks: 2 modules
373        let building_blocks_modules = ["propositional", "proofs"];
374        for module in building_blocks_modules {
375            assert!(engine.get_module("building-blocks", module).is_some(), "building-blocks/{} should exist", module);
376        }
377
378        // Expanding Horizons: 6 modules
379        let expanding_modules = ["quantificational", "relations", "modal", "further_modal", "deontic", "belief"];
380        for module in expanding_modules {
381            assert!(engine.get_module("expanding-horizons", module).is_some(), "expanding-horizons/{} should exist", module);
382        }
383
384        // Mastery: 5 modules
385        let mastery_modules = ["ethics", "metalogic", "history", "deviant", "philosophy"];
386        for module in mastery_modules {
387            assert!(engine.get_module("mastery", module).is_some(), "mastery/{} should exist", module);
388        }
389    }
390}