22
33use std:: collections:: BTreeMap ;
44
5+ use indexmap:: IndexMap ;
56use serde:: Serialize ;
67
78use crate :: {
@@ -344,12 +345,24 @@ impl IngredientList {
344345
345346 /// Split this list into different categories.
346347 ///
348+ /// Categories are returned in the same order as they appear in the aisle configuration.
347349 /// Ingredients without category will be placed in `"other"`.
348350 pub fn categorize ( self , aisle : & AisleConf ) -> CategorizedIngredientList {
349- let iifno = aisle. ingredients_info ( ) ;
350- let mut categorized = CategorizedIngredientList :: default ( ) ;
351+ let iinfo = aisle. ingredients_info ( ) ;
352+
353+ // Pre-create categories in aisle.conf order
354+ let mut categorized = CategorizedIngredientList {
355+ categories : aisle
356+ . categories
357+ . iter ( )
358+ . map ( |cat| ( cat. name . to_string ( ) , IngredientList :: new ( ) ) )
359+ . collect ( ) ,
360+ other : IngredientList :: new ( ) ,
361+ } ;
362+
351363 for ( name, quantity) in self . 0 {
352- if let Some ( info) = iifno. get ( name. as_str ( ) ) {
364+ // Use lowercase for case-insensitive lookup
365+ if let Some ( info) = iinfo. get ( & name. to_lowercase ( ) ) {
353366 categorized
354367 . categories
355368 . entry ( info. category . to_string ( ) )
@@ -360,6 +373,9 @@ impl IngredientList {
360373 categorized. other . 0 . insert ( name, quantity) ;
361374 }
362375 }
376+
377+ // Remove empty categories
378+ categorized. categories . retain ( |_, list| !list. is_empty ( ) ) ;
363379 categorized
364380 }
365381
@@ -369,12 +385,15 @@ impl IngredientList {
369385 }
370386
371387 /// Replace names of ingredients with common names given by aisle configuration.
388+ ///
389+ /// Matching is case-insensitive.
372390 pub fn use_common_names ( self , aisle : & AisleConf , converter : & Converter ) -> Self {
373391 let ingredients_info = aisle. ingredients_info ( ) ;
374392 let mut normalized = Self :: new ( ) ;
375- for ( ingredient_name, quantity) in self . iter ( ) {
393+ for ( ingredient_name, quantity) in self . iter ( ) {
394+ // Use lowercase for case-insensitive lookup
376395 let common_name = ingredients_info
377- . get ( ingredient_name. as_str ( ) )
396+ . get ( & ingredient_name. to_lowercase ( ) )
378397 . map ( |info| info. common_name . to_string ( ) )
379398 . unwrap_or ( ingredient_name. to_string ( ) ) ;
380399 normalized. add_ingredient ( common_name, quantity, converter) ;
@@ -401,8 +420,8 @@ impl IntoIterator for IngredientList {
401420pub struct CategorizedIngredientList {
402421 /// One ingredient list per category
403422 ///
404- /// Because this is a [`BTreeMap`], the categories are sorted by name
405- pub categories : BTreeMap < String , IngredientList > ,
423+ /// Categories are ordered according to the aisle configuration file order.
424+ pub categories : IndexMap < String , IngredientList > ,
406425 /// Ingredients with no category assigned
407426 pub other : IngredientList ,
408427}
@@ -420,7 +439,7 @@ impl CategorizedIngredientList {
420439
421440/// See [`CategorizedIngredientList::iter`]
422441pub struct CategorizedIter < ' a > {
423- categories : std :: collections :: btree_map :: Iter < ' a , String , IngredientList > ,
442+ categories : indexmap :: map :: Iter < ' a , String , IngredientList > ,
424443 other : Option < & ' a IngredientList > ,
425444}
426445
@@ -457,7 +476,7 @@ impl IntoIterator for CategorizedIngredientList {
457476
458477/// See [`CategorizedIngredientList::into_iter`]
459478pub struct CategorizedIntoIter {
460- categories : std :: collections :: btree_map :: IntoIter < String , IngredientList > ,
479+ categories : indexmap :: map :: IntoIter < String , IngredientList > ,
461480 other : Option < IngredientList > ,
462481}
463482
@@ -474,11 +493,141 @@ impl Iterator for CategorizedIntoIter {
474493 }
475494}
476495
477- #[ cfg( all ( test, feature = "pantry" ) ) ]
496+ #[ cfg( test) ]
478497mod tests {
479498 use super :: * ;
480499 use crate :: { CooklangParser , Extensions } ;
481500
501+ #[ test]
502+ fn test_categorize_preserves_aisle_order ( ) {
503+ let converter = Converter :: bundled ( ) ;
504+ let parser = CooklangParser :: new ( Extensions :: all ( ) , converter. clone ( ) ) ;
505+
506+ // Recipe with ingredients from different categories
507+ let recipe = parser
508+ . parse ( "@milk{1%l} @apple{2} @chicken{500%g}" )
509+ . into_output ( )
510+ . unwrap ( ) ;
511+
512+ // Aisle config: produce first, then dairy, then meat
513+ let aisle_conf = r#"
514+ [produce]
515+ apple
516+ banana
517+
518+ [dairy]
519+ milk
520+ butter
521+
522+ [meat]
523+ chicken
524+ beef
525+ "# ;
526+ let aisle = crate :: aisle:: parse ( aisle_conf) . unwrap ( ) ;
527+
528+ let mut list = IngredientList :: new ( ) ;
529+ list. add_recipe ( & recipe, & converter, false ) ;
530+ let categorized = list. categorize ( & aisle) ;
531+
532+ // Categories should be in aisle.conf order: produce, dairy, meat
533+ let category_names: Vec < & str > = categorized. iter ( ) . map ( |( name, _) | name) . collect ( ) ;
534+ assert_eq ! ( category_names, vec![ "produce" , "dairy" , "meat" ] ) ;
535+ }
536+
537+ #[ test]
538+ fn test_categorize_case_insensitive ( ) {
539+ let converter = Converter :: bundled ( ) ;
540+ let parser = CooklangParser :: new ( Extensions :: all ( ) , converter. clone ( ) ) ;
541+
542+ // Recipe with "Chili flakes" (capital C)
543+ let recipe = parser
544+ . parse ( "@Chili flakes{1%tsp}" )
545+ . into_output ( )
546+ . unwrap ( ) ;
547+
548+ // Aisle config has "chili flakes" (lowercase)
549+ let aisle_conf = r#"
550+ [spices]
551+ chili flakes
552+ "# ;
553+ let aisle = crate :: aisle:: parse ( aisle_conf) . unwrap ( ) ;
554+
555+ let mut list = IngredientList :: new ( ) ;
556+ list. add_recipe ( & recipe, & converter, false ) ;
557+ let categorized = list. categorize ( & aisle) ;
558+
559+ // Should find the category despite case difference
560+ let category_names: Vec < & str > = categorized. iter ( ) . map ( |( name, _) | name) . collect ( ) ;
561+ assert_eq ! ( category_names, vec![ "spices" ] ) ;
562+
563+ // Ingredient should use common name from config
564+ let spices = categorized. categories . get ( "spices" ) . unwrap ( ) ;
565+ assert ! ( spices. iter( ) . any( |( name, _) | name == "chili flakes" ) ) ;
566+ }
567+
568+ #[ test]
569+ fn test_use_common_names_case_insensitive ( ) {
570+ let converter = Converter :: bundled ( ) ;
571+ let parser = CooklangParser :: new ( Extensions :: all ( ) , converter. clone ( ) ) ;
572+
573+ // Recipe with various case variations
574+ let recipe = parser
575+ . parse ( "@CHILI FLAKES{1%tsp} @Olive Oil{2%tbsp}" )
576+ . into_output ( )
577+ . unwrap ( ) ;
578+
579+ // Aisle config with specific casing
580+ let aisle_conf = r#"
581+ [spices]
582+ chili flakes
583+
584+ [oils]
585+ olive oil
586+ "# ;
587+ let aisle = crate :: aisle:: parse ( aisle_conf) . unwrap ( ) ;
588+
589+ let mut list = IngredientList :: new ( ) ;
590+ list. add_recipe ( & recipe, & converter, false ) ;
591+ let normalized = list. use_common_names ( & aisle, & converter) ;
592+
593+ // Both should be normalized to lowercase common names
594+ let names: Vec < & String > = normalized. iter ( ) . map ( |( name, _) | name) . collect ( ) ;
595+ assert ! ( names. contains( &&"chili flakes" . to_string( ) ) ) ;
596+ assert ! ( names. contains( &&"olive oil" . to_string( ) ) ) ;
597+ }
598+
599+ #[ test]
600+ fn test_uncategorized_items_go_to_other ( ) {
601+ let converter = Converter :: bundled ( ) ;
602+ let parser = CooklangParser :: new ( Extensions :: all ( ) , converter. clone ( ) ) ;
603+
604+ // Recipe with both categorized and uncategorized ingredients
605+ let recipe = parser
606+ . parse ( "@apple{2} @mystery ingredient{1}" )
607+ . into_output ( )
608+ . unwrap ( ) ;
609+
610+ let aisle_conf = r#"
611+ [produce]
612+ apple
613+ "# ;
614+ let aisle = crate :: aisle:: parse ( aisle_conf) . unwrap ( ) ;
615+
616+ let mut list = IngredientList :: new ( ) ;
617+ list. add_recipe ( & recipe, & converter, false ) ;
618+ let categorized = list. categorize ( & aisle) ;
619+
620+ // "other" should appear at the end
621+ let category_names: Vec < & str > = categorized. iter ( ) . map ( |( name, _) | name) . collect ( ) ;
622+ assert_eq ! ( category_names, vec![ "produce" , "other" ] ) ;
623+ }
624+ }
625+
626+ #[ cfg( all( test, feature = "pantry" ) ) ]
627+ mod pantry_tests {
628+ use super :: * ;
629+ use crate :: { CooklangParser , Extensions } ;
630+
482631 #[ test]
483632 fn test_subtract_pantry_unlimited ( ) {
484633 let converter = Converter :: bundled ( ) ;
0 commit comments