1use crate::lesson::{Language, Lesson};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ExportShape {
31 Snippet,
33 Document,
35}
36
37const FILTER_NAME: &str = "mcmullarkey/blendtutor";
39
40const 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
49const 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
82pub fn key_page_qmd() -> &'static str {
85 KEY_PAGE_QMD
86}
87
88const MIN_FENCE_LEN: usize = 3;
90
91pub 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 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 out.push_str(lesson.exercise.prompt.trim_end());
131 out.push('\n');
132
133 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 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 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 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 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 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 out.push_str(":::\n");
197
198 out
199}
200
201pub 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
221fn 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
233fn 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
244fn language_tag(lang: &Language) -> &'static str {
249 match lang {
250 Language::R => "r",
251 Language::Python => "python",
252 }
253}
254
255fn 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
267fn 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}