Skip to content

Commit 2c2b5a3

Browse files
committed
feat(aisle): preserve ingredient order within categories
Ingredients within each category now appear in the same order as they are listed in the aisle.conf file, not alphabetically. Changed IngredientList from BTreeMap to IndexMap and rewrote categorize() to iterate through aisle.conf ingredients in order.
1 parent d76c548 commit 2c2b5a3

1 file changed

Lines changed: 75 additions & 28 deletions

File tree

src/ingredient_list.rs

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
//! Generate ingredients lists from recipes
22
3-
use std::collections::BTreeMap;
4-
53
use indexmap::IndexMap;
64
use serde::Serialize;
75

@@ -102,11 +100,11 @@ impl Recipe {
102100

103101
/// List of ingredients with quantities.
104102
///
105-
/// This will only store the ingredient name and quantity. Sorted by name. This
106-
/// is used to combine multiple recipes into a single list. For ingredients of a
107-
/// single recipe, check [`ScaledRecipe::group_ingredients`].
103+
/// This will only store the ingredient name and quantity. This is used to
104+
/// combine multiple recipes into a single list. For ingredients of a single
105+
/// recipe, check [`ScaledRecipe::group_ingredients`].
108106
#[derive(Debug, Default)]
109-
pub struct IngredientList(BTreeMap<String, GroupedQuantity>);
107+
pub struct IngredientList(IndexMap<String, GroupedQuantity>);
110108

111109
impl IngredientList {
112110
/// Empty list
@@ -345,41 +343,55 @@ impl IngredientList {
345343

346344
/// Split this list into different categories.
347345
///
348-
/// Categories are returned in the same order as they appear in the aisle configuration.
346+
/// Categories and ingredients within each category are returned in the same
347+
/// order as they appear in the aisle configuration.
349348
/// Ingredients without category will be placed in `"other"`.
350349
pub fn categorize(self, aisle: &AisleConf) -> CategorizedIngredientList {
351-
let iinfo = aisle.ingredients_info();
350+
// Build a lookup from the shopping list (lowercase name -> (original_name, quantity))
351+
let mut shopping_lookup: IndexMap<String, (String, GroupedQuantity)> = self
352+
.0
353+
.into_iter()
354+
.map(|(name, qty)| (name.to_lowercase(), (name, qty)))
355+
.collect();
352356

353-
// Pre-create categories in aisle.conf order
354357
let mut categorized = CategorizedIngredientList {
355-
categories: aisle
356-
.categories
357-
.iter()
358-
.map(|cat| (cat.name.to_string(), IngredientList::new()))
359-
.collect(),
358+
categories: IndexMap::new(),
360359
other: IngredientList::new(),
361360
};
362361

363-
for (name, quantity) in self.0 {
364-
// Use lowercase for case-insensitive lookup
365-
if let Some(info) = iinfo.get(&name.to_lowercase()) {
362+
// Iterate through aisle.conf categories and ingredients in order
363+
for category in &aisle.categories {
364+
let mut category_list = IngredientList::new();
365+
366+
for ingredient in &category.ingredients {
367+
// Check each name variant (synonyms) for this ingredient
368+
for name in &ingredient.names {
369+
let lookup_key = name.to_lowercase();
370+
if let Some((_, quantity)) = shopping_lookup.swap_remove(&lookup_key) {
371+
// Use the common name (first name in the ingredient definition)
372+
let common_name = ingredient.names.first().unwrap_or(name);
373+
category_list.0.insert(common_name.to_string(), quantity);
374+
break; // Found this ingredient, move to next
375+
}
376+
}
377+
}
378+
379+
if !category_list.is_empty() {
366380
categorized
367381
.categories
368-
.entry(info.category.to_string())
369-
.or_default()
370-
.0
371-
.insert(info.common_name.to_string(), quantity);
372-
} else {
373-
categorized.other.0.insert(name, quantity);
382+
.insert(category.name.to_string(), category_list);
374383
}
375384
}
376385

377-
// Remove empty categories
378-
categorized.categories.retain(|_, list| !list.is_empty());
386+
// Any remaining items go to "other"
387+
for (_, (name, quantity)) in shopping_lookup {
388+
categorized.other.0.insert(name, quantity);
389+
}
390+
379391
categorized
380392
}
381393

382-
/// Iterate over all ingredients sorted by name
394+
/// Iterate over all ingredients in insertion order
383395
pub fn iter(&self) -> impl Iterator<Item = (&String, &GroupedQuantity)> {
384396
self.0.iter()
385397
}
@@ -405,9 +417,9 @@ impl IngredientList {
405417
impl IntoIterator for IngredientList {
406418
type Item = (String, GroupedQuantity);
407419

408-
type IntoIter = std::collections::btree_map::IntoIter<String, GroupedQuantity>;
420+
type IntoIter = indexmap::map::IntoIter<String, GroupedQuantity>;
409421

410-
/// Iterate over all ingrediends sorted by name
422+
/// Iterate over all ingredients in insertion order
411423
fn into_iter(self) -> Self::IntoIter {
412424
self.0.into_iter()
413425
}
@@ -621,6 +633,41 @@ apple
621633
let category_names: Vec<&str> = categorized.iter().map(|(name, _)| name).collect();
622634
assert_eq!(category_names, vec!["produce", "other"]);
623635
}
636+
637+
#[test]
638+
fn test_ingredients_preserve_aisle_order_within_category() {
639+
let converter = Converter::bundled();
640+
let parser = CooklangParser::new(Extensions::all(), converter.clone());
641+
642+
// Recipe with ingredients in different order than aisle.conf
643+
// (zucchini comes before apple alphabetically, but apple is first in aisle.conf)
644+
let recipe = parser
645+
.parse("@zucchini{1} @carrot{2} @apple{3} @banana{1}")
646+
.into_output()
647+
.unwrap();
648+
649+
// Aisle config: apple, banana, carrot, zucchini
650+
let aisle_conf = r#"
651+
[produce]
652+
apple
653+
banana
654+
carrot
655+
zucchini
656+
"#;
657+
let aisle = crate::aisle::parse(aisle_conf).unwrap();
658+
659+
let mut list = IngredientList::new();
660+
list.add_recipe(&recipe, &converter, false);
661+
let categorized = list.categorize(&aisle);
662+
663+
// Ingredients within produce should be in aisle.conf order: apple, banana, carrot, zucchini
664+
let produce = categorized.categories.get("produce").unwrap();
665+
let ingredient_names: Vec<&String> = produce.iter().map(|(name, _)| name).collect();
666+
assert_eq!(
667+
ingredient_names,
668+
vec!["apple", "banana", "carrot", "zucchini"]
669+
);
670+
}
624671
}
625672

626673
#[cfg(all(test, feature = "pantry"))]

0 commit comments

Comments
 (0)