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, PathBuf};
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 for decoding 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
58impl RecipeManifest {
59    /// Validate the manifest fields whose meaning depends on a recipe
60    /// directory: embedded relative paths and other per-dir invariants.
61    pub fn validate_for_dir(&self) -> Result<(), Vec<String>> {
62        let mut problems = Vec::new();
63        if let Err(err) = validate_recipe_rel_path("setup", &self.setup) {
64            problems.push(err);
65        }
66        if let Err(err) = validate_recipe_rel_path("purpose", &self.purpose) {
67            problems.push(err);
68        }
69        if problems.is_empty() {
70            Ok(())
71        } else {
72            Err(problems)
73        }
74    }
75}
76
77/// Parsed `book.toml`.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct BookManifest {
80    /// Owning lib id.
81    pub book: String,
82    /// Human title.
83    pub title: String,
84    /// One-line summary (may be empty).
85    pub summary: String,
86    /// Sort key among books.
87    pub order: i64,
88    /// Explicit chapter order by directory name (may be empty).
89    pub chapters: Vec<String>,
90}
91
92/// Parsed `chapter.toml` (every field optional).
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct ChapterManifest {
95    /// Overriding chapter title.
96    pub title: Option<String>,
97    /// Overriding sort key.
98    pub order: Option<i64>,
99    /// One-line summary (may be empty).
100    pub summary: String,
101}
102
103fn required_str(doc: &TomlDoc, key: &str) -> Result<String, String> {
104    let value = doc
105        .get(key)
106        .ok_or_else(|| format!("missing required key `{key}`"))?;
107    let text = value.as_str().map_err(|e| format!("`{key}`: {e}"))?;
108    if text.is_empty() {
109        return Err(format!("`{key}` must not be empty"));
110    }
111    Ok(text.to_string())
112}
113
114fn optional_order(doc: &TomlDoc) -> Result<i64, String> {
115    match doc.get("order") {
116        Some(value) => value.as_int().map_err(|e| format!("`order`: {e}")),
117        None => Ok(DEFAULT_ORDER),
118    }
119}
120
121fn optional_strings(doc: &TomlDoc, key: &str) -> Result<Vec<String>, String> {
122    match doc.get(key) {
123        Some(value) => Ok(value
124            .as_array()
125            .map_err(|e| format!("`{key}`: {e}"))?
126            .to_vec()),
127        None => Ok(Vec::new()),
128    }
129}
130
131fn validate_recipe_rel_path(field: &str, value: &str) -> Result<(), String> {
132    if value.starts_with('/') || value.starts_with('\\') {
133        return Err(format!("`{field}` must be a relative slash path"));
134    }
135    if value.len() >= 2 && value.as_bytes()[1] == b':' && value.as_bytes()[0].is_ascii_alphabetic()
136    {
137        return Err(format!("`{field}` must be a relative slash path"));
138    }
139    if value.contains('\\') {
140        return Err(format!("`{field}` must use `/` separators only"));
141    }
142    for component in value.split('/') {
143        if component.is_empty() {
144            return Err(format!("`{field}` must not contain empty path components"));
145        }
146        if component == "." {
147            return Err(format!("`{field}` must not contain `.` path components"));
148        }
149        if component == ".." {
150            return Err(format!("`{field}` must not contain `..` path components"));
151        }
152    }
153    Ok(())
154}
155
156fn resolve_recipe_rel_path(base: &Path, value: &str) -> PathBuf {
157    let mut path = base.to_path_buf();
158    for component in value.split('/') {
159        path.push(component);
160    }
161    path
162}
163
164/// Parse and validate `recipe.toml` text.
165///
166/// Unknown top-level keys and unknown `[[table]]`s are IGNORED, not rejected
167/// (COOK8.00). A recipe may carry a rich descriptor vocabulary beyond the core
168/// recipe fields -- the `30-agents` `a30-*` descriptors add `recipe_number`,
169/// `source_chapter`, `descriptor_shape`, `assert_*`, and more -- and every such
170/// recipe must still embed and aggregate into the complete cookbook catalog.
171/// Rejecting unknown keys blocked embedding those libs (the EMBED-ALL HAZARD),
172/// which the COVERAGE goal cannot tolerate. Typo protection on the CORE fields
173/// stays: a missing or empty required key (`id`/`title`/`codec`/`setup`/
174/// `purpose`) still errors, and the on-disk `cookbook-lint` gate remains the
175/// place for structural recipe linting.
176pub fn parse_recipe(text: &str) -> Result<RecipeManifest, String> {
177    let doc = toml_lite::parse(text)?;
178    parse_recipe_doc(&doc, None)
179}
180
181#[derive(Clone, Copy)]
182pub(crate) struct RecipeDefaults<'a> {
183    pub(crate) id: &'a str,
184    pub(crate) codec: &'a str,
185    pub(crate) setup: &'a str,
186}
187
188fn required_str_or(doc: &TomlDoc, key: &str, fallback: Option<&str>) -> Result<String, String> {
189    match doc.get(key) {
190        Some(_) => required_str(doc, key),
191        None => fallback
192            .filter(|value| !value.is_empty())
193            .map(str::to_string)
194            .ok_or_else(|| format!("missing required key `{key}`")),
195    }
196}
197
198pub(crate) fn parse_recipe_doc(
199    doc: &TomlDoc,
200    defaults: Option<RecipeDefaults<'_>>,
201) -> Result<RecipeManifest, String> {
202    let mut expect = Vec::new();
203    for table in doc.tables_named("expect") {
204        let form = table
205            .iter()
206            .find(|(k, _)| k == "form")
207            .ok_or("`[[expect]]` missing `form`")?
208            .1
209            .as_int()
210            .map_err(|e| format!("`[[expect]].form`: {e}"))?;
211        if form < 0 {
212            return Err("`[[expect]].form` must be >= 0".to_string());
213        }
214        let result = table
215            .iter()
216            .find(|(k, _)| k == "result")
217            .ok_or("`[[expect]]` missing `result`")?
218            .1
219            .as_str()
220            .map_err(|e| format!("`[[expect]].result`: {e}"))?
221            .to_string();
222        expect.push(Expectation {
223            form: form as usize,
224            result,
225        });
226    }
227    Ok(RecipeManifest {
228        id: required_str_or(doc, "id", defaults.map(|value| value.id))?,
229        title: required_str(doc, "title")?,
230        codec: required_str_or(doc, "codec", defaults.map(|value| value.codec))?,
231        setup: required_str_or(doc, "setup", defaults.map(|value| value.setup))?,
232        purpose: required_str(doc, "purpose")?,
233        order: optional_order(doc)?,
234        tags: optional_strings(doc, "tags")?,
235        requires: optional_strings(doc, "requires")?,
236        expect,
237    })
238}
239
240/// Parse and validate `book.toml` text.
241pub fn parse_book(text: &str) -> Result<BookManifest, String> {
242    let doc = toml_lite::parse(text)?;
243    doc.reject_unknown_top(&["book", "title", "summary", "order", "chapters"])?;
244    doc.reject_unknown_tables(&[])?;
245    Ok(BookManifest {
246        book: required_str(&doc, "book")?,
247        title: required_str(&doc, "title")?,
248        summary: doc
249            .get("summary")
250            .map(|v| v.as_str().map(str::to_string))
251            .transpose()
252            .map_err(|e| format!("`summary`: {e}"))?
253            .unwrap_or_default(),
254        order: optional_order(&doc)?,
255        chapters: optional_strings(&doc, "chapters")?,
256    })
257}
258
259/// Parse `chapter.toml` text (all fields optional).
260///
261/// Like [`parse_recipe`], unknown keys and tables are IGNORED rather than
262/// rejected (COOK8.03). Rich `30-agents`/`40-atelier` chapters across the
263/// constellation carry an extended vocabulary (e.g. `tags`) beyond the core
264/// `title`/`order`/`summary`, and every such chapter must embed and aggregate
265/// into the complete catalog -- rejecting unknown keys blocked embedding those
266/// books (the EMBED-ALL HAZARD). Structural chapter linting stays with the
267/// on-disk `cookbook-lint` gate.
268pub fn parse_chapter(text: &str) -> Result<ChapterManifest, String> {
269    let doc = toml_lite::parse(text)?;
270    let title = match doc.get("title") {
271        Some(v) => Some(v.as_str().map_err(|e| format!("`title`: {e}"))?.to_string()),
272        None => None,
273    };
274    let order = match doc.get("order") {
275        Some(v) => Some(v.as_int().map_err(|e| format!("`order`: {e}"))?),
276        None => None,
277    };
278    let summary = match doc.get("summary") {
279        Some(v) => v
280            .as_str()
281            .map_err(|e| format!("`summary`: {e}"))?
282            .to_string(),
283        None => String::new(),
284    };
285    Ok(ChapterManifest {
286        title,
287        order,
288        summary,
289    })
290}
291
292/// Lint one recipe directory on disk: parse `recipe.toml`, confirm the declared
293/// setup and purpose files exist, and that required fields are present. Returns
294/// every problem found, so an author sees all errors at once.
295pub fn lint_dir(dir: &Path) -> Result<(), Vec<Diagnostic>> {
296    lint_dir_impl(dir, false)
297}
298
299/// Lint one recipe directory and reject bare-quote setup files.
300///
301/// This stricter gate is kept separate from [`lint_dir`] while the constellation
302/// still contains older recipe setups that are being converted repo by repo.
303pub fn lint_dir_strict_no_quote(dir: &Path) -> Result<(), Vec<Diagnostic>> {
304    lint_dir_impl(dir, true)
305}
306
307fn lint_dir_impl(dir: &Path, strict_no_quote: bool) -> Result<(), Vec<Diagnostic>> {
308    let mut problems = Vec::new();
309    let recipe_path = dir.join("recipe.toml");
310    let text = match std::fs::read_to_string(&recipe_path) {
311        Ok(text) => text,
312        Err(err) => {
313            return Err(vec![Diagnostic::new(
314                recipe_path.display().to_string(),
315                format!("cannot read recipe.toml: {err}"),
316            )]);
317        }
318    };
319    let manifest = match parse_recipe(&text) {
320        Ok(manifest) => manifest,
321        Err(err) => {
322            return Err(vec![Diagnostic::new(
323                recipe_path.display().to_string(),
324                err,
325            )]);
326        }
327    };
328    let validated = match manifest.validate_for_dir() {
329        Ok(()) => true,
330        Err(errors) => {
331            for err in errors {
332                problems.push(Diagnostic::new(recipe_path.display().to_string(), err));
333            }
334            false
335        }
336    };
337    let setup_path = if validated {
338        Some(resolve_recipe_rel_path(dir, &manifest.setup))
339    } else {
340        None
341    };
342    if let Some(setup_path) = setup_path.as_ref() {
343        if !setup_path.is_file() {
344            problems.push(Diagnostic::new(
345                recipe_path.display().to_string(),
346                format!("setup file `{}` does not exist", manifest.setup),
347            ));
348        } else if strict_no_quote {
349            match std::fs::read_to_string(setup_path) {
350                Ok(setup) => {
351                    if setup_is_bare_quote(&setup) {
352                        problems.push(Diagnostic::new(
353                            setup_path.display().to_string(),
354                            "recipe setup must not be a bare quote; use an operation, codec form, or read-construct",
355                        ));
356                    }
357                }
358                Err(err) => problems.push(Diagnostic::new(
359                    setup_path.display().to_string(),
360                    format!("cannot read setup file `{}`: {err}", manifest.setup),
361                )),
362            }
363        }
364    }
365    if validated {
366        let purpose_path = resolve_recipe_rel_path(dir, &manifest.purpose);
367        if !purpose_path.is_file() {
368            problems.push(Diagnostic::new(
369                recipe_path.display().to_string(),
370                format!("purpose file `{}` does not exist", manifest.purpose),
371            ));
372        }
373    }
374    if problems.is_empty() {
375        Ok(())
376    } else {
377        Err(problems)
378    }
379}
380
381fn setup_is_bare_quote(source: &str) -> bool {
382    let trimmed = source.trim_start();
383    trimmed.starts_with("(quote") || trimmed.starts_with("( quote")
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    const VALID_RECIPE: &str = r#"
391id = "add-two-numbers"
392title = "Add two numbers"
393codec = "lisp"
394setup = "setup.siml"
395purpose = "purpose.md"
396order = 100
397tags = ["arithmetic", "intro"]
398requires = ["numbers-f64"]
399[[expect]]
400form = 0
401result = "3"
402"#;
403
404    #[test]
405    fn parses_valid_recipe() {
406        let m = parse_recipe(VALID_RECIPE).unwrap();
407        assert_eq!(m.id, "add-two-numbers");
408        assert_eq!(m.codec, "lisp");
409        assert_eq!(m.order, 100);
410        assert_eq!(m.tags, ["arithmetic", "intro"]);
411        assert_eq!(
412            m.expect,
413            [Expectation {
414                form: 0,
415                result: "3".into()
416            }]
417        );
418    }
419
420    #[test]
421    fn recipe_order_defaults() {
422        let m = parse_recipe(
423            "id = \"x\"\ntitle = \"X\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
424        )
425        .unwrap();
426        assert_eq!(m.order, DEFAULT_ORDER);
427        assert!(m.tags.is_empty());
428        assert!(m.requires.is_empty());
429    }
430
431    #[test]
432    fn missing_required_field_errors_clearly() {
433        let err = parse_recipe("title = \"X\"\ncodec = \"lisp\"\n").unwrap_err();
434        assert!(err.contains("missing required key `id`"), "{err}");
435    }
436
437    #[test]
438    fn empty_required_field_errors() {
439        let err = parse_recipe(
440            "id = \"\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n",
441        )
442        .unwrap_err();
443        assert!(err.contains("must not be empty"), "{err}");
444    }
445
446    #[test]
447    fn unknown_key_ignored_not_rejected() {
448        // COOK8.00: unknown top-level keys are ignored so rich descriptors embed.
449        let m = parse_recipe(
450            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\nbogus = 1\n",
451        )
452        .unwrap();
453        assert_eq!(m.id, "x");
454    }
455
456    #[test]
457    fn manifest_validate_for_dir_rejects_unsafe_paths() {
458        let mut manifest = parse_recipe(
459            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"../setup.siml\"\npurpose = \"/tmp/purpose.md\"\n",
460        )
461        .unwrap();
462        let err = manifest.validate_for_dir().unwrap_err();
463        assert!(
464            err.iter()
465                .any(|msg| msg.contains("`setup` must not contain `..`")),
466            "{err:?}"
467        );
468        assert!(
469            err.iter()
470                .any(|msg| msg.contains("`purpose` must be a relative slash path")),
471            "{err:?}"
472        );
473
474        manifest.setup = "nested\\setup.siml".to_string();
475        manifest.purpose = "notes//purpose.md".to_string();
476        let err = manifest.validate_for_dir().unwrap_err();
477        assert!(
478            err.iter()
479                .any(|msg| msg.contains("`setup` must use `/` separators only")),
480            "{err:?}"
481        );
482        assert!(
483            err.iter()
484                .any(|msg| msg.contains("`purpose` must not contain empty path components")),
485            "{err:?}"
486        );
487    }
488
489    #[test]
490    fn parses_rich_descriptor_keys() {
491        // An `a30-*` descriptor carries a structured vocabulary beyond the core
492        // recipe fields. It must parse (and thus embed) unchanged; the extra
493        // keys are ignored, and the core fields still resolve.
494        let rich = r#"
495id = "a30-009-agentic-workflow"
496title = "Agentic workflow"
497codec = "lisp"
498setup = "setup.siml"
499purpose = "purpose.md"
500order = 9
501tags = ["30-agents", "sandbox-descriptor"]
502requires = ["agent", "codec/lisp"]
503recipe_number = 9
504source_chapter = 7
505architecture_family = "agentic-workflow"
506runner_mode = "fake"
507safety_posture = "offline"
508capabilities = ["read-eval", "workflow-state"]
509descriptor_shape = "agentic-workflow-trace"
510assert_tags = ["30-agents", "chapter-07"]
511assert_capabilities = ["read-eval"]
512assert_setup_codec = "lisp"
513expected = "expected.txt"
514[[expect]]
515form = 0
516result = "(agentic-workflow-trace)"
517"#;
518        let m = parse_recipe(rich).unwrap();
519        assert_eq!(m.id, "a30-009-agentic-workflow");
520        assert_eq!(m.codec, "lisp");
521        assert_eq!(m.order, 9);
522        assert_eq!(m.requires, ["agent", "codec/lisp"]);
523        assert_eq!(m.expect[0].result, "(agentic-workflow-trace)");
524    }
525
526    #[test]
527    fn parses_book_and_chapter() {
528        let book = parse_book(
529            "book = \"numbers-f64\"\ntitle = \"Numbers\"\norder = 200\nchapters = [\"01-basics\"]\n",
530        )
531        .unwrap();
532        assert_eq!(book.book, "numbers-f64");
533        assert_eq!(book.order, 200);
534        assert_eq!(book.chapters, ["01-basics"]);
535
536        let chapter = parse_chapter("title = \"Basics\"\norder = 10\n").unwrap();
537        assert_eq!(chapter.title.as_deref(), Some("Basics"));
538        assert_eq!(chapter.order, Some(10));
539    }
540
541    #[test]
542    fn chapter_unknown_key_ignored_not_rejected() {
543        // COOK8.03: rich 30-agents chapters carry `tags` beyond the core chapter
544        // fields; they must embed unchanged, so unknown keys are ignored.
545        let chapter = parse_chapter(
546            "title = \"30 Agents\"\norder = 20\nsummary = \"s\"\ntags = [\"30-agents\"]\n",
547        )
548        .unwrap();
549        assert_eq!(chapter.title.as_deref(), Some("30 Agents"));
550        assert_eq!(chapter.order, Some(20));
551    }
552
553    #[test]
554    fn expect_missing_result_errors() {
555        let err = parse_recipe(
556            "id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n[[expect]]\nform = 0\n",
557        )
558        .unwrap_err();
559        assert!(err.contains("missing `result`"), "{err}");
560    }
561
562    fn temp_recipe_dir(tag: &str) -> std::path::PathBuf {
563        let dir =
564            std::env::temp_dir().join(format!("sim-cookbook-lint-{}-{}", std::process::id(), tag));
565        let _ = std::fs::remove_dir_all(&dir);
566        std::fs::create_dir_all(&dir).unwrap();
567        dir
568    }
569
570    #[test]
571    fn lint_dir_accepts_complete_recipe() {
572        let dir = temp_recipe_dir("ok");
573        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
574        std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
575        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
576        assert!(lint_dir(&dir).is_ok());
577        let _ = std::fs::remove_dir_all(&dir);
578    }
579
580    #[test]
581    fn lint_dir_reports_missing_setup_file() {
582        let dir = temp_recipe_dir("missing-setup");
583        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
584        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
585        let problems = lint_dir(&dir).unwrap_err();
586        assert!(
587            problems.iter().any(|d| d.message.contains("setup.siml")),
588            "{problems:?}"
589        );
590        let _ = std::fs::remove_dir_all(&dir);
591    }
592
593    #[test]
594    fn lint_dir_allows_manifest_id_that_differs_from_the_directory_name() {
595        let dir = temp_recipe_dir("browser-facade");
596        std::fs::write(
597            dir.join("recipe.toml"),
598            VALID_RECIPE.replace("add-two-numbers", "frame-facade"),
599        )
600        .unwrap();
601        std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
602        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
603        assert!(lint_dir(&dir).is_ok());
604        let _ = std::fs::remove_dir_all(&dir);
605    }
606
607    #[test]
608    fn lint_dir_reports_parent_path_components() {
609        let dir = temp_recipe_dir("parent-path");
610        std::fs::write(
611            dir.join("recipe.toml"),
612            VALID_RECIPE.replace("setup.siml", "../setup.siml"),
613        )
614        .unwrap();
615        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
616        let problems = lint_dir(&dir).unwrap_err();
617        assert!(
618            problems
619                .iter()
620                .any(|d| d.message.contains("`setup` must not contain `..`")),
621            "{problems:?}"
622        );
623        let _ = std::fs::remove_dir_all(&dir);
624    }
625
626    #[test]
627    fn lint_dir_reports_absolute_and_backslash_paths() {
628        let dir = temp_recipe_dir("absolute-path");
629        std::fs::write(
630            dir.join("recipe.toml"),
631            VALID_RECIPE
632                .replace("setup.siml", "nested\\\\setup.siml")
633                .replace("purpose.md", "/tmp/purpose.md"),
634        )
635        .unwrap();
636        let problems = lint_dir(&dir).unwrap_err();
637        assert!(
638            problems
639                .iter()
640                .any(|d| d.message.contains("`setup` must use `/` separators only")),
641            "{problems:?}"
642        );
643        assert!(
644            problems.iter().any(|d| d
645                .message
646                .contains("`purpose` must be a relative slash path")),
647            "{problems:?}"
648        );
649        let _ = std::fs::remove_dir_all(&dir);
650    }
651
652    fn write_recipe_with_setup(dir: &Path, setup: &str) {
653        std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
654        std::fs::write(dir.join("setup.siml"), setup).unwrap();
655        std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
656    }
657
658    #[test]
659    fn default_lint_allows_bare_quote_until_strict_gate_is_requested() {
660        let dir = temp_recipe_dir("default-quote");
661        write_recipe_with_setup(&dir, "(quote add-two-numbers)");
662        assert!(lint_dir(&dir).is_ok());
663        let _ = std::fs::remove_dir_all(&dir);
664    }
665
666    #[test]
667    fn strict_lint_reports_bare_quote_setups() {
668        for (tag, setup) in [
669            ("single-line-quote", "(quote add-two-numbers)"),
670            ("spaced-quote", "  ( quote add-two-numbers)"),
671            ("multi-line-quote", "\n(quote\n  add-two-numbers\n)"),
672        ] {
673            let dir = temp_recipe_dir(tag);
674            write_recipe_with_setup(&dir, setup);
675            let problems = lint_dir_strict_no_quote(&dir).unwrap_err();
676            assert!(
677                problems.iter().any(|d| {
678                    d.path.ends_with("setup.siml") && d.message.contains("must not be a bare quote")
679                }),
680                "{tag}: {problems:?}"
681            );
682            let _ = std::fs::remove_dir_all(&dir);
683        }
684    }
685}