1use std::error::Error;
17use std::fmt;
18use std::path::{Path, PathBuf};
19
20use crate::lesson::Language;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct FileSpec {
29 pub path: PathBuf,
31 pub contents: &'static str,
33}
34
35const MANIFEST_TEMPLATE: &str = include_str!("scaffold/blendtutor.toml");
37const LESSON_TEMPLATE: &str = include_str!("scaffold/lesson_hello.yaml");
39const EVAL_TEMPLATE: &str = include_str!("scaffold/eval_lesson_hello.yaml");
41const README_TEMPLATE: &str = include_str!("scaffold/README.md");
43const GITIGNORE_TEMPLATE: &str = include_str!("scaffold/gitignore");
47
48const MANIFEST_FILENAME: &str = "blendtutor.toml";
50const LESSON_FILENAME: &str = "lesson_hello.yaml";
52const EVAL_FILENAME: &str = "eval_lesson_hello.yaml";
54
55pub fn scaffold_plan() -> Vec<FileSpec> {
62 vec![
63 FileSpec {
64 path: PathBuf::from(MANIFEST_FILENAME),
65 contents: MANIFEST_TEMPLATE,
66 },
67 FileSpec {
68 path: PathBuf::from(LESSON_FILENAME),
69 contents: LESSON_TEMPLATE,
70 },
71 FileSpec {
72 path: PathBuf::from(EVAL_FILENAME),
73 contents: EVAL_TEMPLATE,
74 },
75 FileSpec {
76 path: PathBuf::from("README.md"),
77 contents: README_TEMPLATE,
78 },
79 FileSpec {
80 path: PathBuf::from(".gitignore"),
81 contents: GITIGNORE_TEMPLATE,
82 },
83 ]
84}
85
86#[derive(Debug)]
88pub enum ScaffoldError {
89 TargetNotEmpty(PathBuf),
92 Write(std::io::Error),
95}
96
97impl fmt::Display for ScaffoldError {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 match self {
100 ScaffoldError::TargetNotEmpty(path) => write!(
101 f,
102 "target directory {path:?} is not empty; init refuses to overwrite \
103 an existing course — choose an empty or new directory",
104 ),
105 ScaffoldError::Write(e) => write!(f, "could not write course scaffold: {e}"),
106 }
107 }
108}
109
110impl Error for ScaffoldError {
111 fn source(&self) -> Option<&(dyn Error + 'static)> {
112 match self {
113 ScaffoldError::TargetNotEmpty(_) => None,
114 ScaffoldError::Write(e) => Some(e),
115 }
116 }
117}
118
119pub fn scaffold_course(dir: &Path) -> Result<(), ScaffoldError> {
128 if !is_empty_target(dir)? {
129 return Err(ScaffoldError::TargetNotEmpty(dir.to_path_buf()));
130 }
131 std::fs::create_dir_all(dir).map_err(ScaffoldError::Write)?;
132 for spec in scaffold_plan() {
133 std::fs::write(dir.join(&spec.path), spec.contents).map_err(ScaffoldError::Write)?;
134 }
135 Ok(())
136}
137
138fn is_empty_target(dir: &Path) -> Result<bool, ScaffoldError> {
153 match std::fs::read_dir(dir) {
154 Ok(mut entries) => Ok(entries.next().is_none()),
155 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
156 Ok(std::fs::symlink_metadata(dir).is_err())
168 }
169 Err(e) => Err(ScaffoldError::Write(e)),
170 }
171}
172
173const LESSONS_DIR: &str = "lessons";
177
178const R_EXERCISE: &str = r#"exercise:
183 type: "function_writing"
184 prompt: |
185 Write R code that prints the word "hello" on its own line, using cat().
186 code_template: |
187 # Your code here
188 cat("hello\n")
189 example_usage: |
190 cat("hello\n") # prints: hello
191 success_criteria: |
192 - Prints exactly the word "hello"
193 - Uses cat()
194 # Optional learner aids — uncomment to use. hints and gotchas are bullet lists.
195 # solution: |
196 # cat("hello\n")
197 # hints: |
198 # - cat() prints its arguments without quotes or an index.
199 # gotchas: |
200 # - print("hello") adds [1] and quotes; use cat() here.
201 llm_evaluation_prompt: |
202 You are grading a beginner R exercise: print the word "hello" with cat().
203
204 The student submitted this code:
205 {student_code}
206
207 Decide whether it prints "hello" and call respond_with_feedback with your
208 assessment. Set is_correct true when the requirement is met, and give two or
209 three sentences of encouraging feedback.
210# Optional lesson-level fields — uncomment to use.
211# checks:
212# - "stopifnot(is.function(cat))"
213# packages:
214# - dplyr
215"#;
216
217const PYTHON_EXERCISE: &str = r#"exercise:
220 type: "function_writing"
221 prompt: |
222 Write Python code that prints the word "hello" on its own line, using print().
223 code_template: |
224 # Your code here
225 print("hello")
226 example_usage: |
227 print("hello") # prints: hello
228 success_criteria: |
229 - Prints exactly the word "hello"
230 - Uses print()
231 # Optional learner aids — uncomment to use. hints and gotchas are bullet lists.
232 # solution: |
233 # print("hello")
234 # hints: |
235 # - print() adds a trailing newline for you.
236 # gotchas: |
237 # - Quote the word: print(hello) looks up a variable named hello.
238 llm_evaluation_prompt: |
239 You are grading a beginner Python exercise: print the word "hello" with print().
240
241 The student submitted this code:
242 {student_code}
243
244 Decide whether it prints "hello" and call respond_with_feedback with your
245 assessment. Set is_correct true when the requirement is met, and give two or
246 three sentences of encouraging feedback.
247# Optional lesson-level fields — uncomment to use.
248# checks:
249# - "assert callable(print)"
250# packages:
251# - pandas
252"#;
253
254pub fn lesson_template(language: Language, id: &str) -> String {
263 let (lang, exercise) = match language {
264 Language::R => ("R", R_EXERCISE),
265 Language::Python => ("Python", PYTHON_EXERCISE),
266 };
267 format!(
268 "# A lesson scaffolded by `blendtutor new lesson`. Edit it, then check your\n\
269 # changes with `blendtutor validate {LESSONS_DIR}/{id}.yaml`.\n\
270 lesson_name: \"{id}\"\n\
271 language: {lang}\n\
272 description: \"A starter {lang} lesson — replace with your own exercise\"\n\
273 {exercise}"
274 )
275}
276
277pub const EVAL_SIBLING_PREFIX: &str = "eval_";
284
285pub fn eval_sibling_path(lesson_path: &Path) -> PathBuf {
296 let file_name = lesson_path.file_name().unwrap_or_default();
297 let mut suite_name = std::ffi::OsString::from(EVAL_SIBLING_PREFIX);
298 suite_name.push(file_name);
299 lesson_path.with_file_name(suite_name)
300}
301
302pub fn eval_template(language: Language, id: &str) -> String {
313 let submission = match language {
314 Language::R => r#"cat("hello\n")"#,
315 Language::Python => r#"print("hello")"#,
316 };
317 format!(
318 "# The eval suite scaffolded by `blendtutor new lesson` for\n\
319 # lessons/{id}.yaml — this file's `eval_`-prefixed sibling. Each case\n\
320 # pairs a sample submission with the verdict you expect a good grader\n\
321 # to return (correct or incorrect). Edit it, then score it with\n\
322 # `blendtutor eval lessons/{id}.yaml`.\n\
323 cases:\n\
324 \x20 - submission: |-\n\
325 \x20 {submission}\n\
326 \x20 expected: correct\n"
327 )
328}
329
330#[derive(Debug)]
339pub enum AddLessonError {
340 InvalidId(String),
345 NotACourse(PathBuf),
350 AlreadyExists(PathBuf),
355 Write(std::io::Error),
358}
359
360impl fmt::Display for AddLessonError {
361 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362 match self {
363 AddLessonError::InvalidId(id) => write!(
364 f,
365 "lesson id {id:?} is not a valid slug; use letters, digits, \
366 hyphens, and underscores only",
367 ),
368 AddLessonError::NotACourse(dir) => write!(
369 f,
370 "{dir:?} is not a blendtutor course (no blendtutor.toml); run \
371 `blendtutor init` or cd into a course before adding a lesson",
372 ),
373 AddLessonError::AlreadyExists(path) => write!(
374 f,
375 "a file already exists at {path:?}; new lesson refuses to \
376 overwrite it — choose a different id",
377 ),
378 AddLessonError::Write(e) => write!(f, "could not write the new lesson: {e}"),
379 }
380 }
381}
382
383impl Error for AddLessonError {
384 fn source(&self) -> Option<&(dyn Error + 'static)> {
385 match self {
386 AddLessonError::InvalidId(_)
387 | AddLessonError::NotACourse(_)
388 | AddLessonError::AlreadyExists(_) => None,
389 AddLessonError::Write(e) => Some(e),
390 }
391 }
392}
393
394fn is_valid_slug(id: &str) -> bool {
402 !id.is_empty()
403 && id
404 .chars()
405 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
406}
407
408pub fn add_lesson(dir: &Path, language: Language, id: &str) -> Result<PathBuf, AddLessonError> {
436 if !is_valid_slug(id) {
437 return Err(AddLessonError::InvalidId(id.to_string()));
438 }
439 let mut manifest = open_manifest_for_append(dir)?;
440 let rel_path = PathBuf::from(LESSONS_DIR).join(format!("{id}.yaml"));
441 let eval_rel_path = eval_sibling_path(&rel_path);
442 std::fs::create_dir_all(dir.join(LESSONS_DIR)).map_err(AddLessonError::Write)?;
443 write_without_clobber(&dir.join(&rel_path), &lesson_template(language.clone(), id)).map_err(
444 |e| match e.kind() {
445 std::io::ErrorKind::AlreadyExists => AddLessonError::AlreadyExists(rel_path.clone()),
446 _ => AddLessonError::Write(e),
447 },
448 )?;
449 write_without_clobber(&dir.join(&eval_rel_path), &eval_template(language, id)).map_err(
450 |e| match e.kind() {
451 std::io::ErrorKind::AlreadyExists => {
452 AddLessonError::AlreadyExists(eval_rel_path.clone())
453 }
454 _ => AddLessonError::Write(e),
455 },
456 )?;
457 append_lesson_entry(&mut manifest, id).map_err(AddLessonError::Write)?;
458 Ok(rel_path)
459}
460
461fn write_without_clobber(path: &Path, contents: &str) -> std::io::Result<()> {
468 let mut file = std::fs::OpenOptions::new()
469 .write(true)
470 .create_new(true)
471 .open(path)?;
472 std::io::Write::write_all(&mut file, contents.as_bytes())
473}
474
475fn open_manifest_for_append(dir: &Path) -> Result<std::fs::File, AddLessonError> {
484 std::fs::OpenOptions::new()
485 .append(true)
486 .open(dir.join(MANIFEST_FILENAME))
487 .map_err(|e| match e.kind() {
488 std::io::ErrorKind::NotFound => AddLessonError::NotACourse(dir.to_path_buf()),
489 _ => AddLessonError::Write(e),
490 })
491}
492
493fn append_lesson_entry(manifest: &mut std::fs::File, id: &str) -> std::io::Result<()> {
502 let entry = format!("\n[[lessons]]\nid = \"{id}\"\npath = \"{LESSONS_DIR}/{id}.yaml\"\n");
503 std::io::Write::write_all(manifest, entry.as_bytes())
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::course::{Course, Manifest};
510 use crate::eval::parse_eval_suite;
511 use crate::lesson::{Language, Lesson};
512
513 fn planned(path: &str) -> FileSpec {
515 scaffold_plan()
516 .into_iter()
517 .find(|spec| spec.path == Path::new(path))
518 .unwrap_or_else(|| panic!("the plan should include {path:?}"))
519 }
520
521 #[test]
522 fn plan_lists_the_starter_files_as_pure_data() {
523 let mut paths: Vec<String> = scaffold_plan()
528 .iter()
529 .map(|spec| spec.path.to_string_lossy().into_owned())
530 .collect();
531 paths.sort();
532 assert_eq!(
533 paths,
534 vec![
535 ".gitignore",
536 "README.md",
537 "blendtutor.toml",
538 "eval_lesson_hello.yaml",
539 "lesson_hello.yaml",
540 ]
541 );
542 }
543
544 #[test]
545 fn the_scaffolded_lesson_eval_and_manifest_parse_with_production_parsers() {
546 Lesson::parse(planned(LESSON_FILENAME).contents)
551 .expect("the example lesson must satisfy the lesson schema");
552 parse_eval_suite(planned(EVAL_FILENAME).contents)
553 .expect("the example eval suite must satisfy the eval schema");
554 let manifest = Manifest::parse(planned(MANIFEST_FILENAME).contents)
555 .expect("the example manifest must parse");
556
557 assert_eq!(manifest.lessons.len(), 1, "one example lesson is listed");
558 assert_eq!(
559 manifest.lessons[0].path,
560 Path::new(LESSON_FILENAME),
561 "the manifest entry points at the scaffolded lesson file"
562 );
563 }
564
565 #[test]
566 fn scaffold_course_writes_every_planned_file_verbatim() {
567 let dir = tempfile::tempdir().unwrap();
568 scaffold_course(dir.path()).expect("scaffolding an empty dir succeeds");
569
570 for spec in scaffold_plan() {
571 let written = dir.path().join(&spec.path);
572 assert_eq!(
573 std::fs::read_to_string(&written)
574 .unwrap_or_else(|e| panic!("{:?} should have been written: {e}", spec.path)),
575 spec.contents,
576 "{:?} should be written verbatim from its template",
577 spec.path
578 );
579 }
580 }
581
582 #[test]
583 fn scaffold_course_creates_the_target_directory_when_absent() {
584 let parent = tempfile::tempdir().unwrap();
585 let course = parent.path().join("new_course");
586 scaffold_course(&course).expect("a not-yet-existing target is created and written");
587 assert!(
588 course.join(MANIFEST_FILENAME).is_file(),
589 "the manifest lands inside the freshly-created course directory"
590 );
591 }
592
593 #[test]
594 fn scaffold_course_refuses_a_nonempty_target_before_writing_anything() {
595 let dir = tempfile::tempdir().unwrap();
596 std::fs::write(dir.path().join("sentinel.txt"), "KEEP").unwrap();
597
598 let err = scaffold_course(dir.path())
599 .expect_err("a directory that already holds files must be refused");
600 assert!(
601 matches!(err, ScaffoldError::TargetNotEmpty(_)),
602 "a non-empty target is a TargetNotEmpty refusal, got {err:?}"
603 );
604
605 let mut names: Vec<String> = std::fs::read_dir(dir.path())
608 .unwrap()
609 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
610 .collect();
611 names.sort();
612 assert_eq!(names, vec!["sentinel.txt".to_string()]);
613 assert_eq!(
614 std::fs::read_to_string(dir.path().join("sentinel.txt")).unwrap(),
615 "KEEP"
616 );
617 }
618
619 #[test]
620 fn scaffold_course_accepts_an_existing_empty_directory() {
621 let dir = tempfile::tempdir().unwrap();
625 scaffold_course(dir.path()).expect("an existing but empty directory is a valid target");
626 assert!(dir.path().join(MANIFEST_FILENAME).is_file());
627 }
628
629 #[test]
630 fn is_empty_target_surfaces_a_non_notfound_error_rather_than_reading_it_as_empty() {
631 let file = tempfile::NamedTempFile::new().unwrap();
637 let result = is_empty_target(file.path());
638 assert!(
639 matches!(result, Err(ScaffoldError::Write(_))),
640 "a non-directory target is a Write error, not an empty target, got {result:?}"
641 );
642 }
643
644 #[cfg(unix)]
645 #[test]
646 fn scaffold_course_refuses_a_broken_symlink_target_instead_of_a_cryptic_write_error() {
647 let dir = tempfile::tempdir().unwrap();
653 let link = dir.path().join("course_link");
654 let dangling = dir.path().join("nonexistent_target");
655 std::os::unix::fs::symlink(&dangling, &link).unwrap();
656
657 let err =
658 scaffold_course(&link).expect_err("a broken symlink target must be refused, not built");
659 assert!(
660 matches!(err, ScaffoldError::TargetNotEmpty(_)),
661 "a broken symlink is an existing object, refused before any write, got {err:?}"
662 );
663 assert!(
664 !dangling.exists(),
665 "the guard fires before create_dir_all, so the link's target is never created"
666 );
667 }
668
669 #[test]
670 fn scaffold_error_display_and_source_distinguish_each_variant() {
671 use std::io::{Error as IoError, ErrorKind};
672
673 let refused = ScaffoldError::TargetNotEmpty(PathBuf::from("/some/course"));
676 let refused_msg = refused.to_string();
677 assert!(
678 refused_msg.contains("not empty") && refused_msg.contains("/some/course"),
679 "TargetNotEmpty should explain the refusal and name the path, got: {refused_msg}"
680 );
681 assert!(std::error::Error::source(&refused).is_none());
682
683 let write = ScaffoldError::Write(IoError::new(ErrorKind::PermissionDenied, "denied"));
686 let write_msg = write.to_string();
687 assert!(
688 write_msg.contains("could not write course scaffold") && write_msg.contains("denied"),
689 "Write should frame and carry the message, got: {write_msg}"
690 );
691 assert!(std::error::Error::source(&write).is_some());
692 }
693
694 fn uncomment_optional_fields(yaml: &str) -> String {
697 yaml.lines()
698 .map(|line| {
699 let trimmed = line.trim_start();
700 let indent = &line[..line.len() - trimmed.len()];
701 let is_label = trimmed.starts_with("# Optional")
702 || trimmed.starts_with("# A lesson scaffolded")
703 || trimmed.starts_with("# changes with");
704 match trimmed.strip_prefix("# ") {
705 Some(rest) if !is_label => format!("{indent}{rest}"),
706 _ => line.to_string(),
707 }
708 })
709 .collect::<Vec<_>>()
710 .join("\n")
711 }
712
713 #[test]
714 fn lesson_template_optional_fields_parse_once_uncommented() {
715 for language in [Language::R, Language::Python] {
716 let yaml = uncomment_optional_fields(&lesson_template(language.clone(), "aided"));
717 let lesson = Lesson::parse(&yaml).unwrap_or_else(|e| {
718 panic!("uncommented {language:?} template must parse: {e}\n{yaml}")
719 });
720 assert!(lesson.exercise.solution.is_some(), "{language:?} solution");
721 assert!(lesson.exercise.hints.is_some(), "{language:?} hints");
722 assert!(lesson.exercise.gotchas.is_some(), "{language:?} gotchas");
723 assert_eq!(lesson.checks.len(), 1, "{language:?} checks");
724 assert_eq!(lesson.packages.len(), 1, "{language:?} packages");
725 }
726 }
727
728 #[test]
729 fn lesson_template_produces_a_valid_python_lesson() {
730 let yaml = lesson_template(Language::Python, "greet");
734 let lesson = Lesson::parse(&yaml).expect("the generated python lesson must be valid");
735 assert_eq!(lesson.language, Language::Python);
736 assert_eq!(lesson.lesson_name.to_string(), "greet");
737 }
738
739 #[test]
740 fn lesson_template_produces_a_valid_r_lesson() {
741 let yaml = lesson_template(Language::R, "loops");
744 let lesson = Lesson::parse(&yaml).expect("the generated r lesson must be valid");
745 assert_eq!(lesson.language, Language::R);
746 assert_eq!(lesson.lesson_name.to_string(), "loops");
747 }
748
749 #[test]
750 fn add_lesson_writes_the_file_and_registers_it_so_the_course_lists_it() {
751 let dir = tempfile::tempdir().unwrap();
755 scaffold_course(dir.path()).unwrap();
756
757 let rel = add_lesson(dir.path(), Language::Python, "greet")
758 .expect("adding a fresh lesson succeeds");
759 assert_eq!(rel, Path::new("lessons/greet.yaml"));
760 assert!(
761 dir.path().join(&rel).is_file(),
762 "the lesson file is written"
763 );
764
765 let course = Course::open(dir.path()).expect("the course still opens after registration");
766 let greet = course
767 .discover()
768 .into_iter()
769 .filter_map(Result::ok)
770 .find(|s| s.id.to_string() == "greet")
771 .expect("the new lesson is discovered via the manifest");
772 assert_eq!(greet.language, Language::Python);
773 }
774
775 #[test]
776 fn add_lesson_refuses_an_unsafe_id_before_writing_anything() {
777 let dir = tempfile::tempdir().unwrap();
782 scaffold_course(dir.path()).unwrap();
783 let manifest_before = std::fs::read_to_string(dir.path().join(MANIFEST_FILENAME)).unwrap();
784
785 for bad in ["../evil", "a/b", "", "has space", "dot.dot", "quote\"d"] {
786 let err = add_lesson(dir.path(), Language::Python, bad)
787 .expect_err("an unsafe id must be refused");
788 assert!(
789 matches!(err, AddLessonError::InvalidId(_)),
790 "id {bad:?} should be an InvalidId refusal, got {err:?}"
791 );
792 }
793 assert!(
796 !dir.path().join("lessons").exists(),
797 "a refused id must not create the lessons directory"
798 );
799 assert_eq!(
800 std::fs::read_to_string(dir.path().join(MANIFEST_FILENAME)).unwrap(),
801 manifest_before,
802 "a refused id must not touch the manifest"
803 );
804 }
805
806 #[test]
807 fn add_lesson_refuses_a_non_course_directory_without_leaving_an_orphan() {
808 let dir = tempfile::tempdir().unwrap(); let err = add_lesson(dir.path(), Language::Python, "greet")
815 .expect_err("a directory with no manifest is not a course");
816 assert!(
817 matches!(err, AddLessonError::NotACourse(_)),
818 "a missing manifest is a NotACourse refusal, got {err:?}"
819 );
820 assert!(
821 !dir.path().join(LESSONS_DIR).exists(),
822 "a refused non-course add must not write an orphan lesson directory"
823 );
824 }
825
826 #[test]
827 fn add_lesson_error_display_and_source_distinguish_each_variant() {
828 use std::io::{Error as IoError, ErrorKind};
829
830 let invalid = AddLessonError::InvalidId("../evil".to_string());
832 let invalid_msg = invalid.to_string();
833 assert!(
834 invalid_msg.contains("../evil"),
835 "InvalidId should name the rejected id, got: {invalid_msg}"
836 );
837 assert!(std::error::Error::source(&invalid).is_none());
838
839 let exists = AddLessonError::AlreadyExists(PathBuf::from("lessons/greet.yaml"));
841 let exists_msg = exists.to_string();
842 assert!(
843 exists_msg.contains("lessons/greet.yaml") && exists_msg.contains("exists"),
844 "AlreadyExists should name the path and the refusal, got: {exists_msg}"
845 );
846 assert!(std::error::Error::source(&exists).is_none());
847
848 let not_course = AddLessonError::NotACourse(PathBuf::from("/tmp/scratch"));
850 let not_course_msg = not_course.to_string();
851 assert!(
852 not_course_msg.contains("/tmp/scratch") && not_course_msg.contains("blendtutor.toml"),
853 "NotACourse should name the dir and the missing manifest, got: {not_course_msg}"
854 );
855 assert!(std::error::Error::source(¬_course).is_none());
856
857 let write = AddLessonError::Write(IoError::new(ErrorKind::PermissionDenied, "denied"));
859 let write_msg = write.to_string();
860 assert!(
861 write_msg.contains("denied"),
862 "Write should carry the io message, got: {write_msg}"
863 );
864 assert!(std::error::Error::source(&write).is_some());
865 }
866
867 #[test]
868 fn eval_template_produces_a_valid_python_eval_suite() {
869 let yaml = eval_template(Language::Python, "greet");
872 let suite = parse_eval_suite(&yaml).expect("the generated python eval suite must parse");
873 assert_eq!(
874 suite.cases.len(),
875 1,
876 "the starter suite is minimal: one case"
877 );
878 assert_eq!(
879 suite.cases[0].expected,
880 crate::eval::ExpectedVerdict::Correct,
881 "the starter case expects a correct verdict"
882 );
883 }
884
885 #[test]
886 fn eval_template_produces_a_valid_r_eval_suite() {
887 let yaml = eval_template(Language::R, "loops");
890 let suite = parse_eval_suite(&yaml).expect("the generated r eval suite must parse");
891 assert_eq!(
892 suite.cases.len(),
893 1,
894 "the starter suite is minimal: one case"
895 );
896 }
897
898 #[test]
899 fn eval_sibling_path_prefixes_the_lesson_file_name_with_eval() {
900 assert_eq!(
904 eval_sibling_path(Path::new("lessons/tally.yaml")),
905 PathBuf::from("lessons/eval_tally.yaml")
906 );
907 assert_eq!(
908 eval_sibling_path(Path::new("lesson_hello.yaml")),
909 PathBuf::from("eval_lesson_hello.yaml")
910 );
911 assert_eq!(
912 eval_sibling_path(Path::new("/")),
913 PathBuf::from("/eval_"),
914 "a degenerate path yields the prefix alone, so a later read fails \
915 with a path-named error"
916 );
917 }
918
919 #[test]
920 fn add_lesson_writes_the_eval_sibling_next_to_the_lesson() {
921 let dir = tempfile::tempdir().unwrap();
926 scaffold_course(dir.path()).unwrap();
927
928 add_lesson(dir.path(), Language::Python, "greet").expect("adding a fresh lesson succeeds");
929
930 let eval_path = dir.path().join(LESSONS_DIR).join("eval_greet.yaml");
931 let suite_yaml = std::fs::read_to_string(&eval_path)
932 .unwrap_or_else(|e| panic!("the eval sibling should be written at {eval_path:?}: {e}"));
933 let suite = parse_eval_suite(&suite_yaml)
934 .expect("the scaffolded eval sibling must satisfy the eval schema");
935 assert_eq!(
936 suite.cases.len(),
937 1,
938 "the starter suite is minimal: one case"
939 );
940
941 let manifest =
942 Manifest::parse(&std::fs::read_to_string(dir.path().join(MANIFEST_FILENAME)).unwrap())
943 .expect("the manifest still parses after registration");
944 assert_eq!(
945 manifest.lessons.len(),
946 2,
947 "starter + greet; the eval sibling is not registered"
948 );
949 }
950
951 #[test]
952 fn add_lesson_refuses_when_the_eval_sibling_already_exists_without_clobbering_it() {
953 let dir = tempfile::tempdir().unwrap();
958 scaffold_course(dir.path()).unwrap();
959 std::fs::create_dir_all(dir.path().join(LESSONS_DIR)).unwrap();
960 let hand_edited = "cases:\n - submission: '1'\n expected: incorrect\n";
961 std::fs::write(
962 dir.path().join(LESSONS_DIR).join("eval_greet.yaml"),
963 hand_edited,
964 )
965 .unwrap();
966
967 let err = add_lesson(dir.path(), Language::Python, "greet")
968 .expect_err("a taken eval sibling must be refused");
969 assert!(
970 matches!(err, AddLessonError::AlreadyExists(_)),
971 "an existing eval sibling is an AlreadyExists refusal, got {err:?}"
972 );
973 assert_eq!(
974 std::fs::read_to_string(dir.path().join(LESSONS_DIR).join("eval_greet.yaml")).unwrap(),
975 hand_edited,
976 "the pre-existing eval sibling's bytes must be unchanged"
977 );
978 }
979
980 #[test]
981 fn add_lesson_refuses_a_duplicate_without_clobbering_or_double_registering() {
982 let dir = tempfile::tempdir().unwrap();
986 scaffold_course(dir.path()).unwrap();
987 add_lesson(dir.path(), Language::R, "dup").expect("the first add succeeds");
988
989 let lesson_path = dir.path().join(LESSONS_DIR).join("dup.yaml");
990 let lesson_before = std::fs::read(&lesson_path).unwrap();
991 let manifest_before = std::fs::read_to_string(dir.path().join(MANIFEST_FILENAME)).unwrap();
992
993 let err = add_lesson(dir.path(), Language::Python, "dup")
994 .expect_err("a duplicate id must be refused");
995 assert!(
996 matches!(err, AddLessonError::AlreadyExists(_)),
997 "a duplicate is an AlreadyExists refusal, got {err:?}"
998 );
999 assert_eq!(
1000 std::fs::read(&lesson_path).unwrap(),
1001 lesson_before,
1002 "the existing lesson's bytes must be unchanged (still the R template)"
1003 );
1004 assert_eq!(
1005 std::fs::read_to_string(dir.path().join(MANIFEST_FILENAME)).unwrap(),
1006 manifest_before,
1007 "a refused duplicate must not append a second manifest entry"
1008 );
1009 }
1010
1011 #[test]
1012 fn is_valid_slug_accepts_word_chars_hyphens_and_underscores_but_nothing_else() {
1013 for ok in ["greet", "add-two", "my_lesson", "ch2", "a", "R2D2"] {
1018 assert!(is_valid_slug(ok), "{ok:?} should be a valid slug");
1019 }
1020 for bad in [
1021 "",
1022 "../x",
1023 "a/b",
1024 "has space",
1025 "dot.dot",
1026 "quote\"d",
1027 "tab\tx",
1028 ".",
1029 ] {
1030 assert!(!is_valid_slug(bad), "{bad:?} should be rejected");
1031 }
1032 }
1033}