1use std::error::Error;
13use std::fmt;
14use std::path::{Path, PathBuf};
15
16use serde::Deserialize;
17
18use crate::lesson::{Language, Lesson, LoadError, read_lesson_file};
19
20const MANIFEST_FILENAME: &str = "blendtutor.toml";
22
23#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
31pub struct LessonSlug(String);
32
33impl fmt::Display for LessonSlug {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 f.write_str(&self.0)
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct ManifestEntry {
47 pub id: LessonSlug,
49 pub path: PathBuf,
51}
52
53const fn default_max_feedback() -> u32 {
58 20
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct SiteConfig {
73 #[serde(default = "default_max_feedback")]
76 pub max_feedback_per_session: u32,
77}
78
79impl Default for SiteConfig {
80 fn default() -> Self {
81 SiteConfig {
82 max_feedback_per_session: default_max_feedback(),
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct Manifest {
92 pub lessons: Vec<ManifestEntry>,
94 #[serde(default)]
97 pub site: Option<SiteConfig>,
98}
99
100impl Manifest {
101 pub fn parse(toml: &str) -> Result<Manifest, ManifestError> {
111 let manifest: Manifest =
112 toml::from_str(toml).map_err(|e| ManifestError::Parse(e.to_string()))?;
113 manifest.validate_paths()?;
114 Ok(manifest)
115 }
116
117 fn validate_paths(&self) -> Result<(), ManifestError> {
123 for entry in &self.lessons {
124 if escapes_course_dir(&entry.path) {
125 return Err(ManifestError::UnsafePath {
126 id: entry.id.clone(),
127 path: entry.path.clone(),
128 });
129 }
130 }
131 Ok(())
132 }
133}
134
135fn escapes_course_dir(path: &Path) -> bool {
139 path.is_absolute()
140 || path
141 .components()
142 .any(|component| matches!(component, std::path::Component::ParentDir))
143}
144
145#[derive(Debug)]
151pub enum ManifestError {
152 Read(std::io::Error),
154 Parse(String),
157 UnsafePath {
160 id: LessonSlug,
162 path: PathBuf,
164 },
165}
166
167impl fmt::Display for ManifestError {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 match self {
170 ManifestError::Read(e) => write!(f, "could not read course manifest: {e}"),
171 ManifestError::Parse(msg) => write!(f, "invalid course manifest: {msg}"),
172 ManifestError::UnsafePath { id, path } => write!(
173 f,
174 "lesson {id:?} path {path:?} escapes the course directory; \
175 lesson paths must be relative and stay within the course",
176 ),
177 }
178 }
179}
180
181impl Error for ManifestError {
182 fn source(&self) -> Option<&(dyn Error + 'static)> {
183 match self {
184 ManifestError::Read(e) => Some(e),
185 ManifestError::Parse(_) | ManifestError::UnsafePath { .. } => None,
186 }
187 }
188}
189
190#[derive(Debug, Clone)]
195pub struct Course {
196 root: PathBuf,
197 manifest: Manifest,
198}
199
200impl Course {
201 pub fn open(dir: &Path) -> Result<Course, ManifestError> {
208 let text =
209 std::fs::read_to_string(dir.join(MANIFEST_FILENAME)).map_err(ManifestError::Read)?;
210 let manifest = Manifest::parse(&text)?;
211 Ok(Course {
212 root: dir.to_path_buf(),
213 manifest,
214 })
215 }
216
217 pub fn discover(&self) -> Vec<Result<LessonSummary, DiscoveryError>> {
225 self.manifest
226 .lessons
227 .iter()
228 .map(|entry| {
229 read_lesson_file(&self.root.join(&entry.path))
230 .map(|lesson| summarize(entry.id.clone(), &lesson))
231 .map_err(|source| DiscoveryError {
232 id: entry.id.clone(),
233 source,
234 })
235 })
236 .collect()
237 }
238
239 pub fn load_lessons(&self) -> Result<Vec<(LessonSlug, Lesson)>, DiscoveryError> {
248 self.manifest
249 .lessons
250 .iter()
251 .map(|entry| {
252 read_lesson_file(&self.root.join(&entry.path))
253 .map(|lesson| (entry.id.clone(), lesson))
254 .map_err(|source| DiscoveryError {
255 id: entry.id.clone(),
256 source,
257 })
258 })
259 .collect()
260 }
261
262 pub fn site_config(&self) -> Option<&SiteConfig> {
266 self.manifest.site.as_ref()
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct LessonSummary {
277 pub id: LessonSlug,
279 pub language: Language,
281 pub title: String,
283}
284
285fn summarize(id: LessonSlug, lesson: &Lesson) -> LessonSummary {
290 LessonSummary {
291 id,
292 language: lesson.language.clone(),
293 title: lesson.lesson_name.to_string(),
294 }
295}
296
297#[derive(Debug)]
304pub struct DiscoveryError {
305 id: LessonSlug,
306 source: LoadError,
307}
308
309impl DiscoveryError {
310 pub fn id(&self) -> &LessonSlug {
312 &self.id
313 }
314}
315
316impl fmt::Display for DiscoveryError {
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318 self.source.fmt(f)
321 }
322}
323
324impl Error for DiscoveryError {
325 fn source(&self) -> Option<&(dyn Error + 'static)> {
326 Some(&self.source)
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn course_basic() -> &'static Path {
337 Path::new(concat!(
338 env!("CARGO_MANIFEST_DIR"),
339 "/tests/fixtures/course_basic"
340 ))
341 }
342
343 #[test]
344 fn manifest_parse_reads_each_entrys_id_and_path() {
345 let manifest = Manifest::parse(
346 r#"
347[[lessons]]
348id = "add-two"
349path = "add_two.yaml"
350
351[[lessons]]
352id = "greet"
353path = "greet.yaml"
354"#,
355 )
356 .expect("a well-formed manifest should parse");
357
358 assert_eq!(manifest.lessons.len(), 2);
359 assert_eq!(manifest.lessons[0].id, LessonSlug("add-two".to_string()));
360 assert_eq!(manifest.lessons[0].path, PathBuf::from("add_two.yaml"));
361 assert_eq!(manifest.lessons[1].id, LessonSlug("greet".to_string()));
362 }
363
364 #[test]
365 fn manifest_parse_rejects_unknown_key_so_author_typos_surface() {
366 let err = Manifest::parse(
367 r#"
368[[lessons]]
369id = "add-two"
370pathh = "add_two.yaml"
371"#,
372 )
373 .expect_err("a typo'd key must not be silently dropped");
374 assert!(
375 matches!(err, ManifestError::Parse(_)),
376 "a malformed manifest is a parse error, got: {err:?}"
377 );
378 }
379
380 #[test]
381 fn manifest_parse_rejects_a_path_escaping_the_course_directory() {
382 for escaping in ["../secrets.yaml", "/etc/passwd", "nested/../../up.yaml"] {
383 let err = Manifest::parse(&format!("[[lessons]]\nid = \"x\"\npath = {escaping:?}\n"))
384 .expect_err("a path leaving the course directory must be refused");
385 assert!(
386 matches!(err, ManifestError::UnsafePath { .. }),
387 "an escaping path should be UnsafePath, got: {err:?} for {escaping}"
388 );
389 }
390 }
391
392 #[test]
393 fn discover_summarizes_each_lesson_with_its_slug_language_and_title() {
394 let course = Course::open(course_basic()).expect("course_basic should open");
395 let rows = course.discover();
396
397 assert_eq!(rows.len(), 2, "course_basic lists two lessons");
398
399 let summary = |slug: &str| -> LessonSummary {
400 rows.iter()
401 .filter_map(|r| r.as_ref().ok())
402 .find(|s| s.id == LessonSlug(slug.to_string()))
403 .unwrap_or_else(|| panic!("an Ok row with slug {slug:?} should be discovered"))
404 .clone()
405 };
406
407 let add_two = summary("add-two");
410 assert_eq!(add_two.language, Language::R);
411 assert_eq!(add_two.title, "Add Two Numbers");
412
413 let greet = summary("greet");
414 assert_eq!(greet.language, Language::Python);
415 assert_eq!(greet.title, "Greet Someone");
416 }
417
418 #[test]
419 fn discover_reports_a_malformed_lesson_as_an_error_row_keeping_the_good_ones() {
420 let course = Course::open(Path::new(concat!(
421 env!("CARGO_MANIFEST_DIR"),
422 "/tests/fixtures/course_partial"
423 )))
424 .expect("course_partial should open");
425 let rows = course.discover();
426
427 assert_eq!(rows.len(), 3, "one row per manifest entry");
432 assert_eq!(
433 rows.iter().filter(|row| row.is_ok()).count(),
434 2,
435 "the two valid lessons still discover"
436 );
437
438 let errors: Vec<&DiscoveryError> =
439 rows.iter().filter_map(|row| row.as_ref().err()).collect();
440 assert_eq!(errors.len(), 1, "exactly the one malformed lesson errors");
441 assert_eq!(
442 errors[0].id().to_string(),
443 "broken",
444 "the error row carries the manifest slug, not the unparseable lesson's name"
445 );
446 }
447
448 #[test]
449 fn open_missing_manifest_is_a_read_error_not_a_parse_error() {
450 let err = Course::open(Path::new("/no/such/course"))
451 .expect_err("a directory without a manifest cannot open");
452 assert!(
453 matches!(err, ManifestError::Read(_)),
454 "a missing manifest is a read error, got: {err:?}"
455 );
456 }
457
458 #[test]
459 fn manifest_error_display_and_source_distinguish_each_variant() {
460 use std::io::{Error as IoError, ErrorKind};
461
462 let read = ManifestError::Read(IoError::new(ErrorKind::NotFound, "nope"));
464 assert!(
465 read.to_string().contains("could not read course manifest"),
466 "Read should label itself, got: {read}"
467 );
468 assert!(
469 std::error::Error::source(&read).is_some(),
470 "Read should expose the io::Error as its source"
471 );
472
473 let parse = ManifestError::Parse("bad toml".to_string());
475 let parse_msg = parse.to_string();
476 assert!(
477 parse_msg.contains("invalid course manifest") && parse_msg.contains("bad toml"),
478 "Parse should frame and carry the message, got: {parse_msg}"
479 );
480 assert!(std::error::Error::source(&parse).is_none());
481
482 let unsafe_path = ManifestError::UnsafePath {
484 id: LessonSlug("x".to_string()),
485 path: PathBuf::from("../escape.yaml"),
486 };
487 assert!(
488 unsafe_path.to_string().contains("escapes"),
489 "UnsafePath should say the path escapes, got: {unsafe_path}"
490 );
491 assert!(std::error::Error::source(&unsafe_path).is_none());
492 }
493
494 #[test]
495 fn discovery_error_display_and_source_surface_the_underlying_load_failure() {
496 let course = Course::open(Path::new(concat!(
497 env!("CARGO_MANIFEST_DIR"),
498 "/tests/fixtures/course_partial"
499 )))
500 .expect("course_partial should open");
501 let rows = course.discover();
502 let err = rows
503 .iter()
504 .filter_map(|row| row.as_ref().err())
505 .next()
506 .expect("the malformed lesson produces an error");
507
508 assert!(
510 err.to_string().contains("language"),
511 "Display should surface the missing-field cause, got: {err}"
512 );
513 assert!(
515 std::error::Error::source(err).is_some(),
516 "DiscoveryError should expose its LoadError as the source"
517 );
518 }
519
520 #[test]
521 fn load_lessons_returns_every_lesson_in_full_and_in_author_order() {
522 let course = Course::open(course_basic()).expect("course_basic should open");
523 let lessons = course
524 .load_lessons()
525 .expect("every lesson in course_basic loads");
526
527 let pairs: Vec<(String, Language)> = lessons
531 .iter()
532 .map(|(slug, lesson)| (slug.to_string(), lesson.language.clone()))
533 .collect();
534 assert_eq!(
535 pairs,
536 vec![
537 ("add-two".to_string(), Language::R),
538 ("greet".to_string(), Language::Python),
539 ],
540 );
541 }
542
543 #[test]
544 fn load_lessons_fails_on_the_first_broken_lesson_rather_than_dropping_it() {
545 let course = Course::open(Path::new(concat!(
546 env!("CARGO_MANIFEST_DIR"),
547 "/tests/fixtures/course_partial"
548 )))
549 .expect("course_partial should open");
550
551 let err = course
554 .load_lessons()
555 .expect_err("a course with a malformed lesson cannot be loaded in full");
556 assert_eq!(
557 err.id().to_string(),
558 "broken",
559 "the failure names the manifest slug of the lesson that broke"
560 );
561 }
562
563 #[test]
566 fn feedback_rate_limit_manifest_parses_max_from_site_section() {
567 let manifest = Manifest::parse(
570 r#"
571[[lessons]]
572id = "add-two"
573path = "add_two.yaml"
574
575[site]
576max_feedback_per_session = 5
577"#,
578 )
579 .expect("a manifest with [site] max=5 should parse");
580 assert_eq!(
581 manifest
582 .site
583 .as_ref()
584 .expect("site config present when [site] is given")
585 .max_feedback_per_session,
586 5,
587 );
588 }
589
590 #[test]
591 fn feedback_rate_limit_manifest_defaults_to_20_without_site_section() {
592 let manifest = Manifest::parse(
596 r#"
597[[lessons]]
598id = "add-two"
599path = "add_two.yaml"
600"#,
601 )
602 .expect("a manifest without [site] should parse");
603 assert!(
604 manifest.site.is_none(),
605 "absent [site] yields None; the caller applies SiteConfig::default()"
606 );
607 assert_eq!(SiteConfig::default().max_feedback_per_session, 20);
609 }
610
611 #[test]
612 fn feedback_rate_limit_manifest_rejects_negative_max() {
613 let err = Manifest::parse(
616 r#"
617[[lessons]]
618id = "add-two"
619path = "add_two.yaml"
620
621[site]
622max_feedback_per_session = -1
623"#,
624 )
625 .expect_err("a negative max must be rejected (u32 rejects negatives)");
626 assert!(
627 matches!(err, ManifestError::Parse(_)),
628 "a negative max is a parse error, got: {err:?}"
629 );
630 }
631
632 #[test]
635 fn site_config_returns_the_manifests_site_section_when_present() {
636 let manifest = Manifest::parse(
640 r#"
641[[lessons]]
642id = "add-two"
643path = "add_two.yaml"
644
645[site]
646max_feedback_per_session = 5
647"#,
648 )
649 .expect("a manifest with [site] max=5 should parse");
650 let course = Course {
651 root: PathBuf::from("."),
652 manifest,
653 };
654 let config = course
655 .site_config()
656 .expect("site_config must return Some when [site] is present");
657 assert_eq!(config.max_feedback_per_session, 5);
658 }
659
660 #[test]
661 fn site_config_returns_none_when_no_site_section() {
662 let manifest = Manifest::parse(
666 r#"
667[[lessons]]
668id = "add-two"
669path = "add_two.yaml"
670"#,
671 )
672 .expect("a manifest without [site] should parse");
673 let course = Course {
674 root: PathBuf::from("."),
675 manifest,
676 };
677 assert!(
678 course.site_config().is_none(),
679 "site_config must return None when [site] is absent"
680 );
681 }
682}