1use crate::model::{
9 BookView, ChapterView, CookbookView, FamilyView, GroupedView, LibView, RecipeCard,
10};
11use crate::store::RecipeStore;
12
13pub fn family_of(book: &str) -> &str {
20 match book.split_once('/') {
21 Some((family, _)) => family,
22 None => match book.split_once('-') {
23 Some((family, _)) => family,
24 None => book,
25 },
26 }
27}
28
29pub fn ordered_cards(store: &RecipeStore) -> Vec<&RecipeCard> {
33 let mut cards: Vec<&RecipeCard> = store.cards().iter().collect();
34 cards.sort_by(|a, b| {
35 a.book_order
36 .cmp(&b.book_order)
37 .then_with(|| a.book.cmp(&b.book))
38 .then_with(|| a.chapter_order.cmp(&b.chapter_order))
39 .then_with(|| a.chapter.cmp(&b.chapter))
40 .then_with(|| a.order.cmp(&b.order))
41 .then_with(|| a.id.cmp(&b.id))
42 });
43 cards
44}
45
46pub fn view(store: &RecipeStore) -> CookbookView {
48 let mut books: Vec<BookView> = Vec::new();
49 for card in ordered_cards(store) {
50 let bi = match books.iter().position(|b| b.id == card.book) {
51 Some(i) => i,
52 None => {
53 books.push(BookView {
54 id: card.book.clone(),
55 title: card.book_title.clone(),
56 summary: card.book_summary.clone(),
57 chapters: Vec::new(),
58 });
59 books.len() - 1
60 }
61 };
62 let chapters = &mut books[bi].chapters;
63 let ci = match chapters.iter().position(|c| c.name == card.chapter) {
64 Some(i) => i,
65 None => {
66 chapters.push(ChapterView {
67 name: card.chapter.clone(),
68 title: card.chapter_title.clone(),
69 summary: card.chapter_summary.clone(),
70 recipes: Vec::new(),
71 });
72 chapters.len() - 1
73 }
74 };
75 chapters[ci].recipes.push(card.clone());
76 }
77 CookbookView { books }
78}
79
80pub fn lib_view(store: &RecipeStore) -> Vec<LibView> {
85 let mut libs: Vec<LibView> = Vec::new();
86 for card in ordered_cards(store) {
87 let target = lib_target(card);
88 let index = match libs.iter().position(|lib| lib.id == target.id) {
89 Some(index) => index,
90 None => {
91 libs.push(LibView {
92 id: target.id.clone(),
93 title: card.book_title.clone(),
94 loaded: target.loaded,
95 groups: Vec::new(),
96 recipes: Vec::new(),
97 });
98 libs.len() - 1
99 }
100 };
101 let lib = &mut libs[index];
102 lib.loaded |= target.loaded;
103 if target.loaded {
104 push_grouped_recipe(lib, card);
105 } else {
106 lib.recipes.push(card.clone());
107 }
108 }
109 libs
110}
111
112struct LibTarget {
113 id: String,
114 loaded: bool,
115}
116
117fn lib_target(card: &RecipeCard) -> LibTarget {
118 let action = tag_value(card, "cookbook-action:");
119 let lib = tag_value(card, "cookbook-lib:");
120 match (action, lib) {
121 (Some("load"), Some(id)) => LibTarget {
122 id: id.to_owned(),
123 loaded: false,
124 },
125 (Some("unload"), Some(id)) => LibTarget {
126 id: id.to_owned(),
127 loaded: true,
128 },
129 _ => LibTarget {
130 id: card.book.clone(),
131 loaded: true,
132 },
133 }
134}
135
136fn tag_value<'a>(card: &'a RecipeCard, prefix: &str) -> Option<&'a str> {
137 card.tags.iter().find_map(|tag| tag.strip_prefix(prefix))
138}
139
140fn push_grouped_recipe(lib: &mut LibView, card: &RecipeCard) {
141 let index = match lib
142 .groups
143 .iter()
144 .position(|group| group.name == card.chapter)
145 {
146 Some(index) => index,
147 None => {
148 lib.groups.push(ChapterView {
149 name: card.chapter.clone(),
150 title: card.chapter_title.clone(),
151 summary: card.chapter_summary.clone(),
152 recipes: Vec::new(),
153 });
154 lib.groups.len() - 1
155 }
156 };
157 lib.groups[index].recipes.push(card.clone());
158}
159
160pub fn grouped_view(store: &RecipeStore) -> GroupedView {
165 let mut families: Vec<FamilyView> = Vec::new();
166 for book in view(store).books {
167 let family = family_of(&book.id).to_string();
168 match families.iter_mut().find(|f| f.family == family) {
169 Some(f) => f.books.push(book),
170 None => families.push(FamilyView {
171 family,
172 books: vec![book],
173 }),
174 }
175 }
176 GroupedView { families }
179}
180
181pub fn search<'a>(store: &'a RecipeStore, query: &str) -> Vec<&'a RecipeCard> {
185 let q = query.trim().to_ascii_lowercase();
186 if q.is_empty() {
187 return Vec::new();
188 }
189 let mut scored: Vec<(i32, &RecipeCard)> = Vec::new();
190 for card in ordered_cards(store) {
191 let mut score = 0;
192 if card.title.to_ascii_lowercase().contains(&q) {
193 score += 3;
194 }
195 if card
196 .tags
197 .iter()
198 .any(|t| t.to_ascii_lowercase().contains(&q))
199 {
200 score += 2;
201 }
202 if card.purpose.to_ascii_lowercase().contains(&q) {
203 score += 1;
204 }
205 if score > 0 {
206 scored.push((score, card));
207 }
208 }
209 scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
212 scored.into_iter().map(|(_, card)| card).collect()
213}
214
215pub fn next<'a>(store: &'a RecipeStore, id: &str) -> Option<&'a RecipeCard> {
218 let ordered = ordered_cards(store);
219 let pos = ordered.iter().position(|c| c.id == id)?;
220 ordered.get(pos + 1).copied()
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn store() -> RecipeStore {
229 let beta: Vec<(&str, &[u8])> = vec![
230 (
231 "book.toml",
232 b"book = \"beta\"\ntitle = \"Beta\"\nsummary = \"Second book.\"\norder = 300\n" as &[u8],
233 ),
234 (
235 "01-intro/hello/recipe.toml",
236 b"id = \"hello\"\ntitle = \"Hello\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"purpose\"\ntags = [\"intro\"]\n",
237 ),
238 ("01-intro/hello/s", b"(quote hi)"),
239 ("01-intro/hello/purpose", b"a greeting recipe"),
240 ];
241 let alpha: Vec<(&str, &[u8])> = vec![
242 (
243 "book.toml",
244 b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\nchapters = [\"01-basics\"]\n",
245 ),
246 (
247 "01-basics/add/recipe.toml",
248 b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 100\ntags = [\"arithmetic\"]\n",
249 ),
250 ("01-basics/add/s", b"(+ 1 2)"),
251 ("01-basics/add/p", b"add numbers"),
252 (
253 "01-basics/sub/recipe.toml",
254 b"id = \"sub\"\ntitle = \"Subtract\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 200\n",
255 ),
256 ("01-basics/sub/s", b"(- 3 1)"),
257 ("01-basics/sub/p", b"subtract numbers"),
258 ];
259 let mut store = RecipeStore::new();
260 store.register_book(&beta).unwrap();
261 store.register_book(&alpha).unwrap();
262 store
263 }
264
265 #[test]
266 fn view_orders_books_chapters_recipes() {
267 let view = view(&store());
268 assert_eq!(view.books.len(), 2);
270 assert_eq!(view.books[0].id, "alpha");
271 assert_eq!(view.books[1].id, "beta");
272 assert_eq!(view.books[1].summary, "Second book.");
273 let basics = &view.books[0].chapters[0];
274 assert_eq!(basics.name, "01-basics");
275 assert_eq!(basics.recipes[0].id, "alpha/01-basics/add");
277 assert_eq!(basics.recipes[1].id, "alpha/01-basics/sub");
278 }
279
280 #[test]
281 fn next_walks_global_order() {
282 let store = store();
283 assert_eq!(
284 next(&store, "alpha/01-basics/add").unwrap().id,
285 "alpha/01-basics/sub"
286 );
287 assert_eq!(
288 next(&store, "alpha/01-basics/sub").unwrap().id,
289 "beta/01-intro/hello"
290 );
291 assert!(next(&store, "beta/01-intro/hello").is_none()); assert!(next(&store, "nope").is_none());
293 }
294
295 #[test]
296 fn search_ranks_title_over_purpose() {
297 let store = store();
298 let hits = search(&store, "add");
300 assert_eq!(hits[0].id, "alpha/01-basics/add");
301 let hits = search(&store, "numbers");
303 assert_eq!(hits.len(), 2);
304 assert!(search(&store, " ").is_empty());
306 }
307
308 #[test]
309 fn family_of_derives_from_prefix() {
310 assert_eq!(family_of("numbers/cas"), "numbers");
311 assert_eq!(family_of("organ/binding"), "organ");
312 assert_eq!(family_of("codec/lisp"), "codec");
313 assert_eq!(family_of("audio-dsp"), "audio");
314 assert_eq!(family_of("stream-audio"), "stream");
315 assert_eq!(family_of("agent-runner-core"), "agent");
316 assert_eq!(family_of("agent"), "agent");
317 assert_eq!(family_of("core"), "core");
318 }
319
320 fn lifecycle_card(
321 id: &str,
322 lib: &str,
323 title: &str,
324 action: &str,
325 book_order: i64,
326 ) -> RecipeCard {
327 RecipeCard {
328 id: id.to_string(),
329 book: if action == "load" {
330 "cookbook/loadable".to_string()
331 } else {
332 lib.to_string()
333 },
334 chapter: "cookbook-lifecycle".to_string(),
335 chapter_title: "Lifecycle".to_string(),
336 chapter_summary: String::new(),
337 title: title.to_string(),
338 codec: "lisp".to_string(),
339 setup: b"(quote ok)".to_vec(),
340 purpose: title.to_string(),
341 order: if action == "load" { 0 } else { i64::MAX },
342 chapter_order: if action == "load" { 0 } else { i64::MAX },
343 book_order,
344 book_title: title.to_string(),
345 book_summary: String::new(),
346 tags: vec![
347 format!("cookbook-action:{action}"),
348 format!("cookbook-lib:{lib}"),
349 ],
350 requires: Vec::new(),
351 expect: Vec::new(),
352 source: crate::RecipeSource::Crate {
353 lib: "sim/cookbook".to_string(),
354 },
355 }
356 }
357
358 #[test]
359 fn lib_view_uses_top_level_lib_entries_for_loaded_and_unloaded_libs() {
360 let mut store = RecipeStore::new();
361 store
362 .insert_card(lifecycle_card(
363 "cookbook/load/numbers/i64",
364 "numbers/i64",
365 "Numbers (i64)",
366 "load",
367 50,
368 ))
369 .unwrap();
370 for card in ordered_cards(&family_store())
371 .into_iter()
372 .filter(|card| card.book == "codec/lisp")
373 {
374 store.insert_card(card.clone()).unwrap();
375 }
376 store
377 .insert_card(lifecycle_card(
378 "codec/lisp/cookbook-lifecycle/unload",
379 "codec/lisp",
380 "Lisp",
381 "unload",
382 200,
383 ))
384 .unwrap();
385
386 let libs = lib_view(&store);
387
388 assert_eq!(libs.len(), 2);
389 assert_eq!(libs[0].id, "numbers/i64");
390 assert!(!libs[0].loaded);
391 assert!(libs[0].groups.is_empty());
392 assert_eq!(libs[0].recipes.len(), 1);
393 assert_eq!(libs[0].recipes[0].id, "cookbook/load/numbers/i64");
394 assert_eq!(libs[1].id, "codec/lisp");
395 assert!(libs[1].loaded);
396 assert!(libs[1].recipes.is_empty());
397 assert_eq!(
398 libs[1].groups[0].recipes[0].id,
399 "codec/lisp/01-basics/quote"
400 );
401 assert_eq!(
402 libs[1].groups.last().unwrap().recipes[0].id,
403 "codec/lisp/cookbook-lifecycle/unload"
404 );
405 }
406
407 fn family_store() -> RecipeStore {
409 let cas: Vec<(&str, &[u8])> = vec![
410 (
411 "book.toml",
412 b"book = \"numbers/cas\"\ntitle = \"CAS\"\norder = 210\n" as &[u8],
413 ),
414 (
415 "01-basics/simplify/recipe.toml",
416 b"id = \"simplify\"\ntitle = \"Simplify\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
417 ),
418 ("01-basics/simplify/s", b"(quote x)"),
419 ("01-basics/simplify/p", b"simplify"),
420 ];
421 let f64: Vec<(&str, &[u8])> = vec![
422 (
423 "book.toml",
424 b"book = \"numbers/f64\"\ntitle = \"F64\"\norder = 200\n" as &[u8],
425 ),
426 (
427 "01-basics/add/recipe.toml",
428 b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
429 ),
430 ("01-basics/add/s", b"(+ 1 2)"),
431 ("01-basics/add/p", b"add"),
432 ];
433 let lisp: Vec<(&str, &[u8])> = vec![
434 (
435 "book.toml",
436 b"book = \"codec/lisp\"\ntitle = \"Lisp\"\norder = 100\n" as &[u8],
437 ),
438 (
439 "01-basics/quote/recipe.toml",
440 b"id = \"quote\"\ntitle = \"Quote\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
441 ),
442 ("01-basics/quote/s", b"(quote a)"),
443 ("01-basics/quote/p", b"quote"),
444 ];
445 let mut store = RecipeStore::new();
446 store.register_book(&cas).unwrap();
447 store.register_book(&f64).unwrap();
448 store.register_book(&lisp).unwrap();
449 store
450 }
451
452 #[test]
453 fn grouped_view_nests_family_domain_book() {
454 let grouped = grouped_view(&family_store());
455 assert_eq!(grouped.families.len(), 2);
457 assert_eq!(grouped.families[0].family, "codec");
458 assert_eq!(grouped.families[0].books.len(), 1);
459 assert_eq!(grouped.families[0].books[0].id, "codec/lisp");
460 let numbers = &grouped.families[1];
462 assert_eq!(numbers.family, "numbers");
463 let ids: Vec<&str> = numbers.books.iter().map(|b| b.id.as_str()).collect();
464 assert_eq!(ids, ["numbers/f64", "numbers/cas"]);
465 assert_eq!(
467 numbers.books[0].chapters[0].recipes[0].id,
468 "numbers/f64/01-basics/add"
469 );
470 }
471
472 #[test]
473 fn search_tag_match_beats_purpose_only() {
474 let store = store();
475 let hits = search(&store, "intro");
477 assert_eq!(hits.len(), 1);
478 assert_eq!(hits[0].id, "beta/01-intro/hello");
479 }
480}