blendtutor_core/
smevals_gen.rs

1//! smevals eval-dir generation: a pure translator from a lesson + eval suite to
2//! the file tree `smevals run`/`build` consume.
3//!
4//! An eval dir is any directory with an `eval.yaml`, `tasks/`, `configs/`, and
5//! `graders/` — the smevals v0.2.0 layout. [`generate_eval_dir`] produces that
6//! tree as `(relative_path, contents)` pairs: `eval.yaml`, `configs/default.yaml`,
7//! `graders/default.yaml`, and `tasks/case-N.yaml` (one per suite case, 1-based).
8//! It is pure (§2.1): no filesystem, no clock, no iteration-order randomness —
9//! identical inputs yield byte-identical output, and every value is emitted
10//! through an injection-proof YAML scalar discipline proven by round-trip tests
11//! (§1.3.1). The model id in `configs/default.yaml` is single-sourced from
12//! [`ProviderChoice::default_model()`], never a divergent literal.
13//!
14//! This module owns *only* the lesson+suite → smevals-dir translation. It is
15//! NOT the runner (AC-3), the LLM judge (AC-4), or the report command (AC-5);
16//! it merely emits the templates those later slices wire into. Script paths in
17//! the templates (`scripts/smevals/run.sh`, `scripts/smevals/check_polarity.sh`,
18//! `scripts/smevals/judge_feedback.py`) are emitted relative to the generated
19//! `configs/`/`graders/` file, as smevals resolves them relative to the file
20//! that names them.
21//!
22//! YAML emission: serde-saphyr 0.0.27 is parse-only, so the emitter is
23//! hand-rolled. Hostile content (a submission containing `: `, `&anchor`,
24//! `---`, or a leading `- `) is emitted as a double-quoted scalar with every
25//! control character escaped — byte-exact under re-parse (serde_saphyr's `|+`
26//! chomping is broken and `|-` drops trailing newlines, so block scalars cannot
27//! round-trip arbitrary strings; the round-trip test in
28//! `crates/core/tests/generate_eval_dir.rs` pins this). Safe-by-construction
29//! values (slugs, verdict tokens, the pinned script paths) are plain scalars.
30
31use std::error::Error;
32use std::fmt;
33use std::path::{Path, PathBuf};
34
35use crate::eval::{EvalCase, EvalSuite};
36use crate::lesson::Lesson;
37use crate::llm::ProviderChoice;
38
39/// The script-path prefix emitted when no enclosing repo root is discoverable:
40/// the canonical in-repo course layout (`examples/<course>` is two levels below
41/// the repo root, so `configs/` reaches the repo root in four `..` hops).
42/// [`write_eval_dir`] recomputes the exact prefix for the actual course location.
43const DEFAULT_SCRIPTS_REL: &str = "../../../../scripts/smevals/";
44
45/// The relative path from the generated `configs/` (and `graders/`) directory to
46/// the smevals runner, pinned by AC-3. Only the directory portion — the shell
47/// fixes up the `..` prefix for the course's actual location.
48const RUNNER_REL: &str = "run.sh";
49/// The relative path to the polarity checker, pinned by AC-3.
50const CHECKER_REL: &str = "check_polarity.sh";
51/// The relative path to the LLM-judge checker, pinned by AC-4.
52const JUDGE_REL: &str = "judge_feedback.py";
53/// The polarity check is `required: true` so a wrong verdict halts grading
54/// before any later (AC-4 judge) check runs.
55const PASS_THRESHOLD: f64 = 0.8;
56
57/// Why an eval dir could not be generated.
58#[derive(Debug)]
59pub enum GenError {
60    /// The lesson id is not a safe slug: empty, or containing a character
61    /// outside ASCII alphanumerics, `-`, `_`. Mirrors `scaffold::is_valid_slug`
62    /// (scaffold.rs:320), enforced here so a hostile id can never reach a path
63    /// or an emitted template (§1.3.1).
64    InvalidLessonId {
65        /// The rejected id, quoted back to the caller.
66        lesson_id: String,
67    },
68    /// The suite has no cases. A vacuous "100% pass" is a sneaky pass, so an
69    /// empty suite is refused rather than emitting `tasks/` with zero files.
70    EmptySuite,
71    /// No ancestor of the course root contains `scripts/smevals/`, so the
72    /// script-path prefix cannot be computed exactly (and the old four-hop
73    /// guess is exactly the wrong-depth bug this refusal replaces). The
74    /// effectful shell refuses rather than emit a path that resolves
75    /// somewhere else (§1.1).
76    NoRepoRoot {
77        /// The canonicalized course root that was walked up from.
78        course_root: PathBuf,
79    },
80    /// Writing a generated file failed (the effectful shell only — the pure
81    /// generator never touches the filesystem).
82    Write {
83        /// The path that could not be written.
84        path: PathBuf,
85        /// The underlying I/O failure.
86        source: std::io::Error,
87    },
88}
89
90impl fmt::Display for GenError {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match self {
93            GenError::InvalidLessonId { lesson_id } => write!(
94                f,
95                "invalid lesson id {lesson_id:?}: must be non-empty and contain only \
96                 ASCII alphanumerics, '-', '_'"
97            ),
98            GenError::EmptySuite => write!(
99                f,
100                "refusing to generate an eval dir for an empty eval suite: no cases \
101                 to evaluate"
102            ),
103            GenError::NoRepoRoot { course_root } => write!(
104                f,
105                "no repo root (an ancestor containing scripts/smevals/) found above {}",
106                course_root.display()
107            ),
108            GenError::Write { path, source } => {
109                write!(f, "writing {} failed: {source}", path.display())
110            }
111        }
112    }
113}
114
115impl Error for GenError {
116    fn source(&self) -> Option<&(dyn Error + 'static)> {
117        match self {
118            GenError::Write { source, .. } => Some(source),
119            _ => None,
120        }
121    }
122}
123
124/// Generate the smevals eval-dir file tree for `suite`, tagged with `lesson_id`.
125///
126/// Pure and deterministic (§2.1, §5.1): returns `(relative_path, contents)`
127/// pairs — `eval.yaml`, `configs/default.yaml`, `graders/default.yaml`, and
128/// `tasks/case-N.yaml` for each case in document order — with no filesystem
129/// access, so the file set, byte-stability, and injection defense are asserted
130/// directly in integration tests. The effectful half is [`write_eval_dir`].
131///
132/// `lesson_id` must be a slug (see [`GenError::InvalidLessonId`]); the suite
133/// must be non-empty (see [`GenError::EmptySuite`]). `lesson` supplies the
134/// eval's name-space context (its exercise prompt becomes the eval description).
135/// `lesson_path` is data: each task's `lesson:` key carries it (as the file the
136/// runner grades — AC-3 forwards it verbatim to `blendtutor eval <path>`), and
137/// the generator never touches the filesystem it names. The CLI canonicalizes
138/// the real lesson file to an absolute path before calling this; the pure
139/// function emits whatever path it is given.
140pub fn generate_eval_dir(
141    lesson: &Lesson,
142    suite: &EvalSuite,
143    lesson_id: &str,
144    lesson_path: &Path,
145) -> Result<Vec<(PathBuf, String)>, GenError> {
146    generate_eval_dir_with(lesson, suite, lesson_id, lesson_path, DEFAULT_SCRIPTS_REL)
147}
148
149/// [`generate_eval_dir`] with an explicit script-path prefix, so the effectful
150/// shell can emit paths that resolve for the course's actual location.
151fn generate_eval_dir_with(
152    lesson: &Lesson,
153    suite: &EvalSuite,
154    lesson_id: &str,
155    lesson_path: &Path,
156    scripts_rel: &str,
157) -> Result<Vec<(PathBuf, String)>, GenError> {
158    if !is_valid_lesson_id(lesson_id) {
159        return Err(GenError::InvalidLessonId {
160            lesson_id: lesson_id.to_string(),
161        });
162    }
163    if suite.cases.is_empty() {
164        return Err(GenError::EmptySuite);
165    }
166
167    let mut files = vec![
168        (
169            PathBuf::from("eval.yaml"),
170            emit_eval_yaml(lesson, lesson_id),
171        ),
172        (
173            PathBuf::from("configs/default.yaml"),
174            emit_configs_yaml(scripts_rel),
175        ),
176        (
177            PathBuf::from("graders/default.yaml"),
178            emit_graders_yaml(scripts_rel),
179        ),
180    ];
181    for (index, case) in suite.cases.iter().enumerate() {
182        files.push((
183            PathBuf::from(format!("tasks/case-{}.yaml", index + 1)),
184            emit_task_yaml(lesson_path, index + 1, case),
185        ));
186    }
187    Ok(files)
188}
189
190/// The slug rule for a lesson id: non-empty ASCII alphanumerics, `-`, `_`.
191///
192/// Mirrors `scaffold::is_valid_slug` (scaffold.rs:320) so a generated id can
193/// never escape its directory or corrupt a template (§1.3.1). Kept local rather
194/// than shared so `scaffold`'s private helper stays private; a divergence here
195/// would be caught by the path-safety integration tests.
196fn is_valid_lesson_id(id: &str) -> bool {
197    !id.is_empty()
198        && id
199            .chars()
200            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
201}
202
203// ---------------------------------------------------------------------------
204// Emitters — one small pure function per file kind (§5.1)
205// ---------------------------------------------------------------------------
206
207/// The eval manifest: the eval's name and what is being evaluated.
208fn emit_eval_yaml(lesson: &Lesson, lesson_id: &str) -> String {
209    format!(
210        "name: {}\ndescription: {}\n",
211        lesson_id,
212        escape_yaml_double_quoted(&lesson.exercise.prompt),
213    )
214}
215
216/// One task: the submission (as `prompt`, so the runner sees `SMEVALS_PROMPT`)
217/// plus the scalar keys that become `SMEVALS_TASK_LESSON`/`SMEVALS_TASK_CASE`/
218/// `SMEVALS_TASK_EXPECTED` for the runner and polarity checker (AC-3 contract).
219///
220/// `lesson:` carries the lesson file's PATH — the value the runner forwards
221/// verbatim as `blendtutor eval <path>` (AC-1 reads a file, never a slug), so
222/// run.sh never has to resolve a lesson id against the course root. The caller
223/// canonicalizes the path to absolute before generation; the generator treats
224/// it as opaque data (§2.1), emitted through the same scalar discipline as
225/// every other value.
226fn emit_task_yaml(lesson_path: &Path, case_index: usize, case: &EvalCase) -> String {
227    format!(
228        "name: case-{case_index}\nlesson: {}\ncase: {case_index}\nprompt: {}\nexpected: {}\n",
229        emit_inline_scalar(&lesson_path.to_string_lossy()),
230        escape_yaml_double_quoted(&case.submission),
231        emit_inline_scalar(case.expected.token()),
232    )
233}
234
235/// The default config: the runner executable and the model, single-sourced
236/// from the provider default so the runtime model cannot drift from the browser.
237fn emit_configs_yaml(scripts_rel: &str) -> String {
238    format!(
239        "name: default\nrunner: {}\nmodel: {}\n",
240        emit_inline_scalar(&format!("{scripts_rel}{RUNNER_REL}")),
241        emit_inline_scalar(ProviderChoice::Fireworks.default_model()),
242    )
243}
244
245/// The default grader: the polarity check first and `required`, so a wrong
246/// verdict halts grading; the LLM judge second (AC-4) with the model id
247/// single-sourced from the provider default (smevals surfaces it to the judge
248/// as `SMEVALS_CHECK_MODEL`); `pass_threshold` is applied by smevals to the
249/// final check's score — the judge's — so the grade passes iff the polarity
250/// check matched and the judged quality is >= 0.8.
251fn emit_graders_yaml(scripts_rel: &str) -> String {
252    format!(
253        "name: default\nchecks:\n  - checker: {}\n    required: true\n  - checker: {}\n    \
254         model: {}\nscoring:\n  pass_threshold: {PASS_THRESHOLD}\n",
255        emit_inline_scalar(&format!("{scripts_rel}{CHECKER_REL}")),
256        emit_inline_scalar(&format!("{scripts_rel}{JUDGE_REL}")),
257        emit_inline_scalar(ProviderChoice::Fireworks.default_model()),
258    )
259}
260
261// ---------------------------------------------------------------------------
262// YAML scalar discipline — the injection-defense core
263// ---------------------------------------------------------------------------
264
265/// Emit `value` as a YAML inline scalar: plain when unambiguous, double-quoted
266/// otherwise. Plain emission is only used for values that are safe by
267/// construction (slugs, verdict tokens, pinned paths); anything containing a
268/// structural character (`: `, ` #`, a leading `- `/`&`/`*`/`!`/`"`/`|`/`>`…,
269/// newlines) is quoted so it can never inject a sibling key, anchor, document
270/// separator, or comment (§1.3.1).
271fn emit_inline_scalar(value: &str) -> String {
272    if is_plain_safe(value) {
273        value.to_string()
274    } else {
275        escape_yaml_double_quoted(value)
276    }
277}
278
279/// Whether `value` parses as the same bytes when emitted as a plain YAML scalar.
280fn is_plain_safe(value: &str) -> bool {
281    if value.is_empty() || value.contains('\n') {
282        return false;
283    }
284    let first = value.as_bytes()[0];
285    // Leading whitespace or a comment marker would change the meaning.
286    if first.is_ascii_whitespace() || first == b'#' {
287        return false;
288    }
289    // Block/flow indicators at the start: `- ` is a list item, `? `/`: ` a key,
290    // `&` an anchor, `*` an alias, `!` a tag, `|`/`>` a block scalar, quotes a
291    // quoted scalar, `[`/`{`/`,` flow collections, `%` a directive, `@`/`` ` `` reserved.
292    if value.starts_with("- ") || value.starts_with("? ") || value.starts_with(": ") {
293        return false;
294    }
295    if matches!(
296        first,
297        b'-' | b'?'
298            | b':'
299            | b'&'
300            | b'*'
301            | b'!'
302            | b'|'
303            | b'>'
304            | b'"'
305            | b'\''
306            | b'['
307            | b']'
308            | b'{'
309            | b'}'
310            | b','
311            | b'%'
312            | b'@'
313            | b'`'
314    ) {
315        return false;
316    }
317    // Mid-string structures: `key: value` splits a map, ` #` starts a comment,
318    // trailing whitespace is dropped by the parser.
319    if value.contains(": ") || value.contains(" #") {
320        return false;
321    }
322    !value.ends_with(' ') && !value.ends_with('\t')
323}
324
325/// Emit `content` as a YAML double-quoted scalar, escaping every character that
326/// would otherwise change meaning: `"` `\` and all control characters (C0 +
327/// DEL as `\uXXXX`, `\n`/`\t`/`\r` short forms), plus the YAML line separators
328/// U+2028/U+2029. The result is a single logical line that re-parses to the
329/// exact input bytes — proven byte-exact for hostile content in the integration
330/// tests (serde_saphyr's block-scalar `|+` chomping is broken and `|-` drops
331/// trailing newlines, so a quoted scalar is the only byte-exact path).
332fn escape_yaml_double_quoted(content: &str) -> String {
333    let mut out = String::with_capacity(content.len() + 2);
334    out.push('"');
335    for c in content.chars() {
336        match c {
337            '"' => out.push_str("\\\""),
338            '\\' => out.push_str("\\\\"),
339            '\n' => out.push_str("\\n"),
340            '\t' => out.push_str("\\t"),
341            '\r' => out.push_str("\\r"),
342            '\u{2028}' => out.push_str("\\u2028"),
343            '\u{2029}' => out.push_str("\\u2029"),
344            c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
345                out.push_str(&format!("\\u{:04x}", c as u32));
346            }
347            c => out.push(c),
348        }
349    }
350    out.push('"');
351    out
352}
353
354// ---------------------------------------------------------------------------
355// Effectful shell + shared discovery helpers
356// ---------------------------------------------------------------------------
357
358/// The lesson id for a lesson file: its stem, so `lessons/foo.yaml` is `foo`.
359///
360/// Shared with AC-5's report command — the single source for "lesson_id = file
361/// stem", so the generator and the report command cannot derive it differently.
362/// The id names the eval (`eval.yaml`'s `name:`), the report directory
363/// (`docs/evals/<id>/`), and the `/evals/<id>/` URL — it is NOT what a task
364/// yaml's `lesson:` key carries, which is the lesson file's PATH (the value the
365/// runner forwards to `blendtutor eval`, which reads a file).
366pub fn lesson_id_from_path(lesson_path: &Path) -> Option<&str> {
367    lesson_path.file_stem().and_then(|stem| stem.to_str())
368}
369
370/// The course root for `lesson_path`: the nearest ancestor directory containing
371/// a `blendtutor.toml` manifest (the course boundary, per scaffold.rs).
372///
373/// Shared with AC-5's report command, which must find `<course>/.smevals/`
374/// before invoking the generator. Returns `None` when no ancestor is a course.
375pub fn course_root_for(lesson_path: &Path) -> Option<PathBuf> {
376    let mut current = Some(lesson_path);
377    while let Some(dir) = current {
378        if dir.join("blendtutor.toml").is_file() {
379            return Some(dir.to_path_buf());
380        }
381        current = dir.parent();
382    }
383    None
384}
385
386/// Write the generated eval dir into `dir/.smevals/`, where `dir` is the course
387/// root.
388///
389/// The thin effectful shell (§2.4): it resolves the course's location, computes
390/// the script-path prefix that reaches the repo's `scripts/smevals/` from the
391/// generated `configs/`, delegates the pure generation, and writes each file
392/// (creating directories as needed). The pure [`generate_eval_dir`] never
393/// touches the filesystem; this function never emits bytes. `lesson_path` is
394/// threaded into every task's `lesson:` key (the file the runner grades); the
395/// CLI canonicalizes it to absolute before calling, since a relative value
396/// would break `blendtutor eval` for a runner whose CWD is the eval dir.
397pub fn write_eval_dir(
398    dir: &Path,
399    lesson: &Lesson,
400    suite: &EvalSuite,
401    lesson_id: &str,
402    lesson_path: &Path,
403) -> Result<(), GenError> {
404    let dir = dir.canonicalize().map_err(|source| GenError::Write {
405        path: dir.to_path_buf(),
406        source,
407    })?;
408    // Resolve the exact script-path prefix first — the only thing this shell
409    // knows and the pure generator doesn't. Refusal (rather than a wrong-depth
410    // fallback) happens here, before any `create_dir_all`/`fs::write`, so no
411    // partial tree is left behind (§1.1, §2.4).
412    let scripts_rel = scripts_rel_from(&dir).ok_or_else(|| GenError::NoRepoRoot {
413        course_root: dir.clone(),
414    })?;
415    let files = generate_eval_dir_with(lesson, suite, lesson_id, lesson_path, &scripts_rel)?;
416    for (path, contents) in &files {
417        let target = dir.join(".smevals").join(path);
418        if let Some(parent) = target.parent() {
419            std::fs::create_dir_all(parent).map_err(|source| GenError::Write {
420                path: parent.to_path_buf(),
421                source,
422            })?;
423        }
424        std::fs::write(&target, contents).map_err(|source| GenError::Write {
425            path: target.clone(),
426            source,
427        })?;
428    }
429    Ok(())
430}
431
432/// The `scripts/smevals/` prefix (with trailing `/`) that, relative to the
433/// generated `configs/` (or `graders/`) directory, reaches the repo's scripts.
434///
435/// The repo root is the nearest ancestor containing a `scripts/smevals/`
436/// directory (the marker — the repo root always has it; `.git` alone does NOT
437/// count, since a course inside some other git project would otherwise get a
438/// wrong-depth guess). Returns `None` when no such ancestor exists; the
439/// effectful [`write_eval_dir`] then refuses with [`GenError::NoRepoRoot`]
440/// rather than emit a fallback prefix. The four-hop [`DEFAULT_SCRIPTS_REL`]
441/// default belongs to the pure layer only, which has no filesystem context.
442fn scripts_rel_from(course_root: &Path) -> Option<String> {
443    let mut current = Some(course_root);
444    let repo_root = loop {
445        match current {
446            Some(dir) => {
447                if dir.join("scripts").join("smevals").is_dir() {
448                    break Some(dir);
449                }
450                current = dir.parent();
451            }
452            None => break None,
453        }
454    };
455    let repo_root = repo_root?;
456    let configs_dir = course_root.join(".smevals").join("configs");
457    let scripts_dir = repo_root.join("scripts").join("smevals");
458    relative_path(&configs_dir, &scripts_dir).map(|rel| format!("{}/", rel.to_string_lossy()))
459}
460
461/// The path from `from` to `to`, both absolute, as `../..` hops then descent.
462/// `None` when the two share no root (unreachable on single-filesystem Unix
463/// paths, but total anyway).
464fn relative_path(from: &Path, to: &Path) -> Option<PathBuf> {
465    let from_parts: Vec<_> = from.components().collect();
466    let to_parts: Vec<_> = to.components().collect();
467    let common = from_parts
468        .iter()
469        .zip(&to_parts)
470        .take_while(|(a, b)| a == b)
471        .count();
472    if common == 0 {
473        return None;
474    }
475    let mut out = PathBuf::new();
476    for _ in common..from_parts.len() {
477        out.push("..");
478    }
479    for part in &to_parts[common..] {
480        out.push(part.as_os_str());
481    }
482    Some(out)
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::eval::ExpectedVerdict;
489
490    #[test]
491    fn lesson_id_is_the_file_stem() {
492        assert_eq!(
493            lesson_id_from_path(Path::new("lessons/foo.yaml")),
494            Some("foo")
495        );
496        assert_eq!(lesson_id_from_path(Path::new("foo.yaml")), Some("foo"));
497        assert_eq!(
498            lesson_id_from_path(Path::new("foo.bar.yaml")),
499            Some("foo.bar")
500        );
501        assert_eq!(lesson_id_from_path(Path::new("/")), None);
502    }
503
504    #[test]
505    fn course_root_is_the_nearest_ancestor_with_a_manifest() {
506        let root = tempfile::tempdir().unwrap();
507        let course = root.path().join("a").join("b");
508        std::fs::create_dir_all(&course).unwrap();
509        std::fs::write(course.join("blendtutor.toml"), "").unwrap();
510        let lesson = course.join("lessons").join("x.yaml");
511        std::fs::create_dir_all(lesson.parent().unwrap()).unwrap();
512        std::fs::write(&lesson, "").unwrap();
513
514        assert_eq!(
515            course_root_for(&lesson).unwrap(),
516            course,
517            "the walk-up returns the manifest-bearing ancestor unchanged"
518        );
519        assert_eq!(course_root_for(Path::new("/nonexistent/x.yaml")), None);
520    }
521
522    #[test]
523    fn relative_path_walks_up_then_descends() {
524        assert_eq!(
525            relative_path(
526                Path::new("/repo/course/.smevals/configs"),
527                Path::new("/repo/scripts/smevals")
528            ),
529            Some(PathBuf::from("../../../scripts/smevals"))
530        );
531        assert_eq!(
532            relative_path(Path::new("/a/b/c"), Path::new("/a/b/c")),
533            Some(PathBuf::from(""))
534        );
535        assert_eq!(
536            relative_path(Path::new("/a"), Path::new("/b")),
537            Some(PathBuf::from("../b"))
538        );
539    }
540
541    #[test]
542    fn scripts_rel_reaches_repo_scripts_from_a_nested_course() {
543        let repo = tempfile::tempdir().unwrap();
544        std::fs::create_dir_all(repo.path().join(".git")).unwrap();
545        std::fs::create_dir_all(repo.path().join("scripts/smevals")).unwrap();
546        let course = repo.path().join("examples/write-less-code-r");
547        std::fs::create_dir_all(&course).unwrap();
548
549        let rel = scripts_rel_from(&course).unwrap();
550        assert_eq!(rel, "../../../../scripts/smevals/");
551        // The emitted runner path resolves to the real script (canonicalize
552        // requires the leaf to exist, so create the placeholder script).
553        std::fs::create_dir_all(course.join(".smevals/configs")).unwrap();
554        std::fs::write(repo.path().join("scripts/smevals/run.sh"), "#!/bin/sh\n").unwrap();
555        let resolved = course
556            .join(".smevals")
557            .join("configs")
558            .join(&rel)
559            .join("run.sh");
560        assert_eq!(
561            resolved.canonicalize().unwrap(),
562            repo.path()
563                .join("scripts/smevals/run.sh")
564                .canonicalize()
565                .unwrap()
566        );
567    }
568
569    #[test]
570    fn write_eval_dir_errors_when_no_scripts_ancestor() {
571        let dir = tempfile::tempdir().unwrap();
572        // No ancestor of the tempdir contains `scripts/smevals/` — with or
573        // without a `.git` present, the marker walk-up finds no repo root and
574        // the effectful shell must refuse rather than emit the wrong-depth
575        // four-hop default.
576        assert_eq!(
577            scripts_rel_from(dir.path()),
578            None,
579            "no scripts/smevals ancestor must resolve to None, not a default prefix"
580        );
581
582        let lesson = Lesson::parse(
583            "lesson_name: x\nlanguage: R\nexercise:\n  prompt: do it\n  \
584             llm_evaluation_prompt: grade {student_code}\n",
585        )
586        .unwrap();
587        let suite = EvalSuite {
588            cases: vec![EvalCase {
589                submission: "cat(\"hi\\n\")\n".to_string(),
590                expected: ExpectedVerdict::Correct,
591            }],
592        };
593        let err = write_eval_dir(
594            dir.path(),
595            &lesson,
596            &suite,
597            "my-lesson",
598            Path::new("/lessons/my-lesson.yaml"),
599        )
600        .expect_err("a course with no scripts/smevals ancestor must be refused");
601        match err {
602            GenError::NoRepoRoot { course_root } => assert_eq!(
603                course_root,
604                dir.path().canonicalize().unwrap(),
605                "the error names the canonicalized course root it refused"
606            ),
607            other => panic!("expected GenError::NoRepoRoot, got: {other}"),
608        }
609        assert!(
610            !dir.path().join(".smevals").exists(),
611            "refusal must happen before any directory is created — no partial tree"
612        );
613
614        // A `.git`-only ancestor does NOT count as a repo root either: a course
615        // inside a git project that is not this repo must refuse instead of
616        // guessing (behavior change, intentional — §1.1).
617        let git_only = dir.path().join("git-only-course");
618        std::fs::create_dir_all(git_only.join(".git")).unwrap();
619        assert_eq!(
620            scripts_rel_from(&git_only),
621            None,
622            "a .git ancestor without the scripts/smevals marker must not resolve"
623        );
624        let err = write_eval_dir(
625            &git_only,
626            &lesson,
627            &suite,
628            "my-lesson",
629            Path::new("/lessons/my-lesson.yaml"),
630        )
631        .expect_err("a .git-only ancestor must also be refused");
632        assert!(matches!(err, GenError::NoRepoRoot { .. }));
633    }
634
635    #[test]
636    fn write_eval_dir_persists_the_generated_tree() {
637        let repo = tempfile::tempdir().unwrap();
638        std::fs::create_dir_all(repo.path().join(".git")).unwrap();
639        std::fs::create_dir_all(repo.path().join("scripts/smevals")).unwrap();
640        // canonicalize requires the leaf to exist, so create the placeholder
641        // runner before resolving the emitted path.
642        std::fs::write(repo.path().join("scripts/smevals/run.sh"), "#!/bin/sh\n").unwrap();
643        let course = repo.path().join("my-course");
644        std::fs::create_dir_all(&course).unwrap();
645
646        let lesson = Lesson::parse(
647            "lesson_name: x\nlanguage: R\nexercise:\n  prompt: do it\n  \
648             llm_evaluation_prompt: grade {student_code}\n",
649        )
650        .unwrap();
651        let suite = EvalSuite {
652            cases: vec![EvalCase {
653                submission: "cat(\"hi\\n\")\n".to_string(),
654                expected: ExpectedVerdict::Correct,
655            }],
656        };
657        write_eval_dir(
658            &course,
659            &lesson,
660            &suite,
661            "my-lesson",
662            Path::new("/lessons/my-lesson.yaml"),
663        )
664        .unwrap();
665
666        let eval_dir = course.join(".smevals");
667        assert!(eval_dir.join("eval.yaml").is_file());
668        assert!(eval_dir.join("configs/default.yaml").is_file());
669        assert!(eval_dir.join("graders/default.yaml").is_file());
670        assert!(eval_dir.join("tasks/case-1.yaml").is_file());
671        // Course is one level below the repo root here → 3 `..` hops; the
672        // emitted runner must canonicalize-resolve to the real script (not just
673        // contain the expected substring — the whole point of the fix).
674        let resolved_runner = eval_dir
675            .join("configs")
676            .join("../../../scripts/smevals/run.sh");
677        assert_eq!(
678            resolved_runner.canonicalize().unwrap(),
679            repo.path()
680                .join("scripts/smevals/run.sh")
681                .canonicalize()
682                .unwrap(),
683            "runner emitted into configs/default.yaml must resolve to the real script"
684        );
685        // The task's `lesson:` key carries the lesson file path (not the slug):
686        // the runner forwards it verbatim to `blendtutor eval <path>`.
687        let task = std::fs::read_to_string(eval_dir.join("tasks/case-1.yaml")).unwrap();
688        assert!(
689            task.contains("lesson: /lessons/my-lesson.yaml\n"),
690            "task must carry the lesson path the runner grades, got: {task}"
691        );
692    }
693
694    #[test]
695    fn scripts_rel_resolves_at_depth_1_below_repo_root() {
696        let repo = tempfile::tempdir().unwrap();
697        std::fs::create_dir_all(repo.path().join(".git")).unwrap();
698        std::fs::create_dir_all(repo.path().join("scripts/smevals")).unwrap();
699        std::fs::write(repo.path().join("scripts/smevals/run.sh"), "#!/bin/sh\n").unwrap();
700        std::fs::write(
701            repo.path().join("scripts/smevals/check_polarity.sh"),
702            "#!/bin/sh\n",
703        )
704        .unwrap();
705        let course = repo.path().join("my-course");
706        std::fs::create_dir_all(&course).unwrap();
707
708        let rel = scripts_rel_from(&course).unwrap();
709        assert_eq!(rel, "../../../scripts/smevals/", "depth 1 needs 3 hops");
710        // Both consumers (configs runner + graders checker) thread the same
711        // prefix and must canonicalize-resolve to the real scripts dir.
712        // (canonicalize is physical, so the `.smevals` dirs must exist first —
713        // write_eval_dir creates them before any resolution happens.)
714        std::fs::create_dir_all(course.join(".smevals/configs")).unwrap();
715        std::fs::create_dir_all(course.join(".smevals/graders")).unwrap();
716        let configs_resolved = course
717            .join(".smevals")
718            .join("configs")
719            .join(&rel)
720            .join("run.sh");
721        assert_eq!(
722            configs_resolved.canonicalize().unwrap(),
723            repo.path()
724                .join("scripts/smevals/run.sh")
725                .canonicalize()
726                .unwrap()
727        );
728        let graders_resolved = course
729            .join(".smevals")
730            .join("graders")
731            .join(&rel)
732            .join("check_polarity.sh");
733        assert_eq!(
734            graders_resolved.canonicalize().unwrap(),
735            repo.path()
736                .join("scripts/smevals/check_polarity.sh")
737                .canonicalize()
738                .unwrap()
739        );
740    }
741
742    #[test]
743    fn scripts_rel_resolves_at_depth_3_below_repo_root() {
744        let repo = tempfile::tempdir().unwrap();
745        std::fs::create_dir_all(repo.path().join(".git")).unwrap();
746        std::fs::create_dir_all(repo.path().join("scripts/smevals")).unwrap();
747        std::fs::write(repo.path().join("scripts/smevals/run.sh"), "#!/bin/sh\n").unwrap();
748        std::fs::write(
749            repo.path().join("scripts/smevals/check_polarity.sh"),
750            "#!/bin/sh\n",
751        )
752        .unwrap();
753        let course = repo.path().join("a").join("b").join("my-course");
754        std::fs::create_dir_all(&course).unwrap();
755
756        let rel = scripts_rel_from(&course).unwrap();
757        assert_eq!(
758            rel, "../../../../../scripts/smevals/",
759            "depth 3 needs 5 hops"
760        );
761        std::fs::create_dir_all(course.join(".smevals/configs")).unwrap();
762        std::fs::create_dir_all(course.join(".smevals/graders")).unwrap();
763        let resolved = course
764            .join(".smevals")
765            .join("configs")
766            .join(&rel)
767            .join("run.sh");
768        assert_eq!(
769            resolved.canonicalize().unwrap(),
770            repo.path()
771                .join("scripts/smevals/run.sh")
772                .canonicalize()
773                .unwrap()
774        );
775        // Both consumers thread the same prefix — the graders checker must
776        // resolve exactly like the configs runner (dual-consumer drift guard).
777        let graders_resolved = course
778            .join(".smevals")
779            .join("graders")
780            .join(&rel)
781            .join("check_polarity.sh");
782        assert_eq!(
783            graders_resolved.canonicalize().unwrap(),
784            repo.path()
785                .join("scripts/smevals/check_polarity.sh")
786                .canonicalize()
787                .unwrap()
788        );
789    }
790
791    #[test]
792    fn scripts_rel_resolves_without_git_when_marker_present() {
793        // Release tarballs / export-quarto'd courses have no `.git`; the marker
794        // alone must resolve (refusal here would be a false negative).
795        let repo = tempfile::tempdir().unwrap();
796        std::fs::create_dir_all(repo.path().join("scripts/smevals")).unwrap();
797        std::fs::write(repo.path().join("scripts/smevals/run.sh"), "#!/bin/sh\n").unwrap();
798        std::fs::write(
799            repo.path().join("scripts/smevals/check_polarity.sh"),
800            "#!/bin/sh\n",
801        )
802        .unwrap();
803        let course = repo.path().join("examples").join("tarball-course");
804        std::fs::create_dir_all(&course).unwrap();
805
806        let rel = scripts_rel_from(&course).unwrap();
807        assert_eq!(rel, "../../../../scripts/smevals/", "depth 2 needs 4 hops");
808        std::fs::create_dir_all(course.join(".smevals/configs")).unwrap();
809        std::fs::create_dir_all(course.join(".smevals/graders")).unwrap();
810        let resolved = course
811            .join(".smevals")
812            .join("configs")
813            .join(&rel)
814            .join("run.sh");
815        assert_eq!(
816            resolved.canonicalize().unwrap(),
817            repo.path()
818                .join("scripts/smevals/run.sh")
819                .canonicalize()
820                .unwrap()
821        );
822        // Both consumers thread the same prefix — the graders checker must
823        // resolve exactly like the configs runner (dual-consumer drift guard).
824        let graders_resolved = course
825            .join(".smevals")
826            .join("graders")
827            .join(&rel)
828            .join("check_polarity.sh");
829        assert_eq!(
830            graders_resolved.canonicalize().unwrap(),
831            repo.path()
832                .join("scripts/smevals/check_polarity.sh")
833                .canonicalize()
834                .unwrap()
835        );
836    }
837
838    #[test]
839    fn golden_non_default_depth_emits_plain_3hop_prefix() {
840        // P4: the fix is exercised in the pure layer — an explicit 3-hop prefix
841        // flows through BOTH consumers as plain, unquoted YAML scalars (the
842        // same charset as today's `emit_inline_scalar` output, no escaping).
843        let lesson = Lesson::parse(
844            "lesson_name: x\nlanguage: R\nexercise:\n  prompt: do it\n  \
845             llm_evaluation_prompt: grade {student_code}\n",
846        )
847        .unwrap();
848        let suite = EvalSuite {
849            cases: vec![EvalCase {
850                submission: "cat(\"hi\\n\")\n".to_string(),
851                expected: ExpectedVerdict::Correct,
852            }],
853        };
854        let files = generate_eval_dir_with(
855            &lesson,
856            &suite,
857            "x",
858            Path::new("lessons/x.yaml"),
859            "../../../scripts/smevals/",
860        )
861        .unwrap();
862        let contents: std::collections::HashMap<_, _> = files.into_iter().collect();
863
864        let configs = &contents[&PathBuf::from("configs/default.yaml")];
865        assert!(
866            configs.contains("runner: ../../../scripts/smevals/run.sh\n"),
867            "configs runner must be the plain 3-hop scalar, got: {configs}"
868        );
869        assert!(
870            !configs.contains("\"../../../scripts/smevals/"),
871            "configs runner must not be quoted, got: {configs}"
872        );
873        let graders = &contents[&PathBuf::from("graders/default.yaml")];
874        assert!(
875            graders.contains("checker: ../../../scripts/smevals/check_polarity.sh\n"),
876            "graders checker must be the plain 3-hop scalar, got: {graders}"
877        );
878        assert!(
879            !graders.contains("\"../../../scripts/smevals/"),
880            "graders checker must not be quoted, got: {graders}"
881        );
882    }
883
884    #[test]
885    fn empty_suite_is_refused_before_any_emission() {
886        let lesson = Lesson::parse(
887            "lesson_name: x\nlanguage: R\nexercise:\n  prompt: p\n  \
888             llm_evaluation_prompt: grade {student_code}\n",
889        )
890        .unwrap();
891        let err = generate_eval_dir(
892            &lesson,
893            &EvalSuite { cases: vec![] },
894            "x",
895            Path::new("lessons/x.yaml"),
896        )
897        .expect_err("an empty suite must be refused");
898        assert!(matches!(err, GenError::EmptySuite));
899    }
900
901    #[test]
902    fn hostile_scalar_is_quoted_while_safe_values_stay_plain() {
903        assert_eq!(escape_yaml_double_quoted("a\nb"), "\"a\\nb\"");
904        assert_eq!(
905            escape_yaml_double_quoted("say \"hi\""),
906            "\"say \\\"hi\\\"\""
907        );
908        assert_eq!(escape_yaml_double_quoted("tab\there"), "\"tab\\there\"");
909        assert_eq!(escape_yaml_double_quoted(""), "\"\"");
910        // Safe-by-construction values stay plain for readable goldens.
911        assert_eq!(emit_inline_scalar("case-1"), "case-1");
912        assert_eq!(
913            emit_inline_scalar("accounts/fireworks/models/deepseek-v4-flash-0731"),
914            "accounts/fireworks/models/deepseek-v4-flash-0731"
915        );
916        assert_eq!(emit_inline_scalar("correct"), "correct");
917        // Structural values must be quoted, never plain.
918        assert_eq!(emit_inline_scalar("- expected: x"), "\"- expected: x\"");
919        assert_eq!(emit_inline_scalar("&anchor"), "\"&anchor\"");
920        assert_eq!(emit_inline_scalar("a: b"), "\"a: b\"");
921        assert_eq!(emit_inline_scalar("x # c"), "\"x # c\"");
922        assert_eq!(emit_inline_scalar("line1\nline2"), "\"line1\\nline2\"");
923    }
924}