blendtutor_core/
quarto_export.rs

1//! Pure transforms: [`Lesson`] → Quarto `.qmd` fenced-div snippet or complete
2//! page, plus the static API key page (ADR-0019).
3//!
4//! The conversion is a pure function — no I/O, no side effects, deterministic
5//! (§2.1). The CLI command in `blendtutor-cli` is the thin effectful shell that
6//! reads the file, calls this transform, and writes to stdout (§2.2).
7//!
8//! ## Field mapping
9//!
10//! | YAML field            | `.qmd` rendering                          |
11//! |----------------------|-------------------------------------------|
12//! | `exercise.prompt`    | prose after the opening div               |
13//! | `exercise.code_template` | first fenced code block (if `Some`)   |
14//! | `lesson.checks`       | ```` ```{.<lang> .checks} ```` block (if non-empty) |
15//! | `exercise.solution`  | ```` ```{.<lang> .solution} ```` block (if `Some`) |
16//! | `exercise.hints`      | `::: {.hints}` div (if `Some`)           |
17//! | `lesson.language`    | `language="<r|python>"` attribute         |
18//! | `exercise.gotchas`   | `::: {.gotchas}` div (if `Some`)         |
19//! | `exercise.success_criteria` | `::: {.success-criteria}` div (if `Some`, ADR-0020) |
20//! | `lesson.packages`    | `packages="a,b"` attribute (if non-empty) |
21//! | `exercise.llm_evaluation_prompt` | EXCLUDED (author-only, ADR-0006) |
22
23use crate::lesson::{Language, Lesson};
24
25/// What `export_lesson_to_qmd` produces (ADR-0019).
26///
27/// A sum type rather than a bool so each call site names the shape it wants
28/// (§1.2).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ExportShape {
31    /// The bare `::: {.blendtutor}` div, for pasting into an existing page.
32    Snippet,
33    /// The div preceded by front matter, so the page renders on its own.
34    Document,
35}
36
37/// The filter reference every exported page declares.
38const FILTER_NAME: &str = "mcmullarkey/blendtutor";
39
40/// Front-matter comment for R documents: what `coi: true` buys webR, and that
41/// book projects fall back to webR's slower channel (the COI service worker's
42/// scope cannot cover book pages), so R still runs there.
43const R_BOOK_COI_NOTE: &str = "\
44# coi: true lets webR use SharedArrayBuffer for faster R execution.
45# In Quarto `type: book` projects the COI service worker cannot control pages,
46# so R runs on webR's slower fallback channel instead.
47";
48
49/// A complete API key page (ADR-0019), mirroring `demo-book/api-key.qmd`.
50const KEY_PAGE_QMD: &str = r#"---
51title: "API Key"
52filters:
53  - mcmullarkey/blendtutor
54---
55
56# API Key
57
58AI-powered feedback on these exercises uses the [Fireworks AI](https://fireworks.ai)
59API with your own key. Create a key in the
60[Fireworks console](https://app.fireworks.ai/account/keys); keys look like
61`fw_...`.
62
63## Enter your key
64
65::: {.blendtutor-key}
66
67Loading API key settings…
68
69:::
70
71Your key is stored **only** in this browser's `localStorage`, shared across
72every page of the site, and sent **only** in an `Authorization: Bearer` header
73to `api.fireworks.ai`.
74
75## Serve over HTTP
76
77`localStorage` and JavaScript ES modules are blocked for pages opened from the
78local filesystem (`file://`). Preview with `quarto preview`, or serve the
79rendered output directory over HTTP.
80"#;
81
82/// The complete API key page, ready to save as `api-key.qmd` (the filter's
83/// default `bt-key-page` target is `api-key.html`).
84pub fn key_page_qmd() -> &'static str {
85    KEY_PAGE_QMD
86}
87
88/// The minimum fence length for a fenced code block (CommonMark default).
89const MIN_FENCE_LEN: usize = 3;
90
91/// Render a [`Lesson`] as a Quarto `.qmd` fenced-div snippet.
92///
93/// The output is a self-contained block starting with
94/// `::: {.blendtutor language="<r|python>"}` and closing with `:::`. Each
95/// optional section (code template, checks, solution, hints, gotchas, success
96/// criteria) and the
97/// `packages` attribute are emitted only when the corresponding field is
98/// present, so no empty blocks appear for absent fields (§1.1). The
99/// author-only `llm_evaluation_prompt` is excluded (ADR-0006).
100///
101/// With [`ExportShape::Document`] the div is preceded by YAML front matter
102/// (title, the blendtutor filter, and `coi: true` for R).
103///
104/// # Arguments
105/// * `lesson` — A valid, parsed lesson (constructed via [`Lesson::parse`]).
106/// * `shape` — Snippet or complete page.
107///
108/// # Returns
109/// A `String` containing the `.qmd` fenced-div snippet, terminated by a
110/// newline.
111pub fn export_lesson_to_qmd(lesson: &Lesson, shape: ExportShape) -> String {
112    let lang = language_tag(&lesson.language);
113    let mut out = match shape {
114        ExportShape::Snippet => String::new(),
115        ExportShape::Document => front_matter(lesson),
116    };
117
118    // Opening div with the language attribute, plus the comma-separated
119    // packages attribute the Quarto filter splits (`parse_packages`).
120    let packages = if lesson.packages.is_empty() {
121        String::new()
122    } else {
123        format!(" packages=\"{}\"", lesson.packages.join(","))
124    };
125    out.push_str(&format!(
126        "::: {{.blendtutor language=\"{lang}\"{packages}}}\n"
127    ));
128
129    // Prompt as prose (always present — it is a required field).
130    out.push_str(lesson.exercise.prompt.trim_end());
131    out.push('\n');
132
133    // Code template as the first fenced code block (if present).
134    if let Some(ref template) = lesson.exercise.code_template {
135        out.push('\n');
136        let fence = fence_for(template);
137        out.push_str(&format!("{fence}{lang}\n"));
138        out.push_str(template.trim_end());
139        out.push('\n');
140        out.push_str(&fence);
141        out.push('\n');
142    }
143
144    // Checks as a classed code block (if non-empty).
145    if !lesson.checks.is_empty() {
146        out.push('\n');
147        let checks_content = lesson.checks.join("\n");
148        let fence = fence_for(&checks_content);
149        out.push_str(&format!("{fence}{{.{lang} .checks}}\n"));
150        out.push_str(&checks_content);
151        out.push('\n');
152        out.push_str(&fence);
153        out.push('\n');
154    }
155
156    // Solution as a classed code block (if present).
157    if let Some(ref solution) = lesson.exercise.solution {
158        out.push('\n');
159        let fence = fence_for(solution);
160        out.push_str(&format!("{fence}{{.{lang} .solution}}\n"));
161        out.push_str(solution.trim_end());
162        out.push('\n');
163        out.push_str(&fence);
164        out.push('\n');
165    }
166
167    // Hints as a fenced div (if present).
168    if let Some(ref hints) = lesson.exercise.hints {
169        out.push('\n');
170        out.push_str("::: {.hints}\n");
171        out.push_str(hints.trim_end());
172        out.push('\n');
173        out.push_str(":::\n");
174    }
175
176    // Gotchas as a fenced div (if present).
177    if let Some(ref gotchas) = lesson.exercise.gotchas {
178        out.push('\n');
179        out.push_str("::: {.gotchas}\n");
180        out.push_str(gotchas.trim_end());
181        out.push('\n');
182        out.push_str(":::\n");
183    }
184
185    // Success criteria as a fenced div (if present) — the filter carries them
186    // into the feedback prompt (ADR-0020).
187    if let Some(ref criteria) = lesson.exercise.success_criteria {
188        out.push('\n');
189        out.push_str("::: {.success-criteria}\n");
190        out.push_str(criteria.trim_end());
191        out.push('\n');
192        out.push_str(":::\n");
193    }
194
195    // Closing div.
196    out.push_str(":::\n");
197
198    out
199}
200
201/// Warn when `lesson` carries none of the aids that make the Quarto widget more
202/// than a Run button: no `checks`, no `solution`, and no `hints`.
203///
204/// Pure (§2.1): returns the stderr message for the CLI shell to print, or
205/// `None` when any aid is present. Authors mistake such a bare widget for a
206/// broken install, so the export names exactly what is missing.
207pub fn thin_lesson_warning(lesson: &Lesson) -> Option<String> {
208    let has_aid = !lesson.checks.is_empty()
209        || lesson.exercise.solution.is_some()
210        || lesson.exercise.hints.is_some();
211    if has_aid {
212        return None;
213    }
214    Some(
215        "warning: lesson has no checks, solution, or hints; the exported \
216         exercise will offer only Run and LLM feedback"
217            .to_string(),
218    )
219}
220
221/// Render the YAML front matter that makes an exported lesson a standalone page:
222/// title, the blendtutor filter, and — for R only — `coi: true` with a note
223/// that book projects run R without isolation (ADR-0015, ADR-0019).
224fn front_matter(lesson: &Lesson) -> String {
225    let title = yaml_double_quoted(&lesson.lesson_name.to_string());
226    let coi = match lesson.language {
227        Language::R => format!("coi: true\n{R_BOOK_COI_NOTE}"),
228        Language::Python => String::new(),
229    };
230    format!("---\ntitle: {title}\nfilters:\n  - {FILTER_NAME}\n{coi}---\n\n")
231}
232
233/// Quote `value` as a YAML double-quoted scalar, escaping backslashes, quotes,
234/// and line breaks so author text can never end the scalar early.
235fn yaml_double_quoted(value: &str) -> String {
236    let escaped = value
237        .replace('\\', "\\\\")
238        .replace('"', "\\\"")
239        .replace('\n', "\\n")
240        .replace('\r', "\\r");
241    format!("\"{escaped}\"")
242}
243
244/// Map a [`Language`] to its lowercase code-fence tag.
245///
246/// `R` → `"r"`, `Python` → `"python"`. Lowercase matches Pandoc/Quarto
247/// conventions for fenced code block language tags.
248fn language_tag(lang: &Language) -> &'static str {
249    match lang {
250        Language::R => "r",
251        Language::Python => "python",
252    }
253}
254
255/// Compute the fence length needed to safely enclose `content`.
256///
257/// Returns a string of backticks whose length is one more than the longest
258/// run of consecutive backticks in `content`, with a minimum of
259/// [`MIN_FENCE_LEN`] (3). This ensures the fence is never broken by
260/// backticks inside the content (CommonMark §4.5).
261fn fence_for(content: &str) -> String {
262    let max_run = longest_backtick_run(content);
263    let fence_len = max_run.max(MIN_FENCE_LEN - 1) + 1;
264    "`".repeat(fence_len)
265}
266
267/// Find the length of the longest run of consecutive backticks in `content`.
268fn longest_backtick_run(content: &str) -> usize {
269    let mut max_run = 0;
270    let mut current_run = 0;
271    for ch in content.chars() {
272        if ch == '`' {
273            current_run += 1;
274            max_run = max_run.max(current_run);
275        } else {
276            current_run = 0;
277        }
278    }
279    max_run
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    const VALID_YAML: &str = r#"
287lesson_name: "Adder"
288language: R
289exercise:
290  prompt: "Write a function add_two(x, y)."
291  code_template: "add_two <- function(x, y) {}"
292  solution: "add_two <- function(x, y) x + y"
293  hints: |
294    - Remember: R uses '<-' for assignment.
295  llm_evaluation_prompt: "Grade this: {student_code}"
296"#;
297
298    #[test]
299    fn export_opens_with_blendtutor_div_and_language() {
300        let lesson = Lesson::parse(VALID_YAML).unwrap();
301        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
302        assert!(
303            qmd.starts_with("::: {.blendtutor language=\"r\"}\n"),
304            "should open with the blendtutor div, got:\n{qmd}"
305        );
306    }
307
308    #[test]
309    fn export_closes_with_div_marker() {
310        let lesson = Lesson::parse(VALID_YAML).unwrap();
311        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
312        assert!(
313            qmd.trim_end().ends_with(":::"),
314            "should close with :::, got:\n{qmd}"
315        );
316    }
317
318    #[test]
319    fn export_excludes_llm_evaluation_prompt() {
320        let lesson = Lesson::parse(VALID_YAML).unwrap();
321        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
322        assert!(
323            !qmd.contains("llm_evaluation_prompt"),
324            "llm_evaluation_prompt must be absent, got:\n{qmd}"
325        );
326        assert!(
327            !qmd.contains("Grade this"),
328            "llm_evaluation_prompt text must be absent, got:\n{qmd}"
329        );
330    }
331
332    #[test]
333    fn export_renders_gotchas_as_gotchas_div() {
334        let yaml = r#"
335lesson_name: "Gotchas"
336language: R
337exercise:
338  prompt: "Write a function."
339  gotchas: |
340    - R uses '<-' for assignment.
341  llm_evaluation_prompt: "Grade this: {student_code}"
342"#;
343        let lesson = Lesson::parse(yaml).unwrap();
344        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
345        assert!(
346            qmd.contains("::: {.gotchas}\n- R uses '<-' for assignment.\n:::\n"),
347            "gotchas should render as a closed ::: {{.gotchas}} div, got:\n{qmd}"
348        );
349    }
350
351    #[test]
352    fn export_renders_packages_as_comma_separated_attribute() {
353        let yaml = r#"
354lesson_name: "Pkg"
355language: Python
356packages:
357  - pandas
358  - numpy
359exercise:
360  prompt: "Write add."
361  llm_evaluation_prompt: "Grade this: {student_code}"
362"#;
363        let lesson = Lesson::parse(yaml).unwrap();
364        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
365        assert!(
366            qmd.starts_with("::: {.blendtutor language=\"python\" packages=\"pandas,numpy\"}\n"),
367            "packages should be a comma-separated div attribute, got:\n{qmd}"
368        );
369    }
370
371    #[test]
372    fn export_no_empty_blocks_for_absent_fields() {
373        let yaml = r#"
374lesson_name: "Minimal"
375language: R
376exercise:
377  prompt: "Write a function."
378  llm_evaluation_prompt: "Grade this: {student_code}"
379"#;
380        let lesson = Lesson::parse(yaml).unwrap();
381        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
382        assert!(
383            !qmd.contains(".solution"),
384            "no .solution block for absent solution, got:\n{qmd}"
385        );
386        assert!(
387            !qmd.contains(".checks"),
388            "no .checks block for empty checks, got:\n{qmd}"
389        );
390        assert!(
391            !qmd.contains("{.hints}"),
392            "no hints div for absent hints, got:\n{qmd}"
393        );
394        assert!(
395            !qmd.contains("{.gotchas}"),
396            "no gotchas div for absent gotchas, got:\n{qmd}"
397        );
398    }
399
400    #[test]
401    fn export_python_uses_python_language_tag() {
402        let yaml = r#"
403lesson_name: "Py"
404language: Python
405exercise:
406  prompt: "Write add."
407  code_template: "def add(a, b): ..."
408  llm_evaluation_prompt: "Grade this: {student_code}"
409"#;
410        let lesson = Lesson::parse(yaml).unwrap();
411        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
412        assert!(
413            qmd.contains("language=\"python\""),
414            "Python lesson should use language=\"python\", got:\n{qmd}"
415        );
416        assert!(
417            qmd.contains("```python\n"),
418            "code block should use ```python tag, got:\n{qmd}"
419        );
420    }
421
422    #[test]
423    fn export_backtick_in_template_uses_longer_fence() {
424        let yaml = r#"
425lesson_name: "Backtick"
426language: R
427exercise:
428  prompt: "Write a function."
429  code_template: |
430    # Has ``` in it
431    f <- function() {}
432  llm_evaluation_prompt: "Grade this: {student_code}"
433"#;
434        let lesson = Lesson::parse(yaml).unwrap();
435        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
436        assert!(
437            qmd.contains("````r\n"),
438            "fence should be 4 backticks when content has ```, got:\n{qmd}"
439        );
440    }
441
442    #[test]
443    fn export_different_lessons_produce_different_output() {
444        let lesson_a = Lesson::parse(VALID_YAML).unwrap();
445        let yaml_b = r#"
446lesson_name: "Different"
447language: R
448exercise:
449  prompt: "Write a completely different function."
450  llm_evaluation_prompt: "Grade this: {student_code}"
451"#;
452        let lesson_b = Lesson::parse(yaml_b).unwrap();
453        assert_ne!(
454            export_lesson_to_qmd(&lesson_a, ExportShape::Snippet),
455            export_lesson_to_qmd(&lesson_b, ExportShape::Snippet),
456            "different lessons must produce different output"
457        );
458    }
459
460    #[test]
461    fn fence_for_no_backticks_returns_three() {
462        assert_eq!(fence_for("hello world"), "```");
463    }
464
465    #[test]
466    fn fence_for_single_backtick_returns_three() {
467        assert_eq!(fence_for("a ` b"), "```");
468    }
469
470    #[test]
471    fn fence_for_triple_backtick_returns_four() {
472        assert_eq!(fence_for("a ``` b"), "````");
473    }
474
475    #[test]
476    fn fence_for_four_backticks_returns_five() {
477        assert_eq!(fence_for("a ```` b"), "`````");
478    }
479
480    #[test]
481    fn longest_backtick_run_detects_runs() {
482        assert_eq!(longest_backtick_run("no backticks"), 0);
483        assert_eq!(longest_backtick_run("one ` here"), 1);
484        assert_eq!(longest_backtick_run("triple ``` here"), 3);
485        assert_eq!(longest_backtick_run("`` and ``` mixed"), 3);
486    }
487
488    #[test]
489    fn document_shape_prefixes_front_matter_and_keeps_the_snippet_intact() {
490        let lesson = Lesson::parse(VALID_YAML).unwrap();
491        let snippet = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
492        let document = export_lesson_to_qmd(&lesson, ExportShape::Document);
493        assert_eq!(document, format!("{}{snippet}", front_matter(&lesson)));
494    }
495
496    #[test]
497    fn front_matter_adds_coi_only_for_r() {
498        let r = Lesson::parse(VALID_YAML).unwrap();
499        assert!(front_matter(&r).contains("\ncoi: true\n"));
500        let py = Lesson::parse(
501            "lesson_name: \"Py\"\nlanguage: Python\nexercise:\n  prompt: \"p\"\n  llm_evaluation_prompt: \"{student_code}\"\n",
502        )
503        .unwrap();
504        assert!(!front_matter(&py).contains("coi"));
505    }
506
507    #[test]
508    fn yaml_double_quoted_escapes_scalar_terminators() {
509        assert_eq!(yaml_double_quoted("plain"), "\"plain\"");
510        assert_eq!(yaml_double_quoted("a\"b\\c\nd"), "\"a\\\"b\\\\c\\nd\"");
511    }
512
513    #[test]
514    fn key_page_has_front_matter_and_mount_div() {
515        let page = key_page_qmd();
516        assert!(
517            page.starts_with(
518                "---\ntitle: \"API Key\"\nfilters:\n  - mcmullarkey/blendtutor\n---\n"
519            )
520        );
521        assert!(page.contains("\n::: {.blendtutor-key}\n"));
522    }
523
524    #[test]
525    fn thin_lesson_warning_names_every_missing_aid() {
526        let lesson = Lesson::parse(
527            "lesson_name: \"Thin\"\nlanguage: R\nexercise:\n  prompt: \"p\"\n  llm_evaluation_prompt: \"{student_code}\"\n",
528        )
529        .unwrap();
530        let warning = thin_lesson_warning(&lesson).expect("a lesson with no aids warns");
531        assert!(warning.starts_with("warning:"), "got: {warning}");
532        for field in ["checks", "solution", "hints"] {
533            assert!(warning.contains(field), "missing `{field}` in: {warning}");
534        }
535    }
536
537    #[test]
538    fn thin_lesson_warning_is_silent_when_any_aid_is_present() {
539        for aid in ["checks:\n  - \"stopifnot(TRUE)\"\n", ""] {
540            let extra_exercise = if aid.is_empty() {
541                "  hints: |\n    - Try it.\n"
542            } else {
543                ""
544            };
545            let yaml = format!(
546                "lesson_name: \"Aided\"\nlanguage: R\n{aid}exercise:\n  prompt: \"p\"\n{extra_exercise}  llm_evaluation_prompt: \"{{student_code}}\"\n"
547            );
548            let lesson = Lesson::parse(&yaml).unwrap();
549            assert_eq!(thin_lesson_warning(&lesson), None, "yaml:\n{yaml}");
550        }
551        let solved = Lesson::parse(VALID_YAML).unwrap();
552        assert_eq!(thin_lesson_warning(&solved), None);
553    }
554
555    #[test]
556    fn export_renders_success_criteria_as_div() {
557        let yaml = r#"
558lesson_name: "Rubric"
559language: R
560exercise:
561  prompt: "Write pseudocode."
562  success_criteria: |
563    - Uses only comments
564  llm_evaluation_prompt: "Grade this: {student_code}"
565"#;
566        let lesson = Lesson::parse(yaml).unwrap();
567        let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
568        assert!(
569            qmd.contains("::: {.success-criteria}\n- Uses only comments\n:::\n"),
570            "success criteria should render as a closed div, got:\n{qmd}"
571        );
572        let bare = Lesson::parse(VALID_YAML).unwrap();
573        assert!(!export_lesson_to_qmd(&bare, ExportShape::Snippet).contains("success-criteria"));
574    }
575}