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 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
50fn 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
65pub 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 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 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"); assert_eq!(add.chapter_order, 100); assert_eq!(add.setup, b"(+ 1 2)");
204 assert_eq!(add.purpose, "Add two numbers.");
205 assert_eq!(add.requires, ["numbers-f64"]); 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); assert_eq!(round.order, DEFAULT_ORDER); }
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}