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 let mut expect = Vec::new();
179 for table in doc.tables_named("expect") {
180 let form = table
181 .iter()
182 .find(|(k, _)| k == "form")
183 .ok_or("`[[expect]]` missing `form`")?
184 .1
185 .as_int()
186 .map_err(|e| format!("`[[expect]].form`: {e}"))?;
187 if form < 0 {
188 return Err("`[[expect]].form` must be >= 0".to_string());
189 }
190 let result = table
191 .iter()
192 .find(|(k, _)| k == "result")
193 .ok_or("`[[expect]]` missing `result`")?
194 .1
195 .as_str()
196 .map_err(|e| format!("`[[expect]].result`: {e}"))?
197 .to_string();
198 expect.push(Expectation {
199 form: form as usize,
200 result,
201 });
202 }
203 Ok(RecipeManifest {
204 id: required_str(&doc, "id")?,
205 title: required_str(&doc, "title")?,
206 codec: required_str(&doc, "codec")?,
207 setup: required_str(&doc, "setup")?,
208 purpose: required_str(&doc, "purpose")?,
209 order: optional_order(&doc)?,
210 tags: optional_strings(&doc, "tags")?,
211 requires: optional_strings(&doc, "requires")?,
212 expect,
213 })
214}
215
216pub fn parse_book(text: &str) -> Result<BookManifest, String> {
218 let doc = toml_lite::parse(text)?;
219 doc.reject_unknown_top(&["book", "title", "summary", "order", "chapters"])?;
220 doc.reject_unknown_tables(&[])?;
221 Ok(BookManifest {
222 book: required_str(&doc, "book")?,
223 title: required_str(&doc, "title")?,
224 summary: doc
225 .get("summary")
226 .map(|v| v.as_str().map(str::to_string))
227 .transpose()
228 .map_err(|e| format!("`summary`: {e}"))?
229 .unwrap_or_default(),
230 order: optional_order(&doc)?,
231 chapters: optional_strings(&doc, "chapters")?,
232 })
233}
234
235pub fn parse_chapter(text: &str) -> Result<ChapterManifest, String> {
245 let doc = toml_lite::parse(text)?;
246 let title = match doc.get("title") {
247 Some(v) => Some(v.as_str().map_err(|e| format!("`title`: {e}"))?.to_string()),
248 None => None,
249 };
250 let order = match doc.get("order") {
251 Some(v) => Some(v.as_int().map_err(|e| format!("`order`: {e}"))?),
252 None => None,
253 };
254 let summary = match doc.get("summary") {
255 Some(v) => v
256 .as_str()
257 .map_err(|e| format!("`summary`: {e}"))?
258 .to_string(),
259 None => String::new(),
260 };
261 Ok(ChapterManifest {
262 title,
263 order,
264 summary,
265 })
266}
267
268pub fn lint_dir(dir: &Path) -> Result<(), Vec<Diagnostic>> {
272 lint_dir_impl(dir, false)
273}
274
275pub fn lint_dir_strict_no_quote(dir: &Path) -> Result<(), Vec<Diagnostic>> {
280 lint_dir_impl(dir, true)
281}
282
283fn lint_dir_impl(dir: &Path, strict_no_quote: bool) -> Result<(), Vec<Diagnostic>> {
284 let mut problems = Vec::new();
285 let recipe_path = dir.join("recipe.toml");
286 let text = match std::fs::read_to_string(&recipe_path) {
287 Ok(text) => text,
288 Err(err) => {
289 return Err(vec![Diagnostic::new(
290 recipe_path.display().to_string(),
291 format!("cannot read recipe.toml: {err}"),
292 )]);
293 }
294 };
295 let manifest = match parse_recipe(&text) {
296 Ok(manifest) => manifest,
297 Err(err) => {
298 return Err(vec![Diagnostic::new(
299 recipe_path.display().to_string(),
300 err,
301 )]);
302 }
303 };
304 let validated = match manifest.validate_for_dir() {
305 Ok(()) => true,
306 Err(errors) => {
307 for err in errors {
308 problems.push(Diagnostic::new(recipe_path.display().to_string(), err));
309 }
310 false
311 }
312 };
313 let setup_path = if validated {
314 Some(resolve_recipe_rel_path(dir, &manifest.setup))
315 } else {
316 None
317 };
318 if let Some(setup_path) = setup_path.as_ref() {
319 if !setup_path.is_file() {
320 problems.push(Diagnostic::new(
321 recipe_path.display().to_string(),
322 format!("setup file `{}` does not exist", manifest.setup),
323 ));
324 } else if strict_no_quote {
325 match std::fs::read_to_string(setup_path) {
326 Ok(setup) => {
327 if setup_is_bare_quote(&setup) {
328 problems.push(Diagnostic::new(
329 setup_path.display().to_string(),
330 "recipe setup must not be a bare quote; use an operation, codec form, or read-construct",
331 ));
332 }
333 }
334 Err(err) => problems.push(Diagnostic::new(
335 setup_path.display().to_string(),
336 format!("cannot read setup file `{}`: {err}", manifest.setup),
337 )),
338 }
339 }
340 }
341 if validated {
342 let purpose_path = resolve_recipe_rel_path(dir, &manifest.purpose);
343 if !purpose_path.is_file() {
344 problems.push(Diagnostic::new(
345 recipe_path.display().to_string(),
346 format!("purpose file `{}` does not exist", manifest.purpose),
347 ));
348 }
349 }
350 if problems.is_empty() {
351 Ok(())
352 } else {
353 Err(problems)
354 }
355}
356
357fn setup_is_bare_quote(source: &str) -> bool {
358 let trimmed = source.trim_start();
359 trimmed.starts_with("(quote") || trimmed.starts_with("( quote")
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 const VALID_RECIPE: &str = r#"
367id = "add-two-numbers"
368title = "Add two numbers"
369codec = "lisp"
370setup = "setup.siml"
371purpose = "purpose.md"
372order = 100
373tags = ["arithmetic", "intro"]
374requires = ["numbers-f64"]
375[[expect]]
376form = 0
377result = "3"
378"#;
379
380 #[test]
381 fn parses_valid_recipe() {
382 let m = parse_recipe(VALID_RECIPE).unwrap();
383 assert_eq!(m.id, "add-two-numbers");
384 assert_eq!(m.codec, "lisp");
385 assert_eq!(m.order, 100);
386 assert_eq!(m.tags, ["arithmetic", "intro"]);
387 assert_eq!(
388 m.expect,
389 [Expectation {
390 form: 0,
391 result: "3".into()
392 }]
393 );
394 }
395
396 #[test]
397 fn recipe_order_defaults() {
398 let m = parse_recipe(
399 "id = \"x\"\ntitle = \"X\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
400 )
401 .unwrap();
402 assert_eq!(m.order, DEFAULT_ORDER);
403 assert!(m.tags.is_empty());
404 assert!(m.requires.is_empty());
405 }
406
407 #[test]
408 fn missing_required_field_errors_clearly() {
409 let err = parse_recipe("title = \"X\"\ncodec = \"lisp\"\n").unwrap_err();
410 assert!(err.contains("missing required key `id`"), "{err}");
411 }
412
413 #[test]
414 fn empty_required_field_errors() {
415 let err = parse_recipe(
416 "id = \"\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n",
417 )
418 .unwrap_err();
419 assert!(err.contains("must not be empty"), "{err}");
420 }
421
422 #[test]
423 fn unknown_key_ignored_not_rejected() {
424 let m = parse_recipe(
426 "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\nbogus = 1\n",
427 )
428 .unwrap();
429 assert_eq!(m.id, "x");
430 }
431
432 #[test]
433 fn manifest_validate_for_dir_rejects_unsafe_paths() {
434 let mut manifest = parse_recipe(
435 "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"../setup.siml\"\npurpose = \"/tmp/purpose.md\"\n",
436 )
437 .unwrap();
438 let err = manifest.validate_for_dir().unwrap_err();
439 assert!(
440 err.iter()
441 .any(|msg| msg.contains("`setup` must not contain `..`")),
442 "{err:?}"
443 );
444 assert!(
445 err.iter()
446 .any(|msg| msg.contains("`purpose` must be a relative slash path")),
447 "{err:?}"
448 );
449
450 manifest.setup = "nested\\setup.siml".to_string();
451 manifest.purpose = "notes//purpose.md".to_string();
452 let err = manifest.validate_for_dir().unwrap_err();
453 assert!(
454 err.iter()
455 .any(|msg| msg.contains("`setup` must use `/` separators only")),
456 "{err:?}"
457 );
458 assert!(
459 err.iter()
460 .any(|msg| msg.contains("`purpose` must not contain empty path components")),
461 "{err:?}"
462 );
463 }
464
465 #[test]
466 fn parses_rich_descriptor_keys() {
467 let rich = r#"
471id = "a30-009-agentic-workflow"
472title = "Agentic workflow"
473codec = "lisp"
474setup = "setup.siml"
475purpose = "purpose.md"
476order = 9
477tags = ["30-agents", "sandbox-descriptor"]
478requires = ["agent", "codec/lisp"]
479recipe_number = 9
480source_chapter = 7
481architecture_family = "agentic-workflow"
482runner_mode = "fake"
483safety_posture = "offline"
484capabilities = ["read-eval", "workflow-state"]
485descriptor_shape = "agentic-workflow-trace"
486assert_tags = ["30-agents", "chapter-07"]
487assert_capabilities = ["read-eval"]
488assert_setup_codec = "lisp"
489expected = "expected.txt"
490[[expect]]
491form = 0
492result = "(agentic-workflow-trace)"
493"#;
494 let m = parse_recipe(rich).unwrap();
495 assert_eq!(m.id, "a30-009-agentic-workflow");
496 assert_eq!(m.codec, "lisp");
497 assert_eq!(m.order, 9);
498 assert_eq!(m.requires, ["agent", "codec/lisp"]);
499 assert_eq!(m.expect[0].result, "(agentic-workflow-trace)");
500 }
501
502 #[test]
503 fn parses_book_and_chapter() {
504 let book = parse_book(
505 "book = \"numbers-f64\"\ntitle = \"Numbers\"\norder = 200\nchapters = [\"01-basics\"]\n",
506 )
507 .unwrap();
508 assert_eq!(book.book, "numbers-f64");
509 assert_eq!(book.order, 200);
510 assert_eq!(book.chapters, ["01-basics"]);
511
512 let chapter = parse_chapter("title = \"Basics\"\norder = 10\n").unwrap();
513 assert_eq!(chapter.title.as_deref(), Some("Basics"));
514 assert_eq!(chapter.order, Some(10));
515 }
516
517 #[test]
518 fn chapter_unknown_key_ignored_not_rejected() {
519 let chapter = parse_chapter(
522 "title = \"30 Agents\"\norder = 20\nsummary = \"s\"\ntags = [\"30-agents\"]\n",
523 )
524 .unwrap();
525 assert_eq!(chapter.title.as_deref(), Some("30 Agents"));
526 assert_eq!(chapter.order, Some(20));
527 }
528
529 #[test]
530 fn expect_missing_result_errors() {
531 let err = parse_recipe(
532 "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n[[expect]]\nform = 0\n",
533 )
534 .unwrap_err();
535 assert!(err.contains("missing `result`"), "{err}");
536 }
537
538 fn temp_recipe_dir(tag: &str) -> std::path::PathBuf {
539 let dir =
540 std::env::temp_dir().join(format!("sim-cookbook-lint-{}-{}", std::process::id(), tag));
541 let _ = std::fs::remove_dir_all(&dir);
542 std::fs::create_dir_all(&dir).unwrap();
543 dir
544 }
545
546 #[test]
547 fn lint_dir_accepts_complete_recipe() {
548 let dir = temp_recipe_dir("ok");
549 std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
550 std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
551 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
552 assert!(lint_dir(&dir).is_ok());
553 let _ = std::fs::remove_dir_all(&dir);
554 }
555
556 #[test]
557 fn lint_dir_reports_missing_setup_file() {
558 let dir = temp_recipe_dir("missing-setup");
559 std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
560 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
561 let problems = lint_dir(&dir).unwrap_err();
562 assert!(
563 problems.iter().any(|d| d.message.contains("setup.siml")),
564 "{problems:?}"
565 );
566 let _ = std::fs::remove_dir_all(&dir);
567 }
568
569 #[test]
570 fn lint_dir_allows_manifest_id_that_differs_from_the_directory_name() {
571 let dir = temp_recipe_dir("browser-facade");
572 std::fs::write(
573 dir.join("recipe.toml"),
574 VALID_RECIPE.replace("add-two-numbers", "frame-facade"),
575 )
576 .unwrap();
577 std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
578 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
579 assert!(lint_dir(&dir).is_ok());
580 let _ = std::fs::remove_dir_all(&dir);
581 }
582
583 #[test]
584 fn lint_dir_reports_parent_path_components() {
585 let dir = temp_recipe_dir("parent-path");
586 std::fs::write(
587 dir.join("recipe.toml"),
588 VALID_RECIPE.replace("setup.siml", "../setup.siml"),
589 )
590 .unwrap();
591 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
592 let problems = lint_dir(&dir).unwrap_err();
593 assert!(
594 problems
595 .iter()
596 .any(|d| d.message.contains("`setup` must not contain `..`")),
597 "{problems:?}"
598 );
599 let _ = std::fs::remove_dir_all(&dir);
600 }
601
602 #[test]
603 fn lint_dir_reports_absolute_and_backslash_paths() {
604 let dir = temp_recipe_dir("absolute-path");
605 std::fs::write(
606 dir.join("recipe.toml"),
607 VALID_RECIPE
608 .replace("setup.siml", "nested\\\\setup.siml")
609 .replace("purpose.md", "/tmp/purpose.md"),
610 )
611 .unwrap();
612 let problems = lint_dir(&dir).unwrap_err();
613 assert!(
614 problems
615 .iter()
616 .any(|d| d.message.contains("`setup` must use `/` separators only")),
617 "{problems:?}"
618 );
619 assert!(
620 problems.iter().any(|d| d
621 .message
622 .contains("`purpose` must be a relative slash path")),
623 "{problems:?}"
624 );
625 let _ = std::fs::remove_dir_all(&dir);
626 }
627
628 fn write_recipe_with_setup(dir: &Path, setup: &str) {
629 std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
630 std::fs::write(dir.join("setup.siml"), setup).unwrap();
631 std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
632 }
633
634 #[test]
635 fn default_lint_allows_bare_quote_until_strict_gate_is_requested() {
636 let dir = temp_recipe_dir("default-quote");
637 write_recipe_with_setup(&dir, "(quote add-two-numbers)");
638 assert!(lint_dir(&dir).is_ok());
639 let _ = std::fs::remove_dir_all(&dir);
640 }
641
642 #[test]
643 fn strict_lint_reports_bare_quote_setups() {
644 for (tag, setup) in [
645 ("single-line-quote", "(quote add-two-numbers)"),
646 ("spaced-quote", " ( quote add-two-numbers)"),
647 ("multi-line-quote", "\n(quote\n add-two-numbers\n)"),
648 ] {
649 let dir = temp_recipe_dir(tag);
650 write_recipe_with_setup(&dir, setup);
651 let problems = lint_dir_strict_no_quote(&dir).unwrap_err();
652 assert!(
653 problems.iter().any(|d| {
654 d.path.ends_with("setup.siml") && d.message.contains("must not be a bare quote")
655 }),
656 "{tag}: {problems:?}"
657 );
658 let _ = std::fs::remove_dir_all(&dir);
659 }
660 }
661}