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
42/// Turn `name` (a chapter directory like `01-basics`) into a human title by
43/// dropping a leading numeric-and-dash prefix and capitalizing.
44fn humanize(name: &str) -> String {
45    let core = match name.split_once('-') {
46        Some((head, tail)) if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) => tail,
47        _ => name,
48    };
49    let spaced = core.replace('-', " ");
50    let mut chars = spaced.chars();
51    match chars.next() {
52        Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
53        None => spaced,
54    }
55}
56
57/// Parse an embedded `recipes/` tree into recipe cards (unsorted; the cookbook
58/// view applies ordering). Returns an error string on the first malformed
59/// manifest, missing file, or non-UTF-8 purpose document.
60pub fn recipes_from_embedded(dir: &[(&str, &[u8])]) -> Result<Vec<RecipeCard>, String> {
61    let dir = index_embedded(dir)?;
62    let book_bytes = find(&dir, "book.toml").ok_or("missing book.toml")?;
63    let book = manifest::parse_book(bytes_to_str(book_bytes, "book.toml")?)?;
64
65    // Chapter order from the book's explicit `chapters` list: (idx+1)*100.
66    let chapter_order_of = |chapter: &str| -> i64 {
67        match book.chapters.iter().position(|c| c == chapter) {
68            Some(idx) => (idx as i64 + 1) * 100,
69            None => DEFAULT_ORDER,
70        }
71    };
72
73    // Discover recipe dirs: any `<chapter>/<recipe-id>/recipe.toml`.
74    let mut recipe_dirs: BTreeSet<(String, String)> = BTreeSet::new();
75    for path in dir.keys() {
76        let parts: Vec<&str> = path.split('/').collect();
77        if parts.len() == 3 && parts[2] == "recipe.toml" {
78            recipe_dirs.insert((parts[0].to_string(), parts[1].to_string()));
79        }
80    }
81
82    let mut cards = Vec::new();
83    let mut seen_ids = BTreeSet::new();
84    for (chapter, recipe_id) in recipe_dirs {
85        let prefix = format!("{chapter}/{recipe_id}");
86        let recipe_bytes = find(&dir, &format!("{prefix}/recipe.toml"))
87            .ok_or_else(|| format!("{prefix}: missing recipe.toml"))?;
88        let recipe = manifest::parse_recipe(bytes_to_str(recipe_bytes, &prefix)?)
89            .map_err(|e| format!("{prefix}/recipe.toml: {e}"))?;
90        recipe
91            .validate_for_dir()
92            .map_err(|errs| format!("{prefix}/recipe.toml: {}", errs.join("; ")))?;
93
94        let chapter_manifest = match find(&dir, &format!("{chapter}/chapter.toml")) {
95            Some(bytes) => manifest::parse_chapter(bytes_to_str(bytes, "chapter.toml")?)
96                .map_err(|e| format!("{chapter}/chapter.toml: {e}"))?,
97            None => manifest::ChapterManifest::default(),
98        };
99        let chapter_title = chapter_manifest
100            .title
101            .clone()
102            .unwrap_or_else(|| humanize(&chapter));
103        let chapter_order = chapter_manifest
104            .order
105            .unwrap_or_else(|| chapter_order_of(&chapter));
106
107        let setup = find(&dir, &format!("{prefix}/{}", recipe.setup))
108            .ok_or_else(|| format!("{prefix}: setup file `{}` not embedded", recipe.setup))?
109            .to_vec();
110        let purpose_bytes = find(&dir, &format!("{prefix}/{}", recipe.purpose))
111            .ok_or_else(|| format!("{prefix}: purpose file `{}` not embedded", recipe.purpose))?;
112        let purpose = bytes_to_str(purpose_bytes, &format!("{prefix} purpose"))?.to_string();
113
114        let requires = if recipe.requires.is_empty() {
115            vec![book.book.clone()]
116        } else {
117            recipe.requires.clone()
118        };
119
120        let card_id = format!("{}/{}/{}", book.book, chapter, recipe.id);
121        if !seen_ids.insert(card_id.clone()) {
122            return Err(format!(
123                "{prefix}/recipe.toml: duplicate recipe id `{}` in chapter `{chapter}`",
124                recipe.id
125            ));
126        }
127
128        cards.push(RecipeCard {
129            id: card_id,
130            book: book.book.clone(),
131            chapter: chapter.clone(),
132            chapter_title,
133            chapter_summary: chapter_manifest.summary.clone(),
134            title: recipe.title,
135            codec: recipe.codec,
136            setup,
137            purpose,
138            order: recipe.order,
139            chapter_order,
140            book_order: book.order,
141            book_title: book.title.clone(),
142            book_summary: book.summary.clone(),
143            tags: recipe.tags,
144            requires,
145            expect: recipe.expect,
146            source: RecipeSource::Crate {
147                lib: book.book.clone(),
148            },
149        });
150    }
151    Ok(cards)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn fixture() -> Vec<(&'static str, &'static [u8])> {
159        vec![
160            (
161                "book.toml",
162                b"book = \"numbers-f64\"\ntitle = \"Numbers (f64)\"\norder = 200\nchapters = [\"01-basics\", \"02-rounding\"]\n" as &[u8],
163            ),
164            (
165                "01-basics/add/recipe.toml",
166                b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\norder = 100\n[[expect]]\nform = 0\nresult = \"3\"\n",
167            ),
168            ("01-basics/add/setup.siml", b"(+ 1 2)"),
169            ("01-basics/add/purpose.md", b"Add two numbers."),
170            (
171                "02-rounding/round/recipe.toml",
172                b"id = \"round\"\ntitle = \"Round\"\ncodec = \"lisp\"\nsetup = \"s.siml\"\npurpose = \"p.md\"\n",
173            ),
174            ("02-rounding/round/s.siml", b"(round 1.5)"),
175            ("02-rounding/round/p.md", b"Round to even."),
176        ]
177    }
178
179    #[test]
180    fn parses_two_chapters() {
181        let cards = recipes_from_embedded(&fixture()).unwrap();
182        assert_eq!(cards.len(), 2);
183        let add = cards.iter().find(|c| c.id.ends_with("/add")).unwrap();
184        assert_eq!(add.id, "numbers-f64/01-basics/add");
185        assert_eq!(add.book_title, "Numbers (f64)");
186        assert_eq!(add.book_order, 200);
187        assert_eq!(add.chapter_title, "Basics"); // humanized from 01-basics
188        assert_eq!(add.chapter_order, 100); // first in book.chapters
189        assert_eq!(add.setup, b"(+ 1 2)");
190        assert_eq!(add.purpose, "Add two numbers.");
191        assert_eq!(add.requires, ["numbers-f64"]); // defaulted to owning lib
192        assert_eq!(add.expect[0].result, "3");
193
194        let round = cards.iter().find(|c| c.id.ends_with("/round")).unwrap();
195        assert_eq!(round.chapter_order, 200); // second in book.chapters
196        assert_eq!(round.order, DEFAULT_ORDER); // omitted -> default
197    }
198
199    #[test]
200    fn embedded_cards_use_manifest_ids_consistently() {
201        let dir: Vec<(&str, &[u8])> = vec![
202            ("book.toml", b"book = \"wasm\"\ntitle = \"Wasm\"\n" as &[u8]),
203            (
204                "01-basics/browser-facade/recipe.toml",
205                b"id = \"frame-facade\"\ntitle = \"Frame facade\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
206            ),
207            ("01-basics/browser-facade/setup.siml", b"(quote frame-facade)"),
208            ("01-basics/browser-facade/purpose.md", b"Frame facade."),
209        ];
210        let cards = recipes_from_embedded(&dir).unwrap();
211        assert_eq!(cards[0].id, "wasm/01-basics/frame-facade");
212    }
213
214    #[test]
215    fn missing_book_toml_errors() {
216        let err = recipes_from_embedded(&[("x/y/recipe.toml", b"")]).unwrap_err();
217        assert!(err.contains("missing book.toml"), "{err}");
218    }
219
220    #[test]
221    fn missing_setup_file_errors() {
222        let dir: Vec<(&str, &[u8])> = vec![
223            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n"),
224            (
225                "c/r/recipe.toml",
226                b"id = \"r\"\ntitle = \"R\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"p.md\"\n",
227            ),
228            ("c/r/p.md", b"x"),
229        ];
230        let err = recipes_from_embedded(&dir).unwrap_err();
231        assert!(
232            err.contains("setup file `setup.siml` not embedded"),
233            "{err}"
234        );
235    }
236
237    #[test]
238    fn missing_purpose_file_errors() {
239        let dir: Vec<(&str, &[u8])> = vec![
240            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n"),
241            (
242                "c/r/recipe.toml",
243                b"id = \"r\"\ntitle = \"R\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
244            ),
245            ("c/r/setup.siml", b"(quote r)"),
246        ];
247        let err = recipes_from_embedded(&dir).unwrap_err();
248        assert!(
249            err.contains("purpose file `purpose.md` not embedded"),
250            "{err}"
251        );
252    }
253
254    #[test]
255    fn unsafe_embedded_recipe_paths_fail_before_lookup() {
256        let dir: Vec<(&str, &[u8])> = vec![
257            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n"),
258            (
259                "c/r/recipe.toml",
260                b"id = \"r\"\ntitle = \"R\"\ncodec = \"lisp\"\nsetup = \"../setup.siml\"\npurpose = \"purpose.md\"\n",
261            ),
262            ("c/r/setup.siml", b"(quote r)"),
263            ("c/r/purpose.md", b"Purpose."),
264        ];
265        let err = recipes_from_embedded(&dir).unwrap_err();
266        assert!(err.contains("`setup` must not contain `..`"), "{err}");
267    }
268
269    #[test]
270    fn duplicate_recipe_ids_fail_even_when_dirs_differ() {
271        let dir: Vec<(&str, &[u8])> = vec![
272            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n" as &[u8]),
273            (
274                "c/one/recipe.toml",
275                b"id = \"dup\"\ntitle = \"One\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
276            ),
277            ("c/one/setup.siml", b"(quote one)"),
278            ("c/one/purpose.md", b"One."),
279            (
280                "c/two/recipe.toml",
281                b"id = \"dup\"\ntitle = \"Two\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\n",
282            ),
283            ("c/two/setup.siml", b"(quote two)"),
284            ("c/two/purpose.md", b"Two."),
285        ];
286        let err = recipes_from_embedded(&dir).unwrap_err();
287        assert!(err.contains("duplicate recipe id `dup`"), "{err}");
288    }
289
290    #[test]
291    fn humanize_strips_numeric_prefix() {
292        assert_eq!(humanize("01-basics"), "Basics");
293        assert_eq!(humanize("rounding"), "Rounding");
294        assert_eq!(humanize("10-deep-dive"), "Deep dive");
295    }
296}