blendtutor_core/
scaffold.rs

1//! Course scaffolding: the templates a new course starts from and the plan that
2//! writes them.
3//!
4//! Holds the embedded starter templates (`include_str!`), the pure
5//! [`scaffold_plan`] that names the files a fresh course contains (testable with
6//! no filesystem, §2.3), and [`scaffold_course`] — the single effectful step
7//! that refuses a non-empty target before any write (§1.3.1) then writes that
8//! plan into it. It also grows an existing course one lesson at a time: the pure
9//! [`lesson_template`] selects a language-appropriate starter (§2.1), the pure
10//! [`eval_template`] emits the lesson's `eval_`-prefixed grading suite, and the
11//! effectful [`add_lesson`] writes the pair and registers the lesson in the
12//! manifest. This module owns *what a course's content is*; it does not parse
13//! CLI flags or decide where the course lives (§4.1). The templates are data the
14//! writer consumes, so changing a template never changes the writer (§3.2).
15
16use std::error::Error;
17use std::fmt;
18use std::path::{Path, PathBuf};
19
20use crate::lesson::Language;
21
22/// One file a scaffold writes: its path relative to the course directory and the
23/// exact bytes to write there.
24///
25/// A plain data record (§3.2): the [plan](scaffold_plan) is pure data the
26/// effectful writer consumes, so a template edit is invisible to the writer.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct FileSpec {
29    /// Where the file goes, relative to the course directory.
30    pub path: PathBuf,
31    /// The file's contents, verbatim.
32    pub contents: &'static str,
33}
34
35/// The course manifest every scaffold writes.
36const MANIFEST_TEMPLATE: &str = include_str!("scaffold/blendtutor.toml");
37/// The example lesson every scaffold writes.
38const LESSON_TEMPLATE: &str = include_str!("scaffold/lesson_hello.yaml");
39/// The example eval suite paired with the lesson.
40const EVAL_TEMPLATE: &str = include_str!("scaffold/eval_lesson_hello.yaml");
41/// The course README.
42const README_TEMPLATE: &str = include_str!("scaffold/README.md");
43/// The key-ignoring `.gitignore`. Stored dotless in-tree (`scaffold/gitignore`)
44/// so it does not act as a real ignore file over its sibling templates; it is
45/// written to `.gitignore` in the [plan](scaffold_plan).
46const GITIGNORE_TEMPLATE: &str = include_str!("scaffold/gitignore");
47
48/// The manifest filename a scaffolded course writes and `list` later reads.
49const MANIFEST_FILENAME: &str = "blendtutor.toml";
50/// The example lesson's filename; the manifest's one entry points here.
51const LESSON_FILENAME: &str = "lesson_hello.yaml";
52/// The example eval suite's filename, the `eval_<lesson>` sibling of the lesson.
53const EVAL_FILENAME: &str = "eval_lesson_hello.yaml";
54
55/// Compute the set of files a fresh course scaffold writes.
56///
57/// Pure (§2.1, §2.2): it returns the plan as data with no filesystem access, so
58/// the file set — and that each template is internally valid — is asserted
59/// directly in a unit test. [`scaffold_course`] is the effectful step that
60/// writes it (§5.1, plan vs write split).
61pub 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/// Why a course could not be scaffolded into a target directory.
87#[derive(Debug)]
88pub enum ScaffoldError {
89    /// The target directory already holds files, so the scaffold is refused
90    /// before any write (§1.3.1) rather than overwriting an existing course.
91    TargetNotEmpty(PathBuf),
92    /// A filesystem operation failed while writing the scaffold (creating the
93    /// directory or writing a file).
94    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
119/// Scaffold a fresh course into `dir`, writing every file in the
120/// [plan](scaffold_plan).
121///
122/// The effectful shell (§2.2) over the pure plan. The refuse-on-nonempty guard
123/// fires first (§1.3.1): a directory that already holds files is rejected as
124/// [`ScaffoldError::TargetNotEmpty`] *before any write*, so a refused target is
125/// left exactly as it was — never partially clobbered. An absent or empty target
126/// is then created if missing and each [`FileSpec`] written verbatim.
127pub 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
138/// Whether `dir` is a safe scaffold target: it does not yet exist, or exists and
139/// holds no entries.
140///
141/// The boundary check behind the guard (§1.3.1). A directory with any entry is
142/// not empty. A read failure other than `NotFound` (e.g. a permission error)
143/// propagates as a write error rather than being read as "empty", so the guard
144/// never green-lights a target it could not actually inspect.
145///
146/// `read_dir` reports `NotFound` for two different paths: one that truly does not
147/// exist (a creatable, absent target) and a *broken symlink* (the link exists but
148/// its target does not). An `lstat` (`symlink_metadata`, which does not follow the
149/// link) tells them apart: if the path itself exists it is an existing object the
150/// scaffold must refuse, not silently try to create over — which would otherwise
151/// fail later with a cryptic "File exists".
152fn 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            // `read_dir` reports NotFound both for a truly-absent path and for a
157            // broken symlink (the link exists, its target does not). A
158            // non-following lstat tells them apart. This lstat runs *only after*
159            // read_dir already returned NotFound on the same path, so its only
160            // reachable outcomes are NotFound (the path is genuinely absent — a
161            // creatable, empty target) and Ok (the path exists as its own inode,
162            // e.g. a broken symlink — an existing object to refuse). A
163            // non-NotFound lstat error (e.g. a permission failure) cannot arise on
164            // a path read_dir just reported NotFound — that would have surfaced as
165            // the outer non-NotFound arm — so collapsing every lstat error to
166            // "absent" green-lights no un-inspectable target.
167            Ok(std::fs::symlink_metadata(dir).is_err())
168        }
169        Err(e) => Err(ScaffoldError::Write(e)),
170    }
171}
172
173/// The subdirectory new lessons are written into, relative to the course root.
174/// The `init` example lesson sits at the course root; lessons added later live
175/// under here so a growing course keeps its lesson files in one place.
176const LESSONS_DIR: &str = "lessons";
177
178/// The exercise body of an R starter lesson: a valid `exercise` block whose
179/// `llm_evaluation_prompt` carries the required `{student_code}` placeholder,
180/// followed by commented-out optional fields (solution, hints, gotchas, checks,
181/// packages) so authors discover them without the lesson changing meaning.
182const 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
217/// The exercise body of a Python starter lesson: the `print()` twin of
218/// [`R_EXERCISE`], likewise carrying the `{student_code}` placeholder.
219const 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
254/// Render a starter lesson for `language` under the slug `id`.
255///
256/// Pure (§2.1): it picks the language-appropriate exercise body and frames it
257/// with the lesson's `lesson_name`, `language`, and a description — returning the
258/// YAML as data with no filesystem access, so the result is asserted directly
259/// against the production parser. The `language` choice drives the template, so a
260/// `--lang python` lesson never declares `language: R` (§1.2). The caller is
261/// responsible for `id` being a safe slug; [`add_lesson`] guards it.
262pub 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
277/// The `eval_` prefix of the sibling-suite naming convention: a lesson
278/// `lessons/foo.yaml` pairs with the suite `lessons/eval_foo.yaml`. The single
279/// source of the convention — the scaffolding derives the sibling name through
280/// [`eval_sibling_path`], and `cli`'s `sibling_suite_path` (which `eval` and
281/// `eval-report` resolve suites by) delegates to the same function, so the two
282/// ends of the convention can never drift apart.
283pub const EVAL_SIBLING_PREFIX: &str = "eval_";
284
285/// The eval-suite sibling of `lesson_path`: the same file name prefixed with
286/// [`EVAL_SIBLING_PREFIX`], in the same directory.
287///
288/// Pure (§2.1): a path-to-path derivation with no filesystem access. This is
289/// THE convention — `new` scaffolds the sibling here, `eval` and `eval-report`
290/// discover it through `cli`'s delegating `sibling_suite_path` — so a suite
291/// authored (or scaffolded) next to its lesson is found by every consumer
292/// without configuration. A path with no file name (a bare root) yields the
293/// prefix alone, so a subsequent read fails with a path-named error rather
294/// than silently scoring nothing.
295pub 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
302/// Render a starter eval suite for `language` under the lesson slug `id`.
303///
304/// Pure (§2.1): the [`lesson_template`] twin for grading — it picks the
305/// language-appropriate hello-world submission and frames a minimal one-case
306/// suite (a `correct` verdict) as data with no filesystem access, so the result
307/// is asserted directly against the production eval parser. One case, not two:
308/// the scaffold's job is a parseable starting point the instructor edits, and
309/// the starter course's committed suite (with its correct *and* incorrect
310/// cases) remains the fuller example. The caller is responsible for `id` being
311/// a safe slug; [`add_lesson`] guards it.
312pub 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/// Why a lesson could not be added to a course.
331///
332/// The three refusals are kept distinct from a genuine write failure so the cli
333/// can frame each: an [`InvalidId`](AddLessonError::InvalidId), a
334/// [`NotACourse`](AddLessonError::NotACourse), or an
335/// [`AlreadyExists`](AddLessonError::AlreadyExists) is a user-correctable refusal
336/// caught at the boundary *before any write*, whereas a
337/// [`Write`](AddLessonError::Write) is an underlying I/O fault.
338#[derive(Debug)]
339pub enum AddLessonError {
340    /// The lesson id is not a safe slug (empty, or containing a path separator,
341    /// `..`, whitespace, or punctuation), so it is refused before any write
342    /// (§1.3.1) rather than escaping the course's `lessons/` directory or
343    /// breaking the manifest's TOML. Carries the rejected id.
344    InvalidId(String),
345    /// The target directory is not a course: it has no `blendtutor.toml` to
346    /// register the lesson in, so the add is refused before any write (§1.3.1)
347    /// rather than leaving an orphan lesson in a non-course directory. Carries the
348    /// directory. (Run `blendtutor init` first, or `cd` into a course.)
349    NotACourse(PathBuf),
350    /// A file already exists at the target path — the lesson itself, or its
351    /// `eval_`-prefixed sibling suite — so the command refuses to overwrite it
352    /// (no clobber), before any write to that path. Carries the course-relative
353    /// path that is already taken.
354    AlreadyExists(PathBuf),
355    /// A filesystem operation failed while writing the lesson file or registering
356    /// it in the manifest.
357    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
394/// Whether `id` is a safe lesson slug: non-empty and built only from ASCII
395/// letters, digits, hyphens, and underscores.
396///
397/// The boundary check behind [`add_lesson`]'s guard (§1.3.1). The id is used both
398/// as a filename stem and, verbatim, inside the manifest's TOML, so excluding path
399/// separators, `.`, whitespace, and quotes keeps a new lesson from escaping
400/// `lessons/` or corrupting `blendtutor.toml`.
401fn 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
408/// Add a `language` lesson `id` to the course rooted at `dir`: write
409/// `lessons/<id>.yaml` from [`lesson_template`] and its `eval_`-prefixed
410/// sibling suite from [`eval_template`], then register the lesson in the
411/// manifest.
412///
413/// The effectful shell (§2.2) over the pure template selection. Three guards fire
414/// before any write (§1.3.1): an unsafe slug is refused as
415/// [`AddLessonError::InvalidId`]; a directory with no `blendtutor.toml` is refused
416/// as [`AddLessonError::NotACourse`] — its manifest is *opened* up front, so a
417/// non-course directory is never left with an orphan lesson; and an id whose
418/// lesson file — or whose eval sibling — already exists is refused as
419/// [`AddLessonError::AlreadyExists`], the create-new write making that check
420/// atomic so a duplicate never clobbers the existing lesson, a hand-authored
421/// sibling suite, nor appends a second manifest entry. On success the lesson file
422/// and its sibling are written, a `[[lessons]]` entry appended to
423/// `blendtutor.toml`, and the course-relative lesson path returned. The sibling
424/// is deliberately *not* registered in the manifest: it is a derived path
425/// ([`eval_sibling_path`]), so it can never drift from the lesson it grades.
426///
427/// The manifest is opened first (the not-a-course guard) but its entry is appended
428/// last, after the files are written. Two residual non-atomic windows remain, each
429/// leaving a valid, recoverable state: a rare append failure after good writes
430/// leaves an orphan lesson `list` (manifest-driven) simply omits; and a
431/// pre-existing eval sibling refuses *after* the lesson file is written, leaving
432/// an unregistered lesson a re-run then refuses on — recoverable by deleting the
433/// scaffolded lesson or adopting it. Registering first would be worse: a `list`
434/// row pointing at a file that was never written.
435pub 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
461/// Write `contents` to `path`, failing with [`std::io::ErrorKind::AlreadyExists`]
462/// rather than overwriting an existing file.
463///
464/// `create_new` fuses the existence check and the create into one atomic step, so
465/// there is no window between "does it exist?" and "write it" for the no-clobber
466/// guard to miss (§1.3.1) — unlike a separate `exists()` test then `write`.
467fn 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
475/// Open the course manifest in `dir` for appending, or refuse a directory that has
476/// no manifest as [`AddLessonError::NotACourse`].
477///
478/// Opening the manifest up front (it is not written here) turns "this is not a
479/// course" into a boundary refusal *before* any lesson file is written (§1.3.1),
480/// so a missing manifest can never leave an orphan lesson behind. A `NotFound` is
481/// the not-a-course signal; any other open failure is a genuine
482/// [`AddLessonError::Write`].
483fn 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
493/// Append a `[[lessons]]` entry registering lesson `id` to an already-open manifest
494/// handle.
495///
496/// A textual append, not a re-serialize: TOML permits repeated `[[lessons]]`
497/// array-of-tables, the [`Manifest`](crate::course::Manifest) model is parse-only,
498/// and appending leaves every existing entry's bytes exactly where they were. The
499/// id is a validated slug, so the emitted TOML string and `lessons/<id>.yaml` path
500/// are safe to embed verbatim.
501fn 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    /// The planned file at `path`, or a panic naming the missing path.
514    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        // Pure: asserted as data with no directory to write into (§2.3). The set
524        // is exactly what `list` needs (manifest + lesson) plus the authoring
525        // extras (eval, README, gitignore) — pinned so a dropped file is caught
526        // here, not only end to end.
527        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        // The templates are validated by the very parsers production uses, so the
547        // scaffolded course is internally consistent: `list` resolves the manifest
548        // entry to a real, parseable lesson rather than a dangling path. This is
549        // what makes the AC1 "list discovers >=1 lesson" guarantee hold.
550        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        // The guard fires before any write: only the seeded file remains, and it
606        // is untouched. No plan file leaked into the directory.
607        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        // The boundary case the AC2 probe drives: `mktemp -d` yields a directory
622        // that exists and is empty. That is a valid target, distinct from a
623        // non-empty one.
624        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        // A path that is an existing *file* cannot be read as a directory:
632        // `read_dir` fails with a non-NotFound error. The guard must propagate
633        // that, never collapse it to "empty" — otherwise an uninspectable target
634        // would be green-lit. Distinguishes the NotFound arm (empty) from every
635        // other read failure (an error).
636        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        // A broken symlink (its target does not exist) makes `read_dir` report
648        // NotFound, the same as a truly-absent path — but the link itself exists.
649        // The guard must refuse it before any write, rather than treat it as
650        // absent and let `create_dir_all` fail over the symlink inode with a
651        // confusing "File exists".
652        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        // TargetNotEmpty names the refusal, names the path, and has no nested
674        // source.
675        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        // Write labels itself a write failure and exposes the io::Error as its
684        // source, preserving the error chain.
685        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    /// Uncomment every optional field the template shows: drop the `# ` from
695    /// commented lines except the file header and the `# Optional ...` labels.
696    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        // The pure selector (§2.1) returns a Python lesson the production parser
731        // accepts: a template that hardcoded R, or emitted an invalid schema,
732        // fails here without touching any filesystem.
733        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        // The twin: the same selector yields a valid R lesson, so language drives
742        // the template rather than a hardcoded default.
743        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        // Effectful (§2.2): into a real scaffolded course, add a python lesson and
752        // observe the whole AC1 chain in core terms — the file lands under
753        // lessons/, parses, and the manifest now resolves it as a discovered row.
754        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        // The id becomes both a filename stem and a manifest path, so an id with a
778        // path separator, `..`, whitespace, or other unslug character is refused at
779        // the boundary (§1.3.1) before any write — never allowed to escape the
780        // course's lessons/ directory or break the manifest's TOML.
781        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        // No lessons/ dir created, manifest byte-identical: the guard precedes every
794        // write, so a refused id leaves the course exactly as it was.
795        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        // Running `new lesson` outside a course (no blendtutor.toml) must refuse
809        // before any write (§1.3.1): the manifest is opened up front, so a plain
810        // directory is rejected as NotACourse and never contaminated with an orphan
811        // lessons/<id>.yaml that no manifest registers.
812        let dir = tempfile::tempdir().unwrap(); // a bare dir, never `init`-ed
813
814        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        // InvalidId names the rejected id and has no nested source.
831        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        // AlreadyExists names the path and frames the no-clobber refusal, no source.
840        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        // NotACourse names the directory and points at `init`, no source.
849        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(&not_course).is_none());
856
857        // Write frames itself and exposes the io::Error as its source.
858        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        // The pure eval emitter (§2.1) returns a one-case suite the production
870        // parser accepts, so a scaffolded sibling is scoreable by `eval` as-is.
871        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        // The twin: the same emitter yields a valid R suite, so language drives
888        // the submission snippet rather than a hardcoded default.
889        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        // The pure sibling derivation (§2.3), pinned at the same three shapes
901        // `cli`'s `sibling_suite_path` unit tests pin — the two must never
902        // diverge, because `eval` resolves the suite by this exact convention.
903        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        // Scaffolding parity: one `add_lesson` call lands BOTH the lesson and
922        // its `eval_`-prefixed sibling under lessons/, the sibling parses with
923        // the production eval parser, and the manifest registers only the
924        // lesson (the sibling is a derived path, never manifest state).
925        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        // No-clobber covers the sibling too (§1.3.1): a pre-existing (e.g.
954        // hand-authored) eval_<id>.yaml is refused via the atomic create-new
955        // write, never overwritten — and the refusal leaves the user's bytes
956        // exactly as they were.
957        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        // No-clobber (§1.3.1): a second add of the same id is refused before any
983        // write, so the original lesson's bytes and the manifest are both
984        // untouched — no overwrite, no duplicate `[[lessons]]` entry.
985        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        // Pin both sides of the charset: hyphens, underscores, and digits are part
1014        // of a real slug (`add-two`, `my_lesson`, `ch2`) and must be accepted, so a
1015        // dropped allowed-char branch is caught here rather than only when a reject
1016        // case slips through.
1017        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}