Skip to content

Commit a15b276

Browse files
committed
feat(aisle): case-insensitive matching and preserve config order
- Make ingredient matching case-insensitive so "Chili flakes" matches "chili flakes" in aisle.conf (consistent with pantry behavior) - Preserve aisle.conf category order in shopping lists instead of sorting alphabetically - Add indexmap dependency for insertion-order preservation - Add tests for both behaviors
1 parent 600c2cd commit a15b276

4 files changed

Lines changed: 188 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ finl_unicode = { version = "1.2", features = [
2424
], default-features = false }
2525
smallvec = { version = "1" }
2626
unicase = "2.7.0"
27+
indexmap = { version = "2", features = ["serde"] }
2728
yansi = "1.0.1"
2829
serde_yaml = "0.9.34"
2930
toml = { version = "0.8", optional = true }

src/aisle.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ impl AisleConf<'_> {
6565
/// Returns a reversed configuration, where each key is an ingredient
6666
/// and the value is its category.
6767
#[deprecated = "Use `ingredients_info` instead"]
68-
pub fn reverse(&self) -> HashMap<&str, &str> {
68+
pub fn reverse(&self) -> HashMap<String, &str> {
6969
self.ingredients_info()
7070
.into_iter()
7171
.map(|(n, i)| (n, i.name))
@@ -74,7 +74,10 @@ impl AisleConf<'_> {
7474

7575
/// Returns a reversed configuration, where each ingredient has a
7676
/// corresponding [`IngredientInfo`]
77-
pub fn ingredients_info(&self) -> HashMap<&str, IngredientInfo<'_>> {
77+
///
78+
/// The keys are lowercase for case-insensitive lookups. Use
79+
/// `name.to_lowercase()` when looking up ingredients.
80+
pub fn ingredients_info(&self) -> HashMap<String, IngredientInfo<'_>> {
7881
let mut map = HashMap::with_capacity(self.len.get());
7982
for cat in &self.categories {
8083
for igr in &cat.ingredients {
@@ -88,7 +91,8 @@ impl AisleConf<'_> {
8891
common_name,
8992
category: cat.name,
9093
};
91-
map.insert(*name, info);
94+
// Store lowercase key for case-insensitive lookups
95+
map.insert(name.to_lowercase(), info);
9296
}
9397
}
9498
}
@@ -481,15 +485,33 @@ tuna|chicken of the sea
481485
"#;
482486
let a = parse(input).unwrap();
483487
let p = a.ingredients_info();
488+
// Keys are lowercase for case-insensitive lookup
484489
assert_eq!(
485490
vec!["tuna", "tuna"],
486491
["tuna", "chicken of the sea"]
487492
.iter()
488-
.map(|igr| p.get(igr).unwrap().common_name)
493+
.map(|igr| p.get(&igr.to_lowercase()).unwrap().common_name)
489494
.collect::<Vec<&str>>()
490495
)
491496
}
492497

498+
#[test]
499+
fn case_insensitive_lookup() {
500+
let input = r#"[spices]
501+
chili flakes
502+
"#;
503+
let a = parse(input).unwrap();
504+
let p = a.ingredients_info();
505+
// All case variants should find the same ingredient
506+
assert!(p.get("chili flakes").is_some());
507+
assert!(p.get(&"Chili flakes".to_lowercase()).is_some());
508+
assert!(p.get(&"CHILI FLAKES".to_lowercase()).is_some());
509+
assert_eq!(
510+
p.get("chili flakes").unwrap().common_name,
511+
p.get(&"Chili Flakes".to_lowercase()).unwrap().common_name
512+
);
513+
}
514+
493515
#[test]
494516
fn duplicate_ingredient() {
495517
// lf/crlf problem :)

src/ingredient_list.rs

Lines changed: 159 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use std::collections::BTreeMap;
44

5+
use indexmap::IndexMap;
56
use serde::Serialize;
67

78
use 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 {
401420
pub 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`]
422441
pub 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`]
459478
pub 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)]
478497
mod 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

Comments
 (0)