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

trane-project / trane / 12845908616

18 Jan 2025 05:10PM UTC coverage: 99.735% (+0.04%) from 99.691%
12845908616

Pull #322

github

web-flow
Merge 06c8dd83f into 3330fdf96
Pull Request #322: More coverage fixes

57 of 58 new or added lines in 5 files covered. (98.28%)

2 existing lines in 1 file now uncovered.

9019 of 9043 relevant lines covered (99.73%)

112185.21 hits per line

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

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

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

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

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

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

26
/// The name of the file containing the courses or lessons that a lesson supersedes.
27
pub const LESSON_SUPERSEDED_FILE: &str = "lesson.superseded.json";
28

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

21✔
461
        // Filter out exercises that don't have both a front and back file and create the knowledge
21✔
462
        // base exercises.
21✔
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
                )
101✔
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 {
21✔
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, TS)]
31✔
513
#[ts(export)]
514
pub struct KnowledgeBaseConfig {}
515

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

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

558
impl GenerateManifests for KnowledgeBaseConfig {
559
    fn generate_manifests(
2✔
560
        &self,
2✔
561
        course_root: &Path,
2✔
562
        course_manifest: &CourseManifest,
2✔
563
        _preferences: &UserPreferences,
2✔
564
    ) -> Result<GeneratedCourse> {
2✔
565
        // Create the lessons by iterating through all the directories in the course root,
2✔
566
        // processing only those whose name fits the pattern `<SHORT_LESSON_ID>.lesson`.
2✔
567
        let mut lessons = UstrMap::default();
2✔
568
        for entry in read_dir(course_root)? {
22✔
569
            // Ignore the entry if it's not a directory.
570
            let entry = entry?;
22✔
571
            let path = entry.path();
22✔
572
            if !path.is_dir() {
22✔
573
                continue;
2✔
574
            }
20✔
575

20✔
576
            // Check if the directory name is in the format `<SHORT_LESSON_ID>.lesson`. If so, read
20✔
577
            // the knowledge base lesson and its exercises.
20✔
578
            let dir_name = path.file_name().unwrap_or_default().to_str().unwrap();
20✔
579
            if let Some(short_id) = dir_name.strip_suffix(LESSON_SUFFIX) {
20✔
580
                lessons.insert(
20✔
581
                    short_id.into(),
20✔
582
                    KnowledgeBaseLesson::open_lesson(&path, course_manifest, short_id.into())?,
20✔
583
                );
584
            }
×
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

2✔
591
        // Generate the manifests for all the lessons and exercises.
2✔
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

2✔
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
mod test {
612
    use anyhow::Result;
613
    use std::{
614
        fs::{self, Permissions},
615
        io::{BufWriter, Write},
616
        os::unix::prelude::PermissionsExt,
617
    };
618

619
    use super::*;
620

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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