kcal/
food.rs

1use std::{fmt, str::FromStr};
2
3use crate::{Portion, Unit};
4
5#[derive(Debug)]
6pub struct BadFood(String);
7
8impl fmt::Display for BadFood {
9    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10        write!(f, "{}: bad food", self.0)
11    }
12}
13
14#[derive(Clone)]
15pub struct Food {
16    /// kilocalories per 100g of this food
17    pub kcal: f64,
18    /// grams of protein per 100g of this food
19    pub protein: f64,
20}
21
22/// # TODO
23///
24/// * Load data dynamically, rather than using a macro.
25/// * Support non-identifier names, such as "matcha-cake".
26/// * Support singular and plural (e.g., egg and eggs) in a single row.
27macro_rules! food {
28    ($name: ident, $kcal: expr_2021, $protein: expr_2021, $per: expr_2021) => {
29        (
30            stringify!($name),
31            Food {
32                kcal: $kcal as f64 * 100.0 / $per as f64,
33                protein: $protein as f64 * 100.0 / $per as f64,
34            },
35        )
36    };
37}
38
39#[rustfmt::skip]
40const FOODS: &[(&str, Food)] = &[
41    food!(almond,         164,  6.0,  28),
42    food!(apple,           59,  0.3, 100),
43    food!(asparagus,       20,  2.2, 100),
44    food!(avocado,         80,  1.0,  50),
45    food!(bacon,           80,  6.0,  16), // each
46    food!(banana,          89,  1.1, 100),
47    food!(blueberry,       39,  0.5,  68),
48    food!(broccoli,        34,  2.8, 100),
49    food!(brussels,        43,  3.4, 100),
50    food!(butter,         717,  0.9, 100),
51    food!(cheese,         110,  7.0,  28),
52    food!(cabbage,         25,  1.3, 100),
53    food!(carrot,          41,  0.8, 100),
54    food!(cauliflower,     25,  1.9, 100),
55    food!(celery,          14,  0.7, 100),
56    food!(chicken,         60, 11.0,  56),
57    food!(coconutroll,    100,  0.0,  20), // each
58    food!(cucumber,        15,  0.6, 100),
59    food!(eggwhite,        25,  5.0,  46),
60    food!(endive,          17,  1.3, 100),
61    food!(enoki,           44,  2.4, 100),
62    food!(grape,           34,  0.4,  49),
63    food!(greenbean,       44,  2.4, 125),
64    food!(ham,             61,  9.1,  57),
65    food!(honey,           60,  0.0,  21),
66    food!(lettuce,         17,  1.0, 100),
67    food!(matchacake,     167,  1.8,  36), // each
68    food!(mushroom,        22,  3.1, 100),
69    food!(oil,            884,  0.0, 100),
70    food!(onion,           41,  1.3, 100),
71    food!(shallot,         72,  2.5, 100),
72    food!(peanut,         567, 25.8, 100),
73    food!(peanutpowder,    50,  5.0,  12),
74    food!(pepper,          20,  0.9, 100),
75    food!(popcorn,        130,  4.0,  40),
76    food!(potato,          79,  2.1, 100),
77    food!(spinach,         23,  2.9, 100),
78    food!(strawberry,      32,  0.7, 100),
79    food!(sugar,          385,  0.0, 100),
80    food!(tofu,            94, 10.0, 124),
81    food!(thigh,          149, 18.6, 100),
82    food!(tomato,          22,  0.7, 100),
83    food!(turkey,          64,  7.7,  57),
84    food!(veg,             20,  1.5,  57),
85    food!(whiskey,        250,  0.0, 100),
86];
87
88/// Parses foods in the format C,P/Z.
89/// * C is the number of kilocalories per serving
90/// * P is the number of grams of protein per serving
91/// * Z is the serving size
92fn parse_custom(s: &str) -> Option<Food> {
93    let (cp, z) = s.split_once('/')?;
94    let (c, p) = cp.split_once(',')?;
95
96    let kcal: f64 = c.parse().ok()?;
97    let protein: f64 = p.parse().ok()?;
98    let hundreds: f64 = z.parse::<Portion>().ok()?.convert_to(Unit::Gram).number / 100.0;
99
100    Some(Food {
101        kcal: kcal / hundreds,
102        protein: protein / hundreds,
103    })
104}
105
106/// TODO: Support multiple plurals per singular.
107fn pluralize(s: &str) -> String {
108    if let Some(base) = s.strip_suffix('y') {
109        base.to_owned() + "ies"
110    } else if s.ends_with('o') {
111        s.to_owned() + "es"
112    } else {
113        s.to_owned() + "s"
114    }
115}
116
117impl FromStr for Food {
118    type Err = BadFood;
119    fn from_str(s: &str) -> Result<Self, Self::Err> {
120        if let Some(food) = FOODS
121            .iter()
122            .find_map(|(slug, food)| (*slug == s).then_some(food))
123        {
124            Ok(food.clone())
125        } else if let Some(food) = FOODS
126            .iter()
127            .find_map(|(slug, food)| (pluralize(slug) == s).then_some(food))
128        {
129            Ok(food.clone())
130        } else if let Some(food) = parse_custom(s) {
131            Ok(food)
132        } else {
133            Err(BadFood(s.to_string()))
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn pluralize_works() {
144        for (singular, want) in [
145            ("blueberry", "blueberries"),
146            ("tomato", "tomatoes"),
147            ("fig", "figs"),
148        ] {
149            assert_eq!(pluralize(singular), want);
150        }
151    }
152}