1use 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
39const DEFAULT_SCRIPTS_REL: &str = "../../../../scripts/smevals/";
44
45const RUNNER_REL: &str = "run.sh";
49const CHECKER_REL: &str = "check_polarity.sh";
51const JUDGE_REL: &str = "judge_feedback.py";
53const PASS_THRESHOLD: f64 = 0.8;
56
57#[derive(Debug)]
59pub enum GenError {
60 InvalidLessonId {
65 lesson_id: String,
67 },
68 EmptySuite,
71 NoRepoRoot {
77 course_root: PathBuf,
79 },
80 Write {
83 path: PathBuf,
85 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
124pub 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
149fn 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
190fn 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
203fn 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
216fn 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
235fn 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
245fn 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
261fn 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
279fn 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 if first.is_ascii_whitespace() || first == b'#' {
287 return false;
288 }
289 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 if value.contains(": ") || value.contains(" #") {
320 return false;
321 }
322 !value.ends_with(' ') && !value.ends_with('\t')
323}
324
325fn 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
354pub fn lesson_id_from_path(lesson_path: &Path) -> Option<&str> {
367 lesson_path.file_stem().and_then(|stem| stem.to_str())
368}
369
370pub 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
386pub 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 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
432fn 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
461fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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}