use anyhow::{Context, Error, Result, anyhow};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{
collections::{BTreeMap, HashMap, HashSet},
fs::{File, read_dir, read_to_string},
io::BufReader,
path::Path,
};
use ustr::{Ustr, UstrMap};
use crate::data::{
BasicAsset, CourseManifest, ExerciseAsset, ExerciseManifest, ExerciseType, GenerateManifests,
GeneratedCourse, LessonManifest, UserPreferences,
};
pub const LESSON_SUFFIX: &str = ".lesson";
pub const LESSON_DEPENDENCIES_FILE: &str = "lesson.dependencies.json";
pub const LESSON_SUPERSEDED_FILE: &str = "lesson.superseded.json";
pub const LESSON_ENCOMPASSED_FILE: &str = "lesson.encompassed.json";
pub const LESSON_NAME_FILE: &str = "lesson.name.json";
pub const LESSON_DESCRIPTION_FILE: &str = "lesson.description.json";
pub const LESSON_METADATA_FILE: &str = "lesson.metadata.json";
pub const LESSON_INSTRUCTIONS_FILE: &str = "lesson.instructions.md";
pub const LESSON_MATERIAL_FILE: &str = "lesson.material.md";
pub const LESSON_DEFAULT_EXERCISE_TYPE_FILE: &str = "lesson.default_exercise_type.json";
pub const EXERCISE_FRONT_SUFFIX: &str = ".front.md";
pub const EXERCISE_BACK_SUFFIX: &str = ".back.md";
pub const EXERCISE_NAME_SUFFIX: &str = ".name.json";
pub const EXERCISE_DESCRIPTION_SUFFIX: &str = ".description.json";
pub const EXERCISE_TYPE_SUFFIX: &str = ".type.json";
#[derive(Debug, Eq, PartialEq)]
pub enum KnowledgeBaseFile {
LessonName,
LessonDescription,
LessonDependencies,
LessonSuperseded,
LessonEncompassed,
LessonMetadata,
LessonInstructions,
LessonMaterial,
LessonDefaultExerciseType,
ExerciseFront(String),
ExerciseBack(String),
ExerciseName(String),
ExerciseDescription(String),
ExerciseType(String),
}
impl KnowledgeBaseFile {
pub fn open<T: DeserializeOwned>(path: &Path) -> Result<T> {
let display = path.display();
let file =
File::open(path).context(format!("cannot open knowledge base file {display}"))?;
let reader = BufReader::new(file);
serde_json::from_reader(reader)
.context(format!("cannot parse knowledge base file {display}"))
}
}
impl TryFrom<&str> for KnowledgeBaseFile {
type Error = Error;
fn try_from(file_name: &str) -> Result<Self> {
match file_name {
LESSON_DEPENDENCIES_FILE => Ok(KnowledgeBaseFile::LessonDependencies),
LESSON_SUPERSEDED_FILE => Ok(KnowledgeBaseFile::LessonSuperseded),
LESSON_ENCOMPASSED_FILE => Ok(KnowledgeBaseFile::LessonEncompassed),
LESSON_NAME_FILE => Ok(KnowledgeBaseFile::LessonName),
LESSON_DESCRIPTION_FILE => Ok(KnowledgeBaseFile::LessonDescription),
LESSON_METADATA_FILE => Ok(KnowledgeBaseFile::LessonMetadata),
LESSON_MATERIAL_FILE => Ok(KnowledgeBaseFile::LessonMaterial),
LESSON_INSTRUCTIONS_FILE => Ok(KnowledgeBaseFile::LessonInstructions),
LESSON_DEFAULT_EXERCISE_TYPE_FILE => Ok(KnowledgeBaseFile::LessonDefaultExerciseType),
file_name if file_name.ends_with(EXERCISE_FRONT_SUFFIX) => {
let short_id = file_name.strip_suffix(EXERCISE_FRONT_SUFFIX).unwrap();
Ok(KnowledgeBaseFile::ExerciseFront(short_id.to_string()))
}
file_name if file_name.ends_with(EXERCISE_BACK_SUFFIX) => {
let short_id = file_name.strip_suffix(EXERCISE_BACK_SUFFIX).unwrap();
Ok(KnowledgeBaseFile::ExerciseBack(short_id.to_string()))
}
file_name if file_name.ends_with(EXERCISE_NAME_SUFFIX) => {
let short_id = file_name.strip_suffix(EXERCISE_NAME_SUFFIX).unwrap();
Ok(KnowledgeBaseFile::ExerciseName(short_id.to_string()))
}
file_name if file_name.ends_with(EXERCISE_DESCRIPTION_SUFFIX) => {
let short_id = file_name.strip_suffix(EXERCISE_DESCRIPTION_SUFFIX).unwrap();
Ok(KnowledgeBaseFile::ExerciseDescription(short_id.to_string()))
}
file_name if file_name.ends_with(EXERCISE_TYPE_SUFFIX) => {
let short_id = file_name.strip_suffix(EXERCISE_TYPE_SUFFIX).unwrap();
Ok(KnowledgeBaseFile::ExerciseType(short_id.to_string()))
}
_ => Err(anyhow!("Not a valid knowledge base file name: {file_name}")),
}
}
}
#[derive(Clone)]
pub struct KnowledgeBaseExercise {
pub short_id: String,
pub short_lesson_id: Ustr,
pub course_id: Ustr,
pub front_file: String,
pub back_file: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub exercise_type: Option<ExerciseType>,
}
impl KnowledgeBaseExercise {
pub fn to_exercise_manifest(
&self,
default_exercise_type: Option<ExerciseType>,
inlined: bool,
) -> Result<ExerciseManifest> {
let exercise_asset = if inlined {
let front_content = read_to_string(&self.front_file).context(format!(
"failed to read exercise front file {}",
self.front_file
))?;
let back_content = self
.back_file
.as_ref()
.map(|path| {
read_to_string(path)
.context(format!("failed to read exercise back file {path}"))
})
.transpose()?;
ExerciseAsset::InlineFlashcardAsset {
front_content,
back_content,
}
} else {
ExerciseAsset::FlashcardAsset {
front_path: self.front_file.clone(),
back_path: self.back_file.clone(),
}
};
Ok(ExerciseManifest {
id: format!(
"{}::{}::{}",
self.course_id, self.short_lesson_id, self.short_id
)
.into(),
lesson_id: format!("{}::{}", self.course_id, self.short_lesson_id).into(),
course_id: self.course_id,
name: self
.name
.clone()
.unwrap_or(format!("Exercise {}", self.short_id)),
description: self.description.clone(),
exercise_type: self
.exercise_type
.clone()
.unwrap_or(default_exercise_type.unwrap_or(ExerciseType::Procedural)),
exercise_asset,
})
}
fn create_exercise(
lesson_root: &Path,
short_id: &str,
short_lesson_id: Ustr,
course_manifest: &CourseManifest,
files: &[KnowledgeBaseFile],
) -> Result<Self> {
let has_back_file = files.iter().any(|file| match file {
KnowledgeBaseFile::ExerciseBack(id) => id == short_id,
_ => false,
});
let back_file = if has_back_file {
Some(
lesson_root
.join(format!("{short_id}{EXERCISE_BACK_SUFFIX}"))
.to_str()
.unwrap_or_default()
.to_string(),
)
} else {
None
};
let mut exercise = KnowledgeBaseExercise {
short_id: short_id.to_string(),
short_lesson_id,
course_id: course_manifest.id,
front_file: lesson_root
.join(format!("{short_id}{EXERCISE_FRONT_SUFFIX}"))
.to_str()
.unwrap_or_default()
.to_string(),
back_file,
name: None,
description: None,
exercise_type: None,
};
for exercise_file in files {
match exercise_file {
KnowledgeBaseFile::ExerciseName(..) => {
let path = lesson_root.join(format!("{short_id}{EXERCISE_NAME_SUFFIX}"));
exercise.name = Some(KnowledgeBaseFile::open(&path)?);
}
KnowledgeBaseFile::ExerciseDescription(..) => {
let path = lesson_root.join(format!("{short_id}{EXERCISE_DESCRIPTION_SUFFIX}"));
exercise.description = Some(KnowledgeBaseFile::open(&path)?);
}
KnowledgeBaseFile::ExerciseType(..) => {
let path = lesson_root.join(format!("{short_id}{EXERCISE_TYPE_SUFFIX}"));
exercise.exercise_type = Some(KnowledgeBaseFile::open(&path)?);
}
_ => {}
}
}
Ok(exercise)
}
}
#[derive(Clone)]
pub struct KnowledgeBaseLesson {
pub short_id: Ustr,
pub course_id: Ustr,
pub dependencies: Vec<Ustr>,
pub encompassed: Vec<(Ustr, f32)>,
pub superseded: Vec<Ustr>,
pub name: Option<String>,
pub description: Option<String>,
pub metadata: Option<BTreeMap<String, Vec<String>>>,
pub has_instructions: bool,
pub has_material: bool,
pub default_exercise_type: Option<ExerciseType>,
}
impl KnowledgeBaseLesson {
fn filter_matching_exercises(exercise_files: &mut HashMap<String, Vec<KnowledgeBaseFile>>) {
let mut to_remove = Vec::new();
for (short_id, files) in &*exercise_files {
let has_front = files
.iter()
.any(|file| matches!(file, KnowledgeBaseFile::ExerciseFront(_)));
if !has_front {
to_remove.push(short_id.clone());
}
}
for short_id in to_remove {
exercise_files.remove(&short_id);
}
}
fn create_lesson(
lesson_root: &Path,
short_lesson_id: Ustr,
course_manifest: &CourseManifest,
files: &[KnowledgeBaseFile],
) -> Result<Self> {
let mut lesson = Self {
short_id: short_lesson_id,
course_id: course_manifest.id,
dependencies: vec![],
encompassed: vec![],
superseded: vec![],
name: None,
description: None,
metadata: None,
has_instructions: false,
has_material: false,
default_exercise_type: None,
};
for lesson_file in files {
match lesson_file {
KnowledgeBaseFile::LessonDependencies => {
let path = lesson_root.join(LESSON_DEPENDENCIES_FILE);
lesson.dependencies = KnowledgeBaseFile::open(&path)?;
}
KnowledgeBaseFile::LessonEncompassed => {
let path = lesson_root.join(LESSON_ENCOMPASSED_FILE);
lesson.encompassed = KnowledgeBaseFile::open(&path)?;
}
KnowledgeBaseFile::LessonSuperseded => {
let path = lesson_root.join(LESSON_SUPERSEDED_FILE);
lesson.superseded = KnowledgeBaseFile::open(&path)?;
}
KnowledgeBaseFile::LessonName => {
let path = lesson_root.join(LESSON_NAME_FILE);
lesson.name = Some(KnowledgeBaseFile::open(&path)?);
}
KnowledgeBaseFile::LessonDescription => {
let path = lesson_root.join(LESSON_DESCRIPTION_FILE);
lesson.description = Some(KnowledgeBaseFile::open(&path)?);
}
KnowledgeBaseFile::LessonMetadata => {
let path = lesson_root.join(LESSON_METADATA_FILE);
lesson.metadata = Some(KnowledgeBaseFile::open(&path)?);
}
KnowledgeBaseFile::LessonInstructions => lesson.has_instructions = true,
KnowledgeBaseFile::LessonMaterial => lesson.has_material = true,
KnowledgeBaseFile::LessonDefaultExerciseType => {
let path = lesson_root.join(LESSON_DEFAULT_EXERCISE_TYPE_FILE);
lesson.default_exercise_type = Some(KnowledgeBaseFile::open(&path)?);
}
_ => {} }
}
Ok(lesson)
}
fn open_lesson(
lesson_root: &Path,
course_manifest: &CourseManifest,
short_lesson_id: Ustr,
) -> Result<(KnowledgeBaseLesson, Vec<KnowledgeBaseExercise>)> {
let mut lesson_files = Vec::new();
let mut exercise_files = HashMap::new();
let kb_files = read_dir(lesson_root)?
.flatten()
.flat_map(|entry| {
KnowledgeBaseFile::try_from(entry.file_name().to_str().unwrap_or_default())
})
.collect::<Vec<_>>();
for kb_file in kb_files {
match kb_file {
KnowledgeBaseFile::ExerciseFront(ref short_id)
| KnowledgeBaseFile::ExerciseBack(ref short_id)
| KnowledgeBaseFile::ExerciseName(ref short_id)
| KnowledgeBaseFile::ExerciseDescription(ref short_id)
| KnowledgeBaseFile::ExerciseType(ref short_id) => {
exercise_files
.entry(short_id.clone())
.or_insert_with(Vec::new)
.push(kb_file);
}
_ => lesson_files.push(kb_file),
}
}
let lesson =
Self::create_lesson(lesson_root, short_lesson_id, course_manifest, &lesson_files)?;
exercise_files.remove("");
Self::filter_matching_exercises(&mut exercise_files);
let exercises = exercise_files
.into_iter()
.map(|(short_id, files)| {
KnowledgeBaseExercise::create_exercise(
lesson_root,
&short_id,
short_lesson_id,
course_manifest,
&files,
)
})
.collect::<Result<Vec<_>>>()?;
Ok((lesson, exercises))
}
}
impl From<KnowledgeBaseLesson> for LessonManifest {
fn from(lesson: KnowledgeBaseLesson) -> Self {
Self {
id: format!("{}::{}", lesson.course_id, lesson.short_id).into(),
course_id: lesson.course_id,
dependencies: lesson.dependencies,
encompassed: lesson.encompassed,
superseded: lesson.superseded,
name: lesson.name.unwrap_or(format!("Lesson {}", lesson.short_id)),
description: lesson.description,
metadata: lesson.metadata,
lesson_instructions: if lesson.has_instructions {
Some(BasicAsset::MarkdownAsset {
path: LESSON_INSTRUCTIONS_FILE.into(),
})
} else {
None
},
lesson_material: if lesson.has_material {
Some(BasicAsset::MarkdownAsset {
path: LESSON_MATERIAL_FILE.into(),
})
} else {
None
},
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct KnowledgeBaseConfig {
#[serde(default)]
pub inlined: bool,
}
impl KnowledgeBaseConfig {
fn convert_to_full_ids(
course_manifest: &CourseManifest,
short_ids: &HashSet<Ustr>,
lessons: &mut UstrMap<(KnowledgeBaseLesson, Vec<KnowledgeBaseExercise>)>,
) {
for lesson in lessons.values_mut() {
let updated_dependencies = lesson
.0
.dependencies
.iter()
.map(|unit_id| {
if short_ids.contains(unit_id) {
format!("{}::{}", course_manifest.id, unit_id).into()
} else {
*unit_id
}
})
.collect();
lesson.0.dependencies = updated_dependencies;
let updated_encompassed = lesson
.0
.encompassed
.iter()
.map(|(unit_id, weight)| {
if short_ids.contains(unit_id) {
(
format!("{}::{}", course_manifest.id, unit_id).into(),
*weight,
)
} else {
(*unit_id, *weight)
}
})
.collect();
lesson.0.encompassed = updated_encompassed;
let updated_superseded = lesson
.0
.superseded
.iter()
.map(|unit_id| {
if short_ids.contains(unit_id) {
format!("{}::{}", course_manifest.id, unit_id).into()
} else {
*unit_id
}
})
.collect();
lesson.0.superseded = updated_superseded;
}
}
}
impl GenerateManifests for KnowledgeBaseConfig {
fn generate_manifests(
&self,
course_root: &Path,
course_manifest: &CourseManifest,
_preferences: &UserPreferences,
) -> Result<GeneratedCourse> {
let mut lessons = UstrMap::default();
let valid_entries = read_dir(course_root)?
.flatten()
.filter(|entry| {
let path = entry.path();
path.is_dir()
})
.collect::<Vec<_>>();
for entry in valid_entries {
let path = entry.path();
let dir_name = path.file_name().unwrap_or_default().to_str().unwrap();
if let Some(short_id) = dir_name.strip_suffix(LESSON_SUFFIX) {
lessons.insert(
short_id.into(),
KnowledgeBaseLesson::open_lesson(&path, course_manifest, short_id.into())?,
);
}
}
let short_ids: HashSet<Ustr> = lessons.keys().copied().collect();
KnowledgeBaseConfig::convert_to_full_ids(course_manifest, &short_ids, &mut lessons);
let manifests: Vec<(LessonManifest, Vec<ExerciseManifest>)> = lessons
.into_iter()
.map(|(_, (lesson, exercises))| {
let lesson_manifest = LessonManifest::from(lesson.clone());
let exercise_manifests = exercises
.into_iter()
.map(|e| {
e.to_exercise_manifest(lesson.default_exercise_type.clone(), self.inlined)
})
.collect::<Result<Vec<_>>>()?;
Ok((lesson_manifest, exercise_manifests))
})
.collect::<Result<Vec<_>>>()?;
Ok(GeneratedCourse {
lessons: manifests,
updated_instructions: None,
updated_metadata: None,
})
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod test {
use anyhow::Result;
use std::{
fs::{self, Permissions},
io::{BufWriter, Write},
os::unix::prelude::PermissionsExt,
};
use super::*;
#[test]
fn open_knowledge_base_file() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let file_path = temp_dir.path().join("lesson.dependencies.properties");
let mut file = File::create(&file_path)?;
file.write_all(b"[\"lesson1\"]")?;
let dependencies: Vec<String> = KnowledgeBaseFile::open(&file_path)?;
assert_eq!(dependencies, vec!["lesson1".to_string()]);
Ok(())
}
#[test]
fn open_knowledge_base_file_bad_format() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let file_path = temp_dir.path().join("lesson.dependencies.properties");
let mut file = File::create(&file_path)?;
file.write_all(b"[\"lesson1\"")?;
let dependencies: Result<Vec<String>> = KnowledgeBaseFile::open(&file_path);
assert!(dependencies.is_err());
Ok(())
}
#[test]
fn open_knowledge_base_file_bad_permissions() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let file_path = temp_dir.path().join("lesson.dependencies.properties");
let mut file = File::create(&file_path)?;
file.write_all(b"[\"lesson1\"]")?;
std::fs::set_permissions(temp_dir.path(), Permissions::from_mode(0o000))?;
let dependencies: Result<Vec<String>> = KnowledgeBaseFile::open(&file_path);
assert!(dependencies.is_err());
Ok(())
}
#[test]
fn to_knowledge_base_file() {
assert_eq!(
KnowledgeBaseFile::LessonDependencies,
KnowledgeBaseFile::try_from(LESSON_DEPENDENCIES_FILE).unwrap(),
);
assert_eq!(
KnowledgeBaseFile::LessonSuperseded,
KnowledgeBaseFile::try_from(LESSON_SUPERSEDED_FILE).unwrap(),
);
assert_eq!(
KnowledgeBaseFile::LessonEncompassed,
KnowledgeBaseFile::try_from(LESSON_ENCOMPASSED_FILE).unwrap(),
);
assert_eq!(
KnowledgeBaseFile::LessonDescription,
KnowledgeBaseFile::try_from(LESSON_DESCRIPTION_FILE).unwrap(),
);
assert_eq!(
KnowledgeBaseFile::LessonMetadata,
KnowledgeBaseFile::try_from(LESSON_METADATA_FILE).unwrap(),
);
assert_eq!(
KnowledgeBaseFile::LessonInstructions,
KnowledgeBaseFile::try_from(LESSON_INSTRUCTIONS_FILE).unwrap(),
);
assert_eq!(
KnowledgeBaseFile::LessonMaterial,
KnowledgeBaseFile::try_from(LESSON_MATERIAL_FILE).unwrap(),
);
assert_eq!(
KnowledgeBaseFile::ExerciseName("ex1".to_string()),
KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_NAME_SUFFIX).as_str())
.unwrap(),
);
assert_eq!(
KnowledgeBaseFile::ExerciseFront("ex1".to_string()),
KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_FRONT_SUFFIX).as_str())
.unwrap(),
);
assert_eq!(
KnowledgeBaseFile::ExerciseBack("ex1".to_string()),
KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_BACK_SUFFIX).as_str())
.unwrap(),
);
assert_eq!(
KnowledgeBaseFile::ExerciseDescription("ex1".to_string()),
KnowledgeBaseFile::try_from(
format!("{}{}", "ex1", EXERCISE_DESCRIPTION_SUFFIX).as_str()
)
.unwrap(),
);
assert_eq!(
KnowledgeBaseFile::ExerciseType("ex1".to_string()),
KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_TYPE_SUFFIX).as_str())
.unwrap(),
);
assert!(KnowledgeBaseFile::try_from("ex1").is_err());
}
#[test]
fn lesson_to_manifest() {
let lesson = KnowledgeBaseLesson {
short_id: "lesson1".into(),
course_id: "course1".into(),
name: Some("Name".into()),
description: Some("Description".into()),
dependencies: vec!["lesson2".into()],
encompassed: vec![("lesson4".into(), 0.5)],
superseded: vec!["lesson0".into()],
metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
has_instructions: true,
has_material: true,
default_exercise_type: Some(ExerciseType::Declarative),
};
let expected_manifest = LessonManifest {
id: "course1::lesson1".into(),
course_id: "course1".into(),
name: "Name".into(),
description: Some("Description".into()),
dependencies: vec!["lesson2".into()],
encompassed: vec![("lesson4".into(), 0.5)],
superseded: vec!["lesson0".into()],
lesson_instructions: Some(BasicAsset::MarkdownAsset {
path: LESSON_INSTRUCTIONS_FILE.into(),
}),
lesson_material: Some(BasicAsset::MarkdownAsset {
path: LESSON_MATERIAL_FILE.into(),
}),
metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
};
let actual_manifest: LessonManifest = lesson.into();
assert_eq!(actual_manifest, expected_manifest);
}
#[test]
fn exercise_to_manifest() {
let exercise = KnowledgeBaseExercise {
short_id: "ex1".into(),
short_lesson_id: "lesson1".into(),
course_id: "course1".into(),
front_file: "ex1.front.md".into(),
back_file: Some("ex1.back.md".into()),
name: Some("Name".into()),
description: Some("Description".into()),
exercise_type: Some(ExerciseType::Procedural),
};
let expected_manifest = ExerciseManifest {
id: "course1::lesson1::ex1".into(),
lesson_id: "course1::lesson1".into(),
course_id: "course1".into(),
name: "Name".into(),
description: Some("Description".into()),
exercise_type: ExerciseType::Procedural,
exercise_asset: ExerciseAsset::FlashcardAsset {
front_path: "ex1.front.md".into(),
back_path: Some("ex1.back.md".into()),
},
};
let actual_manifest = exercise.to_exercise_manifest(None, false).unwrap();
assert_eq!(actual_manifest, expected_manifest);
}
#[test]
fn exercise_to_manifest_inlined() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let front_path = temp_dir.path().join("ex1.front.md");
let back_path = temp_dir.path().join("ex1.back.md");
fs::write(&front_path, "Front content")?;
fs::write(&back_path, "Back content")?;
let exercise = KnowledgeBaseExercise {
short_id: "ex1".into(),
short_lesson_id: "lesson1".into(),
course_id: "course1".into(),
front_file: front_path.to_str().unwrap().to_string(),
back_file: Some(back_path.to_str().unwrap().to_string()),
name: Some("Name".into()),
description: Some("Description".into()),
exercise_type: Some(ExerciseType::Procedural),
};
let manifest = exercise.to_exercise_manifest(None, true)?;
assert_eq!(
manifest.exercise_asset,
ExerciseAsset::InlineFlashcardAsset {
front_content: "Front content".into(),
back_content: Some("Back content".into()),
}
);
Ok(())
}
#[test]
fn exercise_to_manifest_inlined_missing_front() {
let exercise = KnowledgeBaseExercise {
short_id: "ex1".into(),
short_lesson_id: "lesson1".into(),
course_id: "course1".into(),
front_file: "/path/does/not/exist/front.md".into(),
back_file: None,
name: Some("Name".into()),
description: Some("Description".into()),
exercise_type: Some(ExerciseType::Procedural),
};
let manifest = exercise.to_exercise_manifest(None, true);
assert!(manifest.is_err());
}
#[test]
fn exercise_type_resolution() {
let base_exercise = KnowledgeBaseExercise {
short_id: "ex1".into(),
short_lesson_id: "lesson1".into(),
course_id: "course1".into(),
front_file: "ex1.front.md".into(),
back_file: Some("ex1.back.md".into()),
name: Some("Name".into()),
description: Some("Description".into()),
exercise_type: None,
};
let exercise_with_type = KnowledgeBaseExercise {
exercise_type: Some(ExerciseType::Declarative),
..base_exercise.clone()
};
let manifest = exercise_with_type
.to_exercise_manifest(Some(ExerciseType::Procedural), false)
.unwrap();
assert_eq!(manifest.exercise_type, ExerciseType::Declarative);
let exercise_no_type = KnowledgeBaseExercise {
exercise_type: None,
..base_exercise.clone()
};
let manifest = exercise_no_type
.to_exercise_manifest(Some(ExerciseType::Declarative), false)
.unwrap();
assert_eq!(manifest.exercise_type, ExerciseType::Declarative);
let manifest = exercise_no_type.to_exercise_manifest(None, false).unwrap();
assert_eq!(manifest.exercise_type, ExerciseType::Procedural);
}
#[test]
fn convert_to_full_ids() {
let course_manifest = CourseManifest {
id: "course1".into(),
name: "Course 1".into(),
dependencies: vec![],
encompassed: vec![],
superseded: vec![],
description: Some("Description".into()),
authors: None,
metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
course_instructions: None,
course_material: None,
generator_config: None,
};
let short_lesson_id = Ustr::from("lesson1");
let lesson = KnowledgeBaseLesson {
short_id: short_lesson_id,
course_id: "course1".into(),
name: Some("Name".into()),
description: Some("Description".into()),
dependencies: vec!["lesson2".into(), "other::lesson1".into()],
encompassed: vec![("lesson3".into(), 1.0), ("other::lesson3".into(), 0.5)],
superseded: vec!["lesson0".into(), "other::lesson0".into()],
metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
has_instructions: false,
has_material: false,
default_exercise_type: None,
};
let exercise = KnowledgeBaseExercise {
short_id: "ex1".into(),
short_lesson_id,
course_id: "course1".into(),
front_file: "ex1.front.md".into(),
back_file: Some("ex1.back.md".into()),
name: Some("Name".into()),
description: Some("Description".into()),
exercise_type: Some(ExerciseType::Procedural),
};
let mut lesson_map = UstrMap::default();
lesson_map.insert("lesson1".into(), (lesson, vec![exercise]));
let short_ids = HashSet::from_iter(vec![
"lesson0".into(),
"lesson1".into(),
"lesson2".into(),
"lesson3".into(),
]);
KnowledgeBaseConfig::convert_to_full_ids(&course_manifest, &short_ids, &mut lesson_map);
assert_eq!(
lesson_map.get(&short_lesson_id).unwrap().0.dependencies,
vec![Ustr::from("course1::lesson2"), "other::lesson1".into()]
);
assert_eq!(
lesson_map.get(&short_lesson_id).unwrap().0.encompassed,
vec![
(Ustr::from("course1::lesson3"), 1.0),
("other::lesson3".into(), 0.5)
]
);
assert_eq!(
lesson_map.get(&short_lesson_id).unwrap().0.superseded,
vec![Ustr::from("course1::lesson0"), "other::lesson0".into()]
);
}
#[test]
fn filter_matching_exercises() {
let mut exercise_map = HashMap::default();
let ex1_id: String = "ex1".into();
let ex1_files = vec![
KnowledgeBaseFile::ExerciseFront("ex1".into()),
KnowledgeBaseFile::ExerciseBack("ex1".into()),
];
let ex2_id: String = "ex2".into();
let ex2_files = vec![KnowledgeBaseFile::ExerciseFront("ex2".into())];
let ex3_id: String = "ex3".into();
let ex3_files = vec![KnowledgeBaseFile::ExerciseBack("ex3".into())];
exercise_map.insert(ex1_id.clone(), ex1_files);
exercise_map.insert(ex2_id.clone(), ex2_files);
exercise_map.insert(ex3_id.clone(), ex3_files);
KnowledgeBaseLesson::filter_matching_exercises(&mut exercise_map);
let ex1_expected = vec![
KnowledgeBaseFile::ExerciseFront("ex1".into()),
KnowledgeBaseFile::ExerciseBack("ex1".into()),
];
assert_eq!(exercise_map.get(&ex1_id).unwrap(), &ex1_expected);
let ex2_expected = vec![KnowledgeBaseFile::ExerciseFront("ex2".into())];
assert_eq!(exercise_map.get(&ex2_id).unwrap(), &ex2_expected);
assert!(!exercise_map.contains_key(&ex3_id));
}
fn write_json<T: Serialize>(obj: &T, file: &Path) -> Result<()> {
let file = File::create(file)?;
let writer = BufWriter::new(file);
serde_json::to_writer_pretty(writer, obj)?;
Ok(())
}
#[test]
fn open_lesson_dir() -> Result<()> {
let course_dir = tempfile::tempdir()?;
let lesson_dir = course_dir.path().join("lesson1.lesson");
fs::create_dir(&lesson_dir)?;
let name = "Name";
let name_path = lesson_dir.join(LESSON_NAME_FILE);
write_json(&name, &name_path)?;
let description = "Description";
let description_path = lesson_dir.join(LESSON_DESCRIPTION_FILE);
write_json(&description, &description_path)?;
let dependencies: Vec<Ustr> = vec!["lesson2".into(), "lesson3".into()];
let dependencies_path = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
write_json(&dependencies, &dependencies_path)?;
let encompassed: Vec<(Ustr, f32)> =
vec![("lesson4".into(), 0.75), ("other::lesson4".into(), 0.25)];
let encompassed_path = lesson_dir.join(LESSON_ENCOMPASSED_FILE);
write_json(&encompassed, &encompassed_path)?;
let superseded: Vec<Ustr> = vec!["lesson0".into()];
let superseded_path = lesson_dir.join(LESSON_SUPERSEDED_FILE);
write_json(&superseded, &superseded_path)?;
let metadata: BTreeMap<String, Vec<String>> =
BTreeMap::from([("key".into(), vec!["value".into()])]);
let metadata_path = lesson_dir.join(LESSON_METADATA_FILE);
write_json(&metadata, &metadata_path)?;
let instructions = "instructions";
let instructions_path = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
write_json(&instructions, &instructions_path)?;
let material = "material";
let material_path = lesson_dir.join(LESSON_MATERIAL_FILE);
write_json(&material, &material_path)?;
let default_ex_type = ExerciseType::Declarative;
let default_ex_type_path = lesson_dir.join(LESSON_DEFAULT_EXERCISE_TYPE_FILE);
write_json(&default_ex_type, &default_ex_type_path)?;
let front_content = "Front content";
let front_path = lesson_dir.join("ex1.front.md");
fs::write(front_path, front_content)?;
let back_content = "Back content";
let back_path = lesson_dir.join("ex1.back.md");
fs::write(back_path, back_content)?;
let exercise_name = "Exercise name";
let exercise_name_path = lesson_dir.join("ex1.name.json");
write_json(&exercise_name, &exercise_name_path)?;
let exercise_description = "Exercise description";
let exercise_description_path = lesson_dir.join("ex1.description.json");
write_json(&exercise_description, &exercise_description_path)?;
let exercise_type = ExerciseType::Procedural;
let exercise_type_path = lesson_dir.join("ex1.type.json");
write_json(&exercise_type, &exercise_type_path)?;
let course_manifest = CourseManifest {
id: "course1".into(),
name: "Course 1".into(),
dependencies: vec![],
encompassed: vec![],
superseded: vec![],
description: Some("Description".into()),
authors: None,
metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
course_instructions: None,
course_material: None,
generator_config: None,
};
let (lesson, exercises) =
KnowledgeBaseLesson::open_lesson(&lesson_dir, &course_manifest, "lesson1".into())?;
assert_eq!(lesson.name, Some(name.into()));
assert_eq!(lesson.description, Some(description.into()));
assert_eq!(lesson.dependencies, dependencies);
assert_eq!(lesson.encompassed, encompassed);
assert_eq!(lesson.superseded, superseded);
assert_eq!(lesson.metadata, Some(metadata));
assert!(lesson.has_instructions);
assert!(lesson.has_material);
assert_eq!(lesson.default_exercise_type, Some(default_ex_type));
assert_eq!(exercises.len(), 1);
let exercise = &exercises[0];
assert_eq!(exercise.name, Some(exercise_name.into()));
assert_eq!(exercise.description, Some(exercise_description.into()));
assert_eq!(exercise.exercise_type, Some(exercise_type));
assert_eq!(
exercise.front_file,
lesson_dir
.join("ex1.front.md")
.to_str()
.unwrap()
.to_string()
);
assert_eq!(
exercise.back_file.clone().unwrap_or_default(),
lesson_dir.join("ex1.back.md").to_str().unwrap().to_string()
);
Ok(())
}
}