1use std::sync::Arc;
2
3use cooklang::aisle::parse as parse_aisle_config_original;
4use cooklang::analysis::parse_events;
5use cooklang::parser::PullParser;
6use cooklang::{Converter, Extensions};
7
8pub mod aisle;
9pub mod model;
10
11use aisle::*;
12use model::*;
13
14#[uniffi::export]
15pub fn parse_recipe(input: String) -> CooklangRecipe {
16 let extensions = Extensions::empty();
17 let converter = Converter::empty();
18
19 let mut parser = PullParser::new(&input, extensions);
20 let parsed = parse_events(
21 &mut parser,
22 &input,
23 extensions,
24 &converter,
25 Default::default(),
26 )
27 .unwrap_output();
28
29 into_simple_recipe(&parsed)
30}
31
32#[uniffi::export]
33pub fn parse_metadata(input: String) -> CooklangMetadata {
34 let mut metadata = CooklangMetadata::new();
35 let extensions = Extensions::empty();
36 let converter = Converter::empty();
37
38 let parser = PullParser::new(&input, extensions);
39
40 let parsed = parse_events(
41 parser.into_meta_iter(),
42 &input,
43 extensions,
44 &converter,
45 Default::default(),
46 )
47 .map(|c| c.metadata.map)
48 .unwrap_output();
49
50 let _ = &(parsed).iter().for_each(|(key, value)| {
52 if let (Some(key), Some(value)) = (key.as_str(), value.as_str()) {
53 metadata.insert(key.to_string(), value.to_string());
54 }
55 });
56
57 metadata
58}
59
60#[uniffi::export]
61pub fn deref_component(recipe: &CooklangRecipe, item: Item) -> Component {
62 match item {
63 Item::IngredientRef { index } => {
64 Component::IngredientComponent(recipe.ingredients.get(index as usize).unwrap().clone())
65 }
66 Item::CookwareRef { index } => {
67 Component::CookwareComponent(recipe.cookware.get(index as usize).unwrap().clone())
68 }
69 Item::TimerRef { index } => {
70 Component::TimerComponent(recipe.timers.get(index as usize).unwrap().clone())
71 }
72 Item::Text { value } => Component::TextComponent(value),
73 }
74}
75
76#[uniffi::export]
77pub fn deref_ingredient(recipe: &CooklangRecipe, index: u32) -> Ingredient {
78 recipe.ingredients.get(index as usize).unwrap().clone()
79}
80
81#[uniffi::export]
82pub fn deref_cookware(recipe: &CooklangRecipe, index: u32) -> Cookware {
83 recipe.cookware.get(index as usize).unwrap().clone()
84}
85
86#[uniffi::export]
87pub fn deref_timer(recipe: &CooklangRecipe, index: u32) -> Timer {
88 recipe.timers.get(index as usize).unwrap().clone()
89}
90
91#[uniffi::export]
92pub fn parse_aisle_config(input: String) -> Arc<AisleConf> {
93 let mut categories: Vec<AisleCategory> = Vec::new();
94 let mut cache: AisleReverseCategory = AisleReverseCategory::default();
95
96 let parsed = parse_aisle_config_original(&input).unwrap();
97
98 let _ = &(parsed).categories.iter().for_each(|c| {
99 let category = into_category(c);
100
101 category.ingredients.iter().for_each(|i| {
103 cache.insert(i.name.clone(), category.name.clone());
104
105 i.aliases.iter().for_each(|a| {
106 cache.insert(a.to_string(), category.name.clone());
107 });
108 });
109
110 categories.push(category);
111 });
112
113 let config = AisleConf { categories, cache };
114
115 Arc::new(config)
116}
117
118#[uniffi::export]
119pub fn combine_ingredients(ingredients: &Vec<Ingredient>) -> IngredientList {
120 let indices = (0..ingredients.len()).map(|i| i as u32).collect();
121 combine_ingredients_selected(ingredients, &indices)
122}
123
124#[uniffi::export]
125pub fn combine_ingredients_selected(
126 ingredients: &[Ingredient],
127 indices: &Vec<u32>,
128) -> IngredientList {
129 let mut combined: IngredientList = IngredientList::default();
130
131 expand_with_ingredients(ingredients, &mut combined, indices);
132
133 combined
134}
135
136uniffi::setup_scaffolding!();
137
138#[cfg(test)]
139mod tests {
140
141 #[test]
142 fn test_parse_recipe() {
143 use crate::{
144 deref_component, parse_recipe, Amount, Block, Component, Ingredient, Item, Value,
145 };
146
147 let recipe = parse_recipe(
148 r#"
149a test @step @salt{1%mg} more text
150"#
151 .to_string(),
152 );
153
154 assert_eq!(
155 deref_component(&recipe, Item::IngredientRef { index: 1 }),
156 Component::IngredientComponent(Ingredient {
157 name: "salt".to_string(),
158 amount: Some(Amount {
159 quantity: Value::Number { value: 1.0 },
160 units: Some("mg".to_string())
161 }),
162 descriptor: None
163 })
164 );
165
166 assert_eq!(
167 match recipe
168 .sections
169 .into_iter()
170 .next()
171 .expect("No blocks found")
172 .blocks
173 .into_iter()
174 .next()
175 .expect("No blocks found")
176 {
177 Block::StepBlock(step) => step,
178 _ => panic!("Expected first block to be a Step"),
179 }
180 .items,
181 vec![
182 Item::Text {
183 value: "a test ".to_string()
184 },
185 Item::IngredientRef { index: 0 },
186 Item::Text {
187 value: " ".to_string()
188 },
189 Item::IngredientRef { index: 1 },
190 Item::Text {
191 value: " more text".to_string()
192 }
193 ]
194 );
195
196 assert_eq!(
197 recipe.ingredients,
198 vec![
199 Ingredient {
200 name: "step".to_string(),
201 amount: None,
202 descriptor: None
203 },
204 Ingredient {
205 name: "salt".to_string(),
206 amount: Some(Amount {
207 quantity: Value::Number { value: 1.0 },
208 units: Some("mg".to_string())
209 }),
210 descriptor: None
211 },
212 ]
213 );
214 }
215
216 #[test]
217 fn test_parse_metadata() {
218 use crate::parse_metadata;
219 use std::collections::HashMap;
220
221 let metadata = parse_metadata(
222 r#"---
223source: https://google.com
224---
225a test @step @salt{1%mg} more text
226"#
227 .to_string(),
228 );
229
230 assert_eq!(
231 metadata,
232 HashMap::from([("source".to_string(), "https://google.com".to_string())])
233 );
234 }
235
236 #[test]
237 fn test_parse_aisle_config() {
238 use crate::parse_aisle_config;
239
240 let config = parse_aisle_config(
241 r#"
242[fruit and veg]
243apple gala | apples
244aubergine
245avocado | avocados
246
247[milk and dairy]
248butter
249egg | eggs
250curd cheese
251cheddar cheese
252feta
253
254[dried herbs and spices]
255bay leaves
256black pepper
257cayenne pepper
258dried oregano
259"#
260 .to_string(),
261 );
262
263 assert_eq!(
264 config.category_for("bay leaves".to_string()),
265 Some("dried herbs and spices".to_string())
266 );
267
268 assert_eq!(
269 config.category_for("eggs".to_string()),
270 Some("milk and dairy".to_string())
271 );
272
273 assert_eq!(
274 config.category_for("some weird ingredient".to_string()),
275 None
276 );
277 }
278
279 #[test]
280 fn test_combine_ingredients() {
281 use crate::{
282 combine_ingredients, Amount, GroupedQuantityKey, Ingredient, QuantityType, Value,
283 };
284 use std::collections::HashMap;
285
286 let ingredients = vec![
287 Ingredient {
288 name: "salt".to_string(),
289 amount: Some(Amount {
290 quantity: Value::Number { value: 5.0 },
291 units: Some("g".to_string()),
292 }),
293 descriptor: None,
294 },
295 Ingredient {
296 name: "pepper".to_string(),
297 amount: Some(Amount {
298 quantity: Value::Number { value: 5.0 },
299 units: Some("mg".to_string()),
300 }),
301 descriptor: None,
302 },
303 Ingredient {
304 name: "salt".to_string(),
305 amount: Some(Amount {
306 quantity: Value::Number { value: 0.005 },
307 units: Some("kg".to_string()),
308 }),
309 descriptor: None,
310 },
311 Ingredient {
312 name: "pepper".to_string(),
313 amount: Some(Amount {
314 quantity: Value::Number { value: 1.0 },
315 units: Some("tsp".to_string()),
316 }),
317 descriptor: None,
318 },
319 ];
320
321 let combined = combine_ingredients(&ingredients);
322
323 assert_eq!(
324 *combined.get("salt").unwrap(),
325 HashMap::from([
326 (
327 GroupedQuantityKey {
328 name: "kg".to_string(),
329 unit_type: QuantityType::Number
330 },
331 Value::Number { value: 0.005 }
332 ),
333 (
334 GroupedQuantityKey {
335 name: "g".to_string(),
336 unit_type: QuantityType::Number
337 },
338 Value::Number { value: 5.0 }
339 ),
340 ])
341 );
342
343 assert_eq!(
344 *combined.get("pepper").unwrap(),
345 HashMap::from([
346 (
347 GroupedQuantityKey {
348 name: "mg".to_string(),
349 unit_type: QuantityType::Number
350 },
351 Value::Number { value: 5.0 }
352 ),
353 (
354 GroupedQuantityKey {
355 name: "tsp".to_string(),
356 unit_type: QuantityType::Number
357 },
358 Value::Number { value: 1.0 }
359 ),
360 ])
361 );
362 }
363
364 #[test]
365 fn test_parse_recipe_with_note() {
366 use crate::{parse_recipe, Block, Item};
367
368 let recipe = parse_recipe(
369 r#"
370> This dish is even better the next day, after the flavors have melded overnight.
371
372Cook @onions{3%large} until brown
373"#
374 .to_string(),
375 );
376
377 let first_section = recipe
378 .sections
379 .into_iter()
380 .next()
381 .expect("No sections found");
382
383 assert_eq!(first_section.blocks.len(), 2);
384
385 let mut iterator = first_section.blocks.into_iter();
387 let note_block = iterator.next().expect("No blocks found");
388
389 assert_eq!(
390 match note_block {
391 Block::NoteBlock(note) => note,
392 _ => panic!("Expected first block to be a Note"),
393 }
394 .text,
395 "This dish is even better the next day, after the flavors have melded overnight."
396 .to_string()
397 );
398
399 let step_block = iterator.next().expect("No blocks found");
401
402 assert_eq!(
403 match step_block {
404 Block::StepBlock(step) => step,
405 _ => panic!("Expected second block to be a Step"),
406 }
407 .items,
408 vec![
409 Item::Text {
410 value: "Cook ".to_string()
411 },
412 Item::IngredientRef { index: 0 },
413 Item::Text {
414 value: " until brown".to_string()
415 }
416 ]
417 );
418 }
419
420 #[test]
421 fn test_parse_recipe_with_multiline_steps() {
422 use crate::{parse_recipe, Block, Item};
423
424 let recipe = parse_recipe(
425 r#"
426add @onions{2} to pan
427heat until golden
428
429add @tomatoes{400%g}
430simmer for 10 minutes
431"#
432 .to_string(),
433 );
434 let first_section = recipe
435 .sections
436 .into_iter()
437 .next()
438 .expect("No sections found");
439 assert_eq!(first_section.blocks.len(), 2);
440
441 let mut iterator = first_section.blocks.into_iter();
443 let first_block = iterator.next().expect("No blocks found");
444 let second_block = iterator.next().expect("No blocks found");
445
446 assert_eq!(
447 match first_block {
448 Block::StepBlock(step) => step,
449 _ => panic!("Expected first block to be a Step"),
450 }
451 .items,
452 vec![
453 Item::Text {
454 value: "add ".to_string()
455 },
456 Item::IngredientRef { index: 0 },
457 Item::Text {
458 value: " to pan heat until golden".to_string()
459 }
460 ]
461 );
462
463 assert_eq!(
465 match second_block {
466 Block::StepBlock(step) => step,
467 _ => panic!("Expected second block to be a Step"),
468 }
469 .items,
470 vec![
471 Item::Text {
472 value: "add ".to_string()
473 },
474 Item::IngredientRef { index: 1 },
475 Item::Text {
476 value: " simmer for 10 minutes".to_string()
477 }
478 ]
479 );
480 }
481
482 #[test]
483 fn test_parse_recipe_with_sections() {
484 use crate::{parse_recipe, Block, Item};
485
486 let recipe = parse_recipe(
487 r#"
488= Dough
489
490Mix @flour{200%g} and @water{50%ml} together until smooth.
491
492== Filling ==
493
494Combine @cheese{100%g} and @spinach{50%g}, then season to taste.
495"#
496 .to_string(),
497 );
498
499 let mut sections = recipe.sections.into_iter();
500
501 let first_section = sections.next().expect("No sections found");
503 assert_eq!(first_section.title, Some("Dough".to_string()));
504 assert_eq!(first_section.blocks.len(), 1);
505
506 let first_block = first_section
507 .blocks
508 .into_iter()
509 .next()
510 .expect("No blocks found");
511 assert_eq!(
512 match first_block {
513 Block::StepBlock(step) => step,
514 _ => panic!("Expected block to be a Step"),
515 }
516 .items,
517 vec![
518 Item::Text {
519 value: "Mix ".to_string()
520 },
521 Item::IngredientRef { index: 0 },
522 Item::Text {
523 value: " and ".to_string()
524 },
525 Item::IngredientRef { index: 1 },
526 Item::Text {
527 value: " together until smooth.".to_string()
528 }
529 ]
530 );
531
532 let second_section = sections.next().expect("No second section found");
534 assert_eq!(second_section.title, Some("Filling".to_string()));
535 assert_eq!(second_section.blocks.len(), 1);
536
537 let second_block = second_section
538 .blocks
539 .into_iter()
540 .next()
541 .expect("No blocks found");
542 assert_eq!(
543 match second_block {
544 Block::StepBlock(step) => step,
545 _ => panic!("Expected block to be a Step"),
546 }
547 .items,
548 vec![
549 Item::Text {
550 value: "Combine ".to_string()
551 },
552 Item::IngredientRef { index: 2 },
553 Item::Text {
554 value: " and ".to_string()
555 },
556 Item::IngredientRef { index: 3 },
557 Item::Text {
558 value: ", then season to taste.".to_string()
559 }
560 ]
561 );
562 }
563}