1use std::path::Path;
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
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct BookManifest {
61 pub book: String,
63 pub title: String,
65 pub summary: String,
67 pub order: i64,
69 pub chapters: Vec<String>,
71}
72
73#[derive(Clone, Debug, Default, PartialEq, Eq)]
75pub struct ChapterManifest {
76 pub title: Option<String>,
78 pub order: Option<i64>,
80 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
112pub 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
164pub 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
183pub 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
216pub 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 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 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 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}