Skip to content

Commit aa0ea37

Browse files
authored
Merge pull request #93 from cooklang/feat/shopping-list-checked-support
feat: shopping list checked file support and comment syntax update
2 parents f9e2292 + 93fb44d commit aa0ea37

9 files changed

Lines changed: 837 additions & 21 deletions

File tree

bindings/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ edition = "2021"
55
authors = ["dubadub <dubovskoy.a@gmail.com>"]
66
description = "Cooklang Uniffi bindings"
77
license = "MIT"
8-
keywords = ["cooklang", "unuffi"]
8+
keywords = ["cooklang", "uniffi"]
99
repository = "https://github.com/cooklang/cooklang-rs"
1010
readme = "README.md"
1111

1212
[dependencies]
1313
anyhow = "1.0"
14-
cooklang = { path = "..", default-features = false, features = ["aisle"] }
14+
cooklang = { path = "..", default-features = false, features = ["aisle", "shopping_list"] }
1515
uniffi = "0.28.1"
1616

1717
[lib]

bindings/src/lib.rs

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use cooklang::metadata::StdKey as OriginalStdKey;
55

66
pub mod aisle;
77
pub mod model;
8+
pub mod shopping_list;
89

910
use aisle::*;
1011
use 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+
583694
uniffi::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\nsalt{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

Comments
 (0)