Skip to main content

sim_cookbook/
embed.rs

1//! Turn an embedded `recipes/` tree into [`RecipeCard`]s.
2//!
3//! A crate ships its recipes as files under `recipes/`. A build step embeds
4//! that tree into the compiled lib as an [`EmbeddedDir`] -- a flat list of
5//! `(relative-path, bytes)` pairs, paths using `/` separators relative to
6//! `recipes/`. [`recipes_from_embedded`] parses that list into cards, resolving
7//! book/chapter metadata and reading each recipe's setup and purpose files.
8//!
9//! This keeps recipes traveling inside the crate they teach: nothing reads the
10//! filesystem at runtime, so the recipes install with the lib.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use crate::manifest::{self, DEFAULT_ORDER};
15use crate::model::{RecipeCard, RecipeSource};
16
17/// A crate's embedded `recipes/` tree: `(path-relative-to-recipes, bytes)`.
18/// Paths use `/` separators. Produced at build time (see
19/// [`crate::generate_embed_code`]).
20pub type EmbeddedDir = &'static [(&'static str, &'static [u8])];
21
22fn index_embedded<'a>(
23    dir: &'a [(&'a str, &'a [u8])],
24) -> Result<BTreeMap<&'a str, &'a [u8]>, String> {
25    let mut index = BTreeMap::new();
26    for (path, bytes) in dir {
27        if index.insert(*path, *bytes).is_some() {
28            return Err(format!("duplicate embedded path `{path}`"));
29        }
30    }
31    Ok(index)
32}
33
34fn find<'a>(dir: &'a BTreeMap<&'a str, &'a [u8]>, path: &str) -> Option<&'a [u8]> {
35    dir.get(path).copied()
36}
37
38fn bytes_to_str<'a>(bytes: &'a [u8], what: &str) -> Result<&'a str, String> {
39    std::str::from_utf8(bytes).map_err(|_| format!("{what} is not valid UTF-8"))
40}
41
42fn conventional_setups<'a>(dir: &'a BTreeMap<&str, &[u8]>, prefix: &str) -> Vec<&'a str> {
43    let recipe_prefix = format!("{prefix}/");
44    dir.keys()
45        .filter_map(|path| path.strip_prefix(&recipe_prefix))
46        .filter(|path| !path.contains('/') && path.starts_with("setup."))
47        .collect()
48}
49
50/// Turn `name` (a chapter directory like `01-basics`) into a human title by
51/// dropping a leading numeric-and-dash prefix and capitalizing.
52fn humanize(name: &str) -> String {
53    let core = match name.split_once('-') {
54        Some((head, tail)) if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) => tail,
55        _ => name,
56    };
57    let spaced = core.replace('-', " ");
58    let mut chars = spaced.chars();
59    match chars.next() {
60        Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
61        None => spaced,
62    }
63}
64
65/// Parse an embedded `recipes/` tree into recipe cards (unsorted; the cookbook
66/// view applies ordering). Returns an error string on the first malformed
67/// manifest, missing file, or non-UTF-8 purpose document.
68pub fn recipes_from_embedded(dir: &[(&str, &[u8])]) -> Result<Vec<RecipeCard>, String> {
69    let dir = index_embedded(dir)?;
70    let book_bytes = find(&dir, "book.toml").ok_or("missing book.toml")?;
71    let book = manifest::parse_book(bytes_to_str(book_bytes, "book.toml")?)?;
72
73    // Chapter order from the book's explicit `chapters` list: (idx+1)*100.
74    let chapter_order_of = |chapter: &str| -> i64 {
75        match book.chapters.iter().position(|c| c == chapter) {
76            Some(idx) => (idx as i64 + 1) * 100,
77            None => DEFAULT_ORDER,
78        }
79    };
80
81    // Discover recipe dirs: any `<chapter>/<recipe-id>/recipe.toml`.
82    let mut recipe_dirs: BTreeSet<(String, String)> = BTreeSet::new();
83    for path in dir.keys() {
84        let parts: Vec<&str> = path.split('/').collect();
85        if parts.len() == 3 && parts[2] == "recipe.toml" {
86            recipe_dirs.insert((parts[0].to_string(), parts[1].to_string()));
87        }
88    }
89
90    let mut cards = Vec::new();
91    let mut seen_ids = BTreeSet::new();
92    for (chapter, recipe_id) in recipe_dirs {
93        let prefix = format!("{chapter}/{recipe_id}");
94        let recipe_bytes = find(&dir, &format!("{prefix}/recipe.toml"))
95            .ok_or_else(|| format!("{prefix}: missing recipe.toml"))?;
96        let setups = conventional_setups(&dir, &prefix);
97        let recipe = crate::legacy::parse_embedded_recipe(
98            bytes_to_str(recipe_bytes, &prefix)?,
99            &recipe_id,
100            &setups,
101        )
102        .map_err(|e| format!("{prefix}/recipe.toml: {e}"))?;
103        recipe
104            .validate_for_dir()
105            .map_err(|errs| format!("{prefix}/recipe.toml: {}", errs.join("; ")))?;
106
107        let chapter_manifest = match find(&dir, &format!("{chapter}/chapter.toml")) {
108            Some(bytes) => manifest::parse_chapter(bytes_to_str(bytes, "chapter.toml")?)
109                .map_err(|e| format!("{chapter}/chapter.toml: {e}"))?,
110            None => manifest::ChapterManifest::default(),
111        };
112        let chapter_title = chapter_manifest
113            .title
114            .clone()
115            .unwrap_or_else(|| humanize(&chapter));
116        let chapter_order = chapter_manifest
117            .order
118            .unwrap_or_else(|| chapter_order_of(&chapter));
119
120        let setup = find(&dir, &format!("{prefix}/{}", recipe.setup))
121            .ok_or_else(|| format!("{prefix}: setup file `{}` not embedded", recipe.setup))?
122            .to_vec();
123        let purpose_bytes = find(&dir, &format!("{prefix}/{}", recipe.purpose))
124            .ok_or_else(|| format!("{prefix}: purpose file `{}` not embedded", recipe.purpose))?;
125        let purpose = bytes_to_str(purpose_bytes, &format!("{prefix} purpose"))?.to_string();
126
127        let requires = if recipe.requires.is_empty() {
128            vec![book.book.clone()]
129        } else {
130            recipe.requires.clone()
131        };
132
133        let card_id = format!("{}/{}/{}", book.book, chapter, recipe.id);
134        if !seen_ids.insert(card_id.clone()) {
135            return Err(format!(
136                "{prefix}/recipe.toml: duplicate recipe id `{}` in chapter `{chapter}`",
137                recipe.id
138            ));
139        }
140
141        cards.push(RecipeCard {
142            id: card_id,
143            book: book.book.clone(),
144            chapter: chapter.clone(),
145            chapter_title,
146            chapter_summary: chapter_manifest.summary.clone(),
147            title: recipe.title,
148            codec: recipe.codec,
149            setup,
150            purpose,
151            order: recipe.order,
152            chapter_order,
153            book_order: book.order,
154            book_title: book.title.clone(),
155            book_summary: book.summary.clone(),
156            tags: recipe.tags,
157            requires,
158            expect: recipe.expect,
159            source: RecipeSource::Crate {
160                lib: book.book.clone(),
161            },
162        });
163    }
164    Ok(cards)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn fixture() -> Vec<(&'static str, &'static [u8])> {
172        vec![
173            (
174                "book.toml",
175                b"book = \"numbers-f64\"\ntitle = \"Numbers (f64)\"\norder = 200\nchapters = [\"01-basics\", \"02-rounding\"]\n" as &[u8],
176            ),
177            (
178                "01-basics/add/recipe.toml",
179                b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\norder = 100\n[[expect]]\nform = 0\nresult = \"3\"\n",
180            ),
181            ("01-basics/add/setup.siml", b"(+ 1 2)"),
182            ("01-basics/add/setup.rs", b"// auxiliary setup source"),
183            ("01-basics/add/purpose.md", b"Add two numbers."),
184            (
185                "02-rounding/round/recipe.toml",
186                b"id = \"round\"\ntitle = \"Round\"\ncodec = \"lisp\"\nsetup = \"s.siml\"\npurpose = \"p.md\"\n",
187            ),
188            ("02-rounding/round/s.siml", b"(round 1.5)"),
189            ("02-rounding/round/p.md", b"Round to even."),
190        ]
191    }
192
193    #[test]
194    fn parses_two_chapters() {
195        let cards = recipes_from_embedded(&fixture()).unwrap();
196        assert_eq!(cards.len(), 2);
197        let add = cards.iter().find(|c| c.id.ends_with("/add")).unwrap();
198        assert_eq!(add.id, "numbers-f64/01-basics/add");
199        assert_eq!(add.book_title, "Numbers (f64)");
200        assert_eq!(add.book_order, 200);
201        assert_eq!(add.chapter_title, "Basics"); // humanized from 01-basics
202        assert_eq!(add.chapter_order, 100); // first in book.chapters
203        assert_eq!(add.setup, b"(+ 1 2)");
204        assert_eq!(add.purpose, "Add two numbers.");
205        assert_eq!(add.requires, ["numbers-f64"]); // defaulted to owning lib
206        assert_eq!(add.expect[0].result, "3");
207
208        let round = cards.iter().find(|c| c.id.ends_with("/round")).unwrap();
209        assert_eq!(round.chapter_order, 200); // second in book.chapters
210        assert_eq!(round.order, DEFAULT_ORDER); // omitted -> default
211    }
212
213    #[test]
214    fn loads_the_legacy_schema_shipped_by_published_cookbooks() {
215        let dir: Vec<(&str, &[u8])> = vec![
216            (
217                "book.toml",
218                b"book = \"pitch-serial\"\ntitle = \"Pitch Serial\"\nchapters = [\n  \"01-basics\",\n  \"02-partitions\",\n]\n" as &[u8],
219            ),
220            (
221                "02-partitions/partition-mosaic/recipe.toml",
222                b"title = \"Analyze partitions\"\ncategory = \"Rust\"\ntags = [\"pitch\", \"serial\"]\nrequires = [\"pitch-serial\", \"pitch-core\"]\npurpose = \"purpose.md\"\n",
223            ),
224            (
225                "02-partitions/partition-mosaic/setup.rs",
226                b"pub fn partition_mosaic() {}",
227            ),
228            (
229                "02-partitions/partition-mosaic/purpose.md",
230                b"Analyze a row partition.",
231            ),
232        ];
233        let cards = recipes_from_embedded(&dir).unwrap();
234        assert_eq!(cards.len(), 1);
235        assert_eq!(cards[0].id, "pitch-serial/02-partitions/partition-mosaic");
236        assert_eq!(cards[0].codec, "rust");
237        assert_eq!(cards[0].setup, b"pub fn partition_mosaic() {}");
238        assert_eq!(cards[0].chapter_order, 200);
239    }
240
241    #[test]
242    fn legacy_defaults_do_not_weaken_authoring_validation() {
243        let legacy = "title = \"Legacy\"\ncategory = \"Rust\"\npurpose = \"purpose.md\"\n";
244        let error = manifest::parse_recipe(legacy).unwrap_err();
245        assert!(error.contains("missing required key `id`"), "{error}");
246    }
247
248    #[test]
249    fn legacy_manifest_rejects_ambiguous_conventional_setups() {
250        let dir: Vec<(&str, &[u8])> = vec![
251            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n" as &[u8]),
252            (
253                "c/r/recipe.toml",
254                b"title = \"Legacy\"\ncategory = \"Rust\"\npurpose = \"purpose.md\"\n",
255            ),
256            ("c/r/setup.rs", b"fn main() {}"),
257            ("c/r/setup.siml", b"(quote legacy)"),
258            ("c/r/purpose.md", b"Legacy."),
259        ];
260        let error = recipes_from_embedded(&dir).unwrap_err();
261        assert!(
262            error.contains("ambiguous conventional setup files"),
263            "{error}"
264        );
265    }
266
267    #[test]
268    fn embedded_cards_use_manifest_ids_consistently() {
269        let dir: Vec<(&str, &[u8])> = vec![
270            ("book.toml", b"book = \"wasm\"\ntitle = \"Wasm\"\n" as &[u8]),
271            (
272                "01-basics/browser-facade/recipe.toml",
273                b"id = \"frame-facade\"\ntitle = \"Frame facade\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
274            ),
275            ("01-basics/browser-facade/setup.siml", b"(quote frame-facade)"),
276            ("01-basics/browser-facade/purpose.md", b"Frame facade."),
277        ];
278        let cards = recipes_from_embedded(&dir).unwrap();
279        assert_eq!(cards[0].id, "wasm/01-basics/frame-facade");
280    }
281
282    #[test]
283    fn missing_book_toml_errors() {
284        let err = recipes_from_embedded(&[("x/y/recipe.toml", b"")]).unwrap_err();
285        assert!(err.contains("missing book.toml"), "{err}");
286    }
287
288    #[test]
289    fn missing_setup_file_errors() {
290        let dir: Vec<(&str, &[u8])> = vec![
291            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n"),
292            (
293                "c/r/recipe.toml",
294                b"id = \"r\"\ntitle = \"R\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"p.md\"\n",
295            ),
296            ("c/r/p.md", b"x"),
297        ];
298        let err = recipes_from_embedded(&dir).unwrap_err();
299        assert!(
300            err.contains("setup file `setup.siml` not embedded"),
301            "{err}"
302        );
303    }
304
305    #[test]
306    fn missing_purpose_file_errors() {
307        let dir: Vec<(&str, &[u8])> = vec![
308            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n"),
309            (
310                "c/r/recipe.toml",
311                b"id = \"r\"\ntitle = \"R\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
312            ),
313            ("c/r/setup.siml", b"(quote r)"),
314        ];
315        let err = recipes_from_embedded(&dir).unwrap_err();
316        assert!(
317            err.contains("purpose file `purpose.md` not embedded"),
318            "{err}"
319        );
320    }
321
322    #[test]
323    fn unsafe_embedded_recipe_paths_fail_before_lookup() {
324        let dir: Vec<(&str, &[u8])> = vec![
325            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n"),
326            (
327                "c/r/recipe.toml",
328                b"id = \"r\"\ntitle = \"R\"\ncodec = \"lisp\"\nsetup = \"../setup.siml\"\npurpose = \"purpose.md\"\n",
329            ),
330            ("c/r/setup.siml", b"(quote r)"),
331            ("c/r/purpose.md", b"Purpose."),
332        ];
333        let err = recipes_from_embedded(&dir).unwrap_err();
334        assert!(err.contains("`setup` must not contain `..`"), "{err}");
335    }
336
337    #[test]
338    fn duplicate_recipe_ids_fail_even_when_dirs_differ() {
339        let dir: Vec<(&str, &[u8])> = vec![
340            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n" as &[u8]),
341            (
342                "c/one/recipe.toml",
343                b"id = \"dup\"\ntitle = \"One\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
344            ),
345            ("c/one/setup.siml", b"(quote one)"),
346            ("c/one/purpose.md", b"One."),
347            (
348                "c/two/recipe.toml",
349                b"id = \"dup\"\ntitle = \"Two\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
350            ),
351            ("c/two/setup.siml", b"(quote two)"),
352            ("c/two/purpose.md", b"Two."),
353        ];
354        let err = recipes_from_embedded(&dir).unwrap_err();
355        assert!(err.contains("duplicate recipe id `dup`"), "{err}");
356    }
357
358    #[test]
359    fn humanize_strips_numeric_prefix() {
360        assert_eq!(humanize("01-basics"), "Basics");
361        assert_eq!(humanize("rounding"), "Rounding");
362        assert_eq!(humanize("10-deep-dive"), "Deep dive");
363    }
364}