1pub mod knowledge_base_builder;
11
12use anyhow::{Context, Result, ensure};
13use serde::{Deserialize, Serialize};
14use std::{
15 fs::{File, create_dir_all},
16 io::Write,
17 path::{Path, PathBuf},
18};
19use strum::Display;
20
21use crate::data::{CourseManifest, ExerciseManifestBuilder, LessonManifestBuilder, VerifyPaths};
22
23#[derive(Display)]
25#[strum(serialize_all = "snake_case")]
26#[allow(missing_docs)]
27pub enum TraneMetadata {
28 Skill,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct AssetBuilder {
34 pub file_name: String,
36
37 pub contents: String,
39}
40
41impl AssetBuilder {
42 pub fn build(&self, asset_directory: &Path) -> Result<()> {
44 create_dir_all(asset_directory)?;
46 let asset_path = asset_directory.join(&self.file_name);
47 ensure!(
48 !asset_path.exists(),
49 "asset path {} already exists",
50 asset_path.display()
51 );
52
53 create_dir_all(asset_path.parent().unwrap())?;
56
57 let mut asset_file = File::create(asset_path)?;
59 asset_file.write_all(self.contents.as_bytes())?;
60 Ok(())
61 }
62}
63
64pub struct ExerciseBuilder {
66 pub directory_name: String,
68
69 pub manifest_closure: Box<dyn Fn(ExerciseManifestBuilder) -> ExerciseManifestBuilder>,
72
73 pub asset_builders: Vec<AssetBuilder>,
75}
76
77impl ExerciseBuilder {
78 pub fn build(
80 &self,
81 exercise_directory: &PathBuf,
82 manifest_template: ExerciseManifestBuilder,
83 ) -> Result<()> {
84 create_dir_all(exercise_directory)?;
86 let manifest = (self.manifest_closure)(manifest_template).build()?;
87 let manifest_json = serde_json::to_string_pretty(&manifest)? + "\n";
88 let manifest_path = exercise_directory.join("exercise_manifest.json");
89 let mut manifest_file = File::create(manifest_path)?;
90 manifest_file.write_all(manifest_json.as_bytes())?;
91
92 for asset_builder in &self.asset_builders {
94 asset_builder.build(exercise_directory)?;
95 }
96
97 manifest.verify_paths(exercise_directory).context(format!(
99 "failed to verify files for exercise {}",
100 manifest.id
101 ))?;
102 Ok(())
103 }
104}
105
106pub struct LessonBuilder {
108 pub directory_name: String,
110
111 pub manifest_closure: Box<dyn Fn(LessonManifestBuilder) -> LessonManifestBuilder>,
114
115 pub exercise_manifest_template: ExerciseManifestBuilder,
118
119 pub exercise_builders: Vec<ExerciseBuilder>,
122
123 pub asset_builders: Vec<AssetBuilder>,
125}
126
127impl LessonBuilder {
128 pub fn build(
130 &self,
131 lesson_directory: &PathBuf,
132 manifest_template: LessonManifestBuilder,
133 ) -> Result<()> {
134 create_dir_all(lesson_directory)?;
136 let manifest = (self.manifest_closure)(manifest_template).build()?;
137 let manifest_json = serde_json::to_string_pretty(&manifest)? + "\n";
138 let manifest_path = lesson_directory.join("lesson_manifest.json");
139 let mut manifest_file = File::create(manifest_path)?;
140 manifest_file.write_all(manifest_json.as_bytes())?;
141
142 for asset_builder in &self.asset_builders {
144 asset_builder.build(lesson_directory)?;
145 }
146
147 for exercise_builder in &self.exercise_builders {
149 let exercise_directory = lesson_directory.join(&exercise_builder.directory_name);
150 exercise_builder.build(&exercise_directory, self.exercise_manifest_template.clone())?;
151 }
152
153 manifest
155 .verify_paths(lesson_directory)
156 .context(format!("failed to verify files for lesson {}", manifest.id))?;
157 Ok(())
158 }
159}
160
161pub struct CourseBuilder {
163 pub directory_name: String,
165
166 pub course_manifest: CourseManifest,
168
169 pub lesson_manifest_template: LessonManifestBuilder,
172
173 pub lesson_builders: Vec<LessonBuilder>,
176
177 pub asset_builders: Vec<AssetBuilder>,
179}
180
181impl CourseBuilder {
182 pub fn build(&self, parent_directory: &Path) -> Result<()> {
184 let course_directory = parent_directory.join(&self.directory_name);
186 create_dir_all(&course_directory)?;
187 let manifest_json = serde_json::to_string_pretty(&self.course_manifest)? + "\n";
188 let manifest_path = course_directory.join("course_manifest.json");
189 let mut manifest_file = File::create(manifest_path)?;
190 manifest_file.write_all(manifest_json.as_bytes())?;
191
192 for asset_builder in &self.asset_builders {
194 asset_builder.build(&course_directory)?;
195 }
196
197 for lesson_builder in &self.lesson_builders {
199 let lesson_directory = course_directory.join(&lesson_builder.directory_name);
200 lesson_builder.build(&lesson_directory, self.lesson_manifest_template.clone())?;
201 }
202
203 self.course_manifest
205 .verify_paths(&course_directory)
206 .context(format!(
207 "failed to verify files for course {}",
208 self.course_manifest.id
209 ))?;
210 Ok(())
211 }
212}
213
214#[cfg(test)]
215#[cfg_attr(coverage, coverage(off))]
216mod test {
217 use anyhow::Result;
218 use std::io::Read;
219
220 use super::*;
221 use crate::data::{BasicAsset, ExerciseAsset, ExerciseType};
222
223 #[test]
225 fn asset_builer() -> Result<()> {
226 let temp_dir = tempfile::tempdir()?;
227 let asset_builder = AssetBuilder {
228 file_name: "asset1.md".to_string(),
229 contents: "asset1 contents".to_string(),
230 };
231 asset_builder.build(temp_dir.path())?;
232 assert!(temp_dir.path().join("asset1.md").is_file());
233 let mut file = File::open(temp_dir.path().join("asset1.md"))?;
234 let mut contents = String::new();
235 file.read_to_string(&mut contents)?;
236 assert_eq!(contents, "asset1 contents");
237 Ok(())
238 }
239
240 #[test]
242 fn asset_builer_existing() -> Result<()> {
243 let temp_dir = tempfile::tempdir()?;
245 let asset_path = temp_dir.path().join("asset1.md");
246 File::create(&asset_path)?;
247
248 let asset_builder = AssetBuilder {
250 file_name: "asset1.md".to_string(),
251 contents: "asset1 contents".to_string(),
252 };
253 assert!(asset_builder.build(temp_dir.path()).is_err());
254 Ok(())
255 }
256
257 #[test]
259 fn course_builder() -> Result<()> {
260 let exercise_builder = ExerciseBuilder {
261 directory_name: "exercise1".to_string(),
262 manifest_closure: Box::new(|builder| {
263 builder
264 .clone()
265 .id("exercise1")
266 .name("Exercise 1".into())
267 .exercise_asset(ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset {
268 content: String::new(),
269 }))
270 .clone()
271 }),
272 asset_builders: vec![],
273 };
274 let lesson_builder = LessonBuilder {
275 directory_name: "lesson1".to_string(),
276 manifest_closure: Box::new(|builder| {
277 builder
278 .clone()
279 .id("lesson1")
280 .name("Lesson 1".into())
281 .dependencies(vec![])
282 .clone()
283 }),
284 exercise_manifest_template: ExerciseManifestBuilder::default()
285 .lesson_id("lesson1")
286 .course_id("course1")
287 .exercise_type(ExerciseType::Procedural)
288 .clone(),
289 exercise_builders: vec![exercise_builder],
290 asset_builders: vec![],
291 };
292 let course_builder = CourseBuilder {
293 directory_name: "course1".to_string(),
294 course_manifest: CourseManifest {
295 id: "course1".into(),
296 name: "Course 1".into(),
297 dependencies: vec![],
298 encompassed: vec![],
299 superseded: vec![],
300 description: None,
301 authors: None,
302 metadata: None,
303 course_material: None,
304 course_instructions: None,
305 generator_config: None,
306 },
307 lesson_manifest_template: LessonManifestBuilder::default()
308 .course_id("course1")
309 .clone(),
310 lesson_builders: vec![lesson_builder],
311 asset_builders: vec![],
312 };
313
314 let temp_dir = tempfile::tempdir()?;
315 course_builder.build(temp_dir.path())?;
316
317 let course_dir = temp_dir.path().join("course1");
318 let lesson_dir = course_dir.join("lesson1");
319 let exercise_dir = lesson_dir.join("exercise1");
320 assert!(course_dir.is_dir());
321 assert!(lesson_dir.is_dir());
322 assert!(exercise_dir.is_dir());
323 assert!(course_dir.join("course_manifest.json").is_file());
324 assert!(lesson_dir.join("lesson_manifest.json").is_file());
325 assert!(exercise_dir.join("exercise_manifest.json").is_file());
326 Ok(())
327 }
328
329 #[test]
331 fn trane_metadata_display() {
332 assert_eq!("skill", TraneMetadata::Skill.to_string());
333 }
334}