1use std::error::Error;
11use std::fmt;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16const STUDENT_CODE_PLACEHOLDER: &str = "{student_code}";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub enum Language {
27 R,
29 Python,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct LessonId(String);
40
41impl fmt::Display for LessonId {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.write_str(&self.0)
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum ExerciseKind {
56 FunctionWriting,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct Exercise {
70 pub prompt: String,
72 pub llm_evaluation_prompt: String,
75 #[serde(rename = "type")]
77 pub kind: Option<ExerciseKind>,
78 pub code_template: Option<String>,
80 pub solution: Option<String>,
85 pub hints: Option<String>,
93 pub gotchas: Option<String>,
101 pub example_usage: Option<String>,
103 pub success_criteria: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct Lesson {
114 pub lesson_name: LessonId,
116 pub language: Language,
118 pub exercise: Exercise,
120 #[serde(default)]
127 pub checks: Vec<String>,
128 #[serde(default)]
135 pub packages: Vec<String>,
136 pub description: Option<String>,
138 pub textbook_reference: Option<String>,
140}
141
142#[derive(Debug)]
144pub enum ValidationError {
145 Parse(String),
149 MissingStudentCodePlaceholder,
153 InvalidBulletFormat {
159 field: String,
161 },
162 InvalidPackageName {
167 name: String,
169 },
170}
171
172impl fmt::Display for ValidationError {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 ValidationError::Parse(msg) => write!(f, "invalid lesson: {msg}"),
176 ValidationError::MissingStudentCodePlaceholder => write!(
177 f,
178 "exercise.llm_evaluation_prompt must contain the literal \
179 {STUDENT_CODE_PLACEHOLDER} placeholder so the learner's \
180 submission can be inserted",
181 ),
182 ValidationError::InvalidBulletFormat { field } => write!(
183 f,
184 "exercise.{field} must be bullet-formatted: each non-empty line must \
185 start with `- ` or `* `"
186 ),
187 ValidationError::InvalidPackageName { name } => write!(
188 f,
189 "packages entry {name:?} is invalid: package names must be non-empty \
190 and contain no quotes, commas, or whitespace"
191 ),
192 }
193 }
194}
195
196impl Error for ValidationError {}
197
198fn validate_bullet_format(field: &str, content: &str) -> Result<(), ValidationError> {
205 for line in content.lines() {
206 if line.is_empty() {
207 continue;
208 }
209 if !line.starts_with("- ") && !line.starts_with("* ") {
210 return Err(ValidationError::InvalidBulletFormat {
211 field: field.to_string(),
212 });
213 }
214 }
215 Ok(())
216}
217
218fn validate_package_name(name: &str) -> Result<(), ValidationError> {
222 let list_safe = !name.is_empty()
223 && !name
224 .chars()
225 .any(|c| c == '"' || c == ',' || c.is_whitespace());
226 if list_safe {
227 Ok(())
228 } else {
229 Err(ValidationError::InvalidPackageName {
230 name: name.to_string(),
231 })
232 }
233}
234
235impl Lesson {
236 pub fn parse(yaml: &str) -> Result<Lesson, ValidationError> {
244 let lesson: Lesson =
245 serde_saphyr::from_str(yaml).map_err(|e| ValidationError::Parse(e.to_string()))?;
246 lesson.validate_semantics()?;
247 Ok(lesson)
248 }
249
250 fn validate_semantics(&self) -> Result<(), ValidationError> {
258 if !self
259 .exercise
260 .llm_evaluation_prompt
261 .contains(STUDENT_CODE_PLACEHOLDER)
262 {
263 return Err(ValidationError::MissingStudentCodePlaceholder);
264 }
265 if let Some(ref gotchas) = self.exercise.gotchas {
266 validate_bullet_format("gotchas", gotchas)?;
267 }
268 if let Some(ref hints) = self.exercise.hints {
269 validate_bullet_format("hints", hints)?;
270 }
271 for name in &self.packages {
272 validate_package_name(name)?;
273 }
274 Ok(())
275 }
276}
277
278#[derive(Debug)]
280pub enum LoadError {
281 Read(std::io::Error),
283 Invalid(ValidationError),
285}
286
287impl fmt::Display for LoadError {
288 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289 match self {
290 LoadError::Read(e) => write!(f, "could not read lesson file: {e}"),
291 LoadError::Invalid(e) => write!(f, "{e}"),
292 }
293 }
294}
295
296impl Error for LoadError {
297 fn source(&self) -> Option<&(dyn Error + 'static)> {
298 match self {
299 LoadError::Read(e) => Some(e),
300 LoadError::Invalid(e) => Some(e),
301 }
302 }
303}
304
305pub fn read_lesson_file(path: &Path) -> Result<Lesson, LoadError> {
312 let text = std::fs::read_to_string(path).map_err(LoadError::Read)?;
313 Lesson::parse(&text).map_err(LoadError::Invalid)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 const VALID_YAML: &str = r#"
321lesson_name: "Adder"
322language: R
323description: "Add two numbers"
324textbook_reference: "Chapter 2"
325exercise:
326 type: "function_writing"
327 prompt: "Write a function add_two(x, y)."
328 code_template: "add_two <- function(x, y) {}"
329 example_usage: "add_two(3, 5) # 8"
330 success_criteria: "Returns the sum of its two arguments."
331 llm_evaluation_prompt: "Grade this submission:\n{student_code}\nReply with feedback."
332"#;
333
334 const MISSING_EVAL_PROMPT_YAML: &str = r#"
335lesson_name: "Adder"
336language: R
337exercise:
338 prompt: "Write a function add_two(x, y)."
339"#;
340
341 const PROMPT_WITHOUT_PLACEHOLDER_YAML: &str = r#"
342lesson_name: "Adder"
343language: R
344exercise:
345 prompt: "Write a function add_two(x, y)."
346 llm_evaluation_prompt: "Grade the student's submission and reply with feedback."
347"#;
348
349 #[test]
350 fn parse_accepts_valid_lesson() {
351 let lesson = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
352 assert_eq!(lesson.lesson_name, LessonId("Adder".to_string()));
353 assert_eq!(lesson.language, Language::R);
354 assert_eq!(lesson.exercise.kind, Some(ExerciseKind::FunctionWriting));
357 }
358
359 const LESSON_WITH_CHECKS_YAML: &str = r#"
360lesson_name: "Adder"
361language: R
362checks:
363 - "stopifnot(add_two(2, 3) == 5)"
364 - "stopifnot(is.function(add_two))"
365exercise:
366 prompt: "Write a function add_two(x, y)."
367 llm_evaluation_prompt: "Grade this: {student_code}"
368"#;
369
370 #[test]
371 fn parse_reads_checks_as_ordered_code_strings() {
372 let lesson = Lesson::parse(LESSON_WITH_CHECKS_YAML).expect("a lesson with checks is valid");
373 assert_eq!(
374 lesson.checks,
375 vec![
376 "stopifnot(add_two(2, 3) == 5)".to_string(),
377 "stopifnot(is.function(add_two))".to_string(),
378 ],
379 "checks parse in document order as raw code-strings"
380 );
381 }
382
383 #[test]
384 fn parse_defaults_checks_to_empty_when_absent() {
385 let lesson = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
389 assert!(
390 lesson.checks.is_empty(),
391 "a lesson without a checks key has no checks, got {:?}",
392 lesson.checks
393 );
394 }
395
396 const LESSON_WITH_PACKAGES_YAML: &str = r#"
397lesson_name: "Adder"
398language: Python
399packages:
400 - pandas
401 - numpy
402exercise:
403 prompt: "Write add(x, y)."
404 llm_evaluation_prompt: "Grade this: {student_code}"
405"#;
406
407 #[test]
408 fn parse_reads_packages_as_ordered_strings() {
409 let lesson =
413 Lesson::parse(LESSON_WITH_PACKAGES_YAML).expect("a lesson with packages is valid");
414 assert_eq!(
415 lesson.packages,
416 vec!["pandas".to_string(), "numpy".to_string()],
417 "packages parse in document order as raw strings"
418 );
419 }
420
421 #[test]
422 fn parse_defaults_packages_to_empty_when_absent() {
423 let lesson = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
427 assert!(
428 lesson.packages.is_empty(),
429 "a lesson without a packages key has no packages, got {:?}",
430 lesson.packages
431 );
432 }
433
434 const LESSON_WITH_SOLUTION_YAML: &str = r#"
435lesson_name: "Adder"
436language: R
437exercise:
438 prompt: "Write add_two(x, y)."
439 solution: "add_two <- function(x, y) x + y"
440 llm_evaluation_prompt: "Grade this: {student_code}"
441"#;
442
443 #[test]
444 fn parse_reads_an_optional_reference_solution() {
445 let lesson = Lesson::parse(LESSON_WITH_SOLUTION_YAML)
449 .expect("a lesson may carry a reference solution under exercise.solution");
450 assert_eq!(
451 lesson.exercise.solution.as_deref(),
452 Some("add_two <- function(x, y) x + y"),
453 "the solution is read verbatim from exercise.solution"
454 );
455 }
456
457 #[test]
458 fn parse_defaults_solution_to_none_when_absent() {
459 let lesson = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
462 assert!(
463 lesson.exercise.solution.is_none(),
464 "a lesson without a solution has none, got {:?}",
465 lesson.exercise.solution
466 );
467 }
468
469 const LESSON_WITH_HINTS_YAML: &str = r#"
470lesson_name: "Adder"
471language: R
472exercise:
473 prompt: "Write add_two(x, y)."
474 hints: |
475 - Remember: R uses '<-' for assignment.
476 - Functions return their last expression automatically.
477 llm_evaluation_prompt: "Grade this: {student_code}"
478"#;
479
480 #[test]
481 fn parse_reads_an_optional_hints_field() {
482 let lesson = Lesson::parse(LESSON_WITH_HINTS_YAML)
488 .expect("a lesson may carry hints under exercise.hints");
489 assert_eq!(
490 lesson.exercise.hints.as_deref(),
491 Some(
492 "- Remember: R uses '<-' for assignment.\n- Functions return their last expression automatically.\n"
493 ),
494 "the hints text is read verbatim from exercise.hints"
495 );
496 }
497
498 #[test]
499 fn parse_defaults_hints_to_none_when_absent() {
500 let lesson = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
503 assert!(
504 lesson.exercise.hints.is_none(),
505 "a lesson without a hints key has none, got {:?}",
506 lesson.exercise.hints
507 );
508 }
509
510 const LESSON_WITH_GOTCHAS_YAML: &str = r#"
511lesson_name: "Adder"
512language: R
513exercise:
514 prompt: "Write add_two(x, y)."
515 gotchas: |
516 - R uses '<-' for assignment, not '='.
517 - Functions return their last expression automatically.
518 llm_evaluation_prompt: "Grade this: {student_code}"
519"#;
520
521 #[test]
522 fn parse_reads_an_optional_gotchas_field() {
523 let lesson = Lesson::parse(LESSON_WITH_GOTCHAS_YAML)
527 .expect("a lesson may carry gotchas under exercise.gotchas");
528 assert_eq!(
529 lesson.exercise.gotchas.as_deref(),
530 Some(
531 "- R uses '<-' for assignment, not '='.\n- Functions return their last expression automatically.\n"
532 ),
533 "the gotchas text is read verbatim from exercise.gotchas"
534 );
535 }
536
537 #[test]
538 fn parse_defaults_gotchas_to_none_when_absent() {
539 let lesson = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
542 assert!(
543 lesson.exercise.gotchas.is_none(),
544 "a lesson without a gotchas key has none, got {:?}",
545 lesson.exercise.gotchas
546 );
547 }
548
549 const LESSON_WITH_MALFORMED_GOTCHAS_YAML: &str = r#"
550lesson_name: "Adder"
551language: R
552exercise:
553 prompt: "Write add_two(x, y)."
554 gotchas: "No bullet prefix here."
555 llm_evaluation_prompt: "Grade this: {student_code}"
556"#;
557
558 #[test]
559 fn parse_rejects_gotchas_with_non_bullet_lines() {
560 let err = Lesson::parse(LESSON_WITH_MALFORMED_GOTCHAS_YAML)
564 .expect_err("a gotchas without bullet prefixes is invalid");
565 assert!(
566 matches!(
567 err,
568 ValidationError::InvalidBulletFormat { ref field } if field == "gotchas"
569 ),
570 "expected InvalidBulletFormat for gotchas, got {err:?}"
571 );
572 assert!(
573 err.to_string().contains("gotchas"),
574 "error should name the gotchas field, got: {err}"
575 );
576 }
577
578 const LESSON_WITH_STAR_BULLETS_GOTCHAS_YAML: &str = r#"
579lesson_name: "Adder"
580language: R
581exercise:
582 prompt: "Write add_two(x, y)."
583 gotchas: |
584 * R uses '<-' for assignment.
585 * Functions return their last expression.
586 llm_evaluation_prompt: "Grade this: {student_code}"
587"#;
588
589 #[test]
590 fn parse_accepts_gotchas_with_star_bullets() {
591 let lesson = Lesson::parse(LESSON_WITH_STAR_BULLETS_GOTCHAS_YAML)
594 .expect("star-bullet gotchas should parse");
595 assert!(
596 lesson.exercise.gotchas.is_some(),
597 "star-bullet gotchas should be read"
598 );
599 }
600
601 #[test]
602 fn parse_accepts_none_gotchas() {
603 let lesson = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
606 assert!(lesson.exercise.gotchas.is_none());
607 }
608
609 const LESSON_WITH_EMPTY_GOTCHAS_YAML: &str = r#"
610lesson_name: "Adder"
611language: R
612exercise:
613 prompt: "Write add_two(x, y)."
614 gotchas: ""
615 llm_evaluation_prompt: "Grade this: {student_code}"
616"#;
617
618 #[test]
619 fn parse_accepts_empty_gotchas() {
620 let lesson = Lesson::parse(LESSON_WITH_EMPTY_GOTCHAS_YAML)
623 .expect("an empty gotchas string should parse");
624 assert_eq!(lesson.exercise.gotchas.as_deref(), Some(""));
625 }
626
627 const LESSON_WITH_MALFORMED_HINTS_YAML: &str = r#"
628lesson_name: "Adder"
629language: R
630exercise:
631 prompt: "Write add_two(x, y)."
632 hints: "No bullet prefix here."
633 llm_evaluation_prompt: "Grade this: {student_code}"
634"#;
635
636 #[test]
637 fn parse_rejects_hints_with_non_bullet_lines() {
638 let err = Lesson::parse(LESSON_WITH_MALFORMED_HINTS_YAML)
642 .expect_err("a hints without bullet prefixes is invalid");
643 assert!(
644 matches!(
645 err,
646 ValidationError::InvalidBulletFormat { ref field } if field == "hints"
647 ),
648 "expected InvalidBulletFormat for hints, got {err:?}"
649 );
650 assert!(
651 err.to_string().contains("hints"),
652 "error should name the hints field, got: {err}"
653 );
654 }
655
656 const LESSON_WITH_STAR_BULLETS_HINTS_YAML: &str = r#"
657lesson_name: "Adder"
658language: R
659exercise:
660 prompt: "Write add_two(x, y)."
661 hints: |
662 * R uses '<-' for assignment.
663 * Functions return their last expression.
664 llm_evaluation_prompt: "Grade this: {student_code}"
665"#;
666
667 #[test]
668 fn parse_accepts_hints_with_star_bullets() {
669 let lesson = Lesson::parse(LESSON_WITH_STAR_BULLETS_HINTS_YAML)
672 .expect("star-bullet hints should parse");
673 assert!(
674 lesson.exercise.hints.is_some(),
675 "star-bullet hints should be read"
676 );
677 }
678
679 const LESSON_WITH_EMPTY_HINTS_YAML: &str = r#"
680lesson_name: "Adder"
681language: R
682exercise:
683 prompt: "Write add_two(x, y)."
684 hints: ""
685 llm_evaluation_prompt: "Grade this: {student_code}"
686"#;
687
688 #[test]
689 fn parse_accepts_empty_hints() {
690 let lesson = Lesson::parse(LESSON_WITH_EMPTY_HINTS_YAML)
693 .expect("an empty hints string should parse");
694 assert_eq!(lesson.exercise.hints.as_deref(), Some(""));
695 }
696
697 #[test]
698 fn parse_validates_hints_as_bullets_via_fixture() {
699 let path = Path::new(concat!(
703 env!("CARGO_MANIFEST_DIR"),
704 "/tests/fixtures/r-course/add_two.yaml"
705 ));
706 let lesson = read_lesson_file(path).expect("bullet-formatted hints fixture should parse");
707 assert!(
708 lesson.exercise.hints.is_some(),
709 "the fixture's bullet-formatted hints should be read"
710 );
711 }
712
713 #[test]
714 fn parse_rejects_missing_required_field_naming_it() {
715 let err = Lesson::parse(MISSING_EVAL_PROMPT_YAML)
716 .expect_err("a lesson without llm_evaluation_prompt is invalid");
717 let msg = err.to_string();
718 assert!(
719 msg.contains("llm_evaluation_prompt"),
720 "error should name the missing field, got: {msg}"
721 );
722 }
723
724 #[test]
725 fn parse_rejects_prompt_missing_student_code_placeholder() {
726 let err = Lesson::parse(PROMPT_WITHOUT_PLACEHOLDER_YAML)
727 .expect_err("a prompt without {student_code} is invalid");
728 let msg = err.to_string();
729 assert!(
730 msg.contains("llm_evaluation_prompt"),
731 "error should name the field, got: {msg}"
732 );
733 assert!(
734 msg.contains("{student_code}"),
735 "error should name the placeholder rule, got: {msg}"
736 );
737 }
738
739 const UNKNOWN_LANGUAGE_YAML: &str = r#"
740lesson_name: "Adder"
741language: Go
742exercise:
743 prompt: "Write a function add_two(x, y)."
744 llm_evaluation_prompt: "Grade this: {student_code}"
745"#;
746
747 const UNKNOWN_FIELD_YAML: &str = r#"
748lesson_name: "Adder"
749language: R
750descriptio: "typo'd optional field"
751exercise:
752 prompt: "Write a function add_two(x, y)."
753 llm_evaluation_prompt: "Grade this: {student_code}"
754"#;
755
756 #[test]
757 fn parse_rejects_unknown_language() {
758 let err = Lesson::parse(UNKNOWN_LANGUAGE_YAML)
759 .expect_err("an unknown language is not a valid lesson");
760 assert!(
761 err.to_string().contains("Go"),
762 "error should name the rejected language, got: {err}"
763 );
764 }
765
766 const UNKNOWN_EXERCISE_TYPE_YAML: &str = r#"
767lesson_name: "Adder"
768language: R
769exercise:
770 type: "essay_writing"
771 prompt: "Write a function add_two(x, y)."
772 llm_evaluation_prompt: "Grade this: {student_code}"
773"#;
774
775 #[test]
776 fn parse_rejects_unknown_exercise_type() {
777 let err = Lesson::parse(UNKNOWN_EXERCISE_TYPE_YAML)
778 .expect_err("an unknown exercise type is not a valid lesson");
779 assert!(
780 err.to_string().contains("essay_writing"),
781 "error should name the rejected exercise type, got: {err}"
782 );
783 }
784
785 #[test]
786 fn parse_rejects_unknown_field_so_author_typos_surface() {
787 let err = Lesson::parse(UNKNOWN_FIELD_YAML)
788 .expect_err("a typo'd optional field must not be silently dropped");
789 assert!(
790 err.to_string().contains("descriptio"),
791 "error should name the unknown field, got: {err}"
792 );
793 }
794
795 #[test]
796 fn read_lesson_file_loads_and_parses_the_ported_fixture() {
797 let path = Path::new(concat!(
798 env!("CARGO_MANIFEST_DIR"),
799 "/tests/fixtures/lessons/add_two_numbers.yaml"
800 ));
801 let lesson = read_lesson_file(path).expect("ported fixture should load and validate");
802 assert_eq!(lesson.language, Language::R);
803 assert_eq!(lesson.exercise.kind, Some(ExerciseKind::FunctionWriting));
804 }
805
806 #[test]
807 fn read_lesson_file_missing_path_is_a_read_error_not_a_validation_error() {
808 let err = read_lesson_file(Path::new("/no/such/lesson.yaml"))
809 .expect_err("a missing file cannot load");
810 assert!(
811 matches!(err, LoadError::Read(_)),
812 "a missing file is a read error, got: {err:?}"
813 );
814 }
815
816 #[test]
817 fn read_lesson_file_invalid_contents_is_a_validation_error() {
818 let mut file = tempfile::NamedTempFile::new().unwrap();
820 std::io::Write::write_all(&mut file, PROMPT_WITHOUT_PLACEHOLDER_YAML.as_bytes()).unwrap();
821 let err =
822 read_lesson_file(file.path()).expect_err("a {student_code}-less lesson is invalid");
823 assert!(
824 matches!(
825 err,
826 LoadError::Invalid(ValidationError::MissingStudentCodePlaceholder)
827 ),
828 "invalid contents should surface as a validation error, got: {err:?}"
829 );
830 }
831
832 #[test]
833 fn lesson_json_roundtrip_preserves_value() {
834 let original = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
835 let json = serde_json::to_string(&original).expect("lesson should serialize to JSON");
836 let roundtripped: Lesson =
837 serde_json::from_str(&json).expect("lesson should deserialize from JSON");
838 assert_eq!(roundtripped, original);
839 }
840
841 #[test]
842 fn lesson_id_displays_its_value() {
843 let lesson = Lesson::parse(VALID_YAML).expect("valid lesson should parse");
844 assert_eq!(lesson.lesson_name.to_string(), "Adder");
845 }
846
847 #[test]
848 fn load_error_display_and_source_surface_the_cause() {
849 let invalid = LoadError::Invalid(ValidationError::MissingStudentCodePlaceholder);
850 assert!(
851 invalid.to_string().contains("{student_code}"),
852 "Invalid should display the validation message, got: {invalid}"
853 );
854 assert!(
855 std::error::Error::source(&invalid).is_some(),
856 "Invalid should expose the ValidationError as its source"
857 );
858
859 let read = LoadError::Read(std::io::Error::new(std::io::ErrorKind::NotFound, "nope"));
860 assert!(
861 read.to_string().contains("could not read"),
862 "Read should label itself a read failure, got: {read}"
863 );
864 assert!(
865 std::error::Error::source(&read).is_some(),
866 "Read should expose the io::Error as its source"
867 );
868 }
869
870 fn lesson_with_packages(entries: &str) -> String {
871 format!(
872 "lesson_name: \"Pkg\"\nlanguage: Python\npackages: {entries}\nexercise:\n prompt: \"Write add.\"\n llm_evaluation_prompt: \"Grade: {{student_code}}\"\n"
873 )
874 }
875
876 #[test]
877 fn parse_accepts_plain_and_versioned_package_names() {
878 let lesson = Lesson::parse(&lesson_with_packages("[pandas, 'numpy>=2', purrr]"))
879 .expect("plain and versioned names are list-safe");
880 assert_eq!(lesson.packages, vec!["pandas", "numpy>=2", "purrr"]);
881 }
882
883 #[test]
884 fn parse_rejects_package_names_that_break_comma_lists() {
885 for bad in [r#"['foo"bar']"#, "['a,b']", "['has space']", "['']"] {
886 let err = Lesson::parse(&lesson_with_packages(bad))
887 .expect_err(&format!("{bad} must be rejected"));
888 assert!(
889 matches!(err, ValidationError::InvalidPackageName { .. }),
890 "expected InvalidPackageName for {bad}, got {err:?}"
891 );
892 }
893 }
894}