Skip to main content

sim_cookbook/
manifest.rs

1//! Typed cookbook manifests parsed from the TOML subset, with strict
2//! validation and a filesystem lint pass.
3//!
4//! Three manifest kinds map to three files: `recipe.toml` (one recipe),
5//! `book.toml` (one per crate `recipes/` dir), and `chapter.toml` (optional,
6//! per chapter). Parsing is strict: required fields must be present and
7//! non-empty, and unknown keys are rejected so typos surface immediately.
8
9use std::path::{Path, PathBuf};
10
11use crate::model::Expectation;
12use crate::toml_lite::{self, TomlDoc};
13
14/// Default sort key when `order` is omitted.
15pub const DEFAULT_ORDER: i64 = 1000;
16
17/// One problem found while linting recipe files on disk.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Diagnostic {
20    /// The file or directory the problem concerns.
21    pub path: String,
22    /// A human-readable description of the problem.
23    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/// Parsed `recipe.toml`.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct RecipeManifest {
38    /// Stable recipe id (last path segment of the runtime id).
39    pub id: String,
40    /// Human title.
41    pub title: String,
42    /// Registered codec name for decoding the setup file.
43    pub codec: String,
44    /// Setup file name, relative to the recipe directory.
45    pub setup: String,
46    /// Purpose file name, relative to the recipe directory.
47    pub purpose: String,
48    /// Sort key within the chapter.
49    pub order: i64,
50    /// Free tags.
51    pub tags: Vec<String>,
52    /// Lib ids that must be loaded for this recipe (owning lib added later).
53    pub requires: Vec<String>,
54    /// Declared expectations.
55    pub expect: Vec<Expectation>,
56}
57
58impl RecipeManifest {
59    /// Validate the manifest fields whose meaning depends on a recipe
60    /// directory: embedded relative paths and other per-dir invariants.
61    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/// Parsed `book.toml`.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct BookManifest {
80    /// Owning lib id.
81    pub book: String,
82    /// Human title.
83    pub title: String,
84    /// One-line summary (may be empty).
85    pub summary: String,
86    /// Sort key among books.
87    pub order: i64,
88    /// Explicit chapter order by directory name (may be empty).
89    pub chapters: Vec<String>,
90}
91
92/// Parsed `chapter.toml` (every field optional).
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct ChapterManifest {
95    /// Overriding chapter title.
96    pub title: Option<String>,
97    /// Overriding sort key.
98    pub order: Option<i64>,
99    /// One-line summary (may be empty).
100    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
164/// Parse and validate `recipe.toml` text.
165///
166/// Unknown top-level keys and unknown `[[table]]`s are IGNORED, not rejected
167/// (COOK8.00). A recipe may carry a rich descriptor vocabulary beyond the core
168/// recipe fields -- the `30-agents` `a30-*` descriptors add `recipe_number`,
169/// `source_chapter`, `descriptor_shape`, `assert_*`, and more -- and every such
170/// recipe must still embed and aggregate into the complete cookbook catalog.
171/// Rejecting unknown keys blocked embedding those libs (the EMBED-ALL HAZARD),
172/// which the COVERAGE goal cannot tolerate. Typo protection on the CORE fields
173/// stays: a missing or empty required key (`id`/`title`/`codec`/`setup`/
174/// `purpose`) still errors, and the on-disk `cookbook-lint` gate remains the
175/// place for structural recipe linting.
176pub 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
216/// Parse and validate `book.toml` text.
217pub 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
235/// Parse `chapter.toml` text (all fields optional).
236///
237/// Like [`parse_recipe`], unknown keys and tables are IGNORED rather than
238/// rejected (COOK8.03). Rich `30-agents`/`40-atelier` chapters across the
239/// constellation carry an extended vocabulary (e.g. `tags`) beyond the core
240/// `title`/`order`/`summary`, and every such chapter must embed and aggregate
241/// into the complete catalog -- rejecting unknown keys blocked embedding those
242/// books (the EMBED-ALL HAZARD). Structural chapter linting stays with the
243/// on-disk `cookbook-lint` gate.
244pub 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
268/// Lint one recipe directory on disk: parse `recipe.toml`, confirm the declared
269/// setup and purpose files exist, and that required fields are present. Returns
270/// every problem found, so an author sees all errors at once.
271pub fn lint_dir(dir: &Path) -> Result<(), Vec<Diagnostic>> {
272    lint_dir_impl(dir, false)
273}
274
275/// Lint one recipe directory and reject bare-quote setup files.
276///
277/// This stricter gate is kept separate from [`lint_dir`] while the constellation
278/// still contains older recipe setups that are being converted repo by repo.
279pub 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        // COOK8.00: unknown top-level keys are ignored so rich descriptors embed.
425        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        // An `a30-*` descriptor carries a structured vocabulary beyond the core
468        // recipe fields. It must parse (and thus embed) unchanged; the extra
469        // keys are ignored, and the core fields still resolve.
470        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        // COOK8.03: rich 30-agents chapters carry `tags` beyond the core chapter
520        // fields; they must embed unchanged, so unknown keys are ignored.
521        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}