use crate::model::{BookView, ChapterView, CookbookView, RecipeCard};
use crate::store::RecipeStore;
pub fn ordered_cards(store: &RecipeStore) -> Vec<&RecipeCard> {
let mut cards: Vec<&RecipeCard> = store.cards().iter().collect();
cards.sort_by(|a, b| {
a.book_order
.cmp(&b.book_order)
.then_with(|| a.book.cmp(&b.book))
.then_with(|| a.chapter_order.cmp(&b.chapter_order))
.then_with(|| a.chapter.cmp(&b.chapter))
.then_with(|| a.order.cmp(&b.order))
.then_with(|| a.id.cmp(&b.id))
});
cards
}
pub fn view(store: &RecipeStore) -> CookbookView {
let mut books: Vec<BookView> = Vec::new();
for card in ordered_cards(store) {
let bi = match books.iter().position(|b| b.id == card.book) {
Some(i) => i,
None => {
books.push(BookView {
id: card.book.clone(),
title: card.book_title.clone(),
summary: card.book_summary.clone(),
chapters: Vec::new(),
});
books.len() - 1
}
};
let chapters = &mut books[bi].chapters;
let ci = match chapters.iter().position(|c| c.name == card.chapter) {
Some(i) => i,
None => {
chapters.push(ChapterView {
name: card.chapter.clone(),
title: card.chapter_title.clone(),
summary: card.chapter_summary.clone(),
recipes: Vec::new(),
});
chapters.len() - 1
}
};
chapters[ci].recipes.push(card.clone());
}
CookbookView { books }
}
pub fn search<'a>(store: &'a RecipeStore, query: &str) -> Vec<&'a RecipeCard> {
let q = query.trim().to_ascii_lowercase();
if q.is_empty() {
return Vec::new();
}
let mut scored: Vec<(i32, &RecipeCard)> = Vec::new();
for card in ordered_cards(store) {
let mut score = 0;
if card.title.to_ascii_lowercase().contains(&q) {
score += 3;
}
if card
.tags
.iter()
.any(|t| t.to_ascii_lowercase().contains(&q))
{
score += 2;
}
if card.purpose.to_ascii_lowercase().contains(&q) {
score += 1;
}
if score > 0 {
scored.push((score, card));
}
}
scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
scored.into_iter().map(|(_, card)| card).collect()
}
pub fn next<'a>(store: &'a RecipeStore, id: &str) -> Option<&'a RecipeCard> {
let ordered = ordered_cards(store);
let pos = ordered.iter().position(|c| c.id == id)?;
ordered.get(pos + 1).copied()
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> RecipeStore {
let beta: Vec<(&str, &[u8])> = vec![
(
"book.toml",
b"book = \"beta\"\ntitle = \"Beta\"\nsummary = \"Second book.\"\norder = 300\n" as &[u8],
),
(
"01-intro/hello/recipe.toml",
b"id = \"hello\"\ntitle = \"Hello\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"purpose\"\ntags = [\"intro\"]\n",
),
("01-intro/hello/s", b"(quote hi)"),
("01-intro/hello/purpose", b"a greeting recipe"),
];
let alpha: Vec<(&str, &[u8])> = vec![
(
"book.toml",
b"book = \"alpha\"\ntitle = \"Alpha\"\norder = 100\nchapters = [\"01-basics\"]\n",
),
(
"01-basics/add/recipe.toml",
b"id = \"add\"\ntitle = \"Add\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 100\ntags = [\"arithmetic\"]\n",
),
("01-basics/add/s", b"(+ 1 2)"),
("01-basics/add/p", b"add numbers"),
(
"01-basics/sub/recipe.toml",
b"id = \"sub\"\ntitle = \"Subtract\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\norder = 200\n",
),
("01-basics/sub/s", b"(- 3 1)"),
("01-basics/sub/p", b"subtract numbers"),
];
let mut store = RecipeStore::new();
store.register_book(&beta).unwrap();
store.register_book(&alpha).unwrap();
store
}
#[test]
fn view_orders_books_chapters_recipes() {
let view = view(&store());
assert_eq!(view.books.len(), 2);
assert_eq!(view.books[0].id, "alpha");
assert_eq!(view.books[1].id, "beta");
assert_eq!(view.books[1].summary, "Second book.");
let basics = &view.books[0].chapters[0];
assert_eq!(basics.name, "01-basics");
assert_eq!(basics.recipes[0].id, "alpha/01-basics/add");
assert_eq!(basics.recipes[1].id, "alpha/01-basics/sub");
}
#[test]
fn next_walks_global_order() {
let store = store();
assert_eq!(
next(&store, "alpha/01-basics/add").unwrap().id,
"alpha/01-basics/sub"
);
assert_eq!(
next(&store, "alpha/01-basics/sub").unwrap().id,
"beta/01-intro/hello"
);
assert!(next(&store, "beta/01-intro/hello").is_none()); assert!(next(&store, "nope").is_none());
}
#[test]
fn search_ranks_title_over_purpose() {
let store = store();
let hits = search(&store, "add");
assert_eq!(hits[0].id, "alpha/01-basics/add");
let hits = search(&store, "numbers");
assert_eq!(hits.len(), 2);
assert!(search(&store, " ").is_empty());
}
#[test]
fn search_tag_match_beats_purpose_only() {
let store = store();
let hits = search(&store, "intro");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "beta/01-intro/hello");
}
}