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> {
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
157pub 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
176pub 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
203pub 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}