blendtutor_core/
lesson.rs

1//! The lesson schema: a typed model and its validating parse boundary.
2//!
3//! Holds the [`Lesson`]/[`Exercise`] types, the [`Language`] enum, the
4//! [`LessonId`] newtype, and [`Lesson::parse`] — the only constructor, which
5//! deserializes a YAML document and then enforces the semantic rules (notably
6//! the `{student_code}` placeholder) so a constructed [`Lesson`] is valid by
7//! type (ADR-0003). This module does not execute code, call LLMs, or render
8//! output; that belongs to the runner, provider, and cli layers.
9
10use std::error::Error;
11use std::fmt;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16/// The literal placeholder a lesson's evaluation prompt must contain so the
17/// runner can splice the learner's submission into it before grading.
18const STUDENT_CODE_PLACEHOLDER: &str = "{student_code}";
19
20/// The language a lesson is authored in.
21///
22/// An enum rather than a free string, so an unknown language is rejected at the
23/// parse boundary instead of travelling downstream as data (§1.2). The variant
24/// names match the YAML/JSON spelling (`R`, `Python`).
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub enum Language {
27    /// The R language.
28    R,
29    /// The Python language.
30    Python,
31}
32
33/// A lesson's stable identity.
34///
35/// A newtype over `String` so a lesson id is never confused with arbitrary text
36/// (§1.4). In v1 it carries the `lesson_name` value; a distinct slug id arrives
37/// with lesson discovery (Slice 6, see ADR-0003).
38#[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/// The kind of exercise a lesson poses.
48///
49/// An enum, not a string, so an unknown kind is rejected at the parse boundary
50/// rather than carried downstream (§1.2). The R package authors only
51/// function-writing exercises today; new variants are added as the runner gains
52/// support for them (Slice 9).
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum ExerciseKind {
56    /// Write a function to a specification.
57    FunctionWriting,
58}
59
60/// The exercise a lesson poses.
61///
62/// The two required prompts carry the learner-facing task and the grading
63/// template; the optional fields are authoring aids. `llm_evaluation_prompt`
64/// must contain the `{student_code}` placeholder — enforced by [`Lesson::parse`],
65/// not by this type alone. Unknown keys are rejected (§1.3.1) so an author's
66/// typo in an optional field surfaces rather than silently dropping to `None`.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct Exercise {
70    /// What the learner is asked to do. Required.
71    pub prompt: String,
72    /// The prompt sent to the LLM to grade a submission. Required; must contain
73    /// the `{student_code}` placeholder.
74    pub llm_evaluation_prompt: String,
75    /// The kind of exercise, from the YAML `type` key. Optional.
76    #[serde(rename = "type")]
77    pub kind: Option<ExerciseKind>,
78    /// Optional starter code shown to the learner.
79    pub code_template: Option<String>,
80    /// Optional reference solution: a known-correct answer to the exercise. The
81    /// static-site build serializes it into the lesson JSON so the in-browser
82    /// runner can self-verify a correct submission (ADR-0008). It is authoring
83    /// data, never sent to the LLM; `Option` so every existing lesson stays valid.
84    pub solution: Option<String>,
85    /// Optional learner-facing hints: tips and guidance, authored as a Markdown
86    /// bullet list (each non-empty line starts with `- ` or `* `). The
87    /// static-site build serializes them into the lesson JSON so the
88    /// in-browser runner renders them in an expandable `<details>` panel.
89    /// `Option` so every existing lesson stays valid; validated as bullets at
90    /// the parse boundary (§1.3.1) so malformed content never travels
91    /// downstream. `Option` defaults to `None` without `#[serde(default)]`.
92    pub hints: Option<String>,
93    /// Optional learner-facing gotchas: common pitfalls and mistakes, authored
94    /// as a Markdown bullet list (each non-empty line starts with `- ` or
95    /// `* `). The static-site build serializes them into the lesson JSON so the
96    /// in-browser runner renders them in an expandable `<details>` panel.
97    /// `Option` so every existing lesson stays valid; validated as bullets at
98    /// the parse boundary (§1.3.1) so malformed content never travels
99    /// downstream. `Option` defaults to `None` without `#[serde(default)]`.
100    pub gotchas: Option<String>,
101    /// Optional example invocations.
102    pub example_usage: Option<String>,
103    /// Optional human-readable success criteria.
104    pub success_criteria: Option<String>,
105}
106
107/// A single lesson: its identity, language, and exercise, with optional prose.
108///
109/// Construct one only through [`Lesson::parse`] — a value of this type is, by
110/// construction, structurally complete and semantically valid (ADR-0003).
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct Lesson {
114    /// The lesson's identity (v1: its name). Required.
115    pub lesson_name: LessonId,
116    /// The language the lesson is authored in. Required.
117    pub language: Language,
118    /// The exercise the lesson poses. Required.
119    pub exercise: Exercise,
120    /// Executable check code-strings, run against a submission in the lesson's
121    /// language to grade it (Slice 9). A `Vec` defaulting to empty, not an
122    /// `Option<Vec>`: "no checks" is just the empty list, so there is no
123    /// redundant null state forcing absence-handling downstream (§1.1). A lesson
124    /// graded by the LLM alone (the R package's model) therefore needs no
125    /// `checks` key, and every existing lesson stays valid.
126    #[serde(default)]
127    pub checks: Vec<String>,
128    /// Third-party packages the lesson's code depends on (e.g. `pandas`,
129    /// `purrr`). A `Vec` defaulting to empty, not an `Option<Vec>`: "no
130    /// packages" is just the empty list, mirroring `checks` (§1.1). A lesson
131    /// with no `packages` key stays valid; the browser runner receives an
132    /// empty array and the local Python runner spawns `uv run` with no
133    /// `--with` flags (ADR-0011).
134    #[serde(default)]
135    pub packages: Vec<String>,
136    /// Optional one-line summary.
137    pub description: Option<String>,
138    /// Optional pointer to a textbook section.
139    pub textbook_reference: Option<String>,
140}
141
142/// Why a YAML document is not a valid lesson.
143#[derive(Debug)]
144pub enum ValidationError {
145    /// The document is not structurally a lesson: a required field is missing,
146    /// a value has the wrong type, or the YAML is malformed. Carries the
147    /// underlying parser message, which names the offending field.
148    Parse(String),
149    /// `exercise.llm_evaluation_prompt` is present but lacks the literal
150    /// `{student_code}` placeholder, so a learner's submission could never be
151    /// inserted into it.
152    MissingStudentCodePlaceholder,
153    /// A field that must be bullet-formatted (each non-empty line starting with
154    /// `- ` or `* `) contains a non-empty line without a bullet prefix.
155    /// Parameterized by field name so AC-3 can reuse the variant for `hints`
156    /// without adding a new one. Rejected at the parse boundary (§1.3.1) so
157    /// malformed content never travels downstream.
158    InvalidBulletFormat {
159        /// The name of the field with malformed bullets (e.g. `"gotchas"`).
160        field: String,
161    },
162    /// A `packages` entry is empty or contains a double quote, a comma, or
163    /// whitespace. Every consumer joins or splits the list on commas (the
164    /// Quarto `packages="a,b"` attribute, `uv run --with`), so such a name
165    /// would silently become a different package list or break the attribute.
166    InvalidPackageName {
167        /// The offending entry, verbatim.
168        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
198/// Validate that a field's content is bullet-formatted: each non-empty line
199/// starts with `- ` or `* `. Returns [`ValidationError::InvalidBulletFormat`]
200/// naming the field if any non-empty line lacks a bullet prefix.
201///
202/// Used by [`Lesson::validate_semantics`] for both `hints` and `gotchas` so
203/// malformed content never travels downstream (§1.3.1).
204fn 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
218/// Validate that one `packages` entry survives being joined into a
219/// comma-separated list: non-empty, with no `"`, `,`, or whitespace. Version
220/// specifiers such as `pandas>=2` stay valid.
221fn 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    /// Parse a lesson from a YAML document.
237    ///
238    /// The pure parse boundary (§2.1, §1.3.1): it deserializes the document into
239    /// the typed model — a missing required field or wrong type yields
240    /// [`ValidationError::Parse`] naming the field — then runs
241    /// `validate_semantics` for the rules structure
242    /// alone cannot express. Returns a [`Lesson`] only if both succeed.
243    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    /// Enforce the semantic rules that structure alone cannot.
251    ///
252    /// Currently four rules: the evaluation prompt must contain the
253    /// `{student_code}` placeholder, `exercise.gotchas` and `exercise.hints`
254    /// (if present) must be bullet-formatted, and every `packages` entry must
255    /// be a single list-safe name. Split from the structural deserialize so each name
256    /// covers its body (§5.1).
257    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/// Why a lesson file could not be loaded.
279#[derive(Debug)]
280pub enum LoadError {
281    /// The file could not be read (missing, permissions, or not UTF-8).
282    Read(std::io::Error),
283    /// The file was read, but its contents are not a valid lesson.
284    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
305/// Read and parse a lesson from a file on disk.
306///
307/// The thin effectful shell over the pure [`Lesson::parse`] (§2.2): it performs
308/// the file read, then delegates all structure and validation to `parse`. The
309/// two failure modes stay distinct in the type so a read error is never
310/// mistaken for a validation error (§3.1).
311pub 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        // The snake_case `type` value maps to the enum; the round-trip test then
355        // confirms this populated optional survives JSON unchanged.
356        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        // A lesson with no `checks` key is graded by the LLM alone (the R
386        // package's model); checks must default to empty rather than be required,
387        // so every existing lesson stays valid.
388        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        // A lesson may declare third-party packages (ADR-0011). They parse in
410        // document order as raw strings — the browser runner and local Python
411        // runner consume them to install/load before evaluating code.
412        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        // A lesson with no `packages` key has none — the field defaults to an
424        // empty vec (not None), mirroring `checks` (§1.1). Every existing
425        // lesson stays valid.
426        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        // The static-site build needs an author-provided known-correct answer to
446        // serialize into the lesson JSON (ADR-0008). With `deny_unknown_fields`,
447        // `exercise.solution` must be modelled or it is rejected as a typo.
448        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        // A lesson without a `solution` key has none — the field stays optional so
460        // every existing lesson (none carried one) remains valid.
461        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        // Hints carry learner-facing tips the browser renders in an
483        // expandable <details>. With `deny_unknown_fields`, `exercise.hints`
484        // must be modelled or it is rejected as a typo — mirroring `solution`.
485        // Hints must be bullet-formatted (each non-empty line starts with
486        // `- ` or `* `), validated at the parse boundary (§1.3.1).
487        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        // A lesson without a `hints` key has none — the field stays optional so
501        // every existing lesson (none carried one) remains valid.
502        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        // Gotchas carry learner-facing pitfalls the browser renders in an
524        // expandable panel — mirroring `hints`. With `deny_unknown_fields`,
525        // `exercise.gotchas` must be modelled or it is rejected as a typo.
526        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        // A lesson without a `gotchas` key has none — the field stays optional
540        // so every existing lesson remains valid, mirroring `hints`.
541        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        // Gotchas must be bullet-formatted (each non-empty line starts with
561        // `- ` or `* `). A prose gotchas string is rejected at the parse
562        // boundary (§1.3.1) so malformed content never travels downstream.
563        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        // Star-prefixed bullets (`* `) are as valid as dash-prefixed (`- `),
592        // so an author may use either Markdown bullet style.
593        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        // A lesson with no gotchas key has None — no non-empty lines to
604        // validate, so it passes validation trivially.
605        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        // An empty gotchas string has no non-empty lines to validate, so it
621        // passes validation — mirroring the None case.
622        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        // Hints must be bullet-formatted (each non-empty line starts with
639        // `- ` or `* `). A prose hints string is rejected at the parse
640        // boundary (§1.3.1) so malformed content never travels downstream.
641        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        // Star-prefixed bullets (`* `) are as valid as dash-prefixed (`- `),
670        // so an author may use either Markdown bullet style.
671        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        // An empty hints string has no non-empty lines to validate, so it
691        // passes validation — mirroring the None case.
692        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        // Validation scope now covers BOTH hints and gotchas. The
700        // r-course/add_two.yaml fixture carries bullet-formatted hints;
701        // loading it must succeed and the hints must be read.
702        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        // A unique, auto-removed temp file (no fixed-path race, no leak on panic).
819        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}