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).
184///
185/// Like [`parse_recipe`], unknown keys and tables are IGNORED rather than
186/// rejected (COOK8.03). Rich `30-agents`/`40-atelier` chapters across the
187/// constellation carry an extended vocabulary (e.g. `tags`) beyond the core
188/// `title`/`order`/`summary`, and every such chapter must embed and aggregate
189/// into the complete catalog -- rejecting unknown keys blocked embedding those
190/// books (the EMBED-ALL HAZARD). Structural chapter linting stays with the
191/// on-disk `cookbook-lint` gate.
192pub fn parse_chapter(text: &str) -> Result<ChapterManifest, String> {
193    let doc = toml_lite::parse(text)?;
194    let title = match doc.get("title") {
195        Some(v) => Some(v.as_str().map_err(|e| format!("`title`: {e}"))?.to_string()),
196        None => None,
197    };
198    let order = match doc.get("order") {
199        Some(v) => Some(v.as_int().map_err(|e| format!("`order`: {e}"))?),
200        None => None,
201    };
202    let summary = match doc.get("summary") {
203        Some(v) => v
204            .as_str()
205            .map_err(|e| format!("`summary`: {e}"))?
206            .to_string(),
207        None => String::new(),
208    };
209    Ok(ChapterManifest {
210        title,
211        order,
212        summary,
213    })
214}
215
216/// Lint one recipe directory on disk: parse `recipe.toml`, confirm the declared
217/// setup and purpose files exist, and that required fields are present. Returns
218/// every problem found, so an author sees all errors at once.
219pub fn lint_dir(dir: &Path) -> Result<(), Vec<Diagnostic>> {
220    let mut problems = Vec::new();
221    let recipe_path = dir.join("recipe.toml");
222    let text = match std::fs::read_to_string(&recipe_path) {
223        Ok(text) => text,
224        Err(err) => {
225            return Err(vec![Diagnostic::new(
226                recipe_path.display().to_string(),
227                format!("cannot read recipe.toml: {err}"),
228            )]);
229        }
230    };
231    let manifest = match parse_recipe(&text) {
232        Ok(manifest) => manifest,
233        Err(err) => {
234            return Err(vec![Diagnostic::new(
235                recipe_path.display().to_string(),
236                err,
237            )]);
238        }
239    };
240    if !dir.join(&manifest.setup).is_file() {
241        problems.push(Diagnostic::new(
242            recipe_path.display().to_string(),
243            format!("setup file `{}` does not exist", manifest.setup),
244        ));
245    }
246    if !dir.join(&manifest.purpose).is_file() {
247        problems.push(Diagnostic::new(
248            recipe_path.display().to_string(),
249            format!("purpose file `{}` does not exist", manifest.purpose),
250        ));
251    }
252    if problems.is_empty() {
253        Ok(())
254    } else {
255        Err(problems)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    const VALID_RECIPE: &str = r#"
264id = "add-two-numbers"
265title = "Add two numbers"
266codec = "lisp"
267setup = "setup.siml"
268purpose = "purpose.md"
269order = 100
270tags = ["arithmetic", "intro"]
271requires = ["numbers-f64"]
272[[expect]]
273form = 0
274result = "3"
275"#;
276
277    #[test]
278    fn parses_valid_recipe() {
279        let m = parse_recipe(VALID_RECIPE).unwrap();
280        assert_eq!(m.id, "add-two-numbers");
281        assert_eq!(m.codec, "lisp");
282        assert_eq!(m.order, 100);
283        assert_eq!(m.tags, ["arithmetic", "intro"]);
284        assert_eq!(
285            m.expect,
286            [Expectation {
287                form: 0,
288                result: "3".into()
289            }]
290        );
291    }
292
293    #[test]
294    fn recipe_order_defaults() {
295        let m = parse_recipe(
296            "id = \"x\"\ntitle = \"X\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
297        )
298        .unwrap();
299        assert_eq!(m.order, DEFAULT_ORDER);
300        assert!(m.tags.is_empty());
301        assert!(m.requires.is_empty());
302    }
303
304    #[test]
305    fn missing_required_field_errors_clearly() {
306        let err = parse_recipe("title = \"X\"\ncodec = \"lisp\"\n").unwrap_err();
307        assert!(err.contains("missing required key `id`"), "{err}");
308    }
309
310    #[test]
311    fn empty_required_field_errors() {
312        let err = parse_recipe(
313            "id = \"\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n",
314        )
315        .unwrap_err();
316        assert!(err.contains("must not be empty"), "{err}");
317    }
318
319    #[test]
320    fn unknown_key_ignored_not_rejected() {
321        // COOK8.00: unknown top-level keys are ignored so rich descriptors embed.
322        let m = parse_recipe(
323            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\nbogus = 1\n",
324        )
325        .unwrap();
326        assert_eq!(m.id, "x");
327    }
328
329    #[test]
330    fn parses_rich_descriptor_keys() {
331        // An `a30-*` descriptor carries a structured vocabulary beyond the core
332        // runnable fields. It must parse (and thus embed) unchanged; the extra
333        // keys are ignored, and the core fields still resolve.
334        let rich = r#"
335id = "a30-009-agentic-workflow"
336title = "Agentic workflow"
337codec = "lisp"
338setup = "setup.siml"
339purpose = "purpose.md"
340order = 9
341tags = ["30-agents", "sandbox-descriptor"]
342requires = ["agent", "codec/lisp"]
343recipe_number = 9
344source_chapter = 7
345architecture_family = "agentic-workflow"
346runner_mode = "fake"
347safety_posture = "offline"
348capabilities = ["read-eval", "workflow-state"]
349descriptor_shape = "agentic-workflow-trace"
350assert_tags = ["30-agents", "chapter-07"]
351assert_capabilities = ["read-eval"]
352assert_setup_codec = "lisp"
353expected = "expected.txt"
354[[expect]]
355form = 0
356result = "(agentic-workflow-trace)"
357"#;
358        let m = parse_recipe(rich).unwrap();
359        assert_eq!(m.id, "a30-009-agentic-workflow");
360        assert_eq!(m.codec, "lisp");
361        assert_eq!(m.order, 9);
362        assert_eq!(m.requires, ["agent", "codec/lisp"]);
363        assert_eq!(m.expect[0].result, "(agentic-workflow-trace)");
364    }
365
366    #[test]
367    fn parses_book_and_chapter() {
368        let book = parse_book(
369            "book = \"numbers-f64\"\ntitle = \"Numbers\"\norder = 200\nchapters = [\"01-basics\"]\n",
370        )
371        .unwrap();
372        assert_eq!(book.book, "numbers-f64");
373        assert_eq!(book.order, 200);
374        assert_eq!(book.chapters, ["01-basics"]);
375
376        let chapter = parse_chapter("title = \"Basics\"\norder = 10\n").unwrap();
377        assert_eq!(chapter.title.as_deref(), Some("Basics"));
378        assert_eq!(chapter.order, Some(10));
379    }
380
381    #[test]
382    fn chapter_unknown_key_ignored_not_rejected() {
383        // COOK8.03: rich 30-agents chapters carry `tags` beyond the core chapter
384        // fields; they must embed unchanged, so unknown keys are ignored.
385        let chapter = parse_chapter(
386            "title = \"30 Agents\"\norder = 20\nsummary = \"s\"\ntags = [\"30-agents\"]\n",
387        )
388        .unwrap();
389        assert_eq!(chapter.title.as_deref(), Some("30 Agents"));
390        assert_eq!(chapter.order, Some(20));
391    }
392
393    #[test]
394    fn expect_missing_result_errors() {
395        let err = parse_recipe(
396            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n[[expect]]\nform = 0\n",
397        )
398        .unwrap_err();
399        assert!(err.contains("missing `result`"), "{err}");
400    }
401
402    fn temp_recipe_dir(tag: &str) -> std::path::PathBuf {
403        let dir =
404            std::env::temp_dir().join(format!("sim-cookbook-lint-{}-{}", std::process::id(), tag));
405        let _ = std::fs::remove_dir_all(&dir);
406        std::fs::create_dir_all(&dir).unwrap();
407        dir
408    }
409
410    #[test]
411    fn lint_dir_accepts_complete_recipe() {
412        let dir = temp_recipe_dir("ok");
413        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
414        std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
415        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
416        assert!(lint_dir(&dir).is_ok());
417        let _ = std::fs::remove_dir_all(&dir);
418    }
419
420    #[test]
421    fn lint_dir_reports_missing_setup_file() {
422        let dir = temp_recipe_dir("missing-setup");
423        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
424        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
425        let problems = lint_dir(&dir).unwrap_err();
426        assert!(
427            problems.iter().any(|d| d.message.contains("setup.siml")),
428            "{problems:?}"
429        );
430        let _ = std::fs::remove_dir_all(&dir);
431    }
432}