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

trane-project / trane / 19523095782

20 Nov 2025 02:14AM UTC coverage: 99.063% (-0.008%) from 99.071%
19523095782

Pull #358

github

web-flow
Merge 2c65d794b into 841cd5a2e
Pull Request #358: Fix division by zero bug in weighted average

67 of 68 new or added lines in 15 files covered. (98.53%)

1 existing line in 1 file now uncovered.

4865 of 4911 relevant lines covered (99.06%)

222712.35 hits per line

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

99.64
/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::{Context, Error, Result, anyhow};
5
use serde::{Deserialize, Serialize, de::DeserializeOwned};
6
use std::{
7
    collections::{BTreeMap, HashMap, HashSet},
8
    fs::{File, read_dir},
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 courses or lessons that a lesson supersedes.
26
pub const LESSON_SUPERSEDED_FILE: &str = "lesson.superseded.json";
27

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

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

34
/// The name of the file containing the metadata of a lesson.
35
pub const LESSON_METADATA_FILE: &str = "lesson.metadata.json";
36

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

40
/// The name of the file containing the lesson material.
41
pub const LESSON_MATERIAL_FILE: &str = "lesson.material.md";
42

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

46
/// The suffix of the file containing the back of the flashcard for an exercise.
47
pub const EXERCISE_BACK_SUFFIX: &str = ".back.md";
48

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

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

55
/// The suffix of the file containing the metadata of an exercise.
56
pub const EXERCISE_TYPE_SUFFIX: &str = ".type.json";
57

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

64
    /// The file containing the description of the lesson.
65
    LessonDescription,
66

67
    /// The file containing the dependencies of the lesson.
68
    LessonDependencies,
69

70
    /// The file containing the courses or lessons that the lesson supersedes.
71
    LessonSuperseded,
72

73
    /// The file containing the metadata of the lesson.
74
    LessonMetadata,
75

76
    /// The file containing the lesson instructions.
77
    LessonInstructions,
78

79
    /// The file containing the lesson material.
80
    LessonMaterial,
81

82
    /// The file containing the front of the flashcard for the exercise with the given short ID.
83
    ExerciseFront(String),
84

85
    /// The file containing the back of the flashcard for the exercise with the given short ID.
86
    ExerciseBack(String),
87

88
    /// The file containing the name of the exercise with the given short ID.
89
    ExerciseName(String),
90

91
    /// The file containing the description of the exercise with the given short ID.
92
    ExerciseDescription(String),
93

94
    /// The file containing the type of the exercise with the given short ID.
95
    ExerciseType(String),
96
}
97

98
impl KnowledgeBaseFile {
99
    /// Opens the knowledge base file at the given path and deserializes its contents.
100
    pub fn open<T: DeserializeOwned>(path: &Path) -> Result<T> {
35✔
101
        let display = path.display();
35✔
102
        let file =
34✔
103
            File::open(path).context(format!("cannot open knowledge base file {display}"))?;
35✔
104
        let reader = BufReader::new(file);
34✔
105
        serde_json::from_reader(reader)
34✔
106
            .context(format!("cannot parse knowledge base file {display}"))
34✔
107
    }
35✔
108
}
109

110
impl TryFrom<&str> for KnowledgeBaseFile {
111
    type Error = Error;
112

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

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

174
    /// The short ID of the lesson to which this exercise belongs.
175
    pub short_lesson_id: Ustr,
176

177
    /// The ID of the course to which this lesson belongs.
178
    pub course_id: Ustr,
179

180
    /// The path to the file containing the front of the flashcard.
181
    pub front_file: String,
182

183
    /// The path to the file containing the back of the flashcard. This path is optional, because a
184
    /// flashcard is not required to provide an answer.
185
    pub back_file: Option<String>,
186

187
    /// The name of the exercise to be presented to the user.
188
    pub name: Option<String>,
189

190
    /// An optional description of the exercise.
191
    pub description: Option<String>,
192

193
    /// The type of knowledge the exercise tests. Currently, Trane does not make any distinction
194
    /// between the types of exercises, but that will likely change in the future. The option to set
195
    /// the type is provided, but most users should not need to use it.
196
    pub exercise_type: Option<ExerciseType>,
197
}
198
//>@knowledge-base-exercise
199

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

226
        // Create the exercise with `None` values for all optimal fields.
227
        let mut exercise = KnowledgeBaseExercise {
101✔
228
            short_id: short_id.to_string(),
101✔
229
            short_lesson_id,
101✔
230
            course_id: course_manifest.id,
101✔
231
            front_file: lesson_root
101✔
232
                .join(format!("{short_id}{EXERCISE_FRONT_SUFFIX}"))
101✔
233
                .to_str()
101✔
234
                .unwrap_or_default()
101✔
235
                .to_string(),
101✔
236
            back_file,
101✔
237
            name: None,
101✔
238
            description: None,
101✔
239
            exercise_type: None,
101✔
240
        };
101✔
241

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

266
impl From<KnowledgeBaseExercise> for ExerciseManifest {
267
    /// Generates the manifest for this exercise.
268
    fn from(exercise: KnowledgeBaseExercise) -> Self {
101✔
269
        Self {
101✔
270
            id: format!(
101✔
271
                "{}::{}::{}",
101✔
272
                exercise.course_id, exercise.short_lesson_id, exercise.short_id
101✔
273
            )
101✔
274
            .into(),
101✔
275
            lesson_id: format!("{}::{}", exercise.course_id, exercise.short_lesson_id).into(),
101✔
276
            course_id: exercise.course_id,
101✔
277
            name: exercise
101✔
278
                .name
101✔
279
                .unwrap_or(format!("Exercise {}", exercise.short_id)),
101✔
280
            description: exercise.description,
101✔
281
            exercise_type: exercise.exercise_type.unwrap_or(ExerciseType::Procedural),
101✔
282
            exercise_asset: ExerciseAsset::FlashcardAsset {
101✔
283
                front_path: exercise.front_file,
101✔
284
                back_path: exercise.back_file,
101✔
285
            },
101✔
286
        }
101✔
287
    }
101✔
288
}
289

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

318
    /// The ID of the course to which this lesson belongs.
319
    pub course_id: Ustr,
320

321
    /// The IDs of all dependencies of this lesson. The values can be full lesson IDs or the short
322
    /// ID of one of the other lessons in the course. If Trane finds a dependency with a short ID,
323
    /// it will automatically generate the full lesson ID. Not setting this value will indicate that
324
    /// the lesson has no dependencies.
325
    pub dependencies: Vec<Ustr>,
326

327
    /// The IDs of all courses or lessons that this lesson supersedes. Like the dependencies, the
328
    /// values can be full lesson IDs or the short ID of one of the other lessons in the course. The
329
    /// same resolution rules apply.
330
    pub superseded: Vec<Ustr>,
331

332
    /// The name of the lesson to be presented to the user.
333
    pub name: Option<String>,
334

335
    /// An optional description of the lesson.
336
    pub description: Option<String>,
337

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

344
    /// Indicates whether the `lesson.instructions.md` file is present in the lesson directory.
345
    pub has_instructions: bool,
346

347
    /// Indicates whether the `lesson.material.md` file is present in the lesson directory.
348
    pub has_material: bool,
349
}
350
//>@knowledge-base-lesson
351

352
impl KnowledgeBaseLesson {
353
    // Filters out exercises that don't have both a front file. Exercises without a back file are
354
    // allowed, as it is not required to have one.
355
    fn filter_matching_exercises(exercise_files: &mut HashMap<String, Vec<KnowledgeBaseFile>>) {
22✔
356
        let mut to_remove = Vec::new();
22✔
357
        for (short_id, files) in &*exercise_files {
104✔
358
            let has_front = files
104✔
359
                .iter()
104✔
360
                .any(|file| matches!(file, KnowledgeBaseFile::ExerciseFront(_)));
125✔
361
            if !has_front {
104✔
362
                to_remove.push(short_id.clone());
1✔
363
            }
103✔
364
        }
365
        for short_id in to_remove {
22✔
366
            exercise_files.remove(&short_id);
1✔
367
        }
1✔
368
    }
22✔
369

370
    /// Generates the exercise from a list of knowledge base files.
371
    fn create_lesson(
21✔
372
        lesson_root: &Path,
21✔
373
        short_lesson_id: Ustr,
21✔
374
        course_manifest: &CourseManifest,
21✔
375
        files: &[KnowledgeBaseFile],
21✔
376
    ) -> Result<Self> {
21✔
377
        // Create the lesson with all the optional fields set to a default value.
378
        let mut lesson = Self {
21✔
379
            short_id: short_lesson_id,
21✔
380
            course_id: course_manifest.id,
21✔
381
            dependencies: vec![],
21✔
382
            superseded: vec![],
21✔
383
            name: None,
21✔
384
            description: None,
21✔
385
            metadata: None,
21✔
386
            has_instructions: false,
21✔
387
            has_material: false,
21✔
388
        };
21✔
389

390
        // Iterate through the lesson files found in the lesson directory and set the corresponding
391
        // field in the lesson.
392
        for lesson_file in files {
21✔
393
            match lesson_file {
20✔
394
                KnowledgeBaseFile::LessonDependencies => {
395
                    let path = lesson_root.join(LESSON_DEPENDENCIES_FILE);
14✔
396
                    lesson.dependencies = KnowledgeBaseFile::open(&path)?;
14✔
397
                }
398
                KnowledgeBaseFile::LessonSuperseded => {
399
                    let path = lesson_root.join(LESSON_SUPERSEDED_FILE);
1✔
400
                    lesson.superseded = KnowledgeBaseFile::open(&path)?;
1✔
401
                }
402
                KnowledgeBaseFile::LessonName => {
403
                    let path = lesson_root.join(LESSON_NAME_FILE);
1✔
404
                    lesson.name = Some(KnowledgeBaseFile::open(&path)?);
1✔
405
                }
406
                KnowledgeBaseFile::LessonDescription => {
407
                    let path = lesson_root.join(LESSON_DESCRIPTION_FILE);
1✔
408
                    lesson.description = Some(KnowledgeBaseFile::open(&path)?);
1✔
409
                }
410
                KnowledgeBaseFile::LessonMetadata => {
411
                    let path = lesson_root.join(LESSON_METADATA_FILE);
1✔
412
                    lesson.metadata = Some(KnowledgeBaseFile::open(&path)?);
1✔
413
                }
414
                KnowledgeBaseFile::LessonInstructions => lesson.has_instructions = true,
1✔
415
                KnowledgeBaseFile::LessonMaterial => lesson.has_material = true,
1✔
UNCOV
416
                _ => {}
×
417
            }
418
        }
419
        Ok(lesson)
21✔
420
    }
21✔
421

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

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

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

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

480
impl From<KnowledgeBaseLesson> for LessonManifest {
481
    /// Generates the manifest for this lesson.
482
    fn from(lesson: KnowledgeBaseLesson) -> Self {
21✔
483
        Self {
484
            id: format!("{}::{}", lesson.course_id, lesson.short_id).into(),
21✔
485
            course_id: lesson.course_id,
21✔
486
            dependencies: lesson.dependencies,
21✔
487
            superseded: lesson.superseded,
21✔
488
            name: lesson.name.unwrap_or(format!("Lesson {}", lesson.short_id)),
21✔
489
            description: lesson.description,
21✔
490
            metadata: lesson.metadata,
21✔
491
            lesson_instructions: if lesson.has_instructions {
21✔
492
                Some(BasicAsset::MarkdownAsset {
1✔
493
                    path: LESSON_INSTRUCTIONS_FILE.into(),
1✔
494
                })
1✔
495
            } else {
496
                None
20✔
497
            },
498
            lesson_material: if lesson.has_material {
21✔
499
                Some(BasicAsset::MarkdownAsset {
1✔
500
                    path: LESSON_MATERIAL_FILE.into(),
1✔
501
                })
1✔
502
            } else {
503
                None
20✔
504
            },
505
        }
506
    }
21✔
507
}
508

509
/// The configuration for a knowledge base course. Currently, this is an empty struct, but it is
510
/// added for consistency with other course generators and to implement the [`GenerateManifests`]
511
/// trait.
512
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
513
pub struct KnowledgeBaseConfig {}
514

515
impl KnowledgeBaseConfig {
516
    // Checks if the dependencies and the superseded units refer to another lesson in the course by
517
    // its short ID and updates them to refer to the full lesson ID.
518
    fn convert_to_full_ids(
3✔
519
        course_manifest: &CourseManifest,
3✔
520
        short_ids: &HashSet<Ustr>,
3✔
521
        lessons: &mut UstrMap<(KnowledgeBaseLesson, Vec<KnowledgeBaseExercise>)>,
3✔
522
    ) {
3✔
523
        for lesson in lessons.values_mut() {
21✔
524
            // Update dependencies.
525
            let updated_dependencies = lesson
21✔
526
                .0
21✔
527
                .dependencies
21✔
528
                .iter()
21✔
529
                .map(|unit_id| {
31✔
530
                    if short_ids.contains(unit_id) {
31✔
531
                        format!("{}::{}", course_manifest.id, unit_id).into()
30✔
532
                    } else {
533
                        *unit_id
1✔
534
                    }
535
                })
31✔
536
                .collect();
21✔
537
            lesson.0.dependencies = updated_dependencies;
21✔
538

539
            // Update superseded lessons or courses.
540
            let updated_superseded = lesson
21✔
541
                .0
21✔
542
                .superseded
21✔
543
                .iter()
21✔
544
                .map(|unit_id| {
21✔
545
                    if short_ids.contains(unit_id) {
2✔
546
                        format!("{}::{}", course_manifest.id, unit_id).into()
1✔
547
                    } else {
548
                        *unit_id
1✔
549
                    }
550
                })
2✔
551
                .collect();
21✔
552
            lesson.0.superseded = updated_superseded;
21✔
553
        }
554
    }
3✔
555
}
556

557
impl GenerateManifests for KnowledgeBaseConfig {
558
    fn generate_manifests(
2✔
559
        &self,
2✔
560
        course_root: &Path,
2✔
561
        course_manifest: &CourseManifest,
2✔
562
        _preferences: &UserPreferences,
2✔
563
    ) -> Result<GeneratedCourse> {
2✔
564
        // Create the lessons by iterating through all the directories in the course root,
565
        // processing only those whose name fits the pattern `<SHORT_LESSON_ID>.lesson`.
566
        let mut lessons = UstrMap::default();
2✔
567
        let valid_entries = read_dir(course_root)?
2✔
568
            .flatten()
2✔
569
            .filter(|entry| {
24✔
570
                let path = entry.path();
24✔
571
                path.is_dir()
24✔
572
            })
24✔
573
            .collect::<Vec<_>>();
2✔
574
        for entry in valid_entries {
22✔
575
            // Check if the directory name is in the format `<SHORT_LESSON_ID>.lesson`. If so, read
576
            // the knowledge base lesson and its exercises.
577
            let path = entry.path();
22✔
578
            let dir_name = path.file_name().unwrap_or_default().to_str().unwrap();
22✔
579
            if let Some(short_id) = dir_name.strip_suffix(LESSON_SUFFIX) {
22✔
580
                lessons.insert(
20✔
581
                    short_id.into(),
20✔
582
                    KnowledgeBaseLesson::open_lesson(&path, course_manifest, short_id.into())?,
20✔
583
                );
584
            }
2✔
585
        }
586

587
        // Convert all the dependencies to full lesson IDs.
588
        let short_ids: HashSet<Ustr> = lessons.keys().copied().collect();
2✔
589
        KnowledgeBaseConfig::convert_to_full_ids(course_manifest, &short_ids, &mut lessons);
2✔
590

591
        // Generate the manifests for all the lessons and exercises.
592
        let manifests: Vec<(LessonManifest, Vec<ExerciseManifest>)> = lessons
2✔
593
            .into_iter()
2✔
594
            .map(|(_, (lesson, exercises))| {
20✔
595
                let lesson_manifest = LessonManifest::from(lesson);
20✔
596
                let exercise_manifests =
20✔
597
                    exercises.into_iter().map(ExerciseManifest::from).collect();
20✔
598
                (lesson_manifest, exercise_manifests)
20✔
599
            })
20✔
600
            .collect();
2✔
601

602
        Ok(GeneratedCourse {
2✔
603
            lessons: manifests,
2✔
604
            updated_instructions: None,
2✔
605
            updated_metadata: None,
2✔
606
        })
2✔
607
    }
2✔
608
}
609

610
#[cfg(test)]
611
#[cfg_attr(coverage, coverage(off))]
612
mod test {
613
    use anyhow::Result;
614
    use std::{
615
        fs::{self, Permissions},
616
        io::{BufWriter, Write},
617
        os::unix::prelude::PermissionsExt,
618
    };
619

620
    use super::*;
621

622
    // Verifies opening a valid knowledge base file.
623
    #[test]
624
    fn open_knowledge_base_file() -> Result<()> {
625
        let temp_dir = tempfile::tempdir()?;
626
        let file_path = temp_dir.path().join("lesson.dependencies.properties");
627
        let mut file = File::create(&file_path)?;
628
        file.write_all(b"[\"lesson1\"]")?;
629

630
        let dependencies: Vec<String> = KnowledgeBaseFile::open(&file_path)?;
631
        assert_eq!(dependencies, vec!["lesson1".to_string()]);
632
        Ok(())
633
    }
634

635
    // Verifies the handling of invalid knowledge base files.
636
    #[test]
637
    fn open_knowledge_base_file_bad_format() -> Result<()> {
638
        let temp_dir = tempfile::tempdir()?;
639
        let file_path = temp_dir.path().join("lesson.dependencies.properties");
640
        let mut file = File::create(&file_path)?;
641
        file.write_all(b"[\"lesson1\"")?;
642

643
        let dependencies: Result<Vec<String>> = KnowledgeBaseFile::open(&file_path);
644
        assert!(dependencies.is_err());
645
        Ok(())
646
    }
647

648
    // Verifies the handling of knowledge base files that cannot be opened.
649
    #[test]
650
    fn open_knowledge_base_file_bad_permissions() -> Result<()> {
651
        let temp_dir = tempfile::tempdir()?;
652
        let file_path = temp_dir.path().join("lesson.dependencies.properties");
653
        let mut file = File::create(&file_path)?;
654
        file.write_all(b"[\"lesson1\"]")?;
655

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

659
        let dependencies: Result<Vec<String>> = KnowledgeBaseFile::open(&file_path);
660
        assert!(dependencies.is_err());
661
        Ok(())
662
    }
663

664
    // Verifies that the all the files with knowledge base names are detected correctly.
665
    #[test]
666
    fn to_knowledge_base_file() {
667
        // Parse lesson file names.
668
        assert_eq!(
669
            KnowledgeBaseFile::LessonDependencies,
670
            KnowledgeBaseFile::try_from(LESSON_DEPENDENCIES_FILE).unwrap(),
671
        );
672
        assert_eq!(
673
            KnowledgeBaseFile::LessonSuperseded,
674
            KnowledgeBaseFile::try_from(LESSON_SUPERSEDED_FILE).unwrap(),
675
        );
676
        assert_eq!(
677
            KnowledgeBaseFile::LessonDescription,
678
            KnowledgeBaseFile::try_from(LESSON_DESCRIPTION_FILE).unwrap(),
679
        );
680
        assert_eq!(
681
            KnowledgeBaseFile::LessonMetadata,
682
            KnowledgeBaseFile::try_from(LESSON_METADATA_FILE).unwrap(),
683
        );
684
        assert_eq!(
685
            KnowledgeBaseFile::LessonInstructions,
686
            KnowledgeBaseFile::try_from(LESSON_INSTRUCTIONS_FILE).unwrap(),
687
        );
688
        assert_eq!(
689
            KnowledgeBaseFile::LessonMaterial,
690
            KnowledgeBaseFile::try_from(LESSON_MATERIAL_FILE).unwrap(),
691
        );
692

693
        // Parse exercise file names.
694
        assert_eq!(
695
            KnowledgeBaseFile::ExerciseName("ex1".to_string()),
696
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_NAME_SUFFIX).as_str())
697
                .unwrap(),
698
        );
699
        assert_eq!(
700
            KnowledgeBaseFile::ExerciseFront("ex1".to_string()),
701
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_FRONT_SUFFIX).as_str())
702
                .unwrap(),
703
        );
704
        assert_eq!(
705
            KnowledgeBaseFile::ExerciseBack("ex1".to_string()),
706
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_BACK_SUFFIX).as_str())
707
                .unwrap(),
708
        );
709
        assert_eq!(
710
            KnowledgeBaseFile::ExerciseDescription("ex1".to_string()),
711
            KnowledgeBaseFile::try_from(
712
                format!("{}{}", "ex1", EXERCISE_DESCRIPTION_SUFFIX).as_str()
713
            )
714
            .unwrap(),
715
        );
716
        assert_eq!(
717
            KnowledgeBaseFile::ExerciseType("ex1".to_string()),
718
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_TYPE_SUFFIX).as_str())
719
                .unwrap(),
720
        );
721

722
        // Parse exercise file names with invalid exercise names.
723
        assert!(KnowledgeBaseFile::try_from("ex1").is_err());
724
    }
725

726
    // Verifies the conversion from a knowledge base lesson to a lesson manifest.
727
    #[test]
728
    fn lesson_to_manifest() {
729
        let lesson = KnowledgeBaseLesson {
730
            short_id: "lesson1".into(),
731
            course_id: "course1".into(),
732
            name: Some("Name".into()),
733
            description: Some("Description".into()),
734
            dependencies: vec!["lesson2".into()],
735
            superseded: vec!["lesson0".into()],
736
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
737
            has_instructions: true,
738
            has_material: true,
739
        };
740
        let expected_manifest = LessonManifest {
741
            id: "course1::lesson1".into(),
742
            course_id: "course1".into(),
743
            name: "Name".into(),
744
            description: Some("Description".into()),
745
            dependencies: vec!["lesson2".into()],
746
            superseded: vec!["lesson0".into()],
747
            lesson_instructions: Some(BasicAsset::MarkdownAsset {
748
                path: LESSON_INSTRUCTIONS_FILE.into(),
749
            }),
750
            lesson_material: Some(BasicAsset::MarkdownAsset {
751
                path: LESSON_MATERIAL_FILE.into(),
752
            }),
753
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
754
        };
755
        let actual_manifest: LessonManifest = lesson.into();
756
        assert_eq!(actual_manifest, expected_manifest);
757
    }
758

759
    // Verifies the conversion from a knowledge base exercise to an exercise manifest.
760
    #[test]
761
    fn exercise_to_manifest() {
762
        let exercise = KnowledgeBaseExercise {
763
            short_id: "ex1".into(),
764
            short_lesson_id: "lesson1".into(),
765
            course_id: "course1".into(),
766
            front_file: "ex1.front.md".into(),
767
            back_file: Some("ex1.back.md".into()),
768
            name: Some("Name".into()),
769
            description: Some("Description".into()),
770
            exercise_type: Some(ExerciseType::Procedural),
771
        };
772
        let expected_manifest = ExerciseManifest {
773
            id: "course1::lesson1::ex1".into(),
774
            lesson_id: "course1::lesson1".into(),
775
            course_id: "course1".into(),
776
            name: "Name".into(),
777
            description: Some("Description".into()),
778
            exercise_type: ExerciseType::Procedural,
779
            exercise_asset: ExerciseAsset::FlashcardAsset {
780
                front_path: "ex1.front.md".into(),
781
                back_path: Some("ex1.back.md".into()),
782
            },
783
        };
784
        let actual_manifest: ExerciseManifest = exercise.into();
785
        assert_eq!(actual_manifest, expected_manifest);
786
    }
787

788
    // Verifies that dependencies or superseded units referenced by their short IDs are converted
789
    // to full IDs.
790
    #[test]
791
    fn convert_to_full_ids() {
792
        // Create an example course manifest.
793
        let course_manifest = CourseManifest {
794
            id: "course1".into(),
795
            name: "Course 1".into(),
796
            dependencies: vec![],
797
            superseded: vec![],
798
            description: Some("Description".into()),
799
            authors: None,
800
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
801
            course_instructions: None,
802
            course_material: None,
803
            generator_config: None,
804
        };
805

806
        // Create an example lesson with a dependency referred to by its short ID and an example
807
        // exercise.
808
        let short_lesson_id = Ustr::from("lesson1");
809
        let lesson = KnowledgeBaseLesson {
810
            short_id: short_lesson_id,
811
            course_id: "course1".into(),
812
            name: Some("Name".into()),
813
            description: Some("Description".into()),
814
            dependencies: vec!["lesson2".into(), "other::lesson1".into()],
815
            superseded: vec!["lesson0".into(), "other::lesson0".into()],
816
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
817
            has_instructions: false,
818
            has_material: false,
819
        };
820
        let exercise = KnowledgeBaseExercise {
821
            short_id: "ex1".into(),
822
            short_lesson_id,
823
            course_id: "course1".into(),
824
            front_file: "ex1.front.md".into(),
825
            back_file: Some("ex1.back.md".into()),
826
            name: Some("Name".into()),
827
            description: Some("Description".into()),
828
            exercise_type: Some(ExerciseType::Procedural),
829
        };
830
        let mut lesson_map = UstrMap::default();
831
        lesson_map.insert("lesson1".into(), (lesson, vec![exercise]));
832

833
        // Convert the short IDs to full IDs.
834
        let short_ids =
835
            HashSet::from_iter(vec!["lesson0".into(), "lesson1".into(), "lesson2".into()]);
836
        KnowledgeBaseConfig::convert_to_full_ids(&course_manifest, &short_ids, &mut lesson_map);
837

838
        assert_eq!(
839
            lesson_map.get(&short_lesson_id).unwrap().0.dependencies,
840
            vec![Ustr::from("course1::lesson2"), "other::lesson1".into()]
841
        );
842
        assert_eq!(
843
            lesson_map.get(&short_lesson_id).unwrap().0.superseded,
844
            vec![Ustr::from("course1::lesson0"), "other::lesson0".into()]
845
        );
846
    }
847

848
    /// Verifies that exercises with a missing front or back files are filtered out.
849
    #[test]
850
    fn filter_matching_exercises() {
851
        let mut exercise_map = HashMap::default();
852
        // Exercise 1 has both a front and back file.
853
        let ex1_id: String = "ex1".into();
854
        let ex1_files = vec![
855
            KnowledgeBaseFile::ExerciseFront("ex1".into()),
856
            KnowledgeBaseFile::ExerciseBack("ex1".into()),
857
        ];
858
        // Exercise 2 only has a front file.
859
        let ex2_id: String = "ex2".into();
860
        let ex2_files = vec![KnowledgeBaseFile::ExerciseFront("ex2".into())];
861
        // Exercise 3 only has a back file.
862
        let ex3_id: String = "ex3".into();
863
        let ex3_files = vec![KnowledgeBaseFile::ExerciseBack("ex3".into())];
864
        exercise_map.insert(ex1_id.clone(), ex1_files);
865
        exercise_map.insert(ex2_id.clone(), ex2_files);
866
        exercise_map.insert(ex3_id.clone(), ex3_files);
867

868
        // Verify that the correct exercises were filtered out.
869
        KnowledgeBaseLesson::filter_matching_exercises(&mut exercise_map);
870
        let ex1_expected = vec![
871
            KnowledgeBaseFile::ExerciseFront("ex1".into()),
872
            KnowledgeBaseFile::ExerciseBack("ex1".into()),
873
        ];
874
        assert_eq!(exercise_map.get(&ex1_id).unwrap(), &ex1_expected);
875
        let ex2_expected = vec![KnowledgeBaseFile::ExerciseFront("ex2".into())];
876
        assert_eq!(exercise_map.get(&ex2_id).unwrap(), &ex2_expected);
877
        assert!(!exercise_map.contains_key(&ex3_id));
878
    }
879

880
    // Serializes the object in JSON and writes it to the given file.
881
    fn write_json<T: Serialize>(obj: &T, file: &Path) -> Result<()> {
882
        let file = File::create(file)?;
883
        let writer = BufWriter::new(file);
884
        serde_json::to_writer_pretty(writer, obj)?;
885
        Ok(())
886
    }
887

888
    // Verifies opening a lesson directory.
889
    #[test]
890
    fn open_lesson_dir() -> Result<()> {
891
        // Create a test course and lesson directory.
892
        let course_dir = tempfile::tempdir()?;
893
        let lesson_dir = course_dir.path().join("lesson1.lesson");
894
        fs::create_dir(&lesson_dir)?;
895

896
        // Create lesson files in the directory.
897
        let name = "Name";
898
        let name_path = lesson_dir.join(LESSON_NAME_FILE);
899
        write_json(&name, &name_path)?;
900

901
        let description = "Description";
902
        let description_path = lesson_dir.join(LESSON_DESCRIPTION_FILE);
903
        write_json(&description, &description_path)?;
904

905
        let dependencies: Vec<Ustr> = vec!["lesson2".into(), "lesson3".into()];
906
        let dependencies_path = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
907
        write_json(&dependencies, &dependencies_path)?;
908

909
        let superseded: Vec<Ustr> = vec!["lesson0".into()];
910
        let superseded_path = lesson_dir.join(LESSON_SUPERSEDED_FILE);
911
        write_json(&superseded, &superseded_path)?;
912

913
        let metadata: BTreeMap<String, Vec<String>> =
914
            BTreeMap::from([("key".into(), vec!["value".into()])]);
915
        let metadata_path = lesson_dir.join(LESSON_METADATA_FILE);
916
        write_json(&metadata, &metadata_path)?;
917

918
        let instructions = "instructions";
919
        let instructions_path = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
920
        write_json(&instructions, &instructions_path)?;
921

922
        let material = "material";
923
        let material_path = lesson_dir.join(LESSON_MATERIAL_FILE);
924
        write_json(&material, &material_path)?;
925

926
        // Create an example exercise and all of its files.
927
        let front_content = "Front content";
928
        let front_path = lesson_dir.join("ex1.front.md");
929
        fs::write(front_path, front_content)?;
930

931
        let back_content = "Back content";
932
        let back_path = lesson_dir.join("ex1.back.md");
933
        fs::write(back_path, back_content)?;
934

935
        let exercise_name = "Exercise name";
936
        let exercise_name_path = lesson_dir.join("ex1.name.json");
937
        write_json(&exercise_name, &exercise_name_path)?;
938

939
        let exercise_description = "Exercise description";
940
        let exercise_description_path = lesson_dir.join("ex1.description.json");
941
        write_json(&exercise_description, &exercise_description_path)?;
942

943
        let exercise_type = ExerciseType::Procedural;
944
        let exercise_type_path = lesson_dir.join("ex1.type.json");
945
        write_json(&exercise_type, &exercise_type_path)?;
946

947
        // Create a test course manifest.
948
        let course_manifest = CourseManifest {
949
            id: "course1".into(),
950
            name: "Course 1".into(),
951
            dependencies: vec![],
952
            superseded: vec![],
953
            description: Some("Description".into()),
954
            authors: None,
955
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
956
            course_instructions: None,
957
            course_material: None,
958
            generator_config: None,
959
        };
960

961
        // Open the lesson directory.
962
        let (lesson, exercises) =
963
            KnowledgeBaseLesson::open_lesson(&lesson_dir, &course_manifest, "lesson1".into())?;
964

965
        // Verify the lesson.
966
        assert_eq!(lesson.name, Some(name.into()));
967
        assert_eq!(lesson.description, Some(description.into()));
968
        assert_eq!(lesson.dependencies, dependencies);
969
        assert_eq!(lesson.superseded, superseded);
970
        assert_eq!(lesson.metadata, Some(metadata));
971
        assert!(lesson.has_instructions);
972
        assert!(lesson.has_material);
973

974
        // Verify the exercise.
975
        assert_eq!(exercises.len(), 1);
976
        let exercise = &exercises[0];
977
        assert_eq!(exercise.name, Some(exercise_name.into()));
978
        assert_eq!(exercise.description, Some(exercise_description.into()));
979
        assert_eq!(exercise.exercise_type, Some(exercise_type));
980
        assert_eq!(
981
            exercise.front_file,
982
            lesson_dir
983
                .join("ex1.front.md")
984
                .to_str()
985
                .unwrap()
986
                .to_string()
987
        );
988
        assert_eq!(
989
            exercise.back_file.clone().unwrap_or_default(),
990
            lesson_dir.join("ex1.back.md").to_str().unwrap().to_string()
991
        );
992
        Ok(())
993
    }
994
}
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