1use crate::model::{BookView, ChapterView, CookbookView, RecipeCard};
9use crate::store::RecipeStore;
10
11pub 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
28pub 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
62pub 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 scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
93 scored.into_iter().map(|(_, card)| card).collect()
94}
95
96pub 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 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 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 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()); assert!(next(&store, "nope").is_none());
174 }
175
176 #[test]
177 fn search_ranks_title_over_purpose() {
178 let store = store();
179 let hits = search(&store, "add");
181 assert_eq!(hits[0].id, "alpha/01-basics/add");
182 let hits = search(&store, "numbers");
184 assert_eq!(hits.len(), 2);
185 assert!(search(&store, " ").is_empty());
187 }
188
189 #[test]
190 fn search_tag_match_beats_purpose_only() {
191 let store = store();
192 let hits = search(&store, "intro");
194 assert_eq!(hits.len(), 1);
195 assert_eq!(hits[0].id, "beta/01-intro/hello");
196 }
197}