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.
113pub fn parse_recipe(text: &str) -> Result<RecipeManifest, String> {
114    let doc = toml_lite::parse(text)?;
115    doc.reject_unknown_top(&[
116        "id", "title", "codec", "setup", "purpose", "order", "tags", "requires",
117    ])?;
118    doc.reject_unknown_tables(&["expect"])?;
119    let mut expect = Vec::new();
120    for table in doc.tables_named("expect") {
121        let form = table
122            .iter()
123            .find(|(k, _)| k == "form")
124            .ok_or("`[[expect]]` missing `form`")?
125            .1
126            .as_int()
127            .map_err(|e| format!("`[[expect]].form`: {e}"))?;
128        if form < 0 {
129            return Err("`[[expect]].form` must be >= 0".to_string());
130        }
131        let result = table
132            .iter()
133            .find(|(k, _)| k == "result")
134            .ok_or("`[[expect]]` missing `result`")?
135            .1
136            .as_str()
137            .map_err(|e| format!("`[[expect]].result`: {e}"))?
138            .to_string();
139        expect.push(Expectation {
140            form: form as usize,
141            result,
142        });
143    }
144    Ok(RecipeManifest {
145        id: required_str(&doc, "id")?,
146        title: required_str(&doc, "title")?,
147        codec: required_str(&doc, "codec")?,
148        setup: required_str(&doc, "setup")?,
149        purpose: required_str(&doc, "purpose")?,
150        order: optional_order(&doc)?,
151        tags: optional_strings(&doc, "tags")?,
152        requires: optional_strings(&doc, "requires")?,
153        expect,
154    })
155}
156
157/// Parse and validate `book.toml` text.
158pub fn parse_book(text: &str) -> Result<BookManifest, String> {
159    let doc = toml_lite::parse(text)?;
160    doc.reject_unknown_top(&["book", "title", "summary", "order", "chapters"])?;
161    doc.reject_unknown_tables(&[])?;
162    Ok(BookManifest {
163        book: required_str(&doc, "book")?,
164        title: required_str(&doc, "title")?,
165        summary: doc
166            .get("summary")
167            .map(|v| v.as_str().map(str::to_string))
168            .transpose()
169            .map_err(|e| format!("`summary`: {e}"))?
170            .unwrap_or_default(),
171        order: optional_order(&doc)?,
172        chapters: optional_strings(&doc, "chapters")?,
173    })
174}
175
176/// Parse `chapter.toml` text (all fields optional).
177pub fn parse_chapter(text: &str) -> Result<ChapterManifest, String> {
178    let doc = toml_lite::parse(text)?;
179    doc.reject_unknown_top(&["title", "order", "summary"])?;
180    doc.reject_unknown_tables(&[])?;
181    let title = match doc.get("title") {
182        Some(v) => Some(v.as_str().map_err(|e| format!("`title`: {e}"))?.to_string()),
183        None => None,
184    };
185    let order = match doc.get("order") {
186        Some(v) => Some(v.as_int().map_err(|e| format!("`order`: {e}"))?),
187        None => None,
188    };
189    let summary = match doc.get("summary") {
190        Some(v) => v
191            .as_str()
192            .map_err(|e| format!("`summary`: {e}"))?
193            .to_string(),
194        None => String::new(),
195    };
196    Ok(ChapterManifest {
197        title,
198        order,
199        summary,
200    })
201}
202
203/// Lint one recipe directory on disk: parse `recipe.toml`, confirm the declared
204/// setup and purpose files exist, and that required fields are present. Returns
205/// every problem found, so an author sees all errors at once.
206pub fn lint_dir(dir: &Path) -> Result<(), Vec<Diagnostic>> {
207    let mut problems = Vec::new();
208    let recipe_path = dir.join("recipe.toml");
209    let text = match std::fs::read_to_string(&recipe_path) {
210        Ok(text) => text,
211        Err(err) => {
212            return Err(vec![Diagnostic::new(
213                recipe_path.display().to_string(),
214                format!("cannot read recipe.toml: {err}"),
215            )]);
216        }
217    };
218    let manifest = match parse_recipe(&text) {
219        Ok(manifest) => manifest,
220        Err(err) => {
221            return Err(vec![Diagnostic::new(
222                recipe_path.display().to_string(),
223                err,
224            )]);
225        }
226    };
227    if !dir.join(&manifest.setup).is_file() {
228        problems.push(Diagnostic::new(
229            recipe_path.display().to_string(),
230            format!("setup file `{}` does not exist", manifest.setup),
231        ));
232    }
233    if !dir.join(&manifest.purpose).is_file() {
234        problems.push(Diagnostic::new(
235            recipe_path.display().to_string(),
236            format!("purpose file `{}` does not exist", manifest.purpose),
237        ));
238    }
239    if problems.is_empty() {
240        Ok(())
241    } else {
242        Err(problems)
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    const VALID_RECIPE: &str = r#"
251id = "add-two-numbers"
252title = "Add two numbers"
253codec = "lisp"
254setup = "setup.siml"
255purpose = "purpose.md"
256order = 100
257tags = ["arithmetic", "intro"]
258requires = ["numbers-f64"]
259[[expect]]
260form = 0
261result = "3"
262"#;
263
264    #[test]
265    fn parses_valid_recipe() {
266        let m = parse_recipe(VALID_RECIPE).unwrap();
267        assert_eq!(m.id, "add-two-numbers");
268        assert_eq!(m.codec, "lisp");
269        assert_eq!(m.order, 100);
270        assert_eq!(m.tags, ["arithmetic", "intro"]);
271        assert_eq!(
272            m.expect,
273            [Expectation {
274                form: 0,
275                result: "3".into()
276            }]
277        );
278    }
279
280    #[test]
281    fn recipe_order_defaults() {
282        let m = parse_recipe(
283            "id = \"x\"\ntitle = \"X\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
284        )
285        .unwrap();
286        assert_eq!(m.order, DEFAULT_ORDER);
287        assert!(m.tags.is_empty());
288        assert!(m.requires.is_empty());
289    }
290
291    #[test]
292    fn missing_required_field_errors_clearly() {
293        let err = parse_recipe("title = \"X\"\ncodec = \"lisp\"\n").unwrap_err();
294        assert!(err.contains("missing required key `id`"), "{err}");
295    }
296
297    #[test]
298    fn empty_required_field_errors() {
299        let err = parse_recipe(
300            "id = \"\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n",
301        )
302        .unwrap_err();
303        assert!(err.contains("must not be empty"), "{err}");
304    }
305
306    #[test]
307    fn unknown_key_rejected() {
308        let err = parse_recipe(
309            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\nbogus = 1\n",
310        )
311        .unwrap_err();
312        assert!(err.contains("unknown key `bogus`"), "{err}");
313    }
314
315    #[test]
316    fn parses_book_and_chapter() {
317        let book = parse_book(
318            "book = \"numbers-f64\"\ntitle = \"Numbers\"\norder = 200\nchapters = [\"01-basics\"]\n",
319        )
320        .unwrap();
321        assert_eq!(book.book, "numbers-f64");
322        assert_eq!(book.order, 200);
323        assert_eq!(book.chapters, ["01-basics"]);
324
325        let chapter = parse_chapter("title = \"Basics\"\norder = 10\n").unwrap();
326        assert_eq!(chapter.title.as_deref(), Some("Basics"));
327        assert_eq!(chapter.order, Some(10));
328    }
329
330    #[test]
331    fn expect_missing_result_errors() {
332        let err = parse_recipe(
333            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n[[expect]]\nform = 0\n",
334        )
335        .unwrap_err();
336        assert!(err.contains("missing `result`"), "{err}");
337    }
338
339    fn temp_recipe_dir(tag: &str) -> std::path::PathBuf {
340        let dir =
341            std::env::temp_dir().join(format!("sim-cookbook-lint-{}-{}", std::process::id(), tag));
342        let _ = std::fs::remove_dir_all(&dir);
343        std::fs::create_dir_all(&dir).unwrap();
344        dir
345    }
346
347    #[test]
348    fn lint_dir_accepts_complete_recipe() {
349        let dir = temp_recipe_dir("ok");
350        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
351        std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
352        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
353        assert!(lint_dir(&dir).is_ok());
354        let _ = std::fs::remove_dir_all(&dir);
355    }
356
357    #[test]
358    fn lint_dir_reports_missing_setup_file() {
359        let dir = temp_recipe_dir("missing-setup");
360        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
361        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
362        let problems = lint_dir(&dir).unwrap_err();
363        assert!(
364            problems.iter().any(|d| d.message.contains("setup.siml")),
365            "{problems:?}"
366        );
367        let _ = std::fs::remove_dir_all(&dir);
368    }
369}