• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

trane-project / trane / 4099016804

pending completion
4099016804

Pull #177

github

GitHub
Merge b199a5300 into bafee362f
Pull Request #177: Normalize course instructions file

4 of 4 new or added lines in 1 file covered. (100.0%)

4975 of 4975 relevant lines covered (100.0%)

79003.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/src/data/course_generator/knowledge_base.rs
1
//! Contains the logic to generate a Trane course based on a knowledge base of markdown files
2
//! representing the front and back of flashcard exercises.
3

4
use anyhow::{anyhow, Context, Error, Result};
5
use serde::{de::DeserializeOwned, Deserialize, Serialize};
6
use std::{
7
    collections::{BTreeMap, HashMap, HashSet},
8
    fs::{read_dir, File},
9
    io::BufReader,
10
    path::Path,
11
};
12
use ustr::{Ustr, UstrMap};
13

14
use crate::data::{
15
    BasicAsset, CourseManifest, ExerciseAsset, ExerciseManifest, ExerciseType, GenerateManifests,
16
    GeneratedCourse, LessonManifest, UserPreferences,
17
};
18

19
/// The suffix used to recognize a directory as a knowledge base lesson.
20
pub const LESSON_SUFFIX: &str = ".lesson";
21

22
/// The name of the file containing the dependencies of a lesson.
23
pub const LESSON_DEPENDENCIES_FILE: &str = "lesson.dependencies.json";
24

25
/// The name of the file containing the name of a lesson.
26
pub const LESSON_NAME_FILE: &str = "lesson.name.json";
27

28
/// The name of the file containing the description of a lesson.
29
pub const LESSON_DESCRIPTION_FILE: &str = "lesson.description.json";
30

31
/// The name of the file containing the metadata of a lesson.
32
pub const LESSON_METADATA_FILE: &str = "lesson.metadata.json";
33

34
/// The name of the file containing the lesson instructions.
35
pub const LESSON_INSTRUCTIONS_FILE: &str = "lesson.instructions.md";
36

37
/// The name of the file containing the lesson material.
38
pub const LESSON_MATERIAL_FILE: &str = "lesson.material.md";
39

40
/// The suffix of the file containing the front of the flashcard for an exercise.
41
pub const EXERCISE_FRONT_SUFFIX: &str = ".front.md";
42

43
/// The suffix of the file containing the back of the flashcard for an exercise.
44
pub const EXERCISE_BACK_SUFFIX: &str = ".back.md";
45

46
/// The suffix of the file containing the name of an exercise.
47
pub const EXERCISE_NAME_SUFFIX: &str = ".name.json";
48

49
/// The suffix of the file containing the description of an exercise.
50
pub const EXERCISE_DESCRIPTION_SUFFIX: &str = ".description.json";
51

52
/// The suffix of the file containing the metadata of an exercise.
53
pub const EXERCISE_TYPE_SUFFIX: &str = ".type.json";
54

55
/// An enum representing a type of file that can be found in a knowledge base lesson directory.
56
#[derive(Debug, Eq, PartialEq)]
57
pub enum KnowledgeBaseFile {
58
    /// The file containing the name of the lesson.
59
    LessonName,
60

61
    /// The file containing the description of the lesson.
62
    LessonDescription,
63

64
    /// The file containing the dependencies of the lesson.
65
    LessonDependencies,
66

67
    /// The file containing the metadata of the lesson.
68
    LessonMetadata,
69

70
    /// The file containing the lesson instructions.
71
    LessonInstructions,
72

73
    /// The file containing the lesson material.
74
    LessonMaterial,
75

76
    /// The file containing the front of the flashcard for the exercise with the given short ID.
77
    ExerciseFront(String),
3✔
78

79
    /// The file containing the back of the flashcard for the exercise with the given short ID.
80
    ExerciseBack(String),
2✔
81

82
    /// The file containing the name of the exercise with the given short ID.
83
    ExerciseName(String),
1✔
84

85
    /// The file containing the description of the exercise with the given short ID.
86
    ExerciseDescription(String),
1✔
87

88
    /// The file containing the type of the exercise with the given short ID.
89
    ExerciseType(String),
1✔
90
}
91

92
impl KnowledgeBaseFile {
93
    /// Opens the knowledge base file at the given path and deserializes its contents.
94
    pub fn open<T: DeserializeOwned>(path: &Path) -> Result<T> {
40✔
95
        let file = File::open(path)
80✔
96
            .with_context(|| anyhow!("cannot open knowledge base file {}", path.display()))?;
41✔
97
        let reader = BufReader::new(file);
39✔
98
        serde_json::from_reader(reader)
78✔
99
            .with_context(|| anyhow!("cannot parse knowledge base file {}", path.display()))
40✔
100
    }
40✔
101
}
102

103
impl TryFrom<&str> for KnowledgeBaseFile {
104
    type Error = Error;
105

106
    /// Converts a file name to a `KnowledgeBaseFile` variant.
107
    fn try_from(file_name: &str) -> Result<Self> {
202✔
108
        match file_name {
109
            LESSON_DEPENDENCIES_FILE => Ok(KnowledgeBaseFile::LessonDependencies),
202✔
110
            LESSON_NAME_FILE => Ok(KnowledgeBaseFile::LessonName),
180✔
111
            LESSON_DESCRIPTION_FILE => Ok(KnowledgeBaseFile::LessonDescription),
179✔
112
            LESSON_METADATA_FILE => Ok(KnowledgeBaseFile::LessonMetadata),
177✔
113
            LESSON_MATERIAL_FILE => Ok(KnowledgeBaseFile::LessonMaterial),
175✔
114
            LESSON_INSTRUCTIONS_FILE => Ok(KnowledgeBaseFile::LessonInstructions),
173✔
115
            file_name if file_name.ends_with(EXERCISE_FRONT_SUFFIX) => {
171✔
116
                let short_id = file_name.strip_suffix(EXERCISE_FRONT_SUFFIX).unwrap();
102✔
117
                Ok(KnowledgeBaseFile::ExerciseFront(short_id.to_string()))
102✔
118
            }
102✔
119
            file_name if file_name.ends_with(EXERCISE_BACK_SUFFIX) => {
69✔
120
                let short_id = file_name.strip_suffix(EXERCISE_BACK_SUFFIX).unwrap();
62✔
121
                Ok(KnowledgeBaseFile::ExerciseBack(short_id.to_string()))
62✔
122
            }
62✔
123
            file_name if file_name.ends_with(EXERCISE_NAME_SUFFIX) => {
7✔
124
                let short_id = file_name.strip_suffix(EXERCISE_NAME_SUFFIX).unwrap();
2✔
125
                Ok(KnowledgeBaseFile::ExerciseName(short_id.to_string()))
2✔
126
            }
2✔
127
            file_name if file_name.ends_with(EXERCISE_DESCRIPTION_SUFFIX) => {
5✔
128
                let short_id = file_name.strip_suffix(EXERCISE_DESCRIPTION_SUFFIX).unwrap();
2✔
129
                Ok(KnowledgeBaseFile::ExerciseDescription(short_id.to_string()))
2✔
130
            }
2✔
131
            file_name if file_name.ends_with(EXERCISE_TYPE_SUFFIX) => {
3✔
132
                let short_id = file_name.strip_suffix(EXERCISE_TYPE_SUFFIX).unwrap();
2✔
133
                Ok(KnowledgeBaseFile::ExerciseType(short_id.to_string()))
2✔
134
            }
2✔
135
            _ => Err(anyhow!(
1✔
136
                "Not a valid knowledge base file name: {}",
137
                file_name
138
            )),
1✔
139
        }
140
    }
202✔
141
}
142

143
//@<knowledge-base-exercise
144
/// Represents a knowledge base exercise.
145
///
146
/// Inside a knowledge base lesson directory, Trane will look for matching pairs of files with names
147
/// `<SHORT_EXERCISE_ID>.front.md` and `<SHORT_EXERCISE_ID>.back.md`. The short ID is used to
148
/// generate the final exercise ID, by combining it with the lesson ID. For example, files
149
/// `e.front.md` and `e.back.md` in a course with ID `a::b::c` inside a lesson directory named
150
/// `d.lesson` will generate and exercise with ID `a::b::c::d::e`.
151
///
152
/// Each the optional fields mirror one of the fields in the
153
/// [ExerciseManifest](crate::data::ExerciseManifest) and their values can be set by writing a JSON
154
/// file inside the lesson directory with the name `<SHORT_EXERCISE_ID>.<PROPERTY_NAME>.json`. This
155
/// file should contain a JSON serialization of the desired value. For example, to set the
156
/// exercise's name for an exercise with a short ID value of `ex1`, one would write a file named
157
/// `ex1.name.json` containing a JSON string with the desired name.
158
///
159
/// Trane will ignore any markdown files that do not match the exercise name pattern or that do not
160
/// have a matching pair of front and back files.
161
pub struct KnowledgeBaseExercise {
162
    /// The short ID of the lesson, which is used to easily identify the exercise and to generate
163
    /// the final exercise ID.
164
    pub short_id: String,
165

166
    /// The short ID of the lesson to which this exercise belongs.
167
    pub short_lesson_id: Ustr,
168

169
    /// The ID of the course to which this lesson belongs.
170
    pub course_id: Ustr,
171

172
    /// The path to the file containing the front of the flashcard.
173
    pub front_file: String,
174

175
    /// The path to the file containing the back of the flashcard. This path is optional, because a
176
    /// flashcard is not required to provide an answer.
177
    pub back_file: Option<String>,
178

179
    /// The name of the exercise to be presented to the user.
180
    pub name: Option<String>,
181

182
    /// An optional description of the exercise.
183
    pub description: Option<String>,
184

185
    /// The type of knowledge the exercise tests. Currently, Trane does not make any distinction
186
    /// between the types of exercises, but that will likely change in the future. The option to set
187
    /// the type is provided, but most users should not need to use it.
188
    pub exercise_type: Option<ExerciseType>,
189
}
190
//>@knowledge-base-exercise
191

192
impl KnowledgeBaseExercise {
193
    /// Generates the exercise from a list of knowledge base files.
194
    fn create_exercise(
101✔
195
        lesson_root: &Path,
196
        short_id: &str,
197
        short_lesson_id: Ustr,
198
        course_manifest: &CourseManifest,
199
        files: &[KnowledgeBaseFile],
200
    ) -> Result<Self> {
201
        // Check if the exercise has a back file and create it accordingly.
202
        let has_back_file = files.iter().any(|file| match file {
263✔
203
            KnowledgeBaseFile::ExerciseBack(id) => id == short_id,
61✔
204
            _ => false,
101✔
205
        });
162✔
206
        let back_file = if has_back_file {
101✔
207
            Some(
61✔
208
                lesson_root
61✔
209
                    .join(format!("{short_id}{EXERCISE_BACK_SUFFIX}"))
61✔
210
                    .to_str()
211
                    .unwrap_or_default()
212
                    .to_string(),
213
            )
214
        } else {
61✔
215
            None
40✔
216
        };
217

218
        // Create the exercise with `None` values for all optimal fields.
219
        let mut exercise = KnowledgeBaseExercise {
101✔
220
            short_id: short_id.to_string(),
101✔
221
            short_lesson_id,
222
            course_id: course_manifest.id,
101✔
223
            front_file: lesson_root
101✔
224
                .join(format!("{short_id}{EXERCISE_FRONT_SUFFIX}"))
101✔
225
                .to_str()
226
                .unwrap_or_default()
227
                .to_string(),
228
            back_file,
101✔
229
            name: None,
101✔
230
            description: None,
101✔
231
            exercise_type: None,
101✔
232
        };
101✔
233

234
        // Iterate through the exercise files found in the lesson directory and set the
235
        // corresponding field in the exercise. The front and back files are ignored because the
236
        // correct values were already set above.
237
        for exercise_file in files {
266✔
238
            match exercise_file {
165✔
239
                KnowledgeBaseFile::ExerciseName(..) => {
240
                    let path = lesson_root.join(format!("{short_id}{EXERCISE_NAME_SUFFIX}"));
1✔
241
                    exercise.name = Some(KnowledgeBaseFile::open(&path)?);
1✔
242
                }
1✔
243
                KnowledgeBaseFile::ExerciseDescription(..) => {
244
                    let path = lesson_root.join(format!("{short_id}{EXERCISE_DESCRIPTION_SUFFIX}"));
1✔
245
                    exercise.description = Some(KnowledgeBaseFile::open(&path)?);
1✔
246
                }
1✔
247
                KnowledgeBaseFile::ExerciseType(..) => {
248
                    let path = lesson_root.join(format!("{short_id}{EXERCISE_TYPE_SUFFIX}"));
1✔
249
                    exercise.exercise_type = Some(KnowledgeBaseFile::open(&path)?);
1✔
250
                }
1✔
251
                _ => {}
252
            }
253
        }
254
        Ok(exercise)
101✔
255
    }
101✔
256
}
257

258
impl From<KnowledgeBaseExercise> for ExerciseManifest {
259
    /// Generates the manifest for this exercise.
260
    fn from(exercise: KnowledgeBaseExercise) -> Self {
101✔
261
        Self {
101✔
262
            id: format!(
101✔
263
                "{}::{}::{}",
264
                exercise.course_id, exercise.short_lesson_id, exercise.short_id
265
            )
266
            .into(),
267
            lesson_id: format!("{}::{}", exercise.course_id, exercise.short_lesson_id).into(),
101✔
268
            course_id: exercise.course_id,
101✔
269
            name: exercise
202✔
270
                .name
271
                .unwrap_or(format!("Exercise {}", exercise.short_id)),
101✔
272
            description: exercise.description,
101✔
273
            exercise_type: exercise.exercise_type.unwrap_or(ExerciseType::Procedural),
101✔
274
            exercise_asset: ExerciseAsset::FlashcardAsset {
101✔
275
                front_path: exercise.front_file,
101✔
276
                back_path: exercise.back_file,
101✔
277
            },
278
        }
279
    }
101✔
280
}
281

282
//@<knowledge-base-lesson
283
/// Represents a knowledge base lesson.
284
///
285
/// In a knowledge base course, lessons are generated by searching for all directories with a name
286
/// in the format `<SHORT_LESSON_ID>.lesson`. In this case, the short ID is not the entire lesson ID
287
/// one would use in the lesson manifest, but rather a short identifier that is combined with the
288
/// course ID to generate the final lesson ID. For example, a course with ID `a::b::c` which
289
/// contains a directory of name `d.lesson` will generate the manifest for a lesson with ID
290
/// `a::b::c::d`.
291
///
292
/// All the optional fields mirror one of the fields in the
293
/// [LessonManifest](crate::data::LessonManifest) and their values can be set by writing a JSON file
294
/// inside the lesson directory with the name `lesson.<PROPERTY_NAME>.json`. This file should
295
/// contain a JSON serialization of the desired value. For example, to set the lesson's dependencies
296
/// one would write a file named `lesson.dependencies.json` containing a JSON array of strings, each
297
/// of them the ID of a dependency.
298
///
299
/// The material and instructions of the lesson do not follow this convention. Instead, the files
300
/// `lesson.instructoins.md` and `lesson.material.md` contain the instructions and material of the
301
/// lesson.
302
///
303
/// None of the `<SHORT_LESSON_ID>.lesson` directories should contain a `lesson_manifest.json` file,
304
/// as that file would indicate to Trane that this is a regular lesson and not a generated lesson.
305
pub struct KnowledgeBaseLesson {
306
    /// The short ID of the lesson, which is used to easily identify the lesson and to generate the
307
    /// final lesson ID.
308
    pub short_id: Ustr,
309

310
    /// The ID of the course to which this lesson belongs.
311
    pub course_id: Ustr,
312

313
    /// The IDs of all dependencies of this lesson. The values can be full lesson IDs or the short
314
    /// ID of one of the other lessons in the course. If Trane finds a dependency with a short ID,
315
    /// it will automatically generate the full lesson ID. Not setting this value will indicate that
316
    /// the lesson has no dependencies.
317
    pub dependencies: Option<Vec<Ustr>>,
318

319
    /// The name of the lesson to be presented to the user.
320
    pub name: Option<String>,
321

322
    /// An optional description of the lesson.
323
    pub description: Option<String>,
324

325
    //// A mapping of String keys to a list of String values used to store arbitrary metadata about
326
    ///the lesson. This value is set to a `BTreeMap` to ensure that the keys are sorted in a
327
    ///consistent order when serialized. This is an implementation detail and does not affect how
328
    ///the value should be written to a file. A JSON map of strings to list of strings works.
329
    pub metadata: Option<BTreeMap<String, Vec<String>>>,
330

331
    /// The path to a markdown file containing the instructions common to all exercises in the
332
    /// lesson.
333
    pub instructions: Option<String>,
334

335
    /// The path to a markdown file containing the material covered in the lesson.
336
    pub material: Option<String>,
337
}
338
//>@knowledge-base-lesson
339

340
impl KnowledgeBaseLesson {
341
    // Filters out exercises that don't have both a front file. Exercises without a back file are
342
    // allowed, as it is not required to have one.
343
    fn filter_matching_exercises(exercise_files: &mut HashMap<String, Vec<KnowledgeBaseFile>>) {
22✔
344
        let mut to_remove = Vec::new();
22✔
345
        for (short_id, files) in exercise_files.iter() {
126✔
346
            let has_front = files
104✔
347
                .iter()
348
                .any(|file| matches!(file, KnowledgeBaseFile::ExerciseFront(_)));
104✔
349
            if !has_front {
104✔
350
                to_remove.push(short_id.clone());
1✔
351
            }
352
        }
353
        for short_id in to_remove {
23✔
354
            exercise_files.remove(&short_id);
1✔
355
        }
1✔
356
    }
22✔
357

358
    /// Generates the exercise from a list of knowledge base files.
359
    fn create_lesson(
21✔
360
        lesson_root: &Path,
361
        short_lesson_id: Ustr,
362
        course_manifest: &CourseManifest,
363
        files: &[KnowledgeBaseFile],
364
    ) -> Result<Self> {
365
        // Create the lesson with all the optional fields set to None.
366
        let mut lesson = Self {
42✔
367
            short_id: short_lesson_id,
368
            course_id: course_manifest.id,
21✔
369
            dependencies: None,
21✔
370
            name: None,
21✔
371
            description: None,
21✔
372
            metadata: None,
21✔
373
            instructions: None,
21✔
374
            material: None,
21✔
375
        };
376

377
        // Iterate through the lesson files found in the lesson directory and set the corresponding
378
        // field in the lesson.
379
        for lesson_file in files {
47✔
380
            match lesson_file {
26✔
381
                KnowledgeBaseFile::LessonDependencies => {
382
                    let path = lesson_root.join(LESSON_DEPENDENCIES_FILE);
21✔
383
                    lesson.dependencies = Some(KnowledgeBaseFile::open(&path)?)
21✔
384
                }
21✔
385
                KnowledgeBaseFile::LessonName => {
386
                    let path = lesson_root.join(LESSON_NAME_FILE);
1✔
387
                    lesson.name = Some(KnowledgeBaseFile::open(&path)?)
1✔
388
                }
1✔
389
                KnowledgeBaseFile::LessonDescription => {
390
                    let path = lesson_root.join(LESSON_DESCRIPTION_FILE);
1✔
391
                    lesson.description = Some(KnowledgeBaseFile::open(&path)?)
1✔
392
                }
1✔
393
                KnowledgeBaseFile::LessonMetadata => {
394
                    let path = lesson_root.join(LESSON_METADATA_FILE);
1✔
395
                    lesson.metadata = Some(KnowledgeBaseFile::open(&path)?)
1✔
396
                }
1✔
397
                KnowledgeBaseFile::LessonInstructions => {
398
                    if let Ok(path) = lesson_root
2✔
399
                        .join(LESSON_INSTRUCTIONS_FILE)
400
                        .into_os_string()
401
                        .into_string()
1✔
402
                    {
403
                        lesson.instructions = Some(path)
1✔
404
                    }
405
                }
1✔
406
                KnowledgeBaseFile::LessonMaterial => {
407
                    if let Ok(path) = lesson_root
2✔
408
                        .join(LESSON_MATERIAL_FILE)
409
                        .into_os_string()
410
                        .into_string()
1✔
411
                    {
412
                        lesson.material = Some(path)
1✔
413
                    }
414
                }
1✔
415
                _ => {}
416
            }
417
        }
418
        Ok(lesson)
21✔
419
    }
21✔
420

421
    /// Opens a lesson from the knowledge base with the given root and short ID.
422
    fn open_lesson(
21✔
423
        lesson_root: &Path,
424
        course_manifest: &CourseManifest,
425
        short_lesson_id: Ustr,
426
    ) -> Result<(KnowledgeBaseLesson, Vec<KnowledgeBaseExercise>)> {
427
        // Iterate through the directory to find all the matching files in the lesson directory.
428
        let mut lesson_files = Vec::new();
21✔
429
        let mut exercise_files = HashMap::new();
21✔
430
        for entry in read_dir(lesson_root)? {
212✔
431
            let entry = entry?;
191✔
432
            let file_name = entry.file_name();
191✔
433
            let file_name: &str = file_name.to_str().unwrap_or_default();
191✔
434
            if let Ok(kb_file) = KnowledgeBaseFile::try_from(file_name) {
382✔
435
                match kb_file {
191✔
436
                    KnowledgeBaseFile::ExerciseFront(ref short_id)
101✔
437
                    | KnowledgeBaseFile::ExerciseBack(ref short_id)
61✔
438
                    | KnowledgeBaseFile::ExerciseName(ref short_id)
1✔
439
                    | KnowledgeBaseFile::ExerciseDescription(ref short_id)
1✔
440
                    | KnowledgeBaseFile::ExerciseType(ref short_id) => {
1✔
441
                        exercise_files
330✔
442
                            .entry(short_id.clone())
165✔
443
                            .or_insert_with(Vec::new)
444
                            .push(kb_file);
165✔
445
                    }
446
                    _ => lesson_files.push(kb_file),
26✔
447
                }
448
            }
449
        }
191✔
450

451
        // Create the knowledge base lesson.
452
        let lesson =
453
            Self::create_lesson(lesson_root, short_lesson_id, course_manifest, &lesson_files)?;
21✔
454

455
        // Remove exercises for the empty short ID. This can happen if the user has a file named
456
        // `.front.md`, for example.
457
        exercise_files.remove("");
21✔
458

459
        // Filter out exercises that don't have both a front and back file and create the knowledge
460
        // base exercises.
461
        Self::filter_matching_exercises(&mut exercise_files);
21✔
462
        let exercises = exercise_files
42✔
463
            .into_iter()
464
            .map(|(short_id, files)| {
122✔
465
                KnowledgeBaseExercise::create_exercise(
101✔
466
                    lesson_root,
101✔
467
                    &short_id,
101✔
468
                    short_lesson_id,
101✔
469
                    course_manifest,
101✔
470
                    &files,
101✔
471
                )
472
            })
101✔
473
            .collect::<Result<Vec<_>>>()?; // grcov-excl-line
474
        Ok((lesson, exercises))
21✔
475
    }
21✔
476
}
477

478
impl From<KnowledgeBaseLesson> for LessonManifest {
479
    /// Generates the manifest for this lesson.
480
    fn from(lesson: KnowledgeBaseLesson) -> Self {
21✔
481
        Self {
21✔
482
            id: format!("{}::{}", lesson.course_id, lesson.short_id).into(),
21✔
483
            course_id: lesson.course_id,
21✔
484
            dependencies: lesson.dependencies.unwrap_or_default(),
21✔
485
            name: lesson.name.unwrap_or(format!("Lesson {}", lesson.short_id)),
21✔
486
            description: lesson.description,
21✔
487
            metadata: lesson.metadata,
21✔
488
            lesson_instructions: lesson
21✔
489
                .instructions
490
                .map(|path| BasicAsset::MarkdownAsset { path }),
1✔
491
            lesson_material: lesson
21✔
492
                .material
493
                .map(|path| BasicAsset::MarkdownAsset { path }),
1✔
494
        }
495
    }
21✔
496
}
497

498
/// The configuration for a knowledge base course. Currently, this is an empty struct, but it is
499
/// added for consistency with other course generators and to implement the
500
/// [GenerateManifests](crate::data::GenerateManifests) trait.
501
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
502
pub struct KnowledgeBaseConfig {}
503

504
impl KnowledgeBaseConfig {
505
    // Checks if the dependencies refer to another lesson in the course by its short ID and updates
506
    // them to refer to the full lesson ID.
507
    fn convert_to_full_dependencies(
3✔
508
        course_manifest: &CourseManifest,
509
        short_ids: HashSet<Ustr>,
510
        lessons: &mut UstrMap<(KnowledgeBaseLesson, Vec<KnowledgeBaseExercise>)>,
511
    ) {
512
        lessons.iter_mut().for_each(|(_, lesson)| {
24✔
513
            if let Some(dependencies) = &lesson.0.dependencies {
21✔
514
                let updated_dependencies = dependencies
42✔
515
                    .iter()
516
                    .map(|dependency| {
61✔
517
                        if short_ids.contains(dependency) {
40✔
518
                            // The dependency is a short ID, so we need to update it to the full ID.
519
                            format!("{}::{}", course_manifest.id, dependency).into()
39✔
520
                        } else {
521
                            // The dependency is already a full ID, so we can just add it to the
522
                            // list.
523
                            *dependency
1✔
524
                        }
525
                    })
40✔
526
                    .collect();
527
                lesson.0.dependencies = Some(updated_dependencies);
21✔
528
            }
529
        });
21✔
530
    }
3✔
531
}
532

533
impl GenerateManifests for KnowledgeBaseConfig {
534
    fn generate_manifests(
2✔
535
        &self,
536
        course_root: &Path,
537
        course_manifest: &CourseManifest,
538
        _preferences: &UserPreferences,
539
    ) -> Result<GeneratedCourse> {
540
        // Store the lessons and their exercises in a map of short lesson ID to a tuple of the
541
        // lesson and its exercises.
542
        let mut lessons = UstrMap::default();
2✔
543

544
        // Iterate through all the directories in the course root, processing only those whose name
545
        // fits the pattern `<SHORT_LESSON_ID>.lesson`.
546
        for entry in read_dir(course_root)? {
24✔
547
            // Ignore the entry if it's not a directory.
548
            let entry = entry?;
22✔
549
            let path = entry.path();
22✔
550
            if !path.is_dir() {
22✔
551
                continue;
552
            }
553

554
            // Check if the directory name is in the format `<SHORT_LESSON_ID>.lesson`. If so, read
555
            // the knowledge base lesson and its exercises.
556
            let dir_name = path.file_name().unwrap_or_default().to_str().unwrap();
20✔
557
            if let Some(short_id) = dir_name.strip_suffix(LESSON_SUFFIX) {
20✔
558
                lessons.insert(
20✔
559
                    short_id.into(),
20✔
560
                    KnowledgeBaseLesson::open_lesson(&path, course_manifest, short_id.into())?,
20✔
561
                );
20✔
562
            }
563
        }
22✔
564

565
        // Convert all the dependencies to full lesson IDs.
566
        let short_ids: HashSet<Ustr> = lessons.keys().cloned().collect();
2✔
567
        KnowledgeBaseConfig::convert_to_full_dependencies(course_manifest, short_ids, &mut lessons);
2✔
568

569
        // Generate the manifests for all the lessons and exercises.
570
        let manifests: Vec<(LessonManifest, Vec<ExerciseManifest>)> = lessons
2✔
571
            .into_iter()
572
            .map(|(_, (lesson, exercises))| {
20✔
573
                let lesson_manifest = LessonManifest::from(lesson);
20✔
574
                let exercise_manifests =
575
                    exercises.into_iter().map(ExerciseManifest::from).collect();
20✔
576
                (lesson_manifest, exercise_manifests)
20✔
577
            })
20✔
578
            .collect();
579

580
        Ok(GeneratedCourse {
2✔
581
            lessons: manifests,
2✔
582
            updated_instructions: None,
2✔
583
            updated_metadata: None,
2✔
584
        })
585
    }
2✔
586
}
587

588
#[cfg(test)]
589
mod test {
590
    use anyhow::Result;
591
    use std::{
592
        fs::{self, Permissions},
593
        io::{BufWriter, Write},
594
        os::unix::prelude::PermissionsExt,
595
    };
596

597
    use super::*;
598

599
    // Verifies opening a valid knowledge base file.
600
    #[test]
601
    fn open_knowledge_base_file() -> Result<()> {
2✔
602
        let temp_dir = tempfile::tempdir()?;
1✔
603
        let file_path = temp_dir.path().join("lesson.dependencies.properties");
1✔
604
        let mut file = File::create(&file_path)?;
1✔
605
        file.write_all(b"[\"lesson1\"]")?;
1✔
606

607
        let dependencies: Vec<String> = KnowledgeBaseFile::open(&file_path)?;
1✔
608
        assert_eq!(dependencies, vec!["lesson1".to_string()]);
1✔
609
        Ok(())
1✔
610
    }
2✔
611

612
    // Verifies the handling of invalid knowledge base files.
613
    #[test]
614
    fn open_knowledge_base_file_bad_format() -> Result<()> {
2✔
615
        let temp_dir = tempfile::tempdir()?;
1✔
616
        let file_path = temp_dir.path().join("lesson.dependencies.properties");
1✔
617
        let mut file = File::create(&file_path)?;
1✔
618
        file.write_all(b"[\"lesson1\"")?;
1✔
619

620
        let dependencies: Result<Vec<String>> = KnowledgeBaseFile::open(&file_path);
1✔
621
        assert!(dependencies.is_err());
1✔
622
        Ok(())
1✔
623
    }
2✔
624

625
    // Verifies the handling of knowledge base files that cannot be opened.
626
    #[test]
627
    fn open_knowledge_base_file_bad_permissions() -> Result<()> {
2✔
628
        let temp_dir = tempfile::tempdir()?;
1✔
629
        let file_path = temp_dir.path().join("lesson.dependencies.properties");
1✔
630
        let mut file = File::create(&file_path)?;
1✔
631
        file.write_all(b"[\"lesson1\"]")?;
1✔
632

633
        // Make the directory non-readable to test that the file can't be opened.
634
        std::fs::set_permissions(temp_dir.path(), Permissions::from_mode(0o000))?;
1✔
635

636
        let dependencies: Result<Vec<String>> = KnowledgeBaseFile::open(&file_path);
1✔
637
        assert!(dependencies.is_err());
1✔
638
        Ok(())
1✔
639
    }
2✔
640

641
    // Verifies that the all the files with knowledge base names are detected correctly.
642
    #[test]
643
    fn to_knowledge_base_file() {
2✔
644
        // Parse lesson file names.
645
        assert_eq!(
1✔
646
            KnowledgeBaseFile::LessonDependencies,
647
            KnowledgeBaseFile::try_from(LESSON_DEPENDENCIES_FILE).unwrap(),
1✔
648
        );
649
        assert_eq!(
1✔
650
            KnowledgeBaseFile::LessonDescription,
651
            KnowledgeBaseFile::try_from(LESSON_DESCRIPTION_FILE).unwrap(),
1✔
652
        );
653
        assert_eq!(
1✔
654
            KnowledgeBaseFile::LessonMetadata,
655
            KnowledgeBaseFile::try_from(LESSON_METADATA_FILE).unwrap(),
1✔
656
        );
657
        assert_eq!(
1✔
658
            KnowledgeBaseFile::LessonInstructions,
659
            KnowledgeBaseFile::try_from(LESSON_INSTRUCTIONS_FILE).unwrap(),
1✔
660
        );
661
        assert_eq!(
1✔
662
            KnowledgeBaseFile::LessonMaterial,
663
            KnowledgeBaseFile::try_from(LESSON_MATERIAL_FILE).unwrap(),
1✔
664
        );
665

666
        // Parse exercise file names.
667
        assert_eq!(
1✔
668
            KnowledgeBaseFile::ExerciseName("ex1".to_string()),
1✔
669
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_NAME_SUFFIX).as_str())
1✔
670
                .unwrap(),
671
        );
672
        assert_eq!(
1✔
673
            KnowledgeBaseFile::ExerciseFront("ex1".to_string()),
1✔
674
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_FRONT_SUFFIX).as_str())
1✔
675
                .unwrap(),
676
        );
677
        assert_eq!(
1✔
678
            KnowledgeBaseFile::ExerciseBack("ex1".to_string()),
1✔
679
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_BACK_SUFFIX).as_str())
1✔
680
                .unwrap(),
681
        );
682
        assert_eq!(
1✔
683
            KnowledgeBaseFile::ExerciseDescription("ex1".to_string()),
1✔
684
            KnowledgeBaseFile::try_from(
1✔
685
                format!("{}{}", "ex1", EXERCISE_DESCRIPTION_SUFFIX).as_str()
1✔
686
            )
687
            .unwrap(),
688
        );
689
        assert_eq!(
1✔
690
            KnowledgeBaseFile::ExerciseType("ex1".to_string()),
1✔
691
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_TYPE_SUFFIX).as_str())
1✔
692
                .unwrap(),
693
        );
694

695
        // Parse exercise file names with invalid exercise names.
696
        assert!(KnowledgeBaseFile::try_from("ex1").is_err());
1✔
697
    }
2✔
698

699
    // Verifies the conversion from a knowledge base lesson to a lesson manifest.
700
    #[test]
701
    fn lesson_to_manifest() {
2✔
702
        let lesson = KnowledgeBaseLesson {
1✔
703
            short_id: "lesson1".into(),
1✔
704
            course_id: "course1".into(),
1✔
705
            name: Some("Name".into()),
1✔
706
            description: Some("Description".into()),
1✔
707
            dependencies: Some(vec!["lesson2".into()]),
1✔
708
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
1✔
709
            instructions: Some("Instructions.md".into()),
1✔
710
            material: Some("Material.md".into()),
1✔
711
        };
712
        let expected_manifest = LessonManifest {
1✔
713
            id: "course1::lesson1".into(),
1✔
714
            course_id: "course1".into(),
1✔
715
            name: "Name".into(),
1✔
716
            description: Some("Description".into()),
1✔
717
            dependencies: vec!["lesson2".into()],
1✔
718
            lesson_instructions: Some(BasicAsset::MarkdownAsset {
1✔
719
                path: "Instructions.md".into(),
1✔
720
            }),
721
            lesson_material: Some(BasicAsset::MarkdownAsset {
1✔
722
                path: "Material.md".into(),
1✔
723
            }),
724
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
1✔
725
        };
726
        let actual_manifest: LessonManifest = lesson.into();
1✔
727
        assert_eq!(actual_manifest, expected_manifest);
1✔
728
    }
2✔
729

730
    // Verifies the conversion from a knowledge base exercise to an exercise manifest.
731
    #[test]
732
    fn exercise_to_manifest() {
2✔
733
        let exercise = KnowledgeBaseExercise {
1✔
734
            short_id: "ex1".into(),
1✔
735
            short_lesson_id: "lesson1".into(),
1✔
736
            course_id: "course1".into(),
1✔
737
            front_file: "ex1.front.md".into(),
1✔
738
            back_file: Some("ex1.back.md".into()),
1✔
739
            name: Some("Name".into()),
1✔
740
            description: Some("Description".into()),
1✔
741
            exercise_type: Some(ExerciseType::Procedural),
742
        };
743
        let expected_manifest = ExerciseManifest {
1✔
744
            id: "course1::lesson1::ex1".into(),
1✔
745
            lesson_id: "course1::lesson1".into(),
1✔
746
            course_id: "course1".into(),
1✔
747
            name: "Name".into(),
1✔
748
            description: Some("Description".into()),
1✔
749
            exercise_type: ExerciseType::Procedural,
1✔
750
            exercise_asset: ExerciseAsset::FlashcardAsset {
1✔
751
                front_path: "ex1.front.md".into(),
1✔
752
                back_path: Some("ex1.back.md".into()),
1✔
753
            },
754
        };
755
        let actual_manifest: ExerciseManifest = exercise.into();
1✔
756
        assert_eq!(actual_manifest, expected_manifest);
1✔
757
    }
2✔
758

759
    // Verifies that dependencies referenced by their short IDs are converted to full IDs.
760
    #[test]
761
    fn convert_to_full_dependencies() {
2✔
762
        // Create an example course manifest.
763
        let course_manifest = CourseManifest {
1✔
764
            id: "course1".into(),
1✔
765
            name: "Course 1".into(),
1✔
766
            dependencies: vec![],
1✔
767
            description: Some("Description".into()),
1✔
768
            authors: None,
1✔
769
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
1✔
770
            course_instructions: None,
1✔
771
            course_material: None,
1✔
772
            generator_config: None,
1✔
773
        };
774

775
        // Create an example lesson with a dependency referred to by its short ID and an example
776
        // exercise.
777
        let short_lesson_id = Ustr::from("lesson1");
1✔
778
        let lesson = KnowledgeBaseLesson {
1✔
779
            short_id: short_lesson_id,
1✔
780
            course_id: "course1".into(),
1✔
781
            name: Some("Name".into()),
1✔
782
            description: Some("Description".into()),
1✔
783
            dependencies: Some(vec!["lesson2".into(), "other::lesson1".into()]),
1✔
784
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
1✔
785
            instructions: Some("Instructions.md".into()),
1✔
786
            material: Some("Material.md".into()),
1✔
787
        };
788
        let exercise = KnowledgeBaseExercise {
1✔
789
            short_id: "ex1".into(),
1✔
790
            short_lesson_id,
1✔
791
            course_id: "course1".into(),
1✔
792
            front_file: "ex1.front.md".into(),
1✔
793
            back_file: Some("ex1.back.md".into()),
1✔
794
            name: Some("Name".into()),
1✔
795
            description: Some("Description".into()),
1✔
796
            exercise_type: Some(ExerciseType::Procedural),
797
        };
798
        let mut lesson_map = UstrMap::default();
1✔
799
        lesson_map.insert("lesson1".into(), (lesson, vec![exercise]));
1✔
800

801
        // Convert the short IDs to full IDs.
802
        let short_ids = HashSet::from_iter(vec!["lesson1".into(), "lesson2".into()]);
1✔
803
        KnowledgeBaseConfig::convert_to_full_dependencies(
1✔
804
            &course_manifest,
805
            short_ids.clone(),
1✔
806
            &mut lesson_map,
807
        );
808

809
        assert_eq!(
2✔
810
            lesson_map.get(&short_lesson_id).unwrap().0.dependencies,
1✔
811
            Some(vec!["course1::lesson2".into(), "other::lesson1".into()])
1✔
812
        );
813
    }
2✔
814

815
    /// Verifies that exercises with a missing front or back files are filtered out.
816
    #[test]
817
    fn filter_matching_exercises() {
2✔
818
        let mut exercise_map = HashMap::default();
1✔
819
        // Exercise 1 has both a front and back file.
820
        let ex1_id: String = "ex1".into();
1✔
821
        let ex1_files = vec![
2✔
822
            KnowledgeBaseFile::ExerciseFront("ex1".into()),
1✔
823
            KnowledgeBaseFile::ExerciseBack("ex1".into()),
1✔
824
        ];
825
        // Exercise 2 only has a front file.
826
        let ex2_id: String = "ex2".into();
1✔
827
        let ex2_files = vec![KnowledgeBaseFile::ExerciseFront("ex2".into())];
1✔
828
        // Exercise 3 only has a back file.
829
        let ex3_id: String = "ex3".into();
1✔
830
        let ex3_files = vec![KnowledgeBaseFile::ExerciseBack("ex3".into())];
1✔
831
        exercise_map.insert(ex1_id.clone(), ex1_files);
1✔
832
        exercise_map.insert(ex2_id.clone(), ex2_files);
1✔
833
        exercise_map.insert(ex3_id.clone(), ex3_files);
1✔
834

835
        // Verify that the correct exercises were filtered out.
836
        KnowledgeBaseLesson::filter_matching_exercises(&mut exercise_map);
1✔
837
        let ex1_expected = vec![
2✔
838
            KnowledgeBaseFile::ExerciseFront("ex1".into()),
1✔
839
            KnowledgeBaseFile::ExerciseBack("ex1".into()),
1✔
840
        ];
841
        assert_eq!(exercise_map.get(&ex1_id).unwrap(), &ex1_expected);
1✔
842
        let ex2_expected = vec![KnowledgeBaseFile::ExerciseFront("ex2".into())];
1✔
843
        assert_eq!(exercise_map.get(&ex2_id).unwrap(), &ex2_expected);
1✔
844
        assert!(!exercise_map.contains_key(&ex3_id));
1✔
845
    }
2✔
846

847
    // Serializes the object in JSON and writes it to the given file.
848
    fn write_json<T: Serialize>(obj: &T, file: &Path) -> Result<()> {
9✔
849
        let file = File::create(file)?;
9✔
850
        let writer = BufWriter::new(file);
9✔
851
        serde_json::to_writer_pretty(writer, obj)?;
9✔
852
        Ok(())
9✔
853
    }
9✔
854

855
    // Verifies opening a lesson directory.
856
    #[test]
857
    fn open_lesson_dir() -> Result<()> {
2✔
858
        // Create a test course and lesson directory.
859
        let course_dir = tempfile::tempdir()?;
1✔
860
        let lesson_dir = course_dir.path().join("lesson1.lesson");
1✔
861
        fs::create_dir(&lesson_dir)?;
1✔
862

863
        // Create lesson files in the directory.
864
        let name = "Name";
1✔
865
        let name_path = lesson_dir.join(LESSON_NAME_FILE);
1✔
866
        write_json(&name, &name_path)?;
1✔
867

868
        let description = "Description";
1✔
869
        let description_path = lesson_dir.join(LESSON_DESCRIPTION_FILE);
1✔
870
        write_json(&description, &description_path)?;
1✔
871

872
        let dependencies: Vec<Ustr> = vec!["lesson2".into(), "lesson3".into()];
1✔
873
        let dependencies_path = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
1✔
874
        write_json(&dependencies, &dependencies_path)?;
1✔
875

876
        let metadata: BTreeMap<String, Vec<String>> =
877
            BTreeMap::from([("key".into(), vec!["value".into()])]);
1✔
878
        let metadata_path = lesson_dir.join(LESSON_METADATA_FILE);
1✔
879
        write_json(&metadata, &metadata_path)?;
1✔
880

881
        let instructions = "instructions";
1✔
882
        let instructions_path = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
1✔
883
        write_json(&instructions, &instructions_path)?;
1✔
884

885
        let material = "material";
1✔
886
        let material_path = lesson_dir.join(LESSON_MATERIAL_FILE);
1✔
887
        write_json(&material, &material_path)?;
1✔
888

889
        // Create an example exercise and all of its files.
890
        let front_content = "Front content";
1✔
891
        let front_path = lesson_dir.join("ex1.front.md");
1✔
892
        fs::write(front_path, front_content)?;
1✔
893

894
        let back_content = "Back content";
1✔
895
        let back_path = lesson_dir.join("ex1.back.md");
1✔
896
        fs::write(back_path, back_content)?;
1✔
897

898
        let exercise_name = "Exercise name";
1✔
899
        let exercise_name_path = lesson_dir.join("ex1.name.json");
1✔
900
        write_json(&exercise_name, &exercise_name_path)?;
1✔
901

902
        let exercise_description = "Exercise description";
1✔
903
        let exercise_description_path = lesson_dir.join("ex1.description.json");
1✔
904
        write_json(&exercise_description, &exercise_description_path)?;
1✔
905

906
        let exercise_type = ExerciseType::Procedural;
1✔
907
        let exercise_type_path = lesson_dir.join("ex1.type.json");
1✔
908
        write_json(&exercise_type, &exercise_type_path)?;
1✔
909

910
        // Create a test course manifest.
911
        let course_manifest = CourseManifest {
1✔
912
            id: "course1".into(),
1✔
913
            name: "Course 1".into(),
1✔
914
            dependencies: vec![],
1✔
915
            description: Some("Description".into()),
1✔
916
            authors: None,
1✔
917
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
1✔
918
            course_instructions: None,
1✔
919
            course_material: None,
1✔
920
            generator_config: None,
1✔
921
        };
922

923
        // Open the lesson directory.
924
        let (lesson, exercises) =
1✔
925
            KnowledgeBaseLesson::open_lesson(&lesson_dir, &course_manifest, "lesson1".into())?;
1✔
926

927
        // Verify the lesson.
928
        assert_eq!(lesson.name, Some(name.into()));
1✔
929
        assert_eq!(lesson.description, Some(description.into()));
1✔
930
        assert_eq!(lesson.dependencies, Some(dependencies));
1✔
931
        assert_eq!(lesson.metadata, Some(metadata));
1✔
932
        assert_eq!(
2✔
933
            lesson.instructions,
934
            Some(instructions_path.to_string_lossy().into())
1✔
935
        );
936
        assert_eq!(
2✔
937
            lesson.material,
938
            Some(material_path.to_string_lossy().into())
1✔
939
        );
940

941
        // Verify the exercise.
942
        assert_eq!(exercises.len(), 1);
1✔
943
        let exercise = &exercises[0];
1✔
944
        assert_eq!(exercise.name, Some(exercise_name.into()));
1✔
945
        assert_eq!(exercise.description, Some(exercise_description.into()));
1✔
946
        assert_eq!(exercise.exercise_type, Some(exercise_type));
1✔
947
        assert_eq!(
2✔
948
            exercise.front_file,
949
            lesson_dir
1✔
950
                .join("ex1.front.md")
951
                .to_str()
952
                .unwrap()
953
                .to_string()
954
        );
955
        assert_eq!(
1✔
956
            exercise.back_file.to_owned().unwrap_or_default(),
1✔
957
            lesson_dir.join("ex1.back.md").to_str().unwrap().to_string()
1✔
958
        );
959
        Ok(())
1✔
960
    }
2✔
961
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc