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;
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 used to decode 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
58/// Parsed `book.toml`.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct BookManifest {
61    /// Owning lib id.
62    pub book: String,
63    /// Human title.
64    pub title: String,
65    /// One-line summary (may be empty).
66    pub summary: String,
67    /// Sort key among books.
68    pub order: i64,
69    /// Explicit chapter order by directory name (may be empty).
70    pub chapters: Vec<String>,
71}
72
73/// Parsed `chapter.toml` (every field optional).
74#[derive(Clone, Debug, Default, PartialEq, Eq)]
75pub struct ChapterManifest {
76    /// Overriding chapter title.
77    pub title: Option<String>,
78    /// Overriding sort key.
79    pub order: Option<i64>,
80    /// One-line summary (may be empty).
81    pub summary: String,
82}
83
84fn required_str(doc: &TomlDoc, key: &str) -> Result<String, String> {
85    let value = doc
86        .get(key)
87        .ok_or_else(|| format!("missing required key `{key}`"))?;
88    let text = value.as_str().map_err(|e| format!("`{key}`: {e}"))?;
89    if text.is_empty() {
90        return Err(format!("`{key}` must not be empty"));
91    }
92    Ok(text.to_string())
93}
94
95fn optional_order(doc: &TomlDoc) -> Result<i64, String> {
96    match doc.get("order") {
97        Some(value) => value.as_int().map_err(|e| format!("`order`: {e}")),
98        None => Ok(DEFAULT_ORDER),
99    }
100}
101
102fn optional_strings(doc: &TomlDoc, key: &str) -> Result<Vec<String>, String> {
103    match doc.get(key) {
104        Some(value) => Ok(value
105            .as_array()
106            .map_err(|e| format!("`{key}`: {e}"))?
107            .to_vec()),
108        None => Ok(Vec::new()),
109    }
110}
111
112/// Parse and validate `recipe.toml` text.
113///
114/// Unknown top-level keys and unknown `[[table]]`s are IGNORED, not rejected
115/// (COOK8.00). A recipe may carry a rich descriptor vocabulary beyond the core
116/// runnable fields -- the `30-agents` `a30-*` descriptors add `recipe_number`,
117/// `source_chapter`, `descriptor_shape`, `assert_*`, and more -- and every such
118/// recipe must still embed and aggregate into the complete cookbook catalog.
119/// Rejecting unknown keys blocked embedding those libs (the EMBED-ALL HAZARD),
120/// which the COVERAGE goal cannot tolerate. Typo protection on the CORE fields
121/// stays: a missing or empty required key (`id`/`title`/`codec`/`setup`/
122/// `purpose`) still errors, and the on-disk `cookbook-lint` gate remains the
123/// place for structural recipe linting.
124pub fn parse_recipe(text: &str) -> Result<RecipeManifest, String> {
125    let doc = toml_lite::parse(text)?;
126    let mut expect = Vec::new();
127    for table in doc.tables_named("expect") {
128        let form = table
129            .iter()
130            .find(|(k, _)| k == "form")
131            .ok_or("`[[expect]]` missing `form`")?
132            .1
133            .as_int()
134            .map_err(|e| format!("`[[expect]].form`: {e}"))?;
135        if form < 0 {
136            return Err("`[[expect]].form` must be >= 0".to_string());
137        }
138        let result = table
139            .iter()
140            .find(|(k, _)| k == "result")
141            .ok_or("`[[expect]]` missing `result`")?
142            .1
143            .as_str()
144            .map_err(|e| format!("`[[expect]].result`: {e}"))?
145            .to_string();
146        expect.push(Expectation {
147            form: form as usize,
148            result,
149        });
150    }
151    Ok(RecipeManifest {
152        id: required_str(&doc, "id")?,
153        title: required_str(&doc, "title")?,
154        codec: required_str(&doc, "codec")?,
155        setup: required_str(&doc, "setup")?,
156        purpose: required_str(&doc, "purpose")?,
157        order: optional_order(&doc)?,
158        tags: optional_strings(&doc, "tags")?,
159        requires: optional_strings(&doc, "requires")?,
160        expect,
161    })
162}
163
164/// Parse and validate `book.toml` text.
165pub fn parse_book(text: &str) -> Result<BookManifest, String> {
166    let doc = toml_lite::parse(text)?;
167    doc.reject_unknown_top(&["book", "title", "summary", "order", "chapters"])?;
168    doc.reject_unknown_tables(&[])?;
169    Ok(BookManifest {
170        book: required_str(&doc, "book")?,
171        title: required_str(&doc, "title")?,
172        summary: doc
173            .get("summary")
174            .map(|v| v.as_str().map(str::to_string))
175            .transpose()
176            .map_err(|e| format!("`summary`: {e}"))?
177            .unwrap_or_default(),
178        order: optional_order(&doc)?,
179        chapters: optional_strings(&doc, "chapters")?,
180    })
181}
182
183/// Parse `chapter.toml` text (all fields optional).
184pub fn parse_chapter(text: &str) -> Result<ChapterManifest, String> {
185    let doc = toml_lite::parse(text)?;
186    doc.reject_unknown_top(&["title", "order", "summary"])?;
187    doc.reject_unknown_tables(&[])?;
188    let title = match doc.get("title") {
189        Some(v) => Some(v.as_str().map_err(|e| format!("`title`: {e}"))?.to_string()),
190        None => None,
191    };
192    let order = match doc.get("order") {
193        Some(v) => Some(v.as_int().map_err(|e| format!("`order`: {e}"))?),
194        None => None,
195    };
196    let summary = match doc.get("summary") {
197        Some(v) => v
198            .as_str()
199            .map_err(|e| format!("`summary`: {e}"))?
200            .to_string(),
201        None => String::new(),
202    };
203    Ok(ChapterManifest {
204        title,
205        order,
206        summary,
207    })
208}
209
210/// Lint one recipe directory on disk: parse `recipe.toml`, confirm the declared
211/// setup and purpose files exist, and that required fields are present. Returns
212/// every problem found, so an author sees all errors at once.
213pub fn lint_dir(dir: &Path) -> Result<(), Vec<Diagnostic>> {
214    let mut problems = Vec::new();
215    let recipe_path = dir.join("recipe.toml");
216    let text = match std::fs::read_to_string(&recipe_path) {
217        Ok(text) => text,
218        Err(err) => {
219            return Err(vec![Diagnostic::new(
220                recipe_path.display().to_string(),
221                format!("cannot read recipe.toml: {err}"),
222            )]);
223        }
224    };
225    let manifest = match parse_recipe(&text) {
226        Ok(manifest) => manifest,
227        Err(err) => {
228            return Err(vec![Diagnostic::new(
229                recipe_path.display().to_string(),
230                err,
231            )]);
232        }
233    };
234    if !dir.join(&manifest.setup).is_file() {
235        problems.push(Diagnostic::new(
236            recipe_path.display().to_string(),
237            format!("setup file `{}` does not exist", manifest.setup),
238        ));
239    }
240    if !dir.join(&manifest.purpose).is_file() {
241        problems.push(Diagnostic::new(
242            recipe_path.display().to_string(),
243            format!("purpose file `{}` does not exist", manifest.purpose),
244        ));
245    }
246    if problems.is_empty() {
247        Ok(())
248    } else {
249        Err(problems)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    const VALID_RECIPE: &str = r#"
258id = "add-two-numbers"
259title = "Add two numbers"
260codec = "lisp"
261setup = "setup.siml"
262purpose = "purpose.md"
263order = 100
264tags = ["arithmetic", "intro"]
265requires = ["numbers-f64"]
266[[expect]]
267form = 0
268result = "3"
269"#;
270
271    #[test]
272    fn parses_valid_recipe() {
273        let m = parse_recipe(VALID_RECIPE).unwrap();
274        assert_eq!(m.id, "add-two-numbers");
275        assert_eq!(m.codec, "lisp");
276        assert_eq!(m.order, 100);
277        assert_eq!(m.tags, ["arithmetic", "intro"]);
278        assert_eq!(
279            m.expect,
280            [Expectation {
281                form: 0,
282                result: "3".into()
283            }]
284        );
285    }
286
287    #[test]
288    fn recipe_order_defaults() {
289        let m = parse_recipe(
290            "id = \"x\"\ntitle = \"X\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
291        )
292        .unwrap();
293        assert_eq!(m.order, DEFAULT_ORDER);
294        assert!(m.tags.is_empty());
295        assert!(m.requires.is_empty());
296    }
297
298    #[test]
299    fn missing_required_field_errors_clearly() {
300        let err = parse_recipe("title = \"X\"\ncodec = \"lisp\"\n").unwrap_err();
301        assert!(err.contains("missing required key `id`"), "{err}");
302    }
303
304    #[test]
305    fn empty_required_field_errors() {
306        let err = parse_recipe(
307            "id = \"\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n",
308        )
309        .unwrap_err();
310        assert!(err.contains("must not be empty"), "{err}");
311    }
312
313    #[test]
314    fn unknown_key_ignored_not_rejected() {
315        // COOK8.00: unknown top-level keys are ignored so rich descriptors embed.
316        let m = parse_recipe(
317            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\nbogus = 1\n",
318        )
319        .unwrap();
320        assert_eq!(m.id, "x");
321    }
322
323    #[test]
324    fn parses_rich_descriptor_keys() {
325        // An `a30-*` descriptor carries a structured vocabulary beyond the core
326        // runnable fields. It must parse (and thus embed) unchanged; the extra
327        // keys are ignored, and the core fields still resolve.
328        let rich = r#"
329id = "a30-009-agentic-workflow"
330title = "Agentic workflow"
331codec = "lisp"
332setup = "setup.siml"
333purpose = "purpose.md"
334order = 9
335tags = ["30-agents", "sandbox-descriptor"]
336requires = ["agent", "codec/lisp"]
337recipe_number = 9
338source_chapter = 7
339architecture_family = "agentic-workflow"
340runner_mode = "fake"
341safety_posture = "offline"
342capabilities = ["read-eval", "workflow-state"]
343descriptor_shape = "agentic-workflow-trace"
344assert_tags = ["30-agents", "chapter-07"]
345assert_capabilities = ["read-eval"]
346assert_setup_codec = "lisp"
347expected = "expected.txt"
348[[expect]]
349form = 0
350result = "(agentic-workflow-trace)"
351"#;
352        let m = parse_recipe(rich).unwrap();
353        assert_eq!(m.id, "a30-009-agentic-workflow");
354        assert_eq!(m.codec, "lisp");
355        assert_eq!(m.order, 9);
356        assert_eq!(m.requires, ["agent", "codec/lisp"]);
357        assert_eq!(m.expect[0].result, "(agentic-workflow-trace)");
358    }
359
360    #[test]
361    fn parses_book_and_chapter() {
362        let book = parse_book(
363            "book = \"numbers-f64\"\ntitle = \"Numbers\"\norder = 200\nchapters = [\"01-basics\"]\n",
364        )
365        .unwrap();
366        assert_eq!(book.book, "numbers-f64");
367        assert_eq!(book.order, 200);
368        assert_eq!(book.chapters, ["01-basics"]);
369
370        let chapter = parse_chapter("title = \"Basics\"\norder = 10\n").unwrap();
371        assert_eq!(chapter.title.as_deref(), Some("Basics"));
372        assert_eq!(chapter.order, Some(10));
373    }
374
375    #[test]
376    fn expect_missing_result_errors() {
377        let err = parse_recipe(
378            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n[[expect]]\nform = 0\n",
379        )
380        .unwrap_err();
381        assert!(err.contains("missing `result`"), "{err}");
382    }
383
384    fn temp_recipe_dir(tag: &str) -> std::path::PathBuf {
385        let dir =
386            std::env::temp_dir().join(format!("sim-cookbook-lint-{}-{}", std::process::id(), tag));
387        let _ = std::fs::remove_dir_all(&dir);
388        std::fs::create_dir_all(&dir).unwrap();
389        dir
390    }
391
392    #[test]
393    fn lint_dir_accepts_complete_recipe() {
394        let dir = temp_recipe_dir("ok");
395        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
396        std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
397        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
398        assert!(lint_dir(&dir).is_ok());
399        let _ = std::fs::remove_dir_all(&dir);
400    }
401
402    #[test]
403    fn lint_dir_reports_missing_setup_file() {
404        let dir = temp_recipe_dir("missing-setup");
405        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
406        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
407        let problems = lint_dir(&dir).unwrap_err();
408        assert!(
409            problems.iter().any(|d| d.message.contains("setup.siml")),
410            "{problems:?}"
411        );
412        let _ = std::fs::remove_dir_all(&dir);
413    }
414}