1use std::collections::{BTreeMap, BTreeSet};
13
14use crate::manifest::{self, DEFAULT_ORDER};
15use crate::model::{RecipeCard, RecipeSource};
16
17pub 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 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
57pub 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 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 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"); assert_eq!(add.chapter_order, 100); assert_eq!(add.setup, b"(+ 1 2)");
190 assert_eq!(add.purpose, "Add two numbers.");
191 assert_eq!(add.requires, ["numbers-f64"]); 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); assert_eq!(round.order, DEFAULT_ORDER); }
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}