1use std::path::{Path, PathBuf};
10
11use crate::model::Expectation;
12use crate::toml_lite::{self, TomlDoc};
13
14pub const DEFAULT_ORDER: i64 = 1000;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Diagnostic {
20 pub path: String,
22 pub message: String,
24}
25
26impl Diagnostic {
27 fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
28 Self {
29 path: path.into(),
30 message: message.into(),
31 }
32 }
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct RecipeManifest {
38 pub id: String,
40 pub title: String,
42 pub codec: String,
44 pub setup: String,
46 pub purpose: String,
48 pub order: i64,
50 pub tags: Vec<String>,
52 pub requires: Vec<String>,
54 pub expect: Vec<Expectation>,
56}
57
58impl RecipeManifest {
59 pub fn validate_for_dir(&self) -> Result<(), Vec<String>> {
62 let mut problems = Vec::new();
63 if let Err(err) = validate_recipe_rel_path("setup", &self.setup) {
64 problems.push(err);
65 }
66 if let Err(err) = validate_recipe_rel_path("purpose", &self.purpose) {
67 problems.push(err);
68 }
69 if problems.is_empty() {
70 Ok(())
71 } else {
72 Err(problems)
73 }
74 }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct BookManifest {
80 pub book: String,
82 pub title: String,
84 pub summary: String,
86 pub order: i64,
88 pub chapters: Vec<String>,
90}
91
92#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct ChapterManifest {
95 pub title: Option<String>,
97 pub order: Option<i64>,
99 pub summary: String,
101}
102
103fn required_str(doc: &TomlDoc, key: &str) -> Result<String, String> {
104 let value = doc
105 .get(key)
106 .ok_or_else(|| format!("missing required key `{key}`"))?;
107 let text = value.as_str().map_err(|e| format!("`{key}`: {e}"))?;
108 if text.is_empty() {
109 return Err(format!("`{key}` must not be empty"));
110 }
111 Ok(text.to_string())
112}
113
114fn optional_order(doc: &TomlDoc) -> Result<i64, String> {
115 match doc.get("order") {
116 Some(value) => value.as_int().map_err(|e| format!("`order`: {e}")),
117 None => Ok(DEFAULT_ORDER),
118 }
119}
120
121fn optional_strings(doc: &TomlDoc, key: &str) -> Result<Vec<String>, String> {
122 match doc.get(key) {
123 Some(value) => Ok(value
124 .as_array()
125 .map_err(|e| format!("`{key}`: {e}"))?
126 .to_vec()),
127 None => Ok(Vec::new()),
128 }
129}
130
131fn validate_recipe_rel_path(field: &str, value: &str) -> Result<(), String> {
132 if value.starts_with('/') || value.starts_with('\\') {
133 return Err(format!("`{field}` must be a relative slash path"));
134 }
135 if value.len() >= 2 && value.as_bytes()[1] == b':' && value.as_bytes()[0].is_ascii_alphabetic()
136 {
137 return Err(format!("`{field}` must be a relative slash path"));
138 }
139 if value.contains('\\') {
140 return Err(format!("`{field}` must use `/` separators only"));
141 }
142 for component in value.split('/') {
143 if component.is_empty() {
144 return Err(format!("`{field}` must not contain empty path components"));
145 }
146 if component == "." {
147 return Err(format!("`{field}` must not contain `.` path components"));
148 }
149 if component == ".." {
150 return Err(format!("`{field}` must not contain `..` path components"));
151 }
152 }
153 Ok(())
154}
155
156fn resolve_recipe_rel_path(base: &Path, value: &str) -> PathBuf {
157 let mut path = base.to_path_buf();
158 for component in value.split('/') {
159 path.push(component);
160 }
161 path
162}
163
164pub fn parse_recipe(text: &str) -> Result<RecipeManifest, String> {
177 let doc = toml_lite::parse(text)?;
178 parse_recipe_doc(&doc, None)
179}
180
181#[derive(Clone, Copy)]
182pub(crate) struct RecipeDefaults<'a> {
183 pub(crate) id: &'a str,
184 pub(crate) codec: &'a str,
185 pub(crate) setup: &'a str,
186}
187
188fn required_str_or(doc: &TomlDoc, key: &str, fallback: Option<&str>) -> Result<String, String> {
189 match doc.get(key) {
190 Some(_) => required_str(doc, key),
191 None => fallback
192 .filter(|value| !value.is_empty())
193 .map(str::to_string)
194 .ok_or_else(|| format!("missing required key `{key}`")),
195 }
196}
197
198pub(crate) fn parse_recipe_doc(
199 doc: &TomlDoc,
200 defaults: Option<RecipeDefaults<'_>>,
201) -> Result<RecipeManifest, String> {
202 let mut expect = Vec::new();
203 for table in doc.tables_named("expect") {
204 let form = table
205 .iter()
206 .find(|(k, _)| k == "form")
207 .ok_or("`[[expect]]` missing `form`")?
208 .1
209 .as_int()
210 .map_err(|e| format!("`[[expect]].form`: {e}"))?;
211 if form < 0 {
212 return Err("`[[expect]].form` must be >= 0".to_string());
213 }
214 let result = table
215 .iter()
216 .find(|(k, _)| k == "result")
217 .ok_or("`[[expect]]` missing `result`")?
218 .1
219 .as_str()
220 .map_err(|e| format!("`[[expect]].result`: {e}"))?
221 .to_string();
222 expect.push(Expectation {
223 form: form as usize,
224 result,
225 });
226 }
227 Ok(RecipeManifest {
228 id: required_str_or(doc, "id", defaults.map(|value| value.id))?,
229 title: required_str(doc, "title")?,
230 codec: required_str_or(doc, "codec", defaults.map(|value| value.codec))?,
231 setup: required_str_or(doc, "setup", defaults.map(|value| value.setup))?,
232 purpose: required_str(doc, "purpose")?,
233 order: optional_order(doc)?,
234 tags: optional_strings(doc, "tags")?,
235 requires: optional_strings(doc, "requires")?,
236 expect,
237 })
238}
239
240pub fn parse_book(text: &str) -> Result<BookManifest, String> {
242 let doc = toml_lite::parse(text)?;
243 doc.reject_unknown_top(&["book", "title", "summary", "order", "chapters"])?;
244 doc.reject_unknown_tables(&[])?;
245 Ok(BookManifest {
246 book: required_str(&doc, "book")?,
247 title: required_str(&doc, "title")?,
248 summary: doc
249 .get("summary")
250 .map(|v| v.as_str().map(str::to_string))
251 .transpose()
252 .map_err(|e| format!("`summary`: {e}"))?
253 .unwrap_or_default(),
254 order: optional_order(&doc)?,
255 chapters: optional_strings(&doc, "chapters")?,
256 })
257}
258
259pub fn parse_chapter(text: &str) -> Result<ChapterManifest, String> {
269 let doc = toml_lite::parse(text)?;
270 let title = match doc.get("title") {
271 Some(v) => Some(v.as_str().map_err(|e| format!("`title`: {e}"))?.to_string()),
272 None => None,
273 };
274 let order = match doc.get("order") {
275 Some(v) => Some(v.as_int().map_err(|e| format!("`order`: {e}"))?),
276 None => None,
277 };
278 let summary = match doc.get("summary") {
279 Some(v) => v
280 .as_str()
281 .map_err(|e| format!("`summary`: {e}"))?
282 .to_string(),
283 None => String::new(),
284 };
285 Ok(ChapterManifest {
286 title,
287 order,
288 summary,
289 })
290}
291
292pub fn lint_dir(dir: &Path) -> Result<(), Vec<Diagnostic>> {
296 lint_dir_impl(dir, false)
297}
298
299pub fn lint_dir_strict_no_quote(dir: &Path) -> Result<(), Vec<Diagnostic>> {
304 lint_dir_impl(dir, true)
305}
306
307fn lint_dir_impl(dir: &Path, strict_no_quote: bool) -> Result<(), Vec<Diagnostic>> {
308 let mut problems = Vec::new();
309 let recipe_path = dir.join("recipe.toml");
310 let text = match std::fs::read_to_string(&recipe_path) {
311 Ok(text) => text,
312 Err(err) => {
313 return Err(vec![Diagnostic::new(
314 recipe_path.display().to_string(),
315 format!("cannot read recipe.toml: {err}"),
316 )]);
317 }
318 };
319 let manifest = match parse_recipe(&text) {
320 Ok(manifest) => manifest,
321 Err(err) => {
322 return Err(vec![Diagnostic::new(
323 recipe_path.display().to_string(),
324 err,
325 )]);
326 }
327 };
328 let validated = match manifest.validate_for_dir() {
329 Ok(()) => true,
330 Err(errors) => {
331 for err in errors {
332 problems.push(Diagnostic::new(recipe_path.display().to_string(), err));
333 }
334 false
335 }
336 };
337 let setup_path = if validated {
338 Some(resolve_recipe_rel_path(dir, &manifest.setup))
339 } else {
340 None
341 };
342 if let Some(setup_path) = setup_path.as_ref() {
343 if !setup_path.is_file() {
344 problems.push(Diagnostic::new(
345 recipe_path.display().to_string(),
346 format!("setup file `{}` does not exist", manifest.setup),
347 ));
348 } else if strict_no_quote {
349 match std::fs::read_to_string(setup_path) {
350 Ok(setup) => {
351 if setup_is_bare_quote(&setup) {
352 problems.push(Diagnostic::new(
353 setup_path.display().to_string(),
354 "recipe setup must not be a bare quote; use an operation, codec form, or read-construct",
355 ));
356 }
357 }
358 Err(err) => problems.push(Diagnostic::new(
359 setup_path.display().to_string(),
360 format!("cannot read setup file `{}`: {err}", manifest.setup),
361 )),
362 }
363 }
364 }
365 if validated {
366 let purpose_path = resolve_recipe_rel_path(dir, &manifest.purpose);
367 if !purpose_path.is_file() {
368 problems.push(Diagnostic::new(
369 recipe_path.display().to_string(),
370 format!("purpose file `{}` does not exist", manifest.purpose),
371 ));
372 }
373 }
374 if problems.is_empty() {
375 Ok(())
376 } else {
377 Err(problems)
378 }
379}
380
381fn setup_is_bare_quote(source: &str) -> bool {
382 let trimmed = source.trim_start();
383 trimmed.starts_with("(quote") || trimmed.starts_with("( quote")
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 const VALID_RECIPE: &str = r#"
391id = "add-two-numbers"
392title = "Add two numbers"
393codec = "lisp"
394setup = "setup.siml"
395purpose = "purpose.md"
396order = 100
397tags = ["arithmetic", "intro"]
398requires = ["numbers-f64"]
399[[expect]]
400form = 0
401result = "3"
402"#;
403
404 #[test]
405 fn parses_valid_recipe() {
406 let m = parse_recipe(VALID_RECIPE).unwrap();
407 assert_eq!(m.id, "add-two-numbers");
408 assert_eq!(m.codec, "lisp");
409 assert_eq!(m.order, 100);
410 assert_eq!(m.tags, ["arithmetic", "intro"]);
411 assert_eq!(
412 m.expect,
413 [Expectation {
414 form: 0,
415 result: "3".into()
416 }]
417 );
418 }
419
420 #[test]
421 fn recipe_order_defaults() {
422 let m = parse_recipe(
423 "id = \"x\"\ntitle = \"X\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
424 )
425 .unwrap();
426 assert_eq!(m.order, DEFAULT_ORDER);
427 assert!(m.tags.is_empty());
428 assert!(m.requires.is_empty());
429 }
430
431 #[test]
432 fn missing_required_field_errors_clearly() {
433 let err = parse_recipe("title = \"X\"\ncodec = \"lisp\"\n").unwrap_err();
434 assert!(err.contains("missing required key `id`"), "{err}");
435 }
436
437 #[test]
438 fn empty_required_field_errors() {
439 let err = parse_recipe(
440 "id = \"\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n",
441 )
442 .unwrap_err();
443 assert!(err.contains("must not be empty"), "{err}");
444 }
445
446 #[test]
447 fn unknown_key_ignored_not_rejected() {
448 let m = parse_recipe(
450 "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\nbogus = 1\n",
451 )
452 .unwrap();
453 assert_eq!(m.id, "x");
454 }
455
456 #[test]
457 fn manifest_validate_for_dir_rejects_unsafe_paths() {
458 let mut manifest = parse_recipe(
459 "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"../setup.siml\"\npurpose = \"/tmp/purpose.md\"\n",
460 )
461 .unwrap();
462 let err = manifest.validate_for_dir().unwrap_err();
463 assert!(
464 err.iter()
465 .any(|msg| msg.contains("`setup` must not contain `..`")),
466 "{err:?}"
467 );
468 assert!(
469 err.iter()
470 .any(|msg| msg.contains("`purpose` must be a relative slash path")),
471 "{err:?}"
472 );
473
474 manifest.setup = "nested\\setup.siml".to_string();
475 manifest.purpose = "notes//purpose.md".to_string();
476 let err = manifest.validate_for_dir().unwrap_err();
477 assert!(
478 err.iter()
479 .any(|msg| msg.contains("`setup` must use `/` separators only")),
480 "{err:?}"
481 );
482 assert!(
483 err.iter()
484 .any(|msg| msg.contains("`purpose` must not contain empty path components")),
485 "{err:?}"
486 );
487 }
488
489 #[test]
490 fn parses_rich_descriptor_keys() {
491 let rich = r#"
495id = "a30-009-agentic-workflow"
496title = "Agentic workflow"
497codec = "lisp"
498setup = "setup.siml"
499purpose = "purpose.md"
500order = 9
501tags = ["30-agents", "sandbox-descriptor"]
502requires = ["agent", "codec/lisp"]
503recipe_number = 9
504source_chapter = 7
505architecture_family = "agentic-workflow"
506runner_mode = "fake"
507safety_posture = "offline"
508capabilities = ["read-eval", "workflow-state"]
509descriptor_shape = "agentic-workflow-trace"
510assert_tags = ["30-agents", "chapter-07"]
511assert_capabilities = ["read-eval"]
512assert_setup_codec = "lisp"
513expected = "expected.txt"
514[[expect]]
515form = 0
516result = "(agentic-workflow-trace)"
517"#;
518 let m = parse_recipe(rich).unwrap();
519 assert_eq!(m.id, "a30-009-agentic-workflow");
520 assert_eq!(m.codec, "lisp");
521 assert_eq!(m.order, 9);
522 assert_eq!(m.requires, ["agent", "codec/lisp"]);
523 assert_eq!(m.expect[0].result, "(agentic-workflow-trace)");
524 }
525
526 #[test]
527 fn parses_book_and_chapter() {
528 let book = parse_book(
529 "book = \"numbers-f64\"\ntitle = \"Numbers\"\norder = 200\nchapters = [\"01-basics\"]\n",
530 )
531 .unwrap();
532 assert_eq!(book.book, "numbers-f64");
533 assert_eq!(book.order, 200);
534 assert_eq!(book.chapters, ["01-basics"]);
535
536 let chapter = parse_chapter("title = \"Basics\"\norder = 10\n").unwrap();
537 assert_eq!(chapter.title.as_deref(), Some("Basics"));
538 assert_eq!(chapter.order, Some(10));
539 }
540
541 #[test]
542 fn chapter_unknown_key_ignored_not_rejected() {
543 let chapter = parse_chapter(
546 "title = \"30 Agents\"\norder = 20\nsummary = \"s\"\ntags = [\"30-agents\"]\n",
547 )
548 .unwrap();
549 assert_eq!(chapter.title.as_deref(), Some("30 Agents"));
550 assert_eq!(chapter.order, Some(20));
551 }
552
553 #[test]
554 fn expect_missing_result_errors() {
555 let err = parse_recipe(
556 "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n[[expect]]\nform = 0\n",
557 )
558 .unwrap_err();
559 assert!(err.contains("missing `result`"), "{err}");
560 }
561
562 fn temp_recipe_dir(tag: &str) -> std::path::PathBuf {
563 let dir =
564 std::env::temp_dir().join(format!("sim-cookbook-lint-{}-{}", std::process::id(), tag));
565 let _ = std::fs::remove_dir_all(&dir);
566 std::fs::create_dir_all(&dir).unwrap();
567 dir
568 }
569
570 #[test]
571 fn lint_dir_accepts_complete_recipe() {
572 let dir = temp_recipe_dir("ok");
573 std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
574 std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
575 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
576 assert!(lint_dir(&dir).is_ok());
577 let _ = std::fs::remove_dir_all(&dir);
578 }
579
580 #[test]
581 fn lint_dir_reports_missing_setup_file() {
582 let dir = temp_recipe_dir("missing-setup");
583 std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
584 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
585 let problems = lint_dir(&dir).unwrap_err();
586 assert!(
587 problems.iter().any(|d| d.message.contains("setup.siml")),
588 "{problems:?}"
589 );
590 let _ = std::fs::remove_dir_all(&dir);
591 }
592
593 #[test]
594 fn lint_dir_allows_manifest_id_that_differs_from_the_directory_name() {
595 let dir = temp_recipe_dir("browser-facade");
596 std::fs::write(
597 dir.join("recipe.toml"),
598 VALID_RECIPE.replace("add-two-numbers", "frame-facade"),
599 )
600 .unwrap();
601 std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
602 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
603 assert!(lint_dir(&dir).is_ok());
604 let _ = std::fs::remove_dir_all(&dir);
605 }
606
607 #[test]
608 fn lint_dir_reports_parent_path_components() {
609 let dir = temp_recipe_dir("parent-path");
610 std::fs::write(
611 dir.join("recipe.toml"),
612 VALID_RECIPE.replace("setup.siml", "../setup.siml"),
613 )
614 .unwrap();
615 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
616 let problems = lint_dir(&dir).unwrap_err();
617 assert!(
618 problems
619 .iter()
620 .any(|d| d.message.contains("`setup` must not contain `..`")),
621 "{problems:?}"
622 );
623 let _ = std::fs::remove_dir_all(&dir);
624 }
625
626 #[test]
627 fn lint_dir_reports_absolute_and_backslash_paths() {
628 let dir = temp_recipe_dir("absolute-path");
629 std::fs::write(
630 dir.join("recipe.toml"),
631 VALID_RECIPE
632 .replace("setup.siml", "nested\\\\setup.siml")
633 .replace("purpose.md", "/tmp/purpose.md"),
634 )
635 .unwrap();
636 let problems = lint_dir(&dir).unwrap_err();
637 assert!(
638 problems
639 .iter()
640 .any(|d| d.message.contains("`setup` must use `/` separators only")),
641 "{problems:?}"
642 );
643 assert!(
644 problems.iter().any(|d| d
645 .message
646 .contains("`purpose` must be a relative slash path")),
647 "{problems:?}"
648 );
649 let _ = std::fs::remove_dir_all(&dir);
650 }
651
652 fn write_recipe_with_setup(dir: &Path, setup: &str) {
653 std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
654 std::fs::write(dir.join("setup.siml"), setup).unwrap();
655 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
656 }
657
658 #[test]
659 fn default_lint_allows_bare_quote_until_strict_gate_is_requested() {
660 let dir = temp_recipe_dir("default-quote");
661 write_recipe_with_setup(&dir, "(quote add-two-numbers)");
662 assert!(lint_dir(&dir).is_ok());
663 let _ = std::fs::remove_dir_all(&dir);
664 }
665
666 #[test]
667 fn strict_lint_reports_bare_quote_setups() {
668 for (tag, setup) in [
669 ("single-line-quote", "(quote add-two-numbers)"),
670 ("spaced-quote", " ( quote add-two-numbers)"),
671 ("multi-line-quote", "\n(quote\n add-two-numbers\n)"),
672 ] {
673 let dir = temp_recipe_dir(tag);
674 write_recipe_with_setup(&dir, setup);
675 let problems = lint_dir_strict_no_quote(&dir).unwrap_err();
676 assert!(
677 problems.iter().any(|d| {
678 d.path.ends_with("setup.siml") && d.message.contains("must not be a bare quote")
679 }),
680 "{tag}: {problems:?}"
681 );
682 let _ = std::fs::remove_dir_all(&dir);
683 }
684 }
685}