recipe_parser/
parser.rs

1use std::fmt::Display;
2
3use winnow::ascii::{line_ending, multispace0, multispace1, space0, space1};
4use winnow::combinator::{alt, cut_err, delimited, opt, preceded, repeat};
5use winnow::error::{ContextError, ParseError, StrContext, StrContextValue};
6use winnow::token::{rest, take_till, take_until, take_while};
7use winnow::{LocatingSlice, ModalResult, Parser};
8
9type Input<'a> = LocatingSlice<&'a str>;
10
11/// Parses a valid string from the input.
12///
13/// This function takes a mutable reference to a string slice and parses a valid string from it.
14/// A valid string can contain alphanumeric characters as well as certain symbols and spaces.
15/// The function returns a `PResult` containing the parsed valid string.
16fn parse_valid_string<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
17    let spaces_and_symbols = "\t /-_@.,%#'";
18    take_while(1.., move |c: char| {
19        c.is_alphanumeric() || spaces_and_symbols.contains(c)
20    })
21    .parse_next(input)
22}
23
24/// Parse comments in the form of:
25///
26/// ```recp
27/// /* */
28/// ```
29fn parse_comment<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
30    delimited(
31        "/*",
32        cut_err(take_until(0.., "*/"))
33            .context(StrContext::Expected(StrContextValue::StringLiteral("*/")))
34            .map(|v: &str| v.trim()),
35        ("*/", space0),
36    )
37    .parse_next(input)
38}
39
40/// Parse curly braces delimited utf-8
41///
42/// ```recp
43/// {salt}
44/// {tomatoes}
45/// ```
46fn parse_curly<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
47    delimited(
48        "{",
49        parse_valid_string.map(|v| v.trim()),
50        cut_err("}").context(StrContext::Expected(StrContextValue::CharLiteral('}'))),
51        // "}"
52    )
53    .parse_next(input)
54}
55
56/// The amount of an ingredient must be numeric
57/// with a few symbols allowed.
58///
59/// ```recp
60/// 1
61/// 3.2
62/// 3,2
63/// 3_000_000
64/// 2/3
65/// ```
66fn parse_quantity<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
67    let spaces_and_symbols = ".,/_";
68
69    cut_err(
70        take_while(1.., move |c: char| {
71            c.is_numeric() || spaces_and_symbols.contains(c)
72        })
73        .verify(|s: &str| {
74            // NEXT: Can this be improved?
75            let has_repeated_symbols = s
76                .as_bytes()
77                .windows(2)
78                .any(|v| v[0] == v[1] && spaces_and_symbols.contains(char::from(v[0])));
79            let last_char = &s[s.len() - 1..];
80            !spaces_and_symbols.contains(last_char) && !has_repeated_symbols
81        }),
82    )
83    .context(StrContext::Expected(StrContextValue::Description(
84        "a quantity value, like 3, 1.2, 1/2 or 1_000",
85    )))
86    .parse_next(input)
87}
88
89/// Parse units like kg, kilograms, pinch, etc.
90fn parse_unit<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
91    parse_valid_string.parse_next(input)
92}
93
94/// Ingredient amounts are surrounded by parenthesis
95fn parse_ingredient_amount<'a>(
96    input: &mut Input<'a>,
97) -> ModalResult<(Option<&'a str>, Option<&'a str>)> {
98    delimited(
99        ("(", space0),
100        (
101            opt(parse_quantity),
102            opt(preceded(space0, parse_unit.map(|v| v.trim()))),
103        ),
104        cut_err(")").context(StrContext::Expected(StrContextValue::CharLiteral(')'))), // cut_err(")"),
105    )
106    // .context(StrContext::Expected(StrContextValue::CharLiteral('}')))
107    .parse_next(input)
108}
109
110/// Ingredients come in these formats:
111///
112/// ```recp
113/// {quinoa}(200gr)
114/// {tomatoes}(2)
115/// {sweet potatoes}(2)
116/// ```
117fn parse_ingredient<'a>(
118    input: &mut Input<'a>,
119) -> ModalResult<(&'a str, Option<(Option<&'a str>, Option<&'a str>)>)> {
120    (parse_curly, opt(parse_ingredient_amount)).parse_next(input)
121}
122
123/// Materials format:
124///
125/// ```recp
126/// &{pot}
127/// &{small jar}
128/// &{stick}
129/// ```
130fn parse_material<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
131    preceded("&", parse_curly).parse_next(input)
132}
133
134/// Materials format:
135///
136/// ```recp
137/// t{25 minutes}
138/// t{10 sec}
139/// ```
140fn parse_timer<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
141    preceded("t", parse_curly).parse_next(input)
142}
143
144/// Parse a reference to another recipe
145///
146/// ```recp
147/// @{woile/special-tomato-sauce}
148/// @{woile/special-tomato-sauce}(100 ml)
149/// ```
150fn parse_recipe_ref<'a>(
151    input: &mut Input<'a>,
152) -> ModalResult<(&'a str, Option<(Option<&'a str>, Option<&'a str>)>)> {
153    preceded("@", (parse_curly, opt(parse_ingredient_amount))).parse_next(input)
154}
155
156/// Tokens are separated into words
157fn parse_word<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
158    take_till(1.., (' ', '\t', '\r', '\n', '\'', '`')).parse_next(input)
159}
160
161fn parse_metadata<'a>(input: &mut Input<'a>) -> ModalResult<(&'a str, &'a str)> {
162    preceded(
163        (">>", space0),
164        (
165            take_while(1.., |c| c != ':'),
166            preceded((":", space0), take_until(0.., "\n")),
167        ),
168    )
169    .parse_next(input)
170}
171
172/// The backstory is separated by `---`, and it consumes till the end
173/// ```recp
174/// my recipe bla with {ingredient1}
175/// ---
176/// This recipe was given by my grandma
177/// ```
178fn parse_backstory<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
179    preceded(
180        delimited(
181            preceded(line_ending, multispace0),
182            "---",
183            preceded(line_ending, multispace0),
184        ),
185        rest,
186    )
187    .parse_next(input)
188}
189
190/// Symbols that create conflict when parsing
191///
192/// If you have a recipe like:
193///
194/// ```recp
195/// Take the l'{ingredient}
196/// ```
197///
198/// There's a word connected to the ingredient by a symbol, in order to be prevent
199/// the `parse_word` from ingesting the chunk `l'{ingredient}` as a word, we need
200/// to tell `parse_word` to stop at this character and also we need to catch it here.
201fn parse_special_symbols<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
202    alt(("(", "'", "`")).parse_next(input)
203}
204/* ****************
205* The main parser
206**************** */
207
208/// A recipe string is parsed into many of these tokens
209#[derive(Debug, Clone, Eq, PartialEq, Hash)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize))]
211#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
212// if you use `zod` for example, using a tag makes it easy to use an undiscriminated union
213#[cfg_attr(feature = "serde", serde(tag = "token", content = "content"))]
214#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
215#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
216pub enum Token<'a> {
217    /// Relevant information of a recipe that doesn't make the recipe itself
218    ///
219    /// Example:
220    /// ```recp
221    /// >> name: Salad
222    /// ```
223    Metadata {
224        key: &'a str,
225        value: &'a str,
226    },
227
228    /// Represents a recipe ingredient
229    ///
230    /// Example
231    ///
232    /// ```recp
233    /// {tomato}(1 kg)
234    /// ```
235    Ingredient {
236        name: &'a str,
237        quantity: Option<&'a str>,
238        unit: Option<&'a str>,
239    },
240
241    /// Link to another recipe
242    ///
243    /// Example
244    ///
245    /// ```recp
246    /// @{path/recipe}(30 ml)
247    /// ```
248    RecipeRef {
249        name: &'a str,
250        quantity: Option<&'a str>,
251        unit: Option<&'a str>,
252    },
253
254    /// Mark for a timer
255    ///
256    /// Example
257    ///
258    /// ```recp
259    /// t{25 minutes}
260    /// ```
261    Timer(&'a str),
262
263    /// Mark for a material required
264    ///
265    /// Example
266    ///
267    /// ```recp
268    /// &{blender}
269    /// ```
270    Material(&'a str),
271
272    Word(&'a str),
273    Space(&'a str),
274    Comment(&'a str),
275
276    /// Information, story or notes about a recipe
277    ///
278    /// Example
279    ///
280    /// ```recp
281    /// my recipe
282    /// ---
283    /// shared by my best friend
284    /// ```
285    Backstory(&'a str),
286}
287
288impl Display for Token<'_> {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        match self {
291            Token::Ingredient {
292                name,
293                quantity: _,
294                unit: _,
295            } => write!(f, "{}", name),
296            Token::RecipeRef {
297                name,
298                quantity: _,
299                unit: _,
300            } => write!(f, "\"{}\"", name),
301            Token::Backstory(v)
302            | Token::Timer(v)
303            | Token::Material(v)
304            | Token::Word(v)
305            | Token::Space(v) => {
306                write!(f, "{}", v)
307            }
308            Token::Metadata { key: _, value: _ } => Ok(()),
309            Token::Comment(_) => Ok(()),
310        }
311    }
312}
313
314pub fn recipe_value<'a>(input: &mut Input<'a>) -> ModalResult<Token<'a>> {
315    alt((
316        parse_metadata.map(|(key, value)| Token::Metadata { key, value }),
317        parse_material.map(|m| Token::Material(m)),
318        parse_timer.map(|t| Token::Timer(t)),
319        // Because ingredient doesn't have a prefix before the curly braces, e.g: `&{}`
320        // it must always be parsed after timer and material
321        parse_ingredient.map(|(name, amount)| {
322            let mut quantity = None;
323            let mut unit = None;
324            if let Some((_quantity, _unit)) = amount {
325                quantity = _quantity;
326                unit = _unit;
327            };
328
329            Token::Ingredient {
330                name,
331                quantity,
332                unit,
333            }
334        }),
335        parse_recipe_ref.map(|(name, amount)| {
336            let mut quantity = None;
337            let mut unit = None;
338            if let Some((_quantity, _unit)) = amount {
339                quantity = _quantity;
340                unit = _unit;
341            };
342
343            Token::RecipeRef {
344                name,
345                quantity,
346                unit,
347            }
348        }),
349        parse_backstory.map(|v| Token::Backstory(v)),
350        parse_comment.map(|v| Token::Comment(v)),
351        parse_special_symbols.map(|v| Token::Word(v)),
352        parse_word.map(|v| Token::Word(v)),
353        space1.map(|v| Token::Space(v)),
354        multispace1.map(|v| Token::Space(v)),
355    ))
356    .parse_next(input)
357}
358
359pub fn recipe<'a>(input: &mut Input<'a>) -> ModalResult<Vec<Token<'a>>> {
360    repeat(0.., recipe_value).parse_next(input)
361}
362
363/// Parse recipe tokens from a string
364///
365/// Example:
366///
367/// ```
368/// use recipe_parser::parse;
369///
370/// let input = "Take the {potatoe}(1) and boil it";
371/// let result = parse(input).expect("recipe could not be parsed");
372///
373/// println!("{result:?}");
374/// ```
375pub fn parse(input: &str) -> Result<Vec<Token<'_>>, ParseError<LocatingSlice<&str>, ContextError>> {
376    let input = LocatingSlice::new(input);
377    recipe.parse(input)
378}
379
380#[cfg(test)]
381mod test {
382    use super::*;
383    use rstest::*;
384
385    #[rstest]
386    #[case("salt", "salt")]
387    #[case("sweet potato", "sweet potato")]
388    #[case("ToMaToeS", "ToMaToeS")]
389    #[case("1/2 lemon", "1/2 lemon")]
390    #[case("my-best-sauce", "my-best-sauce")]
391    #[case("1.2", "1.2")]
392    #[case("1,2", "1,2")]
393    #[case("1_200", "1_200")]
394    #[case("@woile", "@woile")]
395    #[case("10%", "10%")]
396    #[case("#vegan", "#vegan")]
397    #[case("mango's", "mango's")]
398    fn test_parse_valid_string(#[case] input: String, #[case] expected: &str) {
399        let mut input = LocatingSlice::new(input.as_str());
400        let valid_str = parse_valid_string(&mut input).unwrap();
401        assert_eq!(valid_str, expected)
402    }
403
404    #[rstest]
405    #[case("/* */", "")]
406    #[case("/* hello */", "hello")]
407    #[case("/* multi\nline\ncomment */", "multi\nline\ncomment")]
408    fn test_parse_comment_ok(#[case] input: String, #[case] expected: &str) {
409        let mut input = LocatingSlice::new(input.as_str());
410        let comment = parse_comment(&mut input).expect("failed to parse comment");
411        assert_eq!(comment, expected)
412    }
413
414    #[test]
415    fn test_parse_comment_wrong() {
416        let mut input = LocatingSlice::new("/* unclosed");
417        let res = parse_comment(&mut input);
418        assert!(res.is_err());
419
420        let err = res.unwrap_err();
421        println!("{:?}", err);
422        assert!(matches!(err, winnow::error::ErrMode::Cut(_)));
423    }
424
425    #[rstest]
426    #[case("{salt}", "salt")]
427    #[case("{black pepper}", "black pepper")]
428    #[case("{smashed potatoes}", "smashed potatoes")]
429    #[case("{15 minutes}", "15 minutes")]
430    #[case("{   15 minutes  }", "15 minutes")]
431    fn test_parse_curly_ok(#[case] input: String, #[case] expected: &str) {
432        let mut input = LocatingSlice::new(input.as_str());
433        let content = parse_curly(&mut input).expect("to work");
434        assert_eq!(expected, content);
435    }
436
437    #[test]
438    fn test_parse_curly_wrong() {
439        let mut input = LocatingSlice::new("{}");
440        let res = parse_curly(&mut input);
441        assert!(res.is_err());
442
443        let mut input = LocatingSlice::new("{unclosed");
444        let res = parse_curly(&mut input);
445        assert!(res.is_err());
446
447        let err = res.unwrap_err();
448        assert!(matches!(err, winnow::error::ErrMode::Cut(_)));
449    }
450
451    #[rstest]
452    #[case("200", "200")]
453    #[case("2.1", "2.1")]
454    #[case("2_1", "2_1")]
455    #[case("2,1", "2,1")]
456    #[case("2.1", "2.1")]
457    #[case("1/2", "1/2")]
458    #[case(".2", ".2")]
459    fn test_parse_quantity_ok(#[case] input: String, #[case] expected: &str) {
460        let mut input = LocatingSlice::new(input.as_str());
461        let content = parse_quantity(&mut input).expect("to work");
462        assert_eq!(expected, content);
463    }
464
465    #[rstest]
466    #[case("2.")]
467    #[case("2..0")]
468    #[case("2,,0")]
469    #[case("2//0")]
470    fn test_parse_quantity_invalid(#[case] input: String) {
471        // TODO: Add verify function to validate the last char
472        let mut input = LocatingSlice::new(input.as_str());
473        let res = parse_quantity(&mut input);
474        let err = res.unwrap_err();
475        assert!(matches!(err, winnow::error::ErrMode::Cut(_)));
476    }
477
478    #[rstest]
479    #[case("(200gr)", (Some("200"), Some("gr")))]
480    #[case("(1/2)", (Some("1/2"), None))]
481    #[case("(100 gr)", (Some("100"), Some("gr")))]
482    #[case("(10 ml)", (Some("10"), Some("ml")))]
483    #[case("( 10 ml )", (Some("10"), Some("ml")))]
484    #[case("(1.5 cups)", (Some("1.5"), Some("cups")))]
485    fn test_parse_ingredient_amount_ok(
486        #[case] input: String,
487        #[case] expected: (Option<&str>, Option<&str>),
488    ) {
489        let mut input = LocatingSlice::new(input.as_str());
490        let content = parse_ingredient_amount(&mut input).expect("to work");
491        assert_eq!(expected, content);
492    }
493
494    #[rstest]
495    #[case("()")]
496    #[case("(unclosed")]
497    fn test_parse_ingredient_amount_invalid_quantity(#[case] input: String) {
498        let mut input = LocatingSlice::new(input.as_str());
499        let res = parse_ingredient_amount(&mut input);
500        match res {
501            Ok(_) => {
502                // should fail the test
503                assert!(false);
504            }
505            Err(e) => match e {
506                winnow::error::ErrMode::Cut(err) => {
507                    println!("{}", err);
508                    assert_eq!(
509                        "expected a quantity value, like 3, 1.2, 1/2 or 1_000",
510                        err.to_string()
511                    );
512                    assert!(true);
513                }
514                _ => {
515                    assert!(false);
516                }
517            },
518        }
519    }
520
521    #[rstest]
522    #[case("(1.5")]
523    fn test_parse_ingredient_amount_invalid_amount(#[case] input: String) {
524        let mut input = LocatingSlice::new(input.as_str());
525        let res = parse_ingredient_amount(&mut input);
526        match res {
527            Ok(_) => {
528                // should fail the test
529                assert!(false);
530            }
531            Err(e) => match e {
532                winnow::error::ErrMode::Cut(err) => {
533                    println!("{}", err);
534                    assert_eq!("expected `)`", err.to_string());
535                    assert!(true);
536                }
537                _ => {
538                    assert!(false);
539                }
540            },
541        }
542    }
543
544    #[rstest]
545    #[case("{sweet potato}(200gr)", "sweet potato", Some((Some("200"),Some("gr"))))]
546    #[case("{sweet potato}", "sweet potato", None)]
547    fn test_parse_ingredient_ok(
548        #[case] input: String,
549        #[case] expected_ingredient: &str,
550        #[case] expected_amount: Option<(Option<&str>, Option<&str>)>,
551    ) {
552        let mut input = LocatingSlice::new(input.as_str());
553        let (ingredient, amount) = parse_ingredient(&mut input).unwrap();
554        assert_eq!(expected_ingredient, ingredient);
555        assert_eq!(expected_amount, amount);
556    }
557
558    #[rstest]
559    #[case("&{pot}", "pot")]
560    #[case("&{small jar}", "small jar")]
561    #[case("&{stick}", "stick")]
562    #[case("&{bricks}", "bricks")]
563    fn test_parse_material_ok(#[case] input: String, #[case] expected: &str) {
564        let mut input = LocatingSlice::new(input.as_str());
565        let material = parse_material(&mut input).expect("Failed to parse material");
566        assert_eq!(material, expected)
567    }
568
569    #[rstest]
570    #[case("t{1 minute}", "1 minute")]
571    fn test_parse_timer_ok(#[case] input: String, #[case] expected: &str) {
572        let mut input = LocatingSlice::new(input.as_str());
573        let timer = parse_timer(&mut input).expect("Failed to parse timer");
574        assert_eq!(timer, expected)
575    }
576
577    #[rstest]
578    #[case("@{woile/tomato-sauce}(200gr)", "woile/tomato-sauce", Some((Some("200"),Some("gr"))))]
579    #[case("@{woile/tomato-sauce}", "woile/tomato-sauce", None)]
580    #[case("@{special stew}", "special stew", None)]
581    fn test_parse_recipe_ok(
582        #[case] input: String,
583        #[case] expected_recipe: &str,
584        #[case] expected_amount: Option<(Option<&str>, Option<&str>)>,
585    ) {
586        let mut input = LocatingSlice::new(input.as_str());
587        let (recipe, amount) = parse_recipe_ref(&mut input).unwrap();
588        assert_eq!(expected_recipe, recipe);
589        assert_eq!(expected_amount, amount);
590    }
591
592    #[rstest]
593    #[case(">> tags: vegan\n", ("tags", "vegan"))]
594    #[case(">> key: pepe\n", ("key", "pepe"))]
595    #[case(">>key: pepe\n", ("key", "pepe"))]
596    #[case(">>    key: pepe\n", ("key", "pepe"))]
597    #[case(">>    key:     pepe\n", ("key", "pepe"))]
598    #[case(">>    key:\t\tpepe\n", ("key", "pepe"))]
599    #[case(">>    key:pepe\n", ("key", "pepe"))]
600    fn test_parse_metadata_ok(#[case] input: String, #[case] expected: (&str, &str)) {
601        let mut input = LocatingSlice::new(input.as_str());
602        let metadata = parse_metadata(&mut input).expect("Failed to parse metadata");
603        assert_eq!(metadata, expected)
604    }
605
606    #[rstest]
607    #[case("\n---\nwhat a backstory", "what a backstory")]
608    #[case("\n   ---\nwhat a backstory", "what a backstory")]
609    #[case("\n   ---\n\nwhat a backstory", "what a backstory")]
610    #[case("\n   ---\n\nthis is **markdown**", "this is **markdown**")]
611    #[case("\n   ---\n\nthis is [markdown](url)", "this is [markdown](url)")]
612    fn test_parse_backstory_ok(#[case] input: String, #[case] expected: &str) {
613        let mut input = LocatingSlice::new(input.as_str());
614        let backsotry = parse_backstory(&mut input).expect("failed to parse backstory");
615        assert_eq!(backsotry, expected)
616    }
617
618    #[rstest]
619    #[case("\n---    \nwhat a backstory")]
620    fn test_parse_backstory_fail(#[case] input: String) {
621        let mut input = LocatingSlice::new(input.as_str());
622        let out = parse_backstory(&mut input);
623        assert!(out.is_err())
624    }
625
626    #[rstest]
627    #[case(" ", Token::Space(" "))]
628    #[case("{holis}(100 gr)", Token::Ingredient { name: "holis", quantity: Some("100"), unit: Some("gr") })]
629    fn test_recipe_value_ok(#[case] input: &str, #[case] expected: Token) {
630        let mut input = LocatingSlice::new(input);
631        let token = recipe_value(&mut input).expect("failed to parse token");
632        assert_eq!(token, expected)
633    }
634
635    #[test]
636    fn test_recipe_ok() {
637        let input = "Boil the quinoa for t{5 minutes} in a &{pot}.\nPut the boiled {quinoa}(200gr) in the base of the bowl.";
638        let expected = "Boil the quinoa for 5 minutes in a pot.\nPut the boiled quinoa in the base of the bowl.";
639        let recipe = recipe
640            .parse(LocatingSlice::new(input))
641            .expect("parse failed");
642        let fmt_recipe = recipe
643            .iter()
644            .fold(String::new(), |acc, val| format!("{acc}{val}"));
645        println!("{}", fmt_recipe);
646
647        assert_eq!(expected, fmt_recipe);
648        println!("{:?}", recipe);
649    }
650
651    #[rstest]
652    #[case(" ", vec![Token::Space(" ")])]
653    #[case("\n\nhello", vec![Token::Space("\n\n"), Token::Word("hello")])]
654    #[case("hello\n", vec![Token::Word("hello"), Token::Space("\n")])]
655    #[case(">> tags: hello\n\nhello", vec![Token::Metadata {key: "tags", value: "hello"}, Token::Space("\n\n"), Token::Word("hello")])]
656    #[case(">> source: https://hello.com\n>> tags: hello\n", vec![Token::Metadata {key: "source", value: "https://hello.com"}, Token::Space("\n"), Token::Metadata {key: "tags", value: "hello"}, Token::Space("\n")])]
657    #[case("{holis}(100 gr)", vec![Token::Ingredient { name: "holis", quantity: Some("100"), unit: Some("gr") }])]
658    fn test_recipe_cases_ok(#[case] input: &str, #[case] expected: Vec<Token>) {
659        let mut input = LocatingSlice::new(input);
660        let token = recipe(&mut input).expect("failed to parse token");
661        assert_eq!(token, expected)
662    }
663
664    #[rstest]
665    #[case("Foo. ")]
666    #[case("Foo.")]
667    #[case("Foo, bar")]
668    #[case("Foo,bar")]
669    #[case("Foo,")]
670    #[case("Foo, ")]
671    #[case("Foo,\n")]
672    #[case("Foo.\n")]
673    #[case("Foo.\nfoo")]
674    #[case("Foo,\nfoo")]
675    fn test_symbol_parsing(#[case] input: &str) {
676        let recipe_result = parse(input);
677
678        assert!(recipe_result.is_ok());
679    }
680
681    #[test]
682    fn test_parse_ok() {
683        let input = "Boil the quinoa for t{5 minutes} in a &{pot}.\nPut the boiled {quinoa}(200gr) in the base of the bowl.";
684        let expected = "Boil the quinoa for 5 minutes in a pot.\nPut the boiled quinoa in the base of the bowl.";
685        let recipe = parse(input).expect("parse failed");
686        let fmt_recipe = recipe
687            .iter()
688            .fold(String::new(), |acc, val| format!("{acc}{val}"));
689        println!("{}", fmt_recipe);
690
691        assert_eq!(expected, fmt_recipe);
692        println!("{:?}", recipe);
693    }
694
695    #[rstest]
696    #[case("l'{ingredient}", vec![
697        Token::Word("l"),
698        Token::Word("'"),
699        Token::Ingredient {
700            name: "ingredient",
701            quantity: None,
702            unit: None,
703        },
704    ])]
705    #[case("l'&{material}", vec![
706        Token::Word("l"),
707        Token::Word("'"),
708        Token::Material("material"),
709    ])]
710    #[case("l't{mytimer}", vec![
711        Token::Word("l"),
712        Token::Word("'"),
713        Token::Timer("mytimer"),
714    ])]
715    #[case("l`{ingredient}", vec![
716        Token::Word("l"),
717        Token::Word("`"),
718        Token::Ingredient {
719            name: "ingredient",
720            quantity: None,
721            unit: None,
722        },
723    ])]
724    #[case("l`&{material}", vec![
725        Token::Word("l"),
726        Token::Word("`"),
727        Token::Material("material"),
728    ])]
729    #[case("l`t{mytimer}", vec![
730        Token::Word("l"),
731        Token::Word("`"),
732        Token::Timer("mytimer"),
733    ])]
734    fn test_parse_ingredients_without_spaces(#[case] input: &str, #[case] expected: Vec<Token>) {
735        let recipe = parse(input).expect("parse failed");
736
737        println!("{:?}", recipe);
738
739        assert_eq!(expected, recipe);
740    }
741
742    #[test]
743    fn test_parse_with_backstory_ok() {
744        let input = "Foo. \n---\nA backstory";
745        let expected = vec![
746            Token::Word("Foo."),
747            Token::Space(" "),
748            Token::Backstory("A backstory"),
749        ];
750        let recipe = parse(input).expect("parse failed");
751
752        println!("{:?}", recipe);
753
754        assert_eq!(expected, recipe);
755        println!("{:?}", recipe);
756    }
757
758    #[test]
759    #[cfg(feature = "serde")]
760    fn test_token_serialization_works() {
761        let token = Token::Ingredient {
762            name: "quinoa",
763            quantity: Some("200"),
764            unit: Some("gr"),
765        };
766
767        let serialized = serde_json::to_string(&token).expect("failed to serialize");
768        println!("{}", serialized);
769    }
770
771    #[test]
772    #[cfg(feature = "serde")]
773    fn test_token_serialization_creates_right_payload() {
774        let token = Token::Ingredient {
775            name: "quinoa",
776            quantity: Some("200"),
777            unit: Some("gr"),
778        };
779
780        let serialized = serde_json::to_string(&token).expect("failed to serialize");
781        assert_eq!(
782            serialized,
783            r#"{"token":"Ingredient","content":{"name":"quinoa","quantity":"200","unit":"gr"}}"#
784        );
785    }
786
787    #[test]
788    #[cfg(feature = "serde")]
789    fn test_token_serialization_creates_right_payload_single_string() {
790        let token = Token::Word("holis");
791
792        let serialized = serde_json::to_string(&token).expect("failed to serialize");
793        assert_eq!(serialized, r#"{"token":"Word","content":"holis"}"#);
794    }
795
796    #[test]
797    #[cfg(feature = "schemars")]
798    fn test_token_json_schema_generation() {
799        use schemars::schema_for;
800        let schema = schema_for!(Token);
801        println!("{}", serde_json::to_string_pretty(&schema).unwrap());
802    }
803}