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