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

trane-project / trane / 4332536058

pending completion
4332536058

Pull #195

github

GitHub
Merge 6f0eb664a into d34a10d66
Pull Request #195: Add default fields to serializable types

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

5411 of 5412 relevant lines covered (99.98%)

102822.13 hits per line

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

100.0
/src/course_builder/knowledge_base_builder.rs
1
//! Contains utilities to make it easier to build knowledge base courses.
2
//!
3
//! The knowledge base course format is a plain-text format that is intended to be easy to edit by
4
//! hand. This module contains utilities to make it easier to generate these files, specially for
5
//! testing purposes.
6

7
use anyhow::{ensure, Context, Result};
8
use serde::{Deserialize, Serialize};
9
use std::{
10
    collections::{BTreeMap, HashSet},
11
    fs::{self, create_dir_all, File},
12
    io::Write,
13
    path::Path,
14
};
15
use ustr::Ustr;
16

17
use crate::{
18
    course_builder::AssetBuilder,
19
    course_library::COURSE_MANIFEST_FILENAME,
20
    data::{course_generator::knowledge_base::*, CourseManifest},
21
};
22

23
/// A builder to generate a knowledge base exercise and associated assets.
24
pub struct ExerciseBuilder {
25
    /// The knowledge base exercise to build.
26
    pub exercise: KnowledgeBaseExercise,
27

28
    /// The assets associated with this exercise, which include the front and back of the flashcard.
29
    pub asset_builders: Vec<AssetBuilder>,
30
}
31

32
impl ExerciseBuilder {
33
    /// Writes the files needed for this exercise to the given directory.
34
    pub fn build(&self, lesson_directory: &Path) -> Result<()> {
105✔
35
        // Build all the assets.
36
        for builder in &self.asset_builders {
273✔
37
            builder.build(lesson_directory)?;
168✔
38
        }
39

40
        // Write the exercise properties to the corresponding file.
41
        if let Some(name) = &self.exercise.name {
105✔
42
            let name_json = serde_json::to_string_pretty(name)?;
1✔
43
            let name_path = lesson_directory.join(format!(
1✔
44
                "{}{}",
45
                self.exercise.short_id, EXERCISE_NAME_SUFFIX
46
            ));
47
            let mut name_file = File::create(name_path)?;
1✔
48
            name_file.write_all(name_json.as_bytes())?;
1✔
49
        }
1✔
50
        if let Some(description) = &self.exercise.description {
105✔
51
            let description_json = serde_json::to_string_pretty(description)?;
1✔
52
            let description_path = lesson_directory.join(format!(
1✔
53
                "{}{}",
54
                self.exercise.short_id, EXERCISE_DESCRIPTION_SUFFIX
55
            ));
56
            let mut description_file = File::create(description_path)?;
1✔
57
            description_file.write_all(description_json.as_bytes())?;
1✔
58
        }
1✔
59
        if let Some(exercise_type) = &self.exercise.exercise_type {
105✔
60
            let exercise_type_json = serde_json::to_string_pretty(exercise_type)?;
1✔
61
            let exercise_type_path = lesson_directory.join(format!(
1✔
62
                "{}{}",
63
                self.exercise.short_id, EXERCISE_TYPE_SUFFIX
64
            ));
65
            let mut exercise_type_file = File::create(exercise_type_path)?;
1✔
66
            exercise_type_file.write_all(exercise_type_json.as_bytes())?;
1✔
67
        }
1✔
68
        Ok(())
105✔
69
    }
105✔
70
}
71

72
/// A builder to generate a knowledge base lesson and associated assets.
73
pub struct LessonBuilder {
74
    /// The knowledge base lesson to build.
75
    pub lesson: KnowledgeBaseLesson,
76

77
    /// The exercise builders for this lesson.
78
    pub exercises: Vec<ExerciseBuilder>,
79

80
    /// The assets associated with this lesson, which include the lesson instructions and materials.
81
    pub asset_builders: Vec<AssetBuilder>,
82
}
83

84
impl LessonBuilder {
85
    /// Writes the files needed for this lesson to the given directory.
86
    pub fn build(&self, lesson_directory: &Path) -> Result<()> {
23✔
87
        // Create the lesson directory.
88
        create_dir_all(lesson_directory)?;
23✔
89

90
        // Build all the assets.
91
        for builder in &self.asset_builders {
28✔
92
            builder.build(lesson_directory)?;
5✔
93
        }
94

95
        // Build all the exercises.
96
        for builder in &self.exercises {
128✔
97
            builder.build(lesson_directory)?;
105✔
98
        }
99

100
        // Write the lesson properties to the corresponding file.
101
        if let Some(name) = &self.lesson.name {
23✔
102
            let name_json = serde_json::to_string_pretty(name)?;
1✔
103
            let name_path = lesson_directory.join(LESSON_NAME_FILE);
1✔
104
            let mut name_file = File::create(name_path)?;
1✔
105
            name_file.write_all(name_json.as_bytes())?;
1✔
106
        }
1✔
107
        if let Some(description) = &self.lesson.description {
23✔
108
            let description_json = serde_json::to_string_pretty(description)?;
1✔
109
            let description_path = lesson_directory.join(LESSON_DESCRIPTION_FILE);
1✔
110
            let mut description_file = File::create(description_path)?;
1✔
111
            description_file.write_all(description_json.as_bytes())?;
1✔
112
        }
1✔
113
        if let Some(dependencies) = &self.lesson.dependencies {
23✔
114
            let dependencies_json = serde_json::to_string_pretty(dependencies)?;
22✔
115
            let dependencies_path = lesson_directory.join(LESSON_DEPENDENCIES_FILE);
22✔
116
            let mut dependencies_file = File::create(dependencies_path)?;
22✔
117
            dependencies_file.write_all(dependencies_json.as_bytes())?;
22✔
118
        }
22✔
119
        if let Some(metadata) = &self.lesson.metadata {
23✔
120
            let metadata_json = serde_json::to_string_pretty(metadata)?;
2✔
121
            let metadata_path = lesson_directory.join(LESSON_METADATA_FILE);
2✔
122
            let mut metadata_file = File::create(metadata_path)?;
2✔
123
            metadata_file.write_all(metadata_json.as_bytes())?;
2✔
124
        }
2✔
125
        Ok(())
23✔
126
    }
23✔
127
}
128

129
/// A builder to generate a knowledge base course and associated assets.
130
pub struct CourseBuilder {
131
    /// Base name of the directory on which to store this course.
132
    pub directory_name: String,
133

134
    /// The builders for the lessons in this course.
135
    pub lessons: Vec<LessonBuilder>,
136

137
    /// The assets associated with this course.
138
    pub assets: Vec<AssetBuilder>,
139

140
    /// The manifest for this course.
141
    pub manifest: CourseManifest,
142
}
143

144
impl CourseBuilder {
145
    /// Writes the files needed for this course to the given directory.
146
    pub fn build(&self, parent_directory: &Path) -> Result<()> {
3✔
147
        // Verify that the directory doesn't already exist and create it.
148
        let course_directory = parent_directory.join(&self.directory_name);
3✔
149
        ensure!(
3✔
150
            !course_directory.is_dir(),
3✔
151
            "course directory {} already exists",
152
            course_directory.display(), // grcov-excl-line
153
        );
154
        create_dir_all(&course_directory)?;
3✔
155

156
        // Write all the assets.
157
        for builder in &self.assets {
5✔
158
            builder.build(&course_directory)?;
2✔
159
        }
160

161
        // For each lesson in the course, create a directory with the name
162
        // `<LESSON_SHORT_ID>.lesson` and build the lesson in that directory.
163
        for builder in &self.lessons {
24✔
164
            let lesson_directory =
165
                course_directory.join(format!("{}{}", builder.lesson.short_id, LESSON_SUFFIX));
21✔
166
            fs::create_dir_all(&lesson_directory)?;
21✔
167
            builder.build(&lesson_directory)?;
21✔
168
        }
21✔
169

170
        // Write the manifest to disk.
171
        let manifest_json = serde_json::to_string_pretty(&self.manifest)? + "\n";
3✔
172
        let manifest_path = course_directory.join("course_manifest.json");
3✔
173
        let mut manifest_file = File::create(manifest_path)?;
3✔
174
        manifest_file.write_all(manifest_json.as_bytes())?;
3✔
175
        Ok(())
3✔
176
    }
3✔
177
}
178

179
/// Represents a simple knowledge base exercise which only specifies the short ID of the exercise,
180
/// and the front and (optional) back of the card, which in a lot of cases are enough to deliver full
181
/// functionality of Trane. It is meant to help course authors write simple knowledge base courses by
182
/// writing a simple configuration to a single JSON file.
183
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184
pub struct SimpleKnowledgeBaseExercise {
185
    /// The short ID of the exercise.
186
    pub short_id: String,
8✔
187

188
    /// The content of the front of the card.
189
    pub front: Vec<String>,
8✔
190

191
    /// The optional content of the back of the card. If the list is empty, no file will be created.
192
    #[serde(default)]
193
    pub back: Vec<String>,
8✔
194
}
195

196
impl SimpleKnowledgeBaseExercise {
197
    /// Generates the exercise builder for this simple knowledge base exercise.
198
    fn generate_exercise_builder(
5✔
199
        &self,
200
        short_lesson_id: Ustr,
201
        course_id: Ustr,
202
    ) -> Result<ExerciseBuilder> {
203
        // Ensure that the short ID is not empty.
204
        ensure!(!self.short_id.is_empty(), "short ID cannot be empty");
5✔
205

206
        // Generate the asset builders for the front and back of the card.
207
        let front_file = format!("{}{}", self.short_id, EXERCISE_FRONT_SUFFIX);
4✔
208
        let back_file = if !self.back.is_empty() {
4✔
209
            Some(format!("{}{}", self.short_id, EXERCISE_BACK_SUFFIX))
2✔
210
        } else {
211
            None
2✔
212
        };
213

214
        let mut asset_builders = vec![AssetBuilder {
8✔
215
            file_name: front_file.clone(),
4✔
216
            contents: self.front.join("\n"),
4✔
217
        }];
218
        if !self.back.is_empty() {
4✔
219
            asset_builders.push(AssetBuilder {
2✔
220
                file_name: back_file.clone().unwrap(),
2✔
221
                contents: self.back.join("\n"),
2✔
222
            })
223
        }
224

225
        // Generate the exercise builder.
226
        Ok(ExerciseBuilder {
4✔
227
            exercise: KnowledgeBaseExercise {
4✔
228
                short_id: self.short_id.to_string(),
4✔
229
                short_lesson_id,
230
                course_id,
231
                front_file,
4✔
232
                back_file,
4✔
233
                name: None,
4✔
234
                description: None,
4✔
235
                exercise_type: None,
236
            },
237
            asset_builders,
4✔
238
        })
239
    }
5✔
240
}
241

242
/// Represents a simple knowledge base lesson which only specifies the short ID of the lesson, the
243
/// dependencies of the lesson, and a list of simple exercises. The instructions, material, and
244
/// metadata can be optionally specified as well. In a lot of cases, this is enough to deliver the
245
/// full functionality of Trane. It is meant to help course authors write simple knowledge base
246
/// courses by writing a simple configuration to a single JSON file.
247
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248
pub struct SimpleKnowledgeBaseLesson {
249
    /// The short ID of the lesson.
250
    pub short_id: Ustr,
4✔
251

252
    /// The dependencies of the lesson.
253
    #[serde(default)]
254
    pub dependencies: Vec<Ustr>,
4✔
255

256
    /// The simple exercises in the lesson.
257
    #[serde(default)]
258
    pub exercises: Vec<SimpleKnowledgeBaseExercise>,
4✔
259

260
    /// The optional metadata for the lesson.
261
    #[serde(default)]
262
    pub metadata: Option<BTreeMap<String, Vec<String>>>,
4✔
263

264
    /// A list of additional files to write in the lesson directory.
265
    #[serde(default)]
266
    pub additional_files: Vec<AssetBuilder>,
4✔
267
}
268

269
impl SimpleKnowledgeBaseLesson {
270
    /// Generates the lesson builder from this simple lesson.
271
    fn generate_lesson_builder(&self, course_id: Ustr) -> Result<LessonBuilder> {
5✔
272
        // Ensure that the lesson short ID is not empty and that the exercise short IDs are unique.
273
        ensure!(
5✔
274
            !self.short_id.is_empty(),
5✔
275
            "short ID of lesson cannot be empty"
276
        );
277
        let mut short_ids = HashSet::new();
4✔
278
        for exercise in &self.exercises {
10✔
279
            ensure!(
7✔
280
                !short_ids.contains(&exercise.short_id),
7✔
281
                "short ID {} of exercise is not unique",
282
                exercise.short_id
283
            );
284
            short_ids.insert(&exercise.short_id);
6✔
285
        }
286

287
        // Generate the exercise builders.
288
        let exercises = self
6✔
289
            .exercises
290
            .iter()
291
            .map(|exercise| exercise.generate_exercise_builder(self.short_id, course_id))
8✔
292
            .collect::<Result<Vec<_>>>()?;
1✔
293

294
        // Generate the lesson builder.
295
        let dependencies = if self.dependencies.is_empty() {
2✔
296
            None
1✔
297
        } else {
298
            Some(self.dependencies.clone())
1✔
299
        };
300
        let has_instructions = self
2✔
301
            .additional_files
302
            .iter()
303
            .any(|asset| asset.file_name == LESSON_INSTRUCTIONS_FILE);
2✔
304
        let has_material = self
2✔
305
            .additional_files
306
            .iter()
307
            .any(|asset| asset.file_name == LESSON_MATERIAL_FILE);
3✔
308
        let lesson_builder = LessonBuilder {
2✔
309
            lesson: KnowledgeBaseLesson {
2✔
310
                short_id: self.short_id,
2✔
311
                course_id,
2✔
312
                dependencies,
2✔
313
                name: None,
2✔
314
                description: None,
2✔
315
                metadata: self.metadata.clone(),
2✔
316
                has_instructions,
317
                has_material,
318
            },
319
            exercises,
2✔
320
            asset_builders: self.additional_files.clone(),
2✔
321
        };
322
        Ok(lesson_builder)
2✔
323
    }
5✔
324
}
325

326
/// Represents a simple knowledge base course which only specifies the course manifest and a list of
327
/// simple lessons. It is meant to help course authors write simple knowledge base courses by
328
/// writing a simple configuration to a single JSON file.
329
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330
pub struct SimpleKnowledgeBaseCourse {
331
    /// The manifest for this course.
332
    pub manifest: CourseManifest,
2✔
333

334
    /// The simple lessons in this course.
335
    #[serde(default)]
336
    pub lessons: Vec<SimpleKnowledgeBaseLesson>,
2✔
337
}
338

339
impl SimpleKnowledgeBaseCourse {
340
    /// Writes the course manifests and the lesson directories with the assets and exercises to the
341
    /// given directory.
342
    pub fn build(&self, root_directory: &Path) -> Result<()> {
5✔
343
        // Ensure that the lesson short IDs are unique.
344
        let mut short_ids = HashSet::new();
5✔
345
        for lesson in &self.lessons {
11✔
346
            ensure!(
7✔
347
                !short_ids.contains(&lesson.short_id),
7✔
348
                "short ID {} of lesson is not unique",
349
                lesson.short_id
350
            );
351
            short_ids.insert(&lesson.short_id);
6✔
352
        }
353

354
        // Generate the lesson builders.
355
        let lesson_builders = self
8✔
356
            .lessons
357
            .iter()
358
            .map(|lesson| lesson.generate_lesson_builder(self.manifest.id))
9✔
359
            .collect::<Result<Vec<_>>>()?;
3✔
360

361
        // Build the lessons in the course.
362
        for lesson_builder in lesson_builders {
3✔
363
            let lesson_directory = root_directory.join(format!(
2✔
364
                "{}{}",
365
                lesson_builder.lesson.short_id, LESSON_SUFFIX
366
            ));
367

368
            // Remove the lesson directories if they already exist.
369
            if lesson_directory.exists() {
3✔
370
                fs::remove_dir_all(&lesson_directory).with_context(|| {
1✔
371
                    // grcov-excl-start
372
                    format!(
373
                        "failed to remove existing lesson directory at {}",
374
                        lesson_directory.display()
375
                    )
376
                    // grcov-excl-stop
377
                })?; // grcov-excl-line
378
            }
379

380
            lesson_builder.build(&lesson_directory)?;
2✔
381
        }
2✔
382

383
        // Write the course manifest.
384
        let manifest_path = root_directory.join(COURSE_MANIFEST_FILENAME);
1✔
385
        let mut manifest_file = fs::File::create(&manifest_path).with_context(|| {
1✔
386
            // grcov-excl-start
387
            format!(
388
                "failed to create course manifest file at {}",
389
                manifest_path.display()
390
            )
391
            // grcov-excl-stop
392
        })?; // grcov-excl-line
393
        manifest_file
2✔
394
            .write_all(serde_json::to_string_pretty(&self.manifest)?.as_bytes())
1✔
395
            .with_context(|| {
1✔
396
                // grcov-excl-start
397
                format!(
398
                    "failed to write course manifest file at {}",
399
                    manifest_path.display()
400
                )
401
                // grcov-excl-stop
402
            }) // grcov-excl-line
403
    }
5✔
404
}
405

406
#[cfg(test)]
407
mod test {
408
    use std::collections::BTreeMap;
409

410
    use anyhow::Result;
411

412
    use crate::{
413
        course_builder::knowledge_base_builder::*,
414
        data::{course_generator::knowledge_base::KnowledgeBaseFile, ExerciseType},
415
    };
416

417
    /// Creates a test lesson builder.
418
    fn test_lesson_builder() -> LessonBuilder {
1✔
419
        let exercise_builder = ExerciseBuilder {
1✔
420
            exercise: KnowledgeBaseExercise {
1✔
421
                short_id: "ex1".to_string(),
1✔
422
                short_lesson_id: "lesson1".into(),
1✔
423
                course_id: "course1".into(),
1✔
424
                front_file: "ex1.front.md".to_string(),
1✔
425
                back_file: Some("ex1.back.md".to_string()),
1✔
426
                name: Some("Exercise 1".to_string()),
1✔
427
                description: Some("Exercise 1 description".to_string()),
1✔
428
                exercise_type: Some(ExerciseType::Procedural),
429
            },
430
            asset_builders: vec![
2✔
431
                AssetBuilder {
1✔
432
                    file_name: "ex1.front.md".to_string(),
1✔
433
                    contents: "Exercise 1 front".to_string(),
1✔
434
                },
435
                AssetBuilder {
1✔
436
                    file_name: "ex1.back.md".to_string(),
1✔
437
                    contents: "Exercise 1 back".to_string(),
1✔
438
                },
439
            ],
440
        };
441
        LessonBuilder {
1✔
442
            lesson: KnowledgeBaseLesson {
1✔
443
                short_id: "lesson1".into(),
1✔
444
                course_id: "course1".into(),
1✔
445
                name: Some("Lesson 1".to_string()),
1✔
446
                description: Some("Lesson 1 description".to_string()),
1✔
447
                dependencies: Some(vec!["lesson2".into()]),
1✔
448
                metadata: Some(BTreeMap::from([(
1✔
449
                    "key".to_string(),
1✔
450
                    vec!["value".to_string()],
1✔
451
                )])),
452
                has_instructions: true,
453
                has_material: true,
454
            },
455
            exercises: vec![exercise_builder],
1✔
456
            asset_builders: vec![
2✔
457
                AssetBuilder {
1✔
458
                    file_name: LESSON_INSTRUCTIONS_FILE.to_string(),
1✔
459
                    contents: "Instructions".to_string(),
1✔
460
                },
461
                AssetBuilder {
1✔
462
                    file_name: LESSON_MATERIAL_FILE.to_string(),
1✔
463
                    contents: "Material".to_string(),
1✔
464
                },
465
            ],
466
        }
467
    }
1✔
468

469
    /// Verifies that the course builder writes the correct files to disk.
470
    #[test]
471
    fn course_builder() -> Result<()> {
2✔
472
        let temp_dir = tempfile::tempdir()?;
1✔
473
        let course_builder = CourseBuilder {
1✔
474
            directory_name: "course1".into(),
1✔
475
            manifest: CourseManifest {
1✔
476
                id: "course1".into(),
1✔
477
                name: "Course 1".into(),
1✔
478
                dependencies: vec![],
1✔
479
                description: None,
1✔
480
                authors: None,
1✔
481
                metadata: None,
1✔
482
                course_material: None,
1✔
483
                course_instructions: None,
1✔
484
                generator_config: None,
1✔
485
            },
486
            lessons: vec![test_lesson_builder()],
1✔
487
            assets: vec![
2✔
488
                AssetBuilder {
1✔
489
                    file_name: "course_instructions.md".to_string(),
1✔
490
                    contents: "Course Instructions".to_string(),
1✔
491
                },
492
                AssetBuilder {
1✔
493
                    file_name: "course_material.md".to_string(),
1✔
494
                    contents: "Course Material".to_string(),
1✔
495
                },
496
            ],
497
        };
498

499
        course_builder.build(temp_dir.path())?;
1✔
500

501
        // Verify that the exercise was built correctly.
502
        let course_dir = temp_dir.path().join("course1");
1✔
503
        let lesson_dir = course_dir.join("lesson1.lesson");
1✔
504
        assert!(lesson_dir.exists());
1✔
505
        let front_file = lesson_dir.join("ex1.front.md");
1✔
506
        assert!(front_file.exists());
1✔
507
        assert_eq!(fs::read_to_string(front_file)?, "Exercise 1 front");
1✔
508
        let back_file = lesson_dir.join("ex1.back.md");
1✔
509
        assert!(back_file.exists());
1✔
510
        assert_eq!(fs::read_to_string(back_file)?, "Exercise 1 back");
1✔
511
        let name_file = lesson_dir.join("ex1.name.json");
1✔
512
        assert!(name_file.exists());
1✔
513
        assert_eq!(KnowledgeBaseFile::open::<String>(&name_file)?, "Exercise 1",);
1✔
514
        let description_file = lesson_dir.join("ex1.description.json");
1✔
515
        assert!(description_file.exists());
1✔
516
        assert_eq!(
1✔
517
            KnowledgeBaseFile::open::<String>(&description_file)?,
1✔
518
            "Exercise 1 description",
519
        );
520
        let type_file = lesson_dir.join("ex1.type.json");
1✔
521
        assert!(type_file.exists());
1✔
522
        assert_eq!(
1✔
523
            KnowledgeBaseFile::open::<ExerciseType>(&type_file)?,
1✔
524
            ExerciseType::Procedural,
525
        );
526

527
        // Verify that the lesson was built correctly.
528
        let name_file = lesson_dir.join(LESSON_NAME_FILE);
1✔
529
        assert!(name_file.exists());
1✔
530
        assert_eq!(KnowledgeBaseFile::open::<String>(&name_file)?, "Lesson 1",);
1✔
531
        let description_file = lesson_dir.join(LESSON_DESCRIPTION_FILE);
1✔
532
        assert!(description_file.exists());
1✔
533
        assert_eq!(
1✔
534
            KnowledgeBaseFile::open::<String>(&description_file)?,
1✔
535
            "Lesson 1 description",
536
        );
537
        let dependencies_file = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
1✔
538
        assert!(lesson_dir.join(LESSON_DEPENDENCIES_FILE).exists());
1✔
539
        assert_eq!(
1✔
540
            KnowledgeBaseFile::open::<Vec<String>>(&dependencies_file)?,
1✔
541
            vec!["lesson2".to_string()],
1✔
542
        );
543
        let metadata_file = lesson_dir.join(LESSON_METADATA_FILE);
1✔
544
        assert!(metadata_file.exists());
1✔
545
        assert_eq!(
1✔
546
            KnowledgeBaseFile::open::<BTreeMap<String, Vec<String>>>(&metadata_file)?,
1✔
547
            BTreeMap::from([("key".to_string(), vec!["value".to_string()])]),
1✔
548
        );
549
        let instructions_file = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
1✔
550
        assert!(instructions_file.exists());
1✔
551
        assert_eq!(fs::read_to_string(instructions_file)?, "Instructions",);
1✔
552
        let material_file = lesson_dir.join(LESSON_MATERIAL_FILE);
1✔
553
        assert!(material_file.exists());
1✔
554
        assert_eq!(fs::read_to_string(material_file)?, "Material",);
1✔
555

556
        // Verify that the course was built correctly.
557
        assert!(course_dir.join("course_manifest.json").exists());
1✔
558
        assert_eq!(
1✔
559
            KnowledgeBaseFile::open::<CourseManifest>(&course_dir.join("course_manifest.json"))
1✔
560
                .unwrap(),
561
            course_builder.manifest,
562
        );
563
        assert!(course_dir.join("course_instructions.md").exists());
1✔
564
        assert_eq!(
1✔
565
            fs::read_to_string(course_dir.join("course_instructions.md"))?,
1✔
566
            "Course Instructions",
567
        );
568
        assert!(course_dir.join("course_material.md").exists());
1✔
569
        assert_eq!(
1✔
570
            fs::read_to_string(course_dir.join("course_material.md"))?,
1✔
571
            "Course Material",
572
        );
573

574
        Ok(())
1✔
575
    }
2✔
576

577
    /// Verifies that the simple course builder writes the correct files to disk.
578
    #[test]
579
    fn build_simple_course() -> Result<()> {
2✔
580
        // Create a simple course. The first lesson sets up the minimum required fields for a
581
        // lesson, and the second lesson sets up all optional fields.
582
        let simple_course = SimpleKnowledgeBaseCourse {
1✔
583
            manifest: CourseManifest {
1✔
584
                id: "course1".into(),
1✔
585
                name: "Course 1".into(),
1✔
586
                dependencies: vec![],
1✔
587
                description: None,
1✔
588
                authors: None,
1✔
589
                metadata: None,
1✔
590
                course_material: None,
1✔
591
                course_instructions: None,
1✔
592
                generator_config: None,
1✔
593
            },
594
            lessons: vec![
2✔
595
                SimpleKnowledgeBaseLesson {
1✔
596
                    short_id: "1".into(),
1✔
597
                    dependencies: vec![],
1✔
598
                    exercises: vec![
2✔
599
                        SimpleKnowledgeBaseExercise {
1✔
600
                            short_id: "1".into(),
1✔
601
                            front: vec!["Lesson 1, Exercise 1 front".into()],
1✔
602
                            back: vec![],
1✔
603
                        },
604
                        SimpleKnowledgeBaseExercise {
1✔
605
                            short_id: "2".into(),
1✔
606
                            front: vec!["Lesson 1, Exercise 2 front".into()],
1✔
607
                            back: vec![],
1✔
608
                        },
609
                    ],
610
                    metadata: None,
1✔
611
                    additional_files: vec![],
1✔
612
                },
613
                SimpleKnowledgeBaseLesson {
1✔
614
                    short_id: "2".into(),
1✔
615
                    dependencies: vec!["1".into()],
1✔
616
                    exercises: vec![
2✔
617
                        SimpleKnowledgeBaseExercise {
1✔
618
                            short_id: "1".into(),
1✔
619
                            front: vec!["Lesson 2, Exercise 1 front".into()],
1✔
620
                            back: vec!["Lesson 2, Exercise 1 back".into()],
1✔
621
                        },
622
                        SimpleKnowledgeBaseExercise {
1✔
623
                            short_id: "2".into(),
1✔
624
                            front: vec!["Lesson 2, Exercise 2 front".into()],
1✔
625
                            back: vec!["Lesson 2, Exercise 2 back".into()],
1✔
626
                        },
627
                    ],
628
                    metadata: Some(BTreeMap::from([(
1✔
629
                        "key".to_string(),
1✔
630
                        vec!["value".to_string()],
1✔
631
                    )])),
632
                    additional_files: vec![
2✔
633
                        AssetBuilder {
1✔
634
                            file_name: "dummy.md".into(),
1✔
635
                            contents: "I'm a dummy file".into(),
1✔
636
                        },
637
                        AssetBuilder {
1✔
638
                            file_name: LESSON_INSTRUCTIONS_FILE.into(),
1✔
639
                            contents: "Lesson 2 instructions".into(),
1✔
640
                        },
641
                        AssetBuilder {
1✔
642
                            file_name: LESSON_MATERIAL_FILE.into(),
1✔
643
                            contents: "Lesson 2 material".into(),
1✔
644
                        },
645
                    ],
646
                },
647
            ],
648
        };
649

650
        // Create a temp directory and one of the lesson directories with some content to ensure
651
        // that is deleted. Then build the course and verify the contents of the output directory.
652
        let temp_dir = tempfile::tempdir()?;
1✔
653
        let dummy_dir = temp_dir.path().join("1.lesson").join("dummy");
1✔
654
        fs::create_dir_all(&dummy_dir)?;
1✔
655
        assert!(dummy_dir.exists());
1✔
656
        simple_course.build(&temp_dir.path())?;
1✔
657
        assert!(!dummy_dir.exists());
1✔
658

659
        // Verify that the first lesson was built correctly.
660
        let lesson_dir = temp_dir.path().join("1.lesson");
1✔
661
        assert!(lesson_dir.exists());
1✔
662
        let front_file = lesson_dir.join("1.front.md");
1✔
663
        assert!(front_file.exists());
1✔
664
        assert_eq!(
1✔
665
            fs::read_to_string(&front_file)?,
1✔
666
            "Lesson 1, Exercise 1 front"
667
        );
668
        let front_file = lesson_dir.join("2.front.md");
1✔
669
        assert!(front_file.exists());
1✔
670
        assert_eq!(
1✔
671
            fs::read_to_string(&front_file)?,
1✔
672
            "Lesson 1, Exercise 2 front"
673
        );
674
        let dependencies_file = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
1✔
675
        assert!(!dependencies_file.exists());
1✔
676
        let instructions_file = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
1✔
677
        assert!(!instructions_file.exists());
1✔
678
        let material_file = lesson_dir.join(LESSON_MATERIAL_FILE);
1✔
679
        assert!(!material_file.exists());
1✔
680

681
        // Verify that the second lesson was built correctly.
682
        let lesson_dir = temp_dir.path().join("2.lesson");
1✔
683
        assert!(lesson_dir.exists());
1✔
684
        let front_file = lesson_dir.join("1.front.md");
1✔
685
        assert!(front_file.exists());
1✔
686
        assert_eq!(
1✔
687
            fs::read_to_string(&front_file)?,
1✔
688
            "Lesson 2, Exercise 1 front"
689
        );
690
        let back_file = lesson_dir.join("1.back.md");
1✔
691
        assert!(back_file.exists());
1✔
692
        assert_eq!(fs::read_to_string(&back_file)?, "Lesson 2, Exercise 1 back");
1✔
693
        let front_file = lesson_dir.join("2.front.md");
1✔
694
        assert!(front_file.exists());
1✔
695
        assert_eq!(
1✔
696
            fs::read_to_string(&front_file)?,
1✔
697
            "Lesson 2, Exercise 2 front"
698
        );
699
        let back_file = lesson_dir.join("2.back.md");
1✔
700
        assert!(back_file.exists());
1✔
701
        assert_eq!(fs::read_to_string(&back_file)?, "Lesson 2, Exercise 2 back");
1✔
702
        let dependencies_file = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
1✔
703
        assert!(dependencies_file.exists());
1✔
704
        assert_eq!(
1✔
705
            KnowledgeBaseFile::open::<Vec<String>>(&dependencies_file)?,
1✔
706
            vec!["1".to_string()]
1✔
707
        );
708
        let instructions_file = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
1✔
709
        assert!(instructions_file.exists());
1✔
710
        assert_eq!(
1✔
711
            fs::read_to_string(&instructions_file)?,
1✔
712
            "Lesson 2 instructions"
713
        );
714
        let material_file = lesson_dir.join(LESSON_MATERIAL_FILE);
1✔
715
        assert!(material_file.exists());
1✔
716
        assert_eq!(fs::read_to_string(&material_file)?, "Lesson 2 material");
1✔
717
        let metadata_file = lesson_dir.join(LESSON_METADATA_FILE);
1✔
718
        assert!(metadata_file.exists());
1✔
719
        assert_eq!(
1✔
720
            KnowledgeBaseFile::open::<BTreeMap<String, Vec<String>>>(&metadata_file)?,
1✔
721
            BTreeMap::from([("key".to_string(), vec!["value".to_string()])])
1✔
722
        );
723
        let dummy_file = lesson_dir.join("dummy.md");
1✔
724
        assert!(dummy_file.exists());
1✔
725
        assert_eq!(fs::read_to_string(&dummy_file)?, "I'm a dummy file");
1✔
726

727
        // Finally, clone the simple knowledge course to satisfy the code coverage check.
728
        assert_eq!(simple_course.clone(), simple_course);
1✔
729
        Ok(())
1✔
730
    }
2✔
731

732
    // Verifies that the simple knowledge course checks for duplicate lesson IDs.
733
    #[test]
734
    fn duplicate_short_lesson_ids() -> Result<()> {
2✔
735
        // Build a simple course with duplicate lesson IDs.
736
        let simple_course = SimpleKnowledgeBaseCourse {
1✔
737
            manifest: CourseManifest {
1✔
738
                id: "course1".into(),
1✔
739
                name: "Course 1".into(),
1✔
740
                dependencies: vec![],
1✔
741
                description: None,
1✔
742
                authors: None,
1✔
743
                metadata: None,
1✔
744
                course_material: None,
1✔
745
                course_instructions: None,
1✔
746
                generator_config: None,
1✔
747
            },
748
            lessons: vec![
2✔
749
                SimpleKnowledgeBaseLesson {
1✔
750
                    short_id: "1".into(),
1✔
751
                    dependencies: vec![],
1✔
752
                    exercises: vec![SimpleKnowledgeBaseExercise {
2✔
753
                        short_id: "1".into(),
1✔
754
                        front: vec!["Lesson 1, Exercise 1 front".into()],
1✔
755
                        back: vec![],
1✔
756
                    }],
757
                    metadata: None,
1✔
758
                    additional_files: vec![],
1✔
759
                },
760
                SimpleKnowledgeBaseLesson {
1✔
761
                    short_id: "1".into(),
1✔
762
                    dependencies: vec![],
1✔
763
                    exercises: vec![SimpleKnowledgeBaseExercise {
2✔
764
                        short_id: "1".into(),
1✔
765
                        front: vec!["Lesson 2, Exercise 1 front".into()],
1✔
766
                        back: vec![],
1✔
767
                    }],
768
                    metadata: None,
1✔
769
                    additional_files: vec![],
1✔
770
                },
771
            ],
772
        };
773

774
        // Verify that the course builder fails.
775
        let temp_dir = tempfile::tempdir()?;
1✔
776
        assert!(simple_course.build(&temp_dir.path()).is_err());
1✔
777
        Ok(())
1✔
778
    }
2✔
779

780
    // Verifies that the simple knowledge course checks for duplicate exercise IDs.
781
    #[test]
782
    fn duplicate_short_exercise_ids() -> Result<()> {
2✔
783
        // Build a simple course with duplicate exercise IDs.
784
        let simple_course = SimpleKnowledgeBaseCourse {
1✔
785
            manifest: CourseManifest {
1✔
786
                id: "course1".into(),
1✔
787
                name: "Course 1".into(),
1✔
788
                dependencies: vec![],
1✔
789
                description: None,
1✔
790
                authors: None,
1✔
791
                metadata: None,
1✔
792
                course_material: None,
1✔
793
                course_instructions: None,
1✔
794
                generator_config: None,
1✔
795
            },
796
            lessons: vec![SimpleKnowledgeBaseLesson {
2✔
797
                short_id: "1".into(),
1✔
798
                dependencies: vec![],
1✔
799
                exercises: vec![
2✔
800
                    SimpleKnowledgeBaseExercise {
1✔
801
                        short_id: "1".into(),
1✔
802
                        front: vec!["Lesson 1, Exercise 1 front".into()],
1✔
803
                        back: vec![],
1✔
804
                    },
805
                    SimpleKnowledgeBaseExercise {
1✔
806
                        short_id: "1".into(),
1✔
807
                        front: vec!["Lesson 1, Exercise 2 front".into()],
1✔
808
                        back: vec![],
1✔
809
                    },
810
                ],
811
                metadata: None,
1✔
812
                additional_files: vec![],
1✔
813
            }],
814
        };
815

816
        // Verify that the course builder fails.
817
        let temp_dir = tempfile::tempdir()?;
1✔
818
        assert!(simple_course.build(&temp_dir.path()).is_err());
1✔
819
        Ok(())
1✔
820
    }
2✔
821

822
    // Verifies that the simple knowledge course checks empty lesson IDs.
823
    #[test]
824
    fn empty_short_lesson_ids() -> Result<()> {
2✔
825
        // Build a simple course with empty lesson IDs.
826
        let simple_course = SimpleKnowledgeBaseCourse {
1✔
827
            manifest: CourseManifest {
1✔
828
                id: "course1".into(),
1✔
829
                name: "Course 1".into(),
1✔
830
                dependencies: vec![],
1✔
831
                description: None,
1✔
832
                authors: None,
1✔
833
                metadata: None,
1✔
834
                course_material: None,
1✔
835
                course_instructions: None,
1✔
836
                generator_config: None,
1✔
837
            },
838
            lessons: vec![SimpleKnowledgeBaseLesson {
2✔
839
                short_id: "".into(),
1✔
840
                dependencies: vec![],
1✔
841
                exercises: vec![SimpleKnowledgeBaseExercise {
2✔
842
                    short_id: "1".into(),
1✔
843
                    front: vec!["Lesson 1, Exercise 1 front".into()],
1✔
844
                    back: vec![],
1✔
845
                }],
846
                metadata: None,
1✔
847
                additional_files: vec![],
1✔
848
            }],
849
        };
850

851
        // Verify that the course builder fails.
852
        let temp_dir = tempfile::tempdir()?;
1✔
853
        assert!(simple_course.build(&temp_dir.path()).is_err());
1✔
854
        Ok(())
1✔
855
    }
2✔
856

857
    // Verifies that the simple knowledge course checks empty exercise IDs.
858
    #[test]
859
    fn empty_short_exercise_ids() -> Result<()> {
2✔
860
        // Build a simple course with empty exercise IDs.
861
        let simple_course = SimpleKnowledgeBaseCourse {
1✔
862
            manifest: CourseManifest {
1✔
863
                id: "course1".into(),
1✔
864
                name: "Course 1".into(),
1✔
865
                dependencies: vec![],
1✔
866
                description: None,
1✔
867
                authors: None,
1✔
868
                metadata: None,
1✔
869
                course_material: None,
1✔
870
                course_instructions: None,
1✔
871
                generator_config: None,
1✔
872
            },
873
            lessons: vec![SimpleKnowledgeBaseLesson {
2✔
874
                short_id: "1".into(),
1✔
875
                dependencies: vec![],
1✔
876
                exercises: vec![SimpleKnowledgeBaseExercise {
2✔
877
                    short_id: "".into(),
1✔
878
                    front: vec!["Lesson 1, Exercise 1 front".into()],
1✔
879
                    back: vec![],
1✔
880
                }],
881
                metadata: None,
1✔
882
                additional_files: vec![],
1✔
883
            }],
884
        };
885

886
        // Verify that the course builder fails.
887
        let temp_dir = tempfile::tempdir()?;
1✔
888
        assert!(simple_course.build(&temp_dir.path()).is_err());
1✔
889
        Ok(())
1✔
890
    }
2✔
891
}
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