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> {
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
210pub 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 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 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}