@@ -5,6 +5,7 @@ use cooklang::metadata::StdKey as OriginalStdKey;
55
66pub mod aisle;
77pub mod model;
8+ pub mod shopping_list;
89
910use aisle:: * ;
1011use model:: * ;
@@ -580,6 +581,116 @@ fn parse_fraction(s: &str) -> Option<f64> {
580581 None
581582}
582583
584+ // ---------------------------------------------------------------------------
585+ // Shopping list functions
586+ // ---------------------------------------------------------------------------
587+
588+ /// Parses a `.shopping-list` file into a ShoppingList structure
589+ ///
590+ /// The format supports recipe references (lines starting with `./`) and
591+ /// free-hand ingredients, with indentation-based nesting for sub-recipes.
592+ ///
593+ /// # Arguments
594+ /// * `input` - The raw shopping list text
595+ ///
596+ /// # Returns
597+ /// A parsed ShoppingList, or an error string if parsing fails
598+ #[ uniffi:: export]
599+ pub fn parse_shopping_list (
600+ input : String ,
601+ ) -> Result < shopping_list:: ShoppingList , String > {
602+ shopping_list:: parse_shopping_list_impl ( & input)
603+ }
604+
605+ /// Serializes a ShoppingList back to the `.shopping-list` text format
606+ ///
607+ /// # Arguments
608+ /// * `list` - The shopping list to serialize
609+ ///
610+ /// # Returns
611+ /// The formatted text, or an error string if serialization fails
612+ #[ uniffi:: export]
613+ pub fn write_shopping_list (
614+ list : & shopping_list:: ShoppingList ,
615+ ) -> Result < String , String > {
616+ shopping_list:: write_shopping_list_impl ( list)
617+ }
618+
619+ /// Parses a `.shopping-checked` log file into a list of check entries
620+ ///
621+ /// The format is an append-only log with `+ name` (checked) and `- name`
622+ /// (unchecked) entries.
623+ ///
624+ /// # Arguments
625+ /// * `input` - The raw checked log text
626+ ///
627+ /// # Returns
628+ /// A list of check/uncheck entries in order
629+ #[ uniffi:: export]
630+ pub fn parse_shopping_checked ( input : String ) -> Vec < shopping_list:: CheckEntry > {
631+ shopping_list:: parse_checked_impl ( & input)
632+ }
633+
634+ /// Replays a checked log and returns the set of currently checked ingredient
635+ /// names (lowercased)
636+ ///
637+ /// Later entries override earlier ones for the same ingredient.
638+ ///
639+ /// # Arguments
640+ /// * `entries` - The check log entries to replay
641+ ///
642+ /// # Returns
643+ /// Set of ingredient names that are currently checked
644+ #[ uniffi:: export]
645+ pub fn shopping_checked_set (
646+ entries : & [ shopping_list:: CheckEntry ] ,
647+ ) -> Vec < String > {
648+ // UniFFI doesn't expose HashSet, so we return a Vec. Sort for
649+ // deterministic ordering across the FFI boundary.
650+ let mut names: Vec < String > = shopping_list:: checked_set_impl ( entries)
651+ . into_iter ( )
652+ . collect ( ) ;
653+ names. sort ( ) ;
654+ names
655+ }
656+
657+ /// Serializes a single check entry to string format
658+ ///
659+ /// # Arguments
660+ /// * `entry` - The check entry to serialize
661+ ///
662+ /// # Returns
663+ /// The formatted string (e.g., `"+ salt\n"` or `"- pepper\n"`)
664+ #[ uniffi:: export]
665+ pub fn write_shopping_check_entry (
666+ entry : & shopping_list:: CheckEntry ,
667+ ) -> Result < String , String > {
668+ shopping_list:: write_check_entry_impl ( entry)
669+ }
670+
671+ /// Compacts a checked log against the set of currently-visible ingredient
672+ /// names.
673+ ///
674+ /// Removes entries for ingredients that are no longer in the shopping list,
675+ /// and collapses the log so each ingredient appears at most once.
676+ ///
677+ /// # Arguments
678+ /// * `entries` - The current check log entries
679+ /// * `current_ingredients` - The fully-aggregated ingredient names as shown
680+ /// to the user. A raw on-disk `ShoppingList` only stores recipe
681+ /// references, so callers must expand recipes first and pass the
682+ /// resulting ingredient names here.
683+ ///
684+ /// # Returns
685+ /// A compacted list of check entries
686+ #[ uniffi:: export]
687+ pub fn compact_shopping_checked (
688+ entries : & [ shopping_list:: CheckEntry ] ,
689+ current_ingredients : & [ String ] ,
690+ ) -> Vec < shopping_list:: CheckEntry > {
691+ shopping_list:: compact_checked_impl ( entries, current_ingredients)
692+ }
693+
583694uniffi:: setup_scaffolding!( ) ;
584695
585696#[ cfg( test) ]
@@ -1337,4 +1448,177 @@ Serve the @./pasta/spaghetti{1%portion} with sauce
13371448 } )
13381449 ) ;
13391450 }
1451+
1452+ #[ test]
1453+ fn test_parse_shopping_list ( ) {
1454+ use crate :: shopping_list:: ShoppingListItem ;
1455+
1456+ let list = crate :: parse_shopping_list (
1457+ "./Breakfast/Easy Pancakes{2}\n ./Shared/Guacamole\n ./Thai Green Curry\n " . to_string ( ) ,
1458+ )
1459+ . unwrap ( ) ;
1460+
1461+ assert_eq ! ( list. items. len( ) , 2 ) ;
1462+
1463+ // First item: Easy Pancakes with multiplier and one child
1464+ match & list. items [ 0 ] {
1465+ ShoppingListItem :: Recipe {
1466+ path,
1467+ multiplier,
1468+ children,
1469+ } => {
1470+ assert_eq ! ( path, "Breakfast/Easy Pancakes" ) ;
1471+ assert_eq ! ( * multiplier, Some ( 2.0 ) ) ;
1472+ assert_eq ! ( children. len( ) , 1 ) ;
1473+ match & children[ 0 ] {
1474+ ShoppingListItem :: Recipe {
1475+ path, multiplier, ..
1476+ } => {
1477+ assert_eq ! ( path, "Shared/Guacamole" ) ;
1478+ assert_eq ! ( * multiplier, None ) ;
1479+ }
1480+ _ => panic ! ( "Expected recipe child" ) ,
1481+ }
1482+ }
1483+ _ => panic ! ( "Expected recipe item" ) ,
1484+ }
1485+
1486+ // Second item: Thai Green Curry, no children
1487+ match & list. items [ 1 ] {
1488+ ShoppingListItem :: Recipe {
1489+ path,
1490+ multiplier,
1491+ children,
1492+ } => {
1493+ assert_eq ! ( path, "Thai Green Curry" ) ;
1494+ assert_eq ! ( * multiplier, None ) ;
1495+ assert ! ( children. is_empty( ) ) ;
1496+ }
1497+ _ => panic ! ( "Expected recipe item" ) ,
1498+ }
1499+ }
1500+
1501+ #[ test]
1502+ fn test_write_shopping_list ( ) {
1503+ use crate :: shopping_list:: { ShoppingList , ShoppingListItem } ;
1504+
1505+ let list = ShoppingList {
1506+ items : vec ! [
1507+ ShoppingListItem :: Recipe {
1508+ path: "Breakfast/Pancakes" . to_string( ) ,
1509+ multiplier: Some ( 2.0 ) ,
1510+ children: vec![ ShoppingListItem :: Recipe {
1511+ path: "Shared/Syrup" . to_string( ) ,
1512+ multiplier: None ,
1513+ children: vec![ ] ,
1514+ } ] ,
1515+ } ,
1516+ ShoppingListItem :: Ingredient {
1517+ name: "salt" . to_string( ) ,
1518+ quantity: Some ( "2%tsp" . to_string( ) ) ,
1519+ } ,
1520+ ] ,
1521+ } ;
1522+
1523+ let output = crate :: write_shopping_list ( & list) . unwrap ( ) ;
1524+ assert_eq ! (
1525+ output,
1526+ "./Breakfast/Pancakes{2}\n ./Shared/Syrup\n salt{2%tsp}\n "
1527+ ) ;
1528+ }
1529+
1530+ #[ test]
1531+ fn test_shopping_list_roundtrip ( ) {
1532+ let input =
1533+ "./Meal Plan.menu\n ./Breakfast/Pancakes{2}\n ./Shared/Guacamole\n ./Dinner/Curry\n " ;
1534+ let list = crate :: parse_shopping_list ( input. to_string ( ) ) . unwrap ( ) ;
1535+ let output = crate :: write_shopping_list ( & list) . unwrap ( ) ;
1536+ assert_eq ! ( output, input) ;
1537+ }
1538+
1539+ #[ test]
1540+ fn test_parse_shopping_checked ( ) {
1541+ use crate :: shopping_list:: CheckEntry ;
1542+
1543+ let entries = crate :: parse_shopping_checked (
1544+ "+ salt\n + pepper\n - salt\n + garlic\n " . to_string ( ) ,
1545+ ) ;
1546+
1547+ assert_eq ! ( entries. len( ) , 4 ) ;
1548+ assert ! ( matches!( & entries[ 0 ] , CheckEntry :: Checked { name } if name == "salt" ) ) ;
1549+ assert ! ( matches!( & entries[ 1 ] , CheckEntry :: Checked { name } if name == "pepper" ) ) ;
1550+ assert ! ( matches!( & entries[ 2 ] , CheckEntry :: Unchecked { name } if name == "salt" ) ) ;
1551+ assert ! ( matches!( & entries[ 3 ] , CheckEntry :: Checked { name } if name == "garlic" ) ) ;
1552+ }
1553+
1554+ #[ test]
1555+ fn test_shopping_checked_set ( ) {
1556+ use crate :: shopping_list:: CheckEntry ;
1557+
1558+ let entries = vec ! [
1559+ CheckEntry :: Checked {
1560+ name: "salt" . to_string( ) ,
1561+ } ,
1562+ CheckEntry :: Checked {
1563+ name: "pepper" . to_string( ) ,
1564+ } ,
1565+ CheckEntry :: Unchecked {
1566+ name: "salt" . to_string( ) ,
1567+ } ,
1568+ ] ;
1569+
1570+ let checked = crate :: shopping_checked_set ( & entries) ;
1571+ // salt was unchecked, pepper stays checked
1572+ assert ! ( !checked. contains( & "salt" . to_string( ) ) ) ;
1573+ assert ! ( checked. contains( & "pepper" . to_string( ) ) ) ;
1574+ }
1575+
1576+ #[ test]
1577+ fn test_write_shopping_check_entry ( ) {
1578+ use crate :: shopping_list:: CheckEntry ;
1579+
1580+ let entry = CheckEntry :: Checked {
1581+ name : "salt" . to_string ( ) ,
1582+ } ;
1583+ assert_eq ! (
1584+ crate :: write_shopping_check_entry( & entry) . unwrap( ) ,
1585+ "+ salt\n "
1586+ ) ;
1587+
1588+ let entry = CheckEntry :: Unchecked {
1589+ name : "pepper" . to_string ( ) ,
1590+ } ;
1591+ assert_eq ! (
1592+ crate :: write_shopping_check_entry( & entry) . unwrap( ) ,
1593+ "- pepper\n "
1594+ ) ;
1595+ }
1596+
1597+ #[ test]
1598+ fn test_compact_shopping_checked ( ) {
1599+ use crate :: shopping_list:: CheckEntry ;
1600+
1601+ let entries = vec ! [
1602+ CheckEntry :: Checked {
1603+ name: "salt" . to_string( ) ,
1604+ } ,
1605+ CheckEntry :: Checked {
1606+ name: "pepper" . to_string( ) ,
1607+ } ,
1608+ CheckEntry :: Checked {
1609+ name: "removed ingredient" . to_string( ) ,
1610+ } ,
1611+ ] ;
1612+
1613+ // Caller has already aggregated the user-visible ingredient names
1614+ // (e.g. by expanding recipe references and categorizing against an
1615+ // aisle config). Only "salt" is currently in the list.
1616+ let current_ingredients = vec ! [ "salt" . to_string( ) ] ;
1617+
1618+ let compacted = crate :: compact_shopping_checked ( & entries, & current_ingredients) ;
1619+ // Only "salt" should remain — it's checked and still in the list.
1620+ // "pepper" and "removed ingredient" are not in the list.
1621+ assert_eq ! ( compacted. len( ) , 1 ) ;
1622+ assert ! ( matches!( & compacted[ 0 ] , CheckEntry :: Checked { name } if name == "salt" ) ) ;
1623+ }
13401624}
0 commit comments