Skip to main content

trane/
course_builder.rs

1//! Defines utilities to make it easier to generate courses and lessons.
2//!
3//! Courses, lessons, and exercises are stored in JSON files that are the serialized versions of the
4//! manifests in the `data` module. This means that writers of Trane courses can simply generate the
5//! files by hand. However, this process is tedious and error-prone, so this module provides
6//! utilities to make it easier to generate these files. In addition, Trane is in early stages of
7//! development, so the format of the manifests is not stable yet. Generating the files by code
8//! makes it easier to make updates to the files as the format changes.
9
10pub mod knowledge_base_builder;
11
12use anyhow::{Context, Result, ensure};
13use serde::{Deserialize, Serialize};
14use std::{
15    fs::{File, create_dir_all},
16    io::Write,
17    path::{Path, PathBuf},
18};
19use strum::Display;
20
21use crate::data::{CourseManifest, ExerciseManifestBuilder, LessonManifestBuilder, VerifyPaths};
22
23/// Common metadata keys for all courses and lessons.
24#[derive(Display)]
25#[strum(serialize_all = "snake_case")]
26#[allow(missing_docs)]
27pub enum TraneMetadata {
28    Skill,
29}
30
31/// A builder to generate plain-text asset files.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct AssetBuilder {
34    /// The name of the file, which will be joined with the directory passed in the build function.
35    pub file_name: String,
36
37    /// The contents of the file as a string.
38    pub contents: String,
39}
40
41impl AssetBuilder {
42    /// Writes the asset to the given directory.
43    pub fn build(&self, asset_directory: &Path) -> Result<()> {
44        // Create the asset directory and verify there's not an existing file with the same name.
45        create_dir_all(asset_directory)?;
46        let asset_path = asset_directory.join(&self.file_name);
47        ensure!(
48            !asset_path.exists(),
49            "asset path {} already exists",
50            asset_path.display()
51        );
52
53        // Create any parent directories to the asset path to support specifying a directory in the
54        // asset path.
55        create_dir_all(asset_path.parent().unwrap())?;
56
57        // Write the asset file.
58        let mut asset_file = File::create(asset_path)?;
59        asset_file.write_all(self.contents.as_bytes())?;
60        Ok(())
61    }
62}
63
64/// A builder that generates all the files needed to add an exercise to a lesson.
65pub struct ExerciseBuilder {
66    /// The base name of the directory on which to store this lesson.
67    pub directory_name: String,
68
69    /// A closure taking a builder common to all exercises which returns the builder for a specific
70    /// exercise manifest.
71    pub manifest_closure: Box<dyn Fn(ExerciseManifestBuilder) -> ExerciseManifestBuilder>,
72
73    /// A list of asset builders to create assets specific to this exercise.
74    pub asset_builders: Vec<AssetBuilder>,
75}
76
77impl ExerciseBuilder {
78    /// Writes the files needed for this exercise to the given directory.
79    pub fn build(
80        &self,
81        exercise_directory: &PathBuf,
82        manifest_template: ExerciseManifestBuilder,
83    ) -> Result<()> {
84        // Create the directory and write the exercise manifest.
85        create_dir_all(exercise_directory)?;
86        let manifest = (self.manifest_closure)(manifest_template).build()?;
87        let manifest_json = serde_json::to_string_pretty(&manifest)? + "\n";
88        let manifest_path = exercise_directory.join("exercise_manifest.json");
89        let mut manifest_file = File::create(manifest_path)?;
90        manifest_file.write_all(manifest_json.as_bytes())?;
91
92        // Write all the assets.
93        for asset_builder in &self.asset_builders {
94            asset_builder.build(exercise_directory)?;
95        }
96
97        // Verify that all paths mentioned in the manifest are valid.
98        manifest.verify_paths(exercise_directory).context(format!(
99            "failed to verify files for exercise {}",
100            manifest.id
101        ))?;
102        Ok(())
103    }
104}
105
106/// A builder that generates the files needed to add a lesson to a course.
107pub struct LessonBuilder {
108    /// Base name of the directory on which to store this lesson.
109    pub directory_name: String,
110
111    /// A closure taking a builder common to all lessons which returns the builder for a specific
112    /// lesson manifest.
113    pub manifest_closure: Box<dyn Fn(LessonManifestBuilder) -> LessonManifestBuilder>,
114
115    /// A template builder used to build the manifests for each exercise in the lesson. Common
116    /// attributes to all exercises should be set here.
117    pub exercise_manifest_template: ExerciseManifestBuilder,
118
119    /// A list of tuples of exercise directory name and exercise builder to create the exercises in
120    /// the lesson.
121    pub exercise_builders: Vec<ExerciseBuilder>,
122
123    /// A list of asset builders to create assets specific to this lesson.
124    pub asset_builders: Vec<AssetBuilder>,
125}
126
127impl LessonBuilder {
128    /// Writes the files needed for this lesson to the given directory.
129    pub fn build(
130        &self,
131        lesson_directory: &PathBuf,
132        manifest_template: LessonManifestBuilder,
133    ) -> Result<()> {
134        // Create the directory and write the lesson manifest.
135        create_dir_all(lesson_directory)?;
136        let manifest = (self.manifest_closure)(manifest_template).build()?;
137        let manifest_json = serde_json::to_string_pretty(&manifest)? + "\n";
138        let manifest_path = lesson_directory.join("lesson_manifest.json");
139        let mut manifest_file = File::create(manifest_path)?;
140        manifest_file.write_all(manifest_json.as_bytes())?;
141
142        // Write all the assets.
143        for asset_builder in &self.asset_builders {
144            asset_builder.build(lesson_directory)?;
145        }
146
147        // Build all the exercises in the lesson.
148        for exercise_builder in &self.exercise_builders {
149            let exercise_directory = lesson_directory.join(&exercise_builder.directory_name);
150            exercise_builder.build(&exercise_directory, self.exercise_manifest_template.clone())?;
151        }
152
153        // Verify that all paths mentioned in the manifest are valid.
154        manifest
155            .verify_paths(lesson_directory)
156            .context(format!("failed to verify files for lesson {}", manifest.id))?;
157        Ok(())
158    }
159}
160
161/// A builder that generates the files needed to add a course.
162pub struct CourseBuilder {
163    /// Base name of the directory on which to store this course.
164    pub directory_name: String,
165
166    /// The manifest for the course.
167    pub course_manifest: CourseManifest,
168
169    /// A template builder used to build the manifests for each lesson in the course. Attributes
170    /// common to all lessons should be set here.
171    pub lesson_manifest_template: LessonManifestBuilder,
172
173    /// A list of tuples of directory names and lesson builders to create the lessons in the
174    /// course.
175    pub lesson_builders: Vec<LessonBuilder>,
176
177    /// A list of asset builders to create assets specific to this course.
178    pub asset_builders: Vec<AssetBuilder>,
179}
180
181impl CourseBuilder {
182    /// Writes the files needed for this course to the given directory.
183    pub fn build(&self, parent_directory: &Path) -> Result<()> {
184        // Create the directory and write the course manifest.
185        let course_directory = parent_directory.join(&self.directory_name);
186        create_dir_all(&course_directory)?;
187        let manifest_json = serde_json::to_string_pretty(&self.course_manifest)? + "\n";
188        let manifest_path = course_directory.join("course_manifest.json");
189        let mut manifest_file = File::create(manifest_path)?;
190        manifest_file.write_all(manifest_json.as_bytes())?;
191
192        // Write all the assets.
193        for asset_builder in &self.asset_builders {
194            asset_builder.build(&course_directory)?;
195        }
196
197        // Build all the lessons in the course.
198        for lesson_builder in &self.lesson_builders {
199            let lesson_directory = course_directory.join(&lesson_builder.directory_name);
200            lesson_builder.build(&lesson_directory, self.lesson_manifest_template.clone())?;
201        }
202
203        // Verify that all paths mentioned in the manifest are valid.
204        self.course_manifest
205            .verify_paths(&course_directory)
206            .context(format!(
207                "failed to verify files for course {}",
208                self.course_manifest.id
209            ))?;
210        Ok(())
211    }
212}
213
214#[cfg(test)]
215#[cfg_attr(coverage, coverage(off))]
216mod test {
217    use anyhow::Result;
218    use std::io::Read;
219
220    use super::*;
221    use crate::data::{BasicAsset, ExerciseAsset, ExerciseType};
222
223    /// Verifies the asset builder writes the contents to the correct file.
224    #[test]
225    fn asset_builer() -> Result<()> {
226        let temp_dir = tempfile::tempdir()?;
227        let asset_builder = AssetBuilder {
228            file_name: "asset1.md".to_string(),
229            contents: "asset1 contents".to_string(),
230        };
231        asset_builder.build(temp_dir.path())?;
232        assert!(temp_dir.path().join("asset1.md").is_file());
233        let mut file = File::open(temp_dir.path().join("asset1.md"))?;
234        let mut contents = String::new();
235        file.read_to_string(&mut contents)?;
236        assert_eq!(contents, "asset1 contents");
237        Ok(())
238    }
239
240    /// Verifies the asset builder fails if there's an existing file.
241    #[test]
242    fn asset_builer_existing() -> Result<()> {
243        // Create the file first.
244        let temp_dir = tempfile::tempdir()?;
245        let asset_path = temp_dir.path().join("asset1.md");
246        File::create(&asset_path)?;
247
248        // Creating the asset builder should fail.
249        let asset_builder = AssetBuilder {
250            file_name: "asset1.md".to_string(),
251            contents: "asset1 contents".to_string(),
252        };
253        assert!(asset_builder.build(temp_dir.path()).is_err());
254        Ok(())
255    }
256
257    /// Verifies the course builder writes the correct files.
258    #[test]
259    fn course_builder() -> Result<()> {
260        let exercise_builder = ExerciseBuilder {
261            directory_name: "exercise1".to_string(),
262            manifest_closure: Box::new(|builder| {
263                builder
264                    .clone()
265                    .id("exercise1")
266                    .name("Exercise 1".into())
267                    .exercise_asset(ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset {
268                        content: String::new(),
269                    }))
270                    .clone()
271            }),
272            asset_builders: vec![],
273        };
274        let lesson_builder = LessonBuilder {
275            directory_name: "lesson1".to_string(),
276            manifest_closure: Box::new(|builder| {
277                builder
278                    .clone()
279                    .id("lesson1")
280                    .name("Lesson 1".into())
281                    .dependencies(vec![])
282                    .clone()
283            }),
284            exercise_manifest_template: ExerciseManifestBuilder::default()
285                .lesson_id("lesson1")
286                .course_id("course1")
287                .exercise_type(ExerciseType::Procedural)
288                .clone(),
289            exercise_builders: vec![exercise_builder],
290            asset_builders: vec![],
291        };
292        let course_builder = CourseBuilder {
293            directory_name: "course1".to_string(),
294            course_manifest: CourseManifest {
295                id: "course1".into(),
296                name: "Course 1".into(),
297                dependencies: vec![],
298                encompassed: vec![],
299                superseded: vec![],
300                description: None,
301                authors: None,
302                metadata: None,
303                course_material: None,
304                course_instructions: None,
305                generator_config: None,
306            },
307            lesson_manifest_template: LessonManifestBuilder::default()
308                .course_id("course1")
309                .clone(),
310            lesson_builders: vec![lesson_builder],
311            asset_builders: vec![],
312        };
313
314        let temp_dir = tempfile::tempdir()?;
315        course_builder.build(temp_dir.path())?;
316
317        let course_dir = temp_dir.path().join("course1");
318        let lesson_dir = course_dir.join("lesson1");
319        let exercise_dir = lesson_dir.join("exercise1");
320        assert!(course_dir.is_dir());
321        assert!(lesson_dir.is_dir());
322        assert!(exercise_dir.is_dir());
323        assert!(course_dir.join("course_manifest.json").is_file());
324        assert!(lesson_dir.join("lesson_manifest.json").is_file());
325        assert!(exercise_dir.join("exercise_manifest.json").is_file());
326        Ok(())
327    }
328
329    /// Tests the Display implementation of TraneMetadata to satisfy coverage.
330    #[test]
331    fn trane_metadata_display() {
332        assert_eq!("skill", TraneMetadata::Skill.to_string());
333    }
334}