use anyhow::{Context, Result, anyhow, ensure};
use parking_lot::RwLock;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use ustr::{Ustr, UstrMap, UstrSet};
use vfs::VfsPath;
use crate::{
data::{
CourseManifest, ExerciseManifest, GenerateManifests, LessonManifest, NormalizePaths,
UnitType, UserPreferences, VerifyPaths,
},
graph::{InMemoryUnitGraph, UnitGraph},
};
pub const COURSE_MANIFEST_FILENAME: &str = "course_manifest.json";
pub const LESSON_MANIFEST_FILENAME: &str = "lesson_manifest.json";
pub const EXERCISE_MANIFEST_FILENAME: &str = "exercise_manifest.json";
pub trait CourseLibrary {
fn get_course_manifest(&self, course_id: Ustr) -> Option<Arc<CourseManifest>>;
fn get_lesson_manifest(&self, lesson_id: Ustr) -> Option<Arc<LessonManifest>>;
fn get_exercise_manifest(&self, exercise_id: Ustr) -> Option<Arc<ExerciseManifest>>;
fn get_course_ids(&self) -> Vec<Ustr>;
fn get_lesson_ids(&self, course_id: Ustr) -> Option<Vec<Ustr>>;
fn get_exercise_ids(&self, lesson_id: Ustr) -> Option<Vec<Ustr>>;
fn get_all_exercise_ids(&self, unit_id: Option<Ustr>) -> Vec<Ustr>;
fn get_matching_prefix(&self, prefix: &str, unit_type: Option<UnitType>) -> UstrSet;
}
pub(crate) trait GetUnitGraph {
fn get_unit_graph(&self) -> Arc<RwLock<InMemoryUnitGraph>>;
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct SerializedCourseLibrary {
unit_graph: InMemoryUnitGraph,
course_map: UstrMap<CourseManifest>,
lesson_map: UstrMap<LessonManifest>,
exercise_map: UstrMap<ExerciseManifest>,
}
impl From<&LocalCourseLibrary> for SerializedCourseLibrary {
fn from(library: &LocalCourseLibrary) -> Self {
SerializedCourseLibrary {
unit_graph: (*library.unit_graph.read()).clone(),
course_map: library
.course_map
.iter()
.map(|(k, v)| (*k, (**v).clone()))
.collect(),
lesson_map: library
.lesson_map
.iter()
.map(|(k, v)| (*k, (**v).clone()))
.collect(),
exercise_map: library
.exercise_map
.iter()
.map(|(k, v)| (*k, (**v).clone()))
.collect(),
}
}
}
struct OpenCourseRequest {
course_root: VfsPath,
course_manifest: CourseManifest,
}
struct OpenCourseResult {
manifest: CourseManifest,
lessons: Vec<(LessonManifest, Vec<ExerciseManifest>)>,
}
pub struct LocalCourseLibrary {
pub unit_graph: Arc<RwLock<InMemoryUnitGraph>>,
pub course_map: UstrMap<Arc<CourseManifest>>,
pub lesson_map: UstrMap<Arc<LessonManifest>>,
pub exercise_map: UstrMap<Arc<ExerciseManifest>>,
pub user_preferences: UserPreferences,
}
impl LocalCourseLibrary {
fn open_manifest<T: DeserializeOwned>(path: &VfsPath) -> Result<T> {
let display = path.as_str();
let file = path
.open_file()
.context(format!("cannot open manifest file {display}"))?;
serde_json::from_reader(file).context(format!("cannot parse manifest file {display}"))
}
fn get_file_name(path: &VfsPath) -> Result<String> {
let file_name = path.filename();
if file_name.is_empty() {
return Err(anyhow!("cannot get file name from VfsPath"));
}
Ok(file_name)
}
#[cfg_attr(coverage, coverage(off))]
fn verify_exercise_manifest(
lesson_manifest: &LessonManifest,
exercise_manifest: &ExerciseManifest,
) -> Result<()> {
ensure!(!exercise_manifest.id.is_empty(), "ID in manifest is empty");
ensure!(
exercise_manifest.lesson_id == lesson_manifest.id,
"lesson_id in manifest for exercise {} does not match the manifest for lesson {}",
exercise_manifest.id,
lesson_manifest.id,
);
ensure!(
exercise_manifest.course_id == lesson_manifest.course_id,
"course_id in manifest for exercise {} does not match the manifest for course {}",
exercise_manifest.id,
lesson_manifest.course_id,
);
Ok(())
}
#[cfg_attr(coverage, coverage(off))]
fn verify_lesson_manifest(
course_manifest: &CourseManifest,
lesson_manifest: &LessonManifest,
) -> Result<()> {
ensure!(!lesson_manifest.id.is_empty(), "ID in manifest is empty");
ensure!(
lesson_manifest.course_id == course_manifest.id,
"course_id in manifest for lesson {} does not match the manifest for course {}",
lesson_manifest.id,
course_manifest.id,
);
Ok(())
}
fn process_lesson_manifest(
lesson_root: &VfsPath,
course_manifest: &CourseManifest,
lesson_manifest: &LessonManifest,
) -> Result<(LessonManifest, Vec<ExerciseManifest>)> {
let library_root = lesson_root.root();
let lesson_manifest = lesson_manifest.normalize_paths(&library_root, lesson_root)?;
ensure!(
lesson_manifest.verify_paths(&library_root)?,
"asset path in lesson {} does not exist",
lesson_manifest.id
);
LocalCourseLibrary::verify_lesson_manifest(course_manifest, &lesson_manifest)?;
let mut exercises = Vec::new();
for exercise_root in lesson_root.read_dir()? {
if !exercise_root.is_dir()? {
continue;
}
let manifest_path = exercise_root.join(EXERCISE_MANIFEST_FILENAME)?;
if !manifest_path.is_file()? {
continue;
}
let exercise_manifest: ExerciseManifest = Self::open_manifest(&manifest_path)?;
let exercise_manifest =
exercise_manifest.normalize_paths(&library_root, &exercise_root)?;
ensure!(
exercise_manifest.verify_paths(&library_root)?,
"asset path in exercise {} does not exist",
exercise_manifest.id
);
LocalCourseLibrary::verify_exercise_manifest(&lesson_manifest, &exercise_manifest)?;
exercises.push(exercise_manifest);
}
Ok((lesson_manifest, exercises))
}
#[cfg_attr(coverage, coverage(off))]
fn verify_course_manifest(course_manifest: &CourseManifest) -> Result<()> {
ensure!(!course_manifest.id.is_empty(), "ID in manifest is empty");
Ok(())
}
fn process_course_manifest(
&self,
course_root: &VfsPath,
course_manifest: &CourseManifest,
) -> Result<OpenCourseResult> {
let library_root = course_root.root();
let mut course_manifest = course_manifest.normalize_paths(&library_root, course_root)?;
ensure!(
course_manifest.verify_paths(&library_root)?,
"asset path in course {} does not exist",
course_manifest.id
);
LocalCourseLibrary::verify_course_manifest(&course_manifest)?;
let mut lessons = Vec::new();
if let Some(generator_config) = &course_manifest.generator_config {
let generated_course = generator_config.generate_manifests(
course_root,
&course_manifest,
&self.user_preferences,
)?;
lessons.extend(generated_course.lessons);
if generated_course.updated_metadata.is_some() {
course_manifest.metadata = generated_course.updated_metadata;
}
if generated_course.updated_instructions.is_some() {
course_manifest.course_instructions = generated_course.updated_instructions;
}
}
for lesson_root in course_root.read_dir()? {
if !lesson_root.is_dir()? {
continue;
}
let manifest_path = lesson_root.join(LESSON_MANIFEST_FILENAME)?;
if !manifest_path.is_file()? {
continue;
}
let lesson_manifest: LessonManifest = Self::open_manifest(&manifest_path)?;
lessons.push(Self::process_lesson_manifest(
&lesson_root,
&course_manifest,
&lesson_manifest,
)?);
}
Ok(OpenCourseResult {
manifest: course_manifest,
lessons,
})
}
fn process_results(&mut self, courses: Vec<OpenCourseResult>) -> Result<()> {
let mut encompassing_equals_dependency = true;
let mut graph = self.unit_graph.write();
for course in courses {
graph.add_course(course.manifest.id)?;
graph.add_dependencies(
course.manifest.id,
UnitType::Course,
&course.manifest.dependencies,
)?;
graph.add_encompassed(
course.manifest.id,
&course.manifest.dependencies,
&course.manifest.encompassed,
)?;
graph.add_superseded(course.manifest.id, &course.manifest.superseded);
if !course.manifest.encompassed.is_empty() {
encompassing_equals_dependency = false;
}
self.course_map
.insert(course.manifest.id, Arc::new(course.manifest));
for (lesson_manifest, exercises) in course.lessons {
graph.add_lesson(lesson_manifest.id, lesson_manifest.course_id)?;
graph.add_dependencies(
lesson_manifest.id,
UnitType::Lesson,
&lesson_manifest.dependencies,
)?;
graph.add_encompassed(
lesson_manifest.id,
&lesson_manifest.dependencies,
&lesson_manifest.encompassed,
)?;
graph.add_superseded(lesson_manifest.id, &lesson_manifest.superseded);
if !lesson_manifest.encompassed.is_empty() {
encompassing_equals_dependency = false;
}
self.lesson_map
.insert(lesson_manifest.id, Arc::new(lesson_manifest));
for exercise_manifest in exercises {
graph.add_exercise(exercise_manifest.id, exercise_manifest.lesson_id)?;
self.exercise_map
.insert(exercise_manifest.id, Arc::new(exercise_manifest));
}
}
}
graph.update_starting_lessons();
if encompassing_equals_dependency {
graph.set_encompasing_equals_dependency();
}
graph.check_cycles()?;
Ok(())
}
pub fn new(library_root: &VfsPath, user_preferences: UserPreferences) -> Result<Self> {
let mut library = LocalCourseLibrary {
course_map: UstrMap::default(),
lesson_map: UstrMap::default(),
exercise_map: UstrMap::default(),
user_preferences,
unit_graph: Arc::new(RwLock::new(InMemoryUnitGraph::default())),
};
let ignored_paths = library
.user_preferences
.ignored_paths
.iter()
.map(|path| library_root.join(path.trim_matches('/')))
.collect::<Result<Vec<_>, _>>()?;
let mut courses = Vec::new();
for entry in library_root.walk_dir()? {
let entry = entry?;
if entry.is_dir()? {
continue;
}
let file_name = Self::get_file_name(&entry)?;
if file_name != COURSE_MANIFEST_FILENAME {
continue;
}
if entry.parent() == *library_root {
continue;
}
if ignored_paths.iter().any(|ignored_path| {
let prefix = format!("{}/", ignored_path.as_str().trim_end_matches('/'));
entry.as_str().starts_with(&prefix)
}) {
continue;
}
let course_manifest: CourseManifest = Self::open_manifest(&entry)?;
let parent = entry.parent();
courses.push(OpenCourseRequest {
course_root: parent,
course_manifest,
});
}
let course_results = courses
.into_par_iter()
.map(|course| {
library.process_course_manifest(&course.course_root, &course.course_manifest)
})
.collect::<Result<Vec<_>>>()?;
library.process_results(course_results)?;
Ok(library)
}
pub fn new_from_serialized(
serialized_library: SerializedCourseLibrary,
user_preferences: UserPreferences,
) -> Result<Self> {
Ok(LocalCourseLibrary {
course_map: serialized_library
.course_map
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.collect(),
lesson_map: serialized_library
.lesson_map
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.collect(),
exercise_map: serialized_library
.exercise_map
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.collect(),
user_preferences,
unit_graph: Arc::new(RwLock::new(serialized_library.unit_graph)),
})
}
}
impl CourseLibrary for LocalCourseLibrary {
fn get_course_manifest(&self, course_id: Ustr) -> Option<Arc<CourseManifest>> {
self.course_map.get(&course_id).cloned()
}
fn get_lesson_manifest(&self, lesson_id: Ustr) -> Option<Arc<LessonManifest>> {
self.lesson_map.get(&lesson_id).cloned()
}
fn get_exercise_manifest(&self, exercise_id: Ustr) -> Option<Arc<ExerciseManifest>> {
self.exercise_map.get(&exercise_id).cloned()
}
fn get_course_ids(&self) -> Vec<Ustr> {
let mut courses = self.course_map.keys().copied().collect::<Vec<Ustr>>();
courses.sort();
courses
}
fn get_lesson_ids(&self, course_id: Ustr) -> Option<Vec<Ustr>> {
let mut lessons = self
.unit_graph
.read()
.get_course_lessons(course_id)?
.iter()
.copied()
.collect::<Vec<Ustr>>();
lessons.sort();
Some(lessons)
}
fn get_exercise_ids(&self, lesson_id: Ustr) -> Option<Vec<Ustr>> {
let mut exercises = self
.unit_graph
.read()
.get_lesson_exercises(lesson_id)?
.iter()
.copied()
.collect::<Vec<Ustr>>();
exercises.sort();
Some(exercises)
}
fn get_all_exercise_ids(&self, unit_id: Option<Ustr>) -> Vec<Ustr> {
let unit_graph = self.unit_graph.read();
let mut exercises = match unit_id {
Some(unit_id) => {
let unit_type = unit_graph.get_unit_type(unit_id);
match unit_type {
Some(UnitType::Course) => unit_graph
.get_course_lessons(unit_id)
.unwrap_or_default()
.iter()
.copied()
.flat_map(|lesson_id| {
unit_graph
.get_lesson_exercises(lesson_id)
.unwrap_or_default()
.iter()
.copied()
.collect::<Vec<Ustr>>()
})
.collect::<Vec<Ustr>>(),
Some(UnitType::Lesson) => unit_graph
.get_lesson_exercises(unit_id)
.unwrap_or_default()
.iter()
.copied()
.collect::<Vec<Ustr>>(),
Some(UnitType::Exercise) => vec![unit_id],
None => vec![],
}
}
None => self.exercise_map.keys().copied().collect::<Vec<Ustr>>(),
};
exercises.sort();
exercises
}
fn get_matching_prefix(&self, prefix: &str, unit_type: Option<UnitType>) -> UstrSet {
match unit_type {
Some(UnitType::Course) => self
.course_map
.keys()
.filter_map(|id| {
if id.starts_with(prefix) {
Some(*id)
} else {
None
}
})
.collect(),
Some(UnitType::Lesson) => self
.lesson_map
.keys()
.filter_map(|id| {
if id.starts_with(prefix) {
Some(*id)
} else {
None
}
})
.collect(),
Some(UnitType::Exercise) => self
.exercise_map
.keys()
.filter_map(|id| {
if id.starts_with(prefix) {
Some(*id)
} else {
None
}
})
.collect(),
None => self
.course_map
.keys()
.chain(self.lesson_map.keys())
.chain(self.exercise_map.keys())
.filter(|id| id.starts_with(prefix))
.copied()
.collect(),
}
}
}
impl GetUnitGraph for LocalCourseLibrary {
fn get_unit_graph(&self) -> Arc<RwLock<InMemoryUnitGraph>> {
self.unit_graph.clone()
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
use crate::data::{ExerciseAsset, ExerciseType};
use serde::Serialize;
use vfs::MemoryFS;
#[test]
fn rejects_root_path_without_filename() {
let root = VfsPath::new(MemoryFS::new());
assert!(LocalCourseLibrary::get_file_name(&root).is_err());
}
fn write_manifest<T: Serialize>(path: &VfsPath, manifest: &T) -> Result<()> {
path.create_file()?
.write_all(&serde_json::to_vec(manifest)?)?;
Ok(())
}
#[test]
fn skips_directories_without_exercise_manifests() -> Result<()> {
let root = VfsPath::new(MemoryFS::new());
let library_root = root.join("library")?;
library_root.create_dir()?;
let category = library_root.join("category")?;
category.create_dir()?;
let course = category.join("course")?;
let lesson = course.join("lesson")?;
let valid_exercise = lesson.join("valid")?;
course.create_dir()?;
lesson.create_dir()?;
lesson.join("empty")?.create_dir()?;
valid_exercise.create_dir()?;
write_manifest(
&course.join(COURSE_MANIFEST_FILENAME)?,
&CourseManifest {
id: "course".into(),
name: "Course".into(),
dependencies: vec![],
encompassed: vec![],
superseded: vec![],
description: None,
authors: None,
metadata: None,
course_material: None,
course_instructions: None,
generator_config: None,
},
)?;
write_manifest(
&library_root.join(COURSE_MANIFEST_FILENAME)?,
&CourseManifest {
id: "ignored_root_course".into(),
name: "Ignored Root Course".into(),
dependencies: vec![],
encompassed: vec![],
superseded: vec![],
description: None,
authors: None,
metadata: None,
course_material: None,
course_instructions: None,
generator_config: None,
},
)?;
write_manifest(
&lesson.join(LESSON_MANIFEST_FILENAME)?,
&LessonManifest {
id: "course::lesson".into(),
dependencies: vec![],
encompassed: vec![],
superseded: vec![],
course_id: "course".into(),
name: "Lesson".into(),
description: None,
metadata: None,
lesson_material: None,
lesson_instructions: None,
},
)?;
write_manifest(
&valid_exercise.join(EXERCISE_MANIFEST_FILENAME)?,
&ExerciseManifest {
id: "course::lesson::valid".into(),
lesson_id: "course::lesson".into(),
course_id: "course".into(),
name: "Exercise".into(),
description: None,
exercise_type: ExerciseType::Procedural,
exercise_asset: ExerciseAsset::InlineFlashcardAsset {
front_content: "Front".into(),
back_content: None,
},
},
)?;
let library = LocalCourseLibrary::new(&library_root, UserPreferences::default())?;
assert_eq!(library.get_course_ids(), vec![Ustr::from("course")]);
assert_eq!(
library.get_exercise_ids("course::lesson".into()).unwrap(),
vec![Ustr::from("course::lesson::valid")]
);
Ok(())
}
}