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

trane-project / trane / 21124797979

19 Jan 2026 03:58AM UTC coverage: 99.07% (+0.008%) from 99.062%
21124797979

Pull #359

github

web-flow
Merge a4ff7b6b4 into c2f7fa87a
Pull Request #359: Do not propagate rewards through units with declarative exercises

70 of 70 new or added lines in 8 files covered. (100.0%)

1 existing line in 1 file now uncovered.

4901 of 4947 relevant lines covered (99.07%)

296831.71 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 name of the file containing the default exercise type for exercises in the lesson.
44
pub const LESSON_DEFAULT_EXERCISE_TYPE_FILE: &str = "lesson.default_exercise_type.json";
45

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

49
/// The suffix of the file containing the back of the flashcard for an exercise.
50
pub const EXERCISE_BACK_SUFFIX: &str = ".back.md";
51

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

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

58
/// The suffix of the file containing the metadata of an exercise.
59
pub const EXERCISE_TYPE_SUFFIX: &str = ".type.json";
60

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

67
    /// The file containing the description of the lesson.
68
    LessonDescription,
69

70
    /// The file containing the dependencies of the lesson.
71
    LessonDependencies,
72

73
    /// The file containing the courses or lessons that the lesson supersedes.
74
    LessonSuperseded,
75

76
    /// The file containing the metadata of the lesson.
77
    LessonMetadata,
78

79
    /// The file containing the lesson instructions.
80
    LessonInstructions,
81

82
    /// The file containing the lesson material.
83
    LessonMaterial,
84

85
    /// The file containing the default exercise type for exercises in the lesson.
86
    LessonDefaultExerciseType,
87

88
    /// The file containing the front of the flashcard for the exercise with the given short ID.
89
    ExerciseFront(String),
90

91
    /// The file containing the back of the flashcard for the exercise with the given short ID.
92
    ExerciseBack(String),
93

94
    /// The file containing the name of the exercise with the given short ID.
95
    ExerciseName(String),
96

97
    /// The file containing the description of the exercise with the given short ID.
98
    ExerciseDescription(String),
99

100
    /// The file containing the type of the exercise with the given short ID.
101
    ExerciseType(String),
102
}
103

104
impl KnowledgeBaseFile {
105
    /// Opens the knowledge base file at the given path and deserializes its contents.
106
    pub fn open<T: DeserializeOwned>(path: &Path) -> Result<T> {
59✔
107
        let display = path.display();
59✔
108
        let file =
58✔
109
            File::open(path).context(format!("cannot open knowledge base file {display}"))?;
59✔
110
        let reader = BufReader::new(file);
58✔
111
        serde_json::from_reader(reader)
58✔
112
            .context(format!("cannot parse knowledge base file {display}"))
58✔
113
    }
59✔
114
}
115

116
impl TryFrom<&str> for KnowledgeBaseFile {
117
    type Error = Error;
118

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

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

178
    /// The short ID of the lesson to which this exercise belongs.
179
    pub short_lesson_id: Ustr,
180

181
    /// The ID of the course to which this lesson belongs.
182
    pub course_id: Ustr,
183

184
    /// The path to the file containing the front of the flashcard.
185
    pub front_file: String,
186

187
    /// The path to the file containing the back of the flashcard. This path is optional, because a
188
    /// flashcard is not required to provide an answer.
189
    pub back_file: Option<String>,
190

191
    /// The name of the exercise to be presented to the user.
192
    pub name: Option<String>,
193

194
    /// An optional description of the exercise.
195
    pub description: Option<String>,
196

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

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

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

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

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

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

322
    /// The ID of the course to which this lesson belongs.
323
    pub course_id: Ustr,
324

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

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

336
    /// The name of the lesson to be presented to the user.
337
    pub name: Option<String>,
338

339
    /// An optional description of the lesson.
340
    pub description: Option<String>,
341

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

348
    /// Indicates whether the `lesson.instructions.md` file is present in the lesson directory.
349
    pub has_instructions: bool,
350

351
    /// Indicates whether the `lesson.material.md` file is present in the lesson directory.
352
    pub has_material: bool,
353

354
    /// The default exercise type for exercises in this lesson.
355
    pub default_exercise_type: Option<ExerciseType>,
356
}
357
//>@knowledge-base-lesson
358

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

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

398
        // Iterate through the lesson files found in the lesson directory and set the corresponding
399
        // field in the lesson.
400
        for lesson_file in files {
44✔
401
            match lesson_file {
44✔
402
                KnowledgeBaseFile::LessonDependencies => {
403
                    let path = lesson_root.join(LESSON_DEPENDENCIES_FILE);
17✔
404
                    lesson.dependencies = KnowledgeBaseFile::open(&path)?;
17✔
405
                }
406
                KnowledgeBaseFile::LessonSuperseded => {
407
                    let path = lesson_root.join(LESSON_SUPERSEDED_FILE);
1✔
408
                    lesson.superseded = KnowledgeBaseFile::open(&path)?;
1✔
409
                }
410
                KnowledgeBaseFile::LessonName => {
411
                    let path = lesson_root.join(LESSON_NAME_FILE);
1✔
412
                    lesson.name = Some(KnowledgeBaseFile::open(&path)?);
1✔
413
                }
414
                KnowledgeBaseFile::LessonDescription => {
415
                    let path = lesson_root.join(LESSON_DESCRIPTION_FILE);
1✔
416
                    lesson.description = Some(KnowledgeBaseFile::open(&path)?);
1✔
417
                }
418
                KnowledgeBaseFile::LessonMetadata => {
419
                    let path = lesson_root.join(LESSON_METADATA_FILE);
1✔
420
                    lesson.metadata = Some(KnowledgeBaseFile::open(&path)?);
1✔
421
                }
422
                KnowledgeBaseFile::LessonInstructions => lesson.has_instructions = true,
1✔
423
                KnowledgeBaseFile::LessonMaterial => lesson.has_material = true,
1✔
424
                KnowledgeBaseFile::LessonDefaultExerciseType => {
425
                    let path = lesson_root.join(LESSON_DEFAULT_EXERCISE_TYPE_FILE);
21✔
426
                    lesson.default_exercise_type = Some(KnowledgeBaseFile::open(&path)?);
21✔
427
                }
UNCOV
428
                _ => {}
×
429
            }
430
        }
431
        Ok(lesson)
21✔
432
    }
21✔
433

434
    /// Opens a lesson from the knowledge base with the given root and short ID.
435
    fn open_lesson(
21✔
436
        lesson_root: &Path,
21✔
437
        course_manifest: &CourseManifest,
21✔
438
        short_lesson_id: Ustr,
21✔
439
    ) -> Result<(KnowledgeBaseLesson, Vec<KnowledgeBaseExercise>)> {
21✔
440
        // Iterate through the directory to find all the matching files in the lesson directory.
441
        let mut lesson_files = Vec::new();
21✔
442
        let mut exercise_files = HashMap::new();
21✔
443
        let kb_files = read_dir(lesson_root)?
21✔
444
            .flatten()
21✔
445
            .flat_map(|entry| {
209✔
446
                KnowledgeBaseFile::try_from(entry.file_name().to_str().unwrap_or_default())
209✔
447
            })
209✔
448
            .collect::<Vec<_>>();
21✔
449
        for kb_file in kb_files {
209✔
450
            match kb_file {
209✔
451
                KnowledgeBaseFile::ExerciseFront(ref short_id)
101✔
452
                | KnowledgeBaseFile::ExerciseBack(ref short_id)
61✔
453
                | KnowledgeBaseFile::ExerciseName(ref short_id)
1✔
454
                | KnowledgeBaseFile::ExerciseDescription(ref short_id)
1✔
455
                | KnowledgeBaseFile::ExerciseType(ref short_id) => {
165✔
456
                    exercise_files
165✔
457
                        .entry(short_id.clone())
165✔
458
                        .or_insert_with(Vec::new)
165✔
459
                        .push(kb_file);
165✔
460
                }
165✔
461
                _ => lesson_files.push(kb_file),
44✔
462
            }
463
        }
464

465
        // Create the knowledge base lesson.
466
        let lesson =
21✔
467
            Self::create_lesson(lesson_root, short_lesson_id, course_manifest, &lesson_files)?;
21✔
468

469
        // Remove exercises for the empty short ID. This can happen if the user has a file named
470
        // `.front.md`, for example.
471
        exercise_files.remove("");
21✔
472

473
        // Filter out exercises that don't have both a front and back file and create the knowledge
474
        // base exercises.
475
        Self::filter_matching_exercises(&mut exercise_files);
21✔
476
        let exercises = exercise_files
21✔
477
            .into_iter()
21✔
478
            .map(|(short_id, files)| {
101✔
479
                KnowledgeBaseExercise::create_exercise(
101✔
480
                    lesson_root,
101✔
481
                    &short_id,
101✔
482
                    short_lesson_id,
101✔
483
                    course_manifest,
101✔
484
                    &files,
101✔
485
                )
486
            })
101✔
487
            .collect::<Result<Vec<_>>>()?;
21✔
488
        Ok((lesson, exercises))
21✔
489
    }
21✔
490
}
491

492
impl From<KnowledgeBaseLesson> for LessonManifest {
493
    /// Generates the manifest for this lesson.
494
    fn from(lesson: KnowledgeBaseLesson) -> Self {
21✔
495
        Self {
496
            id: format!("{}::{}", lesson.course_id, lesson.short_id).into(),
21✔
497
            course_id: lesson.course_id,
21✔
498
            dependencies: lesson.dependencies,
21✔
499
            superseded: lesson.superseded,
21✔
500
            name: lesson.name.unwrap_or(format!("Lesson {}", lesson.short_id)),
21✔
501
            description: lesson.description,
21✔
502
            metadata: lesson.metadata,
21✔
503
            lesson_instructions: if lesson.has_instructions {
21✔
504
                Some(BasicAsset::MarkdownAsset {
1✔
505
                    path: LESSON_INSTRUCTIONS_FILE.into(),
1✔
506
                })
1✔
507
            } else {
508
                None
20✔
509
            },
510
            lesson_material: if lesson.has_material {
21✔
511
                Some(BasicAsset::MarkdownAsset {
1✔
512
                    path: LESSON_MATERIAL_FILE.into(),
1✔
513
                })
1✔
514
            } else {
515
                None
20✔
516
            },
517
            default_exercise_type: lesson.default_exercise_type,
21✔
518
        }
519
    }
21✔
520
}
521

522
/// The configuration for a knowledge base course. Currently, this is an empty struct, but it is
523
/// added for consistency with other course generators and to implement the [`GenerateManifests`]
524
/// trait.
525
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
526
pub struct KnowledgeBaseConfig {}
527

528
impl KnowledgeBaseConfig {
529
    // Checks if the dependencies and the superseded units refer to another lesson in the course by
530
    // its short ID and updates them to refer to the full lesson ID.
531
    fn convert_to_full_ids(
3✔
532
        course_manifest: &CourseManifest,
3✔
533
        short_ids: &HashSet<Ustr>,
3✔
534
        lessons: &mut UstrMap<(KnowledgeBaseLesson, Vec<KnowledgeBaseExercise>)>,
3✔
535
    ) {
3✔
536
        for lesson in lessons.values_mut() {
21✔
537
            // Update dependencies.
538
            let updated_dependencies = lesson
21✔
539
                .0
21✔
540
                .dependencies
21✔
541
                .iter()
21✔
542
                .map(|unit_id| {
44✔
543
                    if short_ids.contains(unit_id) {
44✔
544
                        format!("{}::{}", course_manifest.id, unit_id).into()
43✔
545
                    } else {
546
                        *unit_id
1✔
547
                    }
548
                })
44✔
549
                .collect();
21✔
550
            lesson.0.dependencies = updated_dependencies;
21✔
551

552
            // Update superseded lessons or courses.
553
            let updated_superseded = lesson
21✔
554
                .0
21✔
555
                .superseded
21✔
556
                .iter()
21✔
557
                .map(|unit_id| {
21✔
558
                    if short_ids.contains(unit_id) {
2✔
559
                        format!("{}::{}", course_manifest.id, unit_id).into()
1✔
560
                    } else {
561
                        *unit_id
1✔
562
                    }
563
                })
2✔
564
                .collect();
21✔
565
            lesson.0.superseded = updated_superseded;
21✔
566
        }
567
    }
3✔
568
}
569

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

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

604
        // Generate the manifests for all the lessons and exercises.
605
        let manifests: Vec<(LessonManifest, Vec<ExerciseManifest>)> = lessons
2✔
606
            .into_iter()
2✔
607
            .map(|(_, (lesson, exercises))| {
20✔
608
                let lesson_manifest = LessonManifest::from(lesson);
20✔
609
                let exercise_manifests =
20✔
610
                    exercises.into_iter().map(ExerciseManifest::from).collect();
20✔
611
                (lesson_manifest, exercise_manifests)
20✔
612
            })
20✔
613
            .collect();
2✔
614

615
        Ok(GeneratedCourse {
2✔
616
            lessons: manifests,
2✔
617
            updated_instructions: None,
2✔
618
            updated_metadata: None,
2✔
619
        })
2✔
620
    }
2✔
621
}
622

623
#[cfg(test)]
624
#[cfg_attr(coverage, coverage(off))]
625
mod test {
626
    use anyhow::Result;
627
    use std::{
628
        fs::{self, Permissions},
629
        io::{BufWriter, Write},
630
        os::unix::prelude::PermissionsExt,
631
    };
632

633
    use super::*;
634

635
    // Verifies opening a valid knowledge base file.
636
    #[test]
637
    fn open_knowledge_base_file() -> 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: Vec<String> = KnowledgeBaseFile::open(&file_path)?;
644
        assert_eq!(dependencies, vec!["lesson1".to_string()]);
645
        Ok(())
646
    }
647

648
    // Verifies the handling of invalid knowledge base files.
649
    #[test]
650
    fn open_knowledge_base_file_bad_format() -> 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
        let dependencies: Result<Vec<String>> = KnowledgeBaseFile::open(&file_path);
657
        assert!(dependencies.is_err());
658
        Ok(())
659
    }
660

661
    // Verifies the handling of knowledge base files that cannot be opened.
662
    #[test]
663
    fn open_knowledge_base_file_bad_permissions() -> Result<()> {
664
        let temp_dir = tempfile::tempdir()?;
665
        let file_path = temp_dir.path().join("lesson.dependencies.properties");
666
        let mut file = File::create(&file_path)?;
667
        file.write_all(b"[\"lesson1\"]")?;
668

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

672
        let dependencies: Result<Vec<String>> = KnowledgeBaseFile::open(&file_path);
673
        assert!(dependencies.is_err());
674
        Ok(())
675
    }
676

677
    // Verifies that the all the files with knowledge base names are detected correctly.
678
    #[test]
679
    fn to_knowledge_base_file() {
680
        // Parse lesson file names.
681
        assert_eq!(
682
            KnowledgeBaseFile::LessonDependencies,
683
            KnowledgeBaseFile::try_from(LESSON_DEPENDENCIES_FILE).unwrap(),
684
        );
685
        assert_eq!(
686
            KnowledgeBaseFile::LessonSuperseded,
687
            KnowledgeBaseFile::try_from(LESSON_SUPERSEDED_FILE).unwrap(),
688
        );
689
        assert_eq!(
690
            KnowledgeBaseFile::LessonDescription,
691
            KnowledgeBaseFile::try_from(LESSON_DESCRIPTION_FILE).unwrap(),
692
        );
693
        assert_eq!(
694
            KnowledgeBaseFile::LessonMetadata,
695
            KnowledgeBaseFile::try_from(LESSON_METADATA_FILE).unwrap(),
696
        );
697
        assert_eq!(
698
            KnowledgeBaseFile::LessonInstructions,
699
            KnowledgeBaseFile::try_from(LESSON_INSTRUCTIONS_FILE).unwrap(),
700
        );
701
        assert_eq!(
702
            KnowledgeBaseFile::LessonMaterial,
703
            KnowledgeBaseFile::try_from(LESSON_MATERIAL_FILE).unwrap(),
704
        );
705

706
        // Parse exercise file names.
707
        assert_eq!(
708
            KnowledgeBaseFile::ExerciseName("ex1".to_string()),
709
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_NAME_SUFFIX).as_str())
710
                .unwrap(),
711
        );
712
        assert_eq!(
713
            KnowledgeBaseFile::ExerciseFront("ex1".to_string()),
714
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_FRONT_SUFFIX).as_str())
715
                .unwrap(),
716
        );
717
        assert_eq!(
718
            KnowledgeBaseFile::ExerciseBack("ex1".to_string()),
719
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_BACK_SUFFIX).as_str())
720
                .unwrap(),
721
        );
722
        assert_eq!(
723
            KnowledgeBaseFile::ExerciseDescription("ex1".to_string()),
724
            KnowledgeBaseFile::try_from(
725
                format!("{}{}", "ex1", EXERCISE_DESCRIPTION_SUFFIX).as_str()
726
            )
727
            .unwrap(),
728
        );
729
        assert_eq!(
730
            KnowledgeBaseFile::ExerciseType("ex1".to_string()),
731
            KnowledgeBaseFile::try_from(format!("{}{}", "ex1", EXERCISE_TYPE_SUFFIX).as_str())
732
                .unwrap(),
733
        );
734

735
        // Parse exercise file names with invalid exercise names.
736
        assert!(KnowledgeBaseFile::try_from("ex1").is_err());
737
    }
738

739
    // Verifies the conversion from a knowledge base lesson to a lesson manifest.
740
    #[test]
741
    fn lesson_to_manifest() {
742
        let lesson = KnowledgeBaseLesson {
743
            short_id: "lesson1".into(),
744
            course_id: "course1".into(),
745
            name: Some("Name".into()),
746
            description: Some("Description".into()),
747
            dependencies: vec!["lesson2".into()],
748
            superseded: vec!["lesson0".into()],
749
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
750
            has_instructions: true,
751
            has_material: true,
752
            default_exercise_type: Some(ExerciseType::Declarative),
753
        };
754
        let expected_manifest = LessonManifest {
755
            id: "course1::lesson1".into(),
756
            course_id: "course1".into(),
757
            name: "Name".into(),
758
            description: Some("Description".into()),
759
            dependencies: vec!["lesson2".into()],
760
            superseded: vec!["lesson0".into()],
761
            lesson_instructions: Some(BasicAsset::MarkdownAsset {
762
                path: LESSON_INSTRUCTIONS_FILE.into(),
763
            }),
764
            lesson_material: Some(BasicAsset::MarkdownAsset {
765
                path: LESSON_MATERIAL_FILE.into(),
766
            }),
767
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
768
            default_exercise_type: Some(ExerciseType::Declarative),
769
        };
770
        let actual_manifest: LessonManifest = lesson.into();
771
        assert_eq!(actual_manifest, expected_manifest);
772
    }
773

774
    // Verifies the conversion from a knowledge base exercise to an exercise manifest.
775
    #[test]
776
    fn exercise_to_manifest() {
777
        let exercise = KnowledgeBaseExercise {
778
            short_id: "ex1".into(),
779
            short_lesson_id: "lesson1".into(),
780
            course_id: "course1".into(),
781
            front_file: "ex1.front.md".into(),
782
            back_file: Some("ex1.back.md".into()),
783
            name: Some("Name".into()),
784
            description: Some("Description".into()),
785
            exercise_type: Some(ExerciseType::Procedural),
786
        };
787
        let expected_manifest = ExerciseManifest {
788
            id: "course1::lesson1::ex1".into(),
789
            lesson_id: "course1::lesson1".into(),
790
            course_id: "course1".into(),
791
            name: "Name".into(),
792
            description: Some("Description".into()),
793
            exercise_type: ExerciseType::Procedural,
794
            exercise_asset: ExerciseAsset::FlashcardAsset {
795
                front_path: "ex1.front.md".into(),
796
                back_path: Some("ex1.back.md".into()),
797
            },
798
        };
799
        let actual_manifest: ExerciseManifest = exercise.into();
800
        assert_eq!(actual_manifest, expected_manifest);
801
    }
802

803
    // Verifies that dependencies or superseded units referenced by their short IDs are converted
804
    // to full IDs.
805
    #[test]
806
    fn convert_to_full_ids() {
807
        // Create an example course manifest.
808
        let course_manifest = CourseManifest {
809
            id: "course1".into(),
810
            name: "Course 1".into(),
811
            dependencies: vec![],
812
            superseded: vec![],
813
            description: Some("Description".into()),
814
            authors: None,
815
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
816
            course_instructions: None,
817
            course_material: None,
818
            default_exercise_type: None,
819
            generator_config: None,
820
        };
821

822
        // Create an example lesson with a dependency referred to by its short ID and an example
823
        // exercise.
824
        let short_lesson_id = Ustr::from("lesson1");
825
        let lesson = KnowledgeBaseLesson {
826
            short_id: short_lesson_id,
827
            course_id: "course1".into(),
828
            name: Some("Name".into()),
829
            description: Some("Description".into()),
830
            dependencies: vec!["lesson2".into(), "other::lesson1".into()],
831
            superseded: vec!["lesson0".into(), "other::lesson0".into()],
832
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
833
            has_instructions: false,
834
            has_material: false,
835
            default_exercise_type: None,
836
        };
837
        let exercise = KnowledgeBaseExercise {
838
            short_id: "ex1".into(),
839
            short_lesson_id,
840
            course_id: "course1".into(),
841
            front_file: "ex1.front.md".into(),
842
            back_file: Some("ex1.back.md".into()),
843
            name: Some("Name".into()),
844
            description: Some("Description".into()),
845
            exercise_type: Some(ExerciseType::Procedural),
846
        };
847
        let mut lesson_map = UstrMap::default();
848
        lesson_map.insert("lesson1".into(), (lesson, vec![exercise]));
849

850
        // Convert the short IDs to full IDs.
851
        let short_ids =
852
            HashSet::from_iter(vec!["lesson0".into(), "lesson1".into(), "lesson2".into()]);
853
        KnowledgeBaseConfig::convert_to_full_ids(&course_manifest, &short_ids, &mut lesson_map);
854

855
        assert_eq!(
856
            lesson_map.get(&short_lesson_id).unwrap().0.dependencies,
857
            vec![Ustr::from("course1::lesson2"), "other::lesson1".into()]
858
        );
859
        assert_eq!(
860
            lesson_map.get(&short_lesson_id).unwrap().0.superseded,
861
            vec![Ustr::from("course1::lesson0"), "other::lesson0".into()]
862
        );
863
    }
864

865
    /// Verifies that exercises with a missing front or back files are filtered out.
866
    #[test]
867
    fn filter_matching_exercises() {
868
        let mut exercise_map = HashMap::default();
869
        // Exercise 1 has both a front and back file.
870
        let ex1_id: String = "ex1".into();
871
        let ex1_files = vec![
872
            KnowledgeBaseFile::ExerciseFront("ex1".into()),
873
            KnowledgeBaseFile::ExerciseBack("ex1".into()),
874
        ];
875
        // Exercise 2 only has a front file.
876
        let ex2_id: String = "ex2".into();
877
        let ex2_files = vec![KnowledgeBaseFile::ExerciseFront("ex2".into())];
878
        // Exercise 3 only has a back file.
879
        let ex3_id: String = "ex3".into();
880
        let ex3_files = vec![KnowledgeBaseFile::ExerciseBack("ex3".into())];
881
        exercise_map.insert(ex1_id.clone(), ex1_files);
882
        exercise_map.insert(ex2_id.clone(), ex2_files);
883
        exercise_map.insert(ex3_id.clone(), ex3_files);
884

885
        // Verify that the correct exercises were filtered out.
886
        KnowledgeBaseLesson::filter_matching_exercises(&mut exercise_map);
887
        let ex1_expected = vec![
888
            KnowledgeBaseFile::ExerciseFront("ex1".into()),
889
            KnowledgeBaseFile::ExerciseBack("ex1".into()),
890
        ];
891
        assert_eq!(exercise_map.get(&ex1_id).unwrap(), &ex1_expected);
892
        let ex2_expected = vec![KnowledgeBaseFile::ExerciseFront("ex2".into())];
893
        assert_eq!(exercise_map.get(&ex2_id).unwrap(), &ex2_expected);
894
        assert!(!exercise_map.contains_key(&ex3_id));
895
    }
896

897
    // Serializes the object in JSON and writes it to the given file.
898
    fn write_json<T: Serialize>(obj: &T, file: &Path) -> Result<()> {
899
        let file = File::create(file)?;
900
        let writer = BufWriter::new(file);
901
        serde_json::to_writer_pretty(writer, obj)?;
902
        Ok(())
903
    }
904

905
    // Verifies opening a lesson directory.
906
    #[test]
907
    fn open_lesson_dir() -> Result<()> {
908
        // Create a test course and lesson directory.
909
        let course_dir = tempfile::tempdir()?;
910
        let lesson_dir = course_dir.path().join("lesson1.lesson");
911
        fs::create_dir(&lesson_dir)?;
912

913
        // Create lesson files in the directory.
914
        let name = "Name";
915
        let name_path = lesson_dir.join(LESSON_NAME_FILE);
916
        write_json(&name, &name_path)?;
917

918
        let description = "Description";
919
        let description_path = lesson_dir.join(LESSON_DESCRIPTION_FILE);
920
        write_json(&description, &description_path)?;
921

922
        let dependencies: Vec<Ustr> = vec!["lesson2".into(), "lesson3".into()];
923
        let dependencies_path = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
924
        write_json(&dependencies, &dependencies_path)?;
925

926
        let superseded: Vec<Ustr> = vec!["lesson0".into()];
927
        let superseded_path = lesson_dir.join(LESSON_SUPERSEDED_FILE);
928
        write_json(&superseded, &superseded_path)?;
929

930
        let metadata: BTreeMap<String, Vec<String>> =
931
            BTreeMap::from([("key".into(), vec!["value".into()])]);
932
        let metadata_path = lesson_dir.join(LESSON_METADATA_FILE);
933
        write_json(&metadata, &metadata_path)?;
934

935
        let instructions = "instructions";
936
        let instructions_path = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
937
        write_json(&instructions, &instructions_path)?;
938

939
        let material = "material";
940
        let material_path = lesson_dir.join(LESSON_MATERIAL_FILE);
941
        write_json(&material, &material_path)?;
942

943
        let default_ex_type = ExerciseType::Declarative;
944
        let default_ex_type_path = lesson_dir.join(LESSON_DEFAULT_EXERCISE_TYPE_FILE);
945
        write_json(&default_ex_type, &default_ex_type_path)?;
946

947
        // Create an example exercise and all of its files.
948
        let front_content = "Front content";
949
        let front_path = lesson_dir.join("ex1.front.md");
950
        fs::write(front_path, front_content)?;
951

952
        let back_content = "Back content";
953
        let back_path = lesson_dir.join("ex1.back.md");
954
        fs::write(back_path, back_content)?;
955

956
        let exercise_name = "Exercise name";
957
        let exercise_name_path = lesson_dir.join("ex1.name.json");
958
        write_json(&exercise_name, &exercise_name_path)?;
959

960
        let exercise_description = "Exercise description";
961
        let exercise_description_path = lesson_dir.join("ex1.description.json");
962
        write_json(&exercise_description, &exercise_description_path)?;
963

964
        let exercise_type = ExerciseType::Procedural;
965
        let exercise_type_path = lesson_dir.join("ex1.type.json");
966
        write_json(&exercise_type, &exercise_type_path)?;
967

968
        // Create a test course manifest.
969
        let course_manifest = CourseManifest {
970
            id: "course1".into(),
971
            name: "Course 1".into(),
972
            dependencies: vec![],
973
            superseded: vec![],
974
            description: Some("Description".into()),
975
            authors: None,
976
            metadata: Some(BTreeMap::from([("key".into(), vec!["value".into()])])),
977
            course_instructions: None,
978
            course_material: None,
979
            default_exercise_type: None,
980
            generator_config: None,
981
        };
982

983
        // Open the lesson directory.
984
        let (lesson, exercises) =
985
            KnowledgeBaseLesson::open_lesson(&lesson_dir, &course_manifest, "lesson1".into())?;
986

987
        // Verify the lesson.
988
        assert_eq!(lesson.name, Some(name.into()));
989
        assert_eq!(lesson.description, Some(description.into()));
990
        assert_eq!(lesson.dependencies, dependencies);
991
        assert_eq!(lesson.superseded, superseded);
992
        assert_eq!(lesson.metadata, Some(metadata));
993
        assert!(lesson.has_instructions);
994
        assert!(lesson.has_material);
995
        assert_eq!(lesson.default_exercise_type, Some(default_ex_type));
996

997
        // Verify the exercise.
998
        assert_eq!(exercises.len(), 1);
999
        let exercise = &exercises[0];
1000
        assert_eq!(exercise.name, Some(exercise_name.into()));
1001
        assert_eq!(exercise.description, Some(exercise_description.into()));
1002
        assert_eq!(exercise.exercise_type, Some(exercise_type));
1003
        assert_eq!(
1004
            exercise.front_file,
1005
            lesson_dir
1006
                .join("ex1.front.md")
1007
                .to_str()
1008
                .unwrap()
1009
                .to_string()
1010
        );
1011
        assert_eq!(
1012
            exercise.back_file.clone().unwrap_or_default(),
1013
            lesson_dir.join("ex1.back.md").to_str().unwrap().to_string()
1014
        );
1015
        Ok(())
1016
    }
1017
}
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