Skip to main content

sweet_potator/recipe/
ingredient.rs

1use std::{fmt, num::IntErrorKind, ops::Not};
2
3use serde::Serialize;
4
5use super::{
6    errors::{ParseError, ParseResult},
7    ParseFromStr,
8};
9
10#[derive(Clone, Copy, Debug, Serialize)]
11pub struct Integer(u32);
12
13impl Integer {
14    fn try_parse_from_str(s: &str) -> ParseResult<Option<Self>> {
15        match s.parse() {
16            Ok(int) => Ok(Some(Self(int))),
17            Err(error) if *error.kind() != IntErrorKind::PosOverflow => Ok(None),
18            Err(_) => Err("integer is out of range".into()),
19        }
20    }
21}
22
23#[derive(Clone, Copy, Debug, Serialize)]
24pub struct Decimal {
25    int: u16,
26    frac: u16,
27}
28
29impl Decimal {
30    fn try_parse_from_str(s: &str) -> ParseResult<Option<Self>> {
31        let Some((int, frac)) = s.split_once('.') else {
32            return Ok(None);
33        };
34        match (int.parse(), frac.parse()) {
35            (Ok(int), Ok(frac)) => Ok(Some(Self { int, frac })),
36            (Err(error), _) if *error.kind() != IntErrorKind::PosOverflow => Ok(None),
37            (_, Err(error)) if *error.kind() != IntErrorKind::PosOverflow => Ok(None),
38            (Err(_), _) => Err("decimal integral part is out of range".into()),
39            (_, Err(_)) => Err("decimal fractional part is out of range".into()),
40        }
41    }
42}
43
44#[derive(Clone, Copy, Debug, Serialize)]
45pub struct Fraction {
46    numer: u8,
47    denom: u8,
48}
49
50impl Fraction {
51    fn add_integer(self, int: u8) -> Option<Self> {
52        Some(Self {
53            numer: int
54                .checked_mul(self.denom)
55                .and_then(|product| product.checked_add(self.numer))?,
56            denom: self.denom,
57        })
58    }
59
60    fn try_parse_from_str(s: &str) -> ParseResult<Option<Self>> {
61        let Some((numer, denom)) = s.split_once('/') else {
62            return Ok(None);
63        };
64        match (numer.parse(), denom.parse()) {
65            (Ok(numer), Ok(denom)) => Ok(Some(Self { numer, denom })),
66            (Err(error), _) if *error.kind() != IntErrorKind::PosOverflow => Ok(None),
67            (_, Err(error)) if *error.kind() != IntErrorKind::PosOverflow => Ok(None),
68            (Err(_), _) => Err("fraction numerator is out of range".into()),
69            (_, Err(_)) => Err("fraction denominator is out of range".into()),
70        }
71    }
72}
73
74#[derive(Debug, Serialize)]
75#[serde(rename_all = "snake_case")]
76pub enum QuantityValue {
77    Integer(Integer),
78    Decimal(Decimal),
79    Fraction(Fraction),
80}
81
82impl QuantityValue {
83    pub fn parse_from_str(value: &str) -> ParseResult<(Self, &str)> {
84        let (value, rest) = value.split_once(' ').unwrap_or((value, ""));
85        if let Some(integer) = Integer::try_parse_from_str(value)? {
86            match Self::parse_mixed_number(integer, rest)? {
87                Some((fraction, rest)) => Ok((Self::Fraction(fraction), rest)),
88                None => Ok((Self::Integer(integer), rest)),
89            }
90        } else if let Some(decimal) = Decimal::try_parse_from_str(value)? {
91            Ok((Self::Decimal(decimal), rest))
92        } else if let Some(fraction) = Fraction::try_parse_from_str(value)? {
93            Ok((Self::Fraction(fraction), rest))
94        } else {
95            Err(format!("invalid ingredient quantity value: '{value}'").into())
96        }
97    }
98
99    fn parse_mixed_number(
100        Integer(int): Integer,
101        value: &str,
102    ) -> ParseResult<Option<(Fraction, &str)>> {
103        let (value, rest) = value.split_once(' ').unwrap_or((value, ""));
104        let Some(fraction) = Fraction::try_parse_from_str(value)? else {
105            return Ok(None);
106        };
107        let fraction = int
108            .try_into()
109            .ok()
110            .and_then(|int| fraction.add_integer(int))
111            .ok_or_else(|| format!("mixed number fraction '{value}' is out of range"))?;
112        Ok(Some((fraction, rest)))
113    }
114}
115
116#[derive(Debug, Serialize)]
117pub struct Quantity {
118    pub value: QuantityValue,
119    pub unit: Option<String>,
120    pub note: Option<String>,
121}
122
123impl fmt::Display for QuantityValue {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Self::Integer(Integer(value)) => {
127                write!(f, "{value}")
128            }
129            Self::Decimal(Decimal { int, frac }) => {
130                write!(f, "{int}.{frac}")
131            }
132            Self::Fraction(Fraction { numer, denom }) => {
133                write!(f, "{numer}/{denom}")
134            }
135        }
136    }
137}
138
139impl fmt::Display for Quantity {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        write!(f, "{}", self.value)?;
142        if let Some(unit) = &self.unit {
143            write!(f, " {unit}")?;
144        }
145        if let Some(note) = &self.note {
146            write!(f, " ({note})")?;
147        }
148        Ok(())
149    }
150}
151
152impl ParseFromStr for Quantity {
153    fn parse_from_str(s: &str) -> ParseResult<Self> {
154        let (value, rest) = QuantityValue::parse_from_str(s)?;
155        let (unit, note) = if let Some((unit, note)) = rest
156            .split_once(" (")
157            .or_else(|| rest.strip_prefix('(').map(|note| ("", note)))
158        {
159            let unit = unit.trim().to_string();
160            let note = note
161                .strip_suffix(')')
162                .ok_or("missing closing parenthesis of quantity note")?
163                .trim();
164            (unit, note.is_empty().not().then(|| note.into()))
165        } else {
166            (rest.trim_start().into(), None)
167        };
168        let unit = unit.is_empty().not().then_some(unit);
169        Ok(Self { value, unit, note })
170    }
171}
172
173#[derive(Debug, Serialize)]
174pub struct Ingredient {
175    pub name: String,
176    pub kind: Option<String>,
177    pub quantity: Option<Quantity>,
178}
179
180impl fmt::Display for Ingredient {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(f, "{}", self.name)?;
183        if let Some(kind) = &self.kind {
184            write!(f, ", {kind}")?;
185        }
186        if let Some(quantity) = &self.quantity {
187            write!(f, ": {quantity}")?;
188        }
189        Ok(())
190    }
191}
192
193impl ParseFromStr for Ingredient {
194    fn parse_from_str(s: &str) -> ParseResult<Self> {
195        let (name, quantity) = s.split_once(": ").map_or((s, ""), |(name, quantity)| {
196            (name.trim_end(), quantity.trim_start())
197        });
198        let (name, kind) = name.split_once(", ").map_or((name, ""), |(name, kind)| {
199            (name.trim_end(), kind.trim_start())
200        });
201        if name.is_empty() {
202            return Err(ParseError::empty("ingredient name"));
203        }
204        let kind = kind.is_empty().not().then(|| kind.into());
205        let quantity = if quantity.is_empty() {
206            None
207        } else {
208            Some(Quantity::parse_from_str(quantity)?)
209        };
210        Ok(Self {
211            name: name.into(),
212            kind,
213            quantity,
214        })
215    }
216}
217
218#[cfg(test)]
219mod tests {
220
221    use super::*;
222
223    #[test]
224    fn test_display_quantity() {
225        let mut quantity = Quantity {
226            value: QuantityValue::Decimal(Decimal { int: 0, frac: 5 }),
227            unit: None,
228            note: None,
229        };
230        assert_eq!(quantity.to_string(), "0.5");
231        quantity.value = QuantityValue::Fraction(Fraction { numer: 1, denom: 2 });
232        assert_eq!(quantity.to_string(), "1/2");
233        quantity.value = QuantityValue::Integer(Integer(1));
234        assert_eq!(quantity.to_string(), "1");
235        quantity.unit = Some("unit".into());
236        assert_eq!(quantity.to_string(), "1 unit");
237        quantity.note = Some("note".into());
238        assert_eq!(quantity.to_string(), "1 unit (note)");
239        quantity.unit = None;
240        assert_eq!(quantity.to_string(), "1 (note)");
241    }
242
243    #[test]
244    fn test_parse_quantity() {
245        let quantity = Quantity::parse_from_str("1").unwrap();
246        assert!(matches!(quantity.value, QuantityValue::Integer(Integer(1))));
247        assert_eq!(quantity.unit, None);
248        assert_eq!(quantity.note, None);
249        let quantity = Quantity::parse_from_str("0.5").unwrap();
250        assert!(matches!(
251            quantity.value,
252            QuantityValue::Decimal(Decimal { int: 0, frac: 5 })
253        ));
254        let quantity = Quantity::parse_from_str("1/2").unwrap();
255        assert!(matches!(
256            quantity.value,
257            QuantityValue::Fraction(Fraction { numer: 1, denom: 2 })
258        ));
259        let quantity = Quantity::parse_from_str("1  a unit").unwrap();
260        assert!(matches!(quantity.value, QuantityValue::Integer(Integer(1))));
261        assert_eq!(quantity.unit, Some("a unit".into()));
262        assert_eq!(quantity.note, None);
263        let quantity = Quantity::parse_from_str("1  ( a note )").unwrap();
264        assert!(matches!(quantity.value, QuantityValue::Integer(Integer(1))));
265        assert_eq!(quantity.unit, None);
266        assert_eq!(quantity.note, Some("a note".into()));
267        let quantity = Quantity::parse_from_str("10 1 unit  ( a note )").unwrap();
268        assert!(matches!(
269            quantity.value,
270            QuantityValue::Integer(Integer(10))
271        ));
272        assert_eq!(quantity.unit, Some("1 unit".into()));
273        assert_eq!(quantity.note, Some("a note".into()));
274    }
275
276    #[test]
277    fn test_display_ingredient() {
278        let quantity = Quantity {
279            value: QuantityValue::Integer(Integer(1)),
280            unit: None,
281            note: None,
282        };
283        let mut ingredient = Ingredient {
284            name: "name".into(),
285            kind: None,
286            quantity: None,
287        };
288        assert_eq!(ingredient.to_string(), "name");
289        ingredient.kind = Some("kind".into());
290        assert_eq!(ingredient.to_string(), "name, kind");
291        ingredient.quantity = Some(quantity);
292        assert_eq!(ingredient.to_string(), "name, kind: 1");
293        ingredient.kind = None;
294        assert_eq!(ingredient.to_string(), "name: 1");
295    }
296
297    #[test]
298    fn test_parse_ingredient() {
299        let ingredient = Ingredient::parse_from_str("a name").unwrap();
300        assert_eq!(ingredient.name, "a name");
301        assert_eq!(ingredient.kind, None);
302        assert!(ingredient.quantity.is_none());
303        let ingredient = Ingredient::parse_from_str("a name ,  a kind").unwrap();
304        assert_eq!(ingredient.name, "a name");
305        assert_eq!(ingredient.kind, Some("a kind".into()));
306        assert!(ingredient.quantity.is_none());
307        let ingredient = Ingredient::parse_from_str("a name :  1 unit (note)").unwrap();
308        assert_eq!(ingredient.name, "a name");
309        assert_eq!(ingredient.kind, None);
310        assert_eq!(ingredient.quantity.unwrap().to_string(), "1 unit (note)");
311        let ingredient = Ingredient::parse_from_str("a name ,  a kind :  1 unit (note)").unwrap();
312        assert_eq!(ingredient.name, "a name");
313        assert_eq!(ingredient.kind, Some("a kind".into()));
314        let quantity = ingredient.quantity.unwrap();
315        assert_eq!(quantity.value.to_string(), "1");
316        assert_eq!(quantity.unit.unwrap(), "unit");
317        assert_eq!(quantity.note.unwrap(), "note");
318        let ingredient = Ingredient::parse_from_str("name: 0.5 (note)").unwrap();
319        assert_eq!(ingredient.name, "name");
320        assert_eq!(ingredient.kind, None);
321        let quantity = ingredient.quantity.unwrap();
322        assert_eq!(quantity.value.to_string(), "0.5");
323        assert!(quantity.unit.is_none());
324        assert_eq!(quantity.note.unwrap(), "note");
325        let ingredient = Ingredient::parse_from_str("name: 1/2").unwrap();
326        assert_eq!(ingredient.name, "name");
327        assert_eq!(ingredient.kind, None);
328        assert_eq!(ingredient.quantity.unwrap().to_string(), "1/2");
329        let ingredient = Ingredient::parse_from_str("name: 1 1/2").unwrap();
330        assert_eq!(ingredient.name, "name");
331        assert_eq!(ingredient.kind, None);
332        assert_eq!(ingredient.quantity.unwrap().value.to_string(), "3/2");
333    }
334}