Skip to main content

sim_cookbook/
project.rs

1//! Project a [`RecipeStore`] into the cookbook view, plus search and
2//! next-recipe navigation.
3//!
4//! The view is computed, never stored: group every loaded recipe by book then
5//! chapter and sort deterministically. Loading another lib adds its book; the
6//! view always reflects exactly the recipes currently loaded.
7
8use crate::model::{
9    BookView, ChapterView, CookbookView, FamilyView, GroupedView, LibView, RecipeCard,
10};
11use crate::store::RecipeStore;
12
13/// The level-1 family id for a book, derived from its id prefix with NO extra
14/// metadata: the segment before the first `/` (`numbers/cas` -> `numbers`,
15/// `organ/binding` -> `organ`, `codec/lisp` -> `codec`), else before the first
16/// `-` (`audio-dsp` -> `audio`, `stream-audio` -> `stream`), else the whole id
17/// (`agent` -> `agent`, `core` -> `core`). This is how the constellation groups
18/// by subsystem without a hand-maintained family list.
19pub 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
29/// All cards in deterministic global order: by book order then id, chapter
30/// order then name, recipe order then id. Two recipes never compare equal
31/// because ids are unique, so the order is total and stable.
32pub 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
46/// Group the store's cards into the nested [`CookbookView`].
47pub 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
80/// Group the projected store into one top-level entry per known loadable lib.
81///
82/// Loaded libs expose their recipe chapters as `groups`; unloaded libs expose
83/// the single lifecycle load recipe that projected the lib into the store.
84pub 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
160/// Group the store's cards into the two-level [`GroupedView`]: family -> domain
161/// book -> chapter -> recipe. Books keep the deterministic [`view`] order, and a
162/// family appears where its lowest-ordered book falls, so the whole catalog
163/// presents by subsystem stably.
164pub 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    // `view` already ordered books by book_order; the first book in each family
177    // is therefore its lowest-ordered book, giving a stable family order.
178    GroupedView { families }
179}
180
181/// Rank recipes matching `query` (case-insensitive). A title match scores
182/// highest, then a tag match, then a purpose match; scores add. Recipes that
183/// match nothing are dropped. Ties keep deterministic global order.
184pub 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    // Stable sort by descending score; `ordered_cards` already gave a
210    // deterministic secondary order, preserved for equal scores.
211    scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
212    scored.into_iter().map(|(_, card)| card).collect()
213}
214
215/// The recipe immediately after `id` in global order, for "continue" buttons.
216/// `None` if `id` is unknown or is the last recipe.
217pub 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    // Two books, deliberately registered out of final order to prove sorting.
228    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        // alpha (order 100) before beta (order 300) despite registration order.
269        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        // add (order 100) before sub (order 200).
276        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()); // last
292        assert!(next(&store, "nope").is_none());
293    }
294
295    #[test]
296    fn search_ranks_title_over_purpose() {
297        let store = store();
298        // "add" matches the title of add (3) and nothing else strongly.
299        let hits = search(&store, "add");
300        assert_eq!(hits[0].id, "alpha/01-basics/add");
301        // "numbers" only appears in purposes -> score 1, both basics recipes.
302        let hits = search(&store, "numbers");
303        assert_eq!(hits.len(), 2);
304        // empty query returns nothing.
305        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    // Two books under the same family plus one under another, to prove grouping.
408    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        // codec (book_order 100) sorts before numbers (lowest book_order 200).
456        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        // numbers holds both f64 (200) and cas (210), f64 first by book_order.
461        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        // Leaves reach the recipes intact.
466        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        // "intro" is a tag on beta/hello (score 2) and not in alpha recipes.
476        let hits = search(&store, "intro");
477        assert_eq!(hits.len(), 1);
478        assert_eq!(hits[0].id, "beta/01-intro/hello");
479    }
480}