1use include_dir::{include_dir, Dir};
2use serde::Deserialize;
3use std::collections::HashMap;
4
5static 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#[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#[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 Symbols {
86 title: String,
87 symbols: Vec<SymbolDef>,
88 },
89 Quiz {
91 question: String,
92 options: Vec<String>,
93 correct: usize,
94 #[serde(default)]
95 explanation: Option<String>,
96 },
97}
98
99#[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 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 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 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 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 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 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 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 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 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}