cooklang_bindings/
lib.rs

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    // converting IndexMap into HashMap
51    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        // building cache
102        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        // Check note block
386        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        // Check step block
400        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        // Check first step
442        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        // Check second step
464        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        // Check first section
502        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        // Check second section
533        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}