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::{BookView, ChapterView, CookbookView, RecipeCard};
9use crate::store::RecipeStore;
10
11/// All cards in deterministic global order: by book order then id, chapter
12/// order then name, recipe order then id. Two recipes never compare equal
13/// because ids are unique, so the order is total and stable.
14pub fn ordered_cards(store: &RecipeStore) -> Vec<&RecipeCard> {
15    let mut cards: Vec<&RecipeCard> = store.cards().iter().collect();
16    cards.sort_by(|a, b| {
17        a.book_order
18            .cmp(&b.book_order)
19            .then_with(|| a.book.cmp(&b.book))
20            .then_with(|| a.chapter_order.cmp(&b.chapter_order))
21            .then_with(|| a.chapter.cmp(&b.chapter))
22            .then_with(|| a.order.cmp(&b.order))
23            .then_with(|| a.id.cmp(&b.id))
24    });
25    cards
26}
27
28/// Group the store's cards into the nested [`CookbookView`].
29pub fn view(store: &RecipeStore) -> CookbookView {
30    let mut books: Vec<BookView> = Vec::new();
31    for card in ordered_cards(store) {
32        let bi = match books.iter().position(|b| b.id == card.book) {
33            Some(i) => i,
34            None => {
35                books.push(BookView {
36                    id: card.book.clone(),
37                    title: card.book_title.clone(),
38                    summary: card.book_summary.clone(),
39                    chapters: Vec::new(),
40                });
41                books.len() - 1
42            }
43        };
44        let chapters = &mut books[bi].chapters;
45        let ci = match chapters.iter().position(|c| c.name == card.chapter) {
46            Some(i) => i,
47            None => {
48                chapters.push(ChapterView {
49                    name: card.chapter.clone(),
50                    title: card.chapter_title.clone(),
51                    summary: card.chapter_summary.clone(),
52                    recipes: Vec::new(),
53                });
54                chapters.len() - 1
55            }
56        };
57        chapters[ci].recipes.push(card.clone());
58    }
59    CookbookView { books }
60}
61
62/// Rank recipes matching `query` (case-insensitive). A title match scores
63/// highest, then a tag match, then a purpose match; scores add. Recipes that
64/// match nothing are dropped. Ties keep deterministic global order.
65pub fn search<'a>(store: &'a RecipeStore, query: &str) -> Vec<&'a RecipeCard> {
66    let q = query.trim().to_ascii_lowercase();
67    if q.is_empty() {
68        return Vec::new();
69    }
70    let mut scored: Vec<(i32, &RecipeCard)> = Vec::new();
71    for card in ordered_cards(store) {
72        let mut score = 0;
73        if card.title.to_ascii_lowercase().contains(&q) {
74            score += 3;
75        }
76        if card
77            .tags
78            .iter()
79            .any(|t| t.to_ascii_lowercase().contains(&q))
80        {
81            score += 2;
82        }
83        if card.purpose.to_ascii_lowercase().contains(&q) {
84            score += 1;
85        }
86        if score > 0 {
87            scored.push((score, card));
88        }
89    }
90    // Stable sort by descending score; `ordered_cards` already gave a
91    // deterministic secondary order, preserved for equal scores.
92    scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
93    scored.into_iter().map(|(_, card)| card).collect()
94}
95
96/// The recipe immediately after `id` in global order, for "continue" buttons.
97/// `None` if `id` is unknown or is the last recipe.
98pub fn next<'a>(store: &'a RecipeStore, id: &str) -> Option<&'a RecipeCard> {
99    let ordered = ordered_cards(store);
100    let pos = ordered.iter().position(|c| c.id == id)?;
101    ordered.get(pos + 1).copied()
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    // Two books, deliberately registered out of final order to prove sorting.
109    fn store() -> RecipeStore {
110        let beta: Vec<(&str, &[u8])> = vec![
111            (
112                "book.toml",
113                b"book = \"beta\"\ntitle = \"Beta\"\nsummary = \"Second book.\"\norder = 300\n" as &[u8],
114            ),
115            (
116                "01-intro/hello/recipe.toml",
117                b"id = \"hello\"\ntitle = \"Hello\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"purpose\"\ntags = [\"intro\"]\n",
118            ),
119            ("01-intro/hello/s", b"(quote hi)"),
120            ("01-intro/hello/purpose", b"a greeting recipe"),
121        ];
122        let alpha: Vec<(&str, &[u8])> = vec![
123            (
124                "book.toml",
125                b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\nchapters = [\"01-basics\"]\n",
126            ),
127            (
128                "01-basics/add/recipe.toml",
129                b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 100\ntags = [\"arithmetic\"]\n",
130            ),
131            ("01-basics/add/s", b"(+ 1 2)"),
132            ("01-basics/add/p", b"add numbers"),
133            (
134                "01-basics/sub/recipe.toml",
135                b"id = \"sub\"\ntitle = \"Subtract\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 200\n",
136            ),
137            ("01-basics/sub/s", b"(- 3 1)"),
138            ("01-basics/sub/p", b"subtract numbers"),
139        ];
140        let mut store = RecipeStore::new();
141        store.register_book(&beta).unwrap();
142        store.register_book(&alpha).unwrap();
143        store
144    }
145
146    #[test]
147    fn view_orders_books_chapters_recipes() {
148        let view = view(&store());
149        // alpha (order 100) before beta (order 300) despite registration order.
150        assert_eq!(view.books.len(), 2);
151        assert_eq!(view.books[0].id, "alpha");
152        assert_eq!(view.books[1].id, "beta");
153        assert_eq!(view.books[1].summary, "Second book.");
154        let basics = &view.books[0].chapters[0];
155        assert_eq!(basics.name, "01-basics");
156        // add (order 100) before sub (order 200).
157        assert_eq!(basics.recipes[0].id, "alpha/01-basics/add");
158        assert_eq!(basics.recipes[1].id, "alpha/01-basics/sub");
159    }
160
161    #[test]
162    fn next_walks_global_order() {
163        let store = store();
164        assert_eq!(
165            next(&store, "alpha/01-basics/add").unwrap().id,
166            "alpha/01-basics/sub"
167        );
168        assert_eq!(
169            next(&store, "alpha/01-basics/sub").unwrap().id,
170            "beta/01-intro/hello"
171        );
172        assert!(next(&store, "beta/01-intro/hello").is_none()); // last
173        assert!(next(&store, "nope").is_none());
174    }
175
176    #[test]
177    fn search_ranks_title_over_purpose() {
178        let store = store();
179        // "add" matches the title of add (3) and nothing else strongly.
180        let hits = search(&store, "add");
181        assert_eq!(hits[0].id, "alpha/01-basics/add");
182        // "numbers" only appears in purposes -> score 1, both basics recipes.
183        let hits = search(&store, "numbers");
184        assert_eq!(hits.len(), 2);
185        // empty query returns nothing.
186        assert!(search(&store, "  ").is_empty());
187    }
188
189    #[test]
190    fn search_tag_match_beats_purpose_only() {
191        let store = store();
192        // "intro" is a tag on beta/hello (score 2) and not in alpha recipes.
193        let hits = search(&store, "intro");
194        assert_eq!(hits.len(), 1);
195        assert_eq!(hits[0].id, "beta/01-intro/hello");
196    }
197}