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::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 find<'a>(dir: &'a [(&str, &'a [u8])], path: &str) -> Option<&'a [u8]> {
23    dir.iter().find(|(p, _)| *p == path).map(|(_, b)| *b)
24}
25
26fn bytes_to_str<'a>(bytes: &'a [u8], what: &str) -> Result<&'a str, String> {
27    std::str::from_utf8(bytes).map_err(|_| format!("{what} is not valid UTF-8"))
28}
29
30/// Turn `name` (a chapter directory like `01-basics`) into a human title by
31/// dropping a leading numeric-and-dash prefix and capitalizing.
32fn humanize(name: &str) -> String {
33    let core = match name.split_once('-') {
34        Some((head, tail)) if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) => tail,
35        _ => name,
36    };
37    let spaced = core.replace('-', " ");
38    let mut chars = spaced.chars();
39    match chars.next() {
40        Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
41        None => spaced,
42    }
43}
44
45/// Parse an embedded `recipes/` tree into recipe cards (unsorted; the cookbook
46/// view applies ordering). Returns an error string on the first malformed
47/// manifest, missing file, or non-UTF-8 purpose document.
48pub fn recipes_from_embedded(dir: &[(&str, &[u8])]) -> Result<Vec<RecipeCard>, String> {
49    let book_bytes = find(dir, "book.toml").ok_or("missing book.toml")?;
50    let book = manifest::parse_book(bytes_to_str(book_bytes, "book.toml")?)?;
51
52    // Chapter order from the book's explicit `chapters` list: (idx+1)*100.
53    let chapter_order_of = |chapter: &str| -> i64 {
54        match book.chapters.iter().position(|c| c == chapter) {
55            Some(idx) => (idx as i64 + 1) * 100,
56            None => DEFAULT_ORDER,
57        }
58    };
59
60    // Discover recipe dirs: any `<chapter>/<recipe-id>/recipe.toml`.
61    let mut recipe_dirs: BTreeSet<(String, String)> = BTreeSet::new();
62    for (path, _) in dir {
63        let parts: Vec<&str> = path.split('/').collect();
64        if parts.len() == 3 && parts[2] == "recipe.toml" {
65            recipe_dirs.insert((parts[0].to_string(), parts[1].to_string()));
66        }
67    }
68
69    let mut cards = Vec::new();
70    for (chapter, recipe_id) in recipe_dirs {
71        let prefix = format!("{chapter}/{recipe_id}");
72        let recipe_bytes = find(dir, &format!("{prefix}/recipe.toml"))
73            .ok_or_else(|| format!("{prefix}: missing recipe.toml"))?;
74        let recipe = manifest::parse_recipe(bytes_to_str(recipe_bytes, &prefix)?)
75            .map_err(|e| format!("{prefix}/recipe.toml: {e}"))?;
76
77        let chapter_manifest = match find(dir, &format!("{chapter}/chapter.toml")) {
78            Some(bytes) => manifest::parse_chapter(bytes_to_str(bytes, "chapter.toml")?)
79                .map_err(|e| format!("{chapter}/chapter.toml: {e}"))?,
80            None => manifest::ChapterManifest::default(),
81        };
82        let chapter_title = chapter_manifest
83            .title
84            .clone()
85            .unwrap_or_else(|| humanize(&chapter));
86        let chapter_order = chapter_manifest
87            .order
88            .unwrap_or_else(|| chapter_order_of(&chapter));
89
90        let setup = find(dir, &format!("{prefix}/{}", recipe.setup))
91            .ok_or_else(|| format!("{prefix}: setup file `{}` not embedded", recipe.setup))?
92            .to_vec();
93        let purpose_bytes = find(dir, &format!("{prefix}/{}", recipe.purpose))
94            .ok_or_else(|| format!("{prefix}: purpose file `{}` not embedded", recipe.purpose))?;
95        let purpose = bytes_to_str(purpose_bytes, &format!("{prefix} purpose"))?.to_string();
96
97        let requires = if recipe.requires.is_empty() {
98            vec![book.book.clone()]
99        } else {
100            recipe.requires.clone()
101        };
102
103        cards.push(RecipeCard {
104            id: format!("{}/{}/{}", book.book, chapter, recipe_id),
105            book: book.book.clone(),
106            chapter: chapter.clone(),
107            chapter_title,
108            chapter_summary: chapter_manifest.summary.clone(),
109            title: recipe.title,
110            codec: recipe.codec,
111            setup,
112            purpose,
113            order: recipe.order,
114            chapter_order,
115            book_order: book.order,
116            book_title: book.title.clone(),
117            book_summary: book.summary.clone(),
118            tags: recipe.tags,
119            requires,
120            expect: recipe.expect,
121            source: RecipeSource::Crate {
122                lib: book.book.clone(),
123            },
124        });
125    }
126    Ok(cards)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn fixture() -> Vec<(&'static str, &'static [u8])> {
134        vec![
135            (
136                "book.toml",
137                b"book = \"numbers-f64\"\ntitle = \"Numbers (f64)\"\norder = 200\nchapters = [\"01-basics\", \"02-rounding\"]\n" as &[u8],
138            ),
139            (
140                "01-basics/add/recipe.toml",
141                b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"purpose.md\"\norder = 100\n[[expect]]\nform = 0\nresult = \"3\"\n",
142            ),
143            ("01-basics/add/setup.siml", b"(+ 1 2)"),
144            ("01-basics/add/purpose.md", b"Add two numbers."),
145            (
146                "02-rounding/round/recipe.toml",
147                b"id = \"round\"\ntitle = \"Round\"\ncodec = \"lisp\"\nsetup = \"s.siml\"\npurpose = \"p.md\"\n",
148            ),
149            ("02-rounding/round/s.siml", b"(round 1.5)"),
150            ("02-rounding/round/p.md", b"Round to even."),
151        ]
152    }
153
154    #[test]
155    fn parses_two_chapters() {
156        let cards = recipes_from_embedded(&fixture()).unwrap();
157        assert_eq!(cards.len(), 2);
158        let add = cards.iter().find(|c| c.id.ends_with("/add")).unwrap();
159        assert_eq!(add.id, "numbers-f64/01-basics/add");
160        assert_eq!(add.book_title, "Numbers (f64)");
161        assert_eq!(add.book_order, 200);
162        assert_eq!(add.chapter_title, "Basics"); // humanized from 01-basics
163        assert_eq!(add.chapter_order, 100); // first in book.chapters
164        assert_eq!(add.setup, b"(+ 1 2)");
165        assert_eq!(add.purpose, "Add two numbers.");
166        assert_eq!(add.requires, ["numbers-f64"]); // defaulted to owning lib
167        assert_eq!(add.expect[0].result, "3");
168
169        let round = cards.iter().find(|c| c.id.ends_with("/round")).unwrap();
170        assert_eq!(round.chapter_order, 200); // second in book.chapters
171        assert_eq!(round.order, DEFAULT_ORDER); // omitted -> default
172    }
173
174    #[test]
175    fn missing_book_toml_errors() {
176        let err = recipes_from_embedded(&[("x/y/recipe.toml", b"")]).unwrap_err();
177        assert!(err.contains("missing book.toml"), "{err}");
178    }
179
180    #[test]
181    fn missing_setup_file_errors() {
182        let dir: Vec<(&str, &[u8])> = vec![
183            ("book.toml", b"book = \"b\"\ntitle = \"B\"\n"),
184            (
185                "c/r/recipe.toml",
186                b"id = \"r\"\ntitle = \"R\"\ncodec = \"lisp\"\nsetup = \"setup.siml\"\npurpose = \"p.md\"\n",
187            ),
188            ("c/r/p.md", b"x"),
189        ];
190        let err = recipes_from_embedded(&dir).unwrap_err();
191        assert!(
192            err.contains("setup file `setup.siml` not embedded"),
193            "{err}"
194        );
195    }
196
197    #[test]
198    fn humanize_strips_numeric_prefix() {
199        assert_eq!(humanize("01-basics"), "Basics");
200        assert_eq!(humanize("rounding"), "Rounding");
201        assert_eq!(humanize("10-deep-dive"), "Deep dive");
202    }
203}