11//! Generate ingredients lists from recipes
22
3- use std:: collections:: BTreeMap ;
4-
53use indexmap:: IndexMap ;
64use 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
111109impl 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 {
405417impl 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