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!(salmon,         121, 17.0,  85),
78    food!(spinach,         23,  2.9, 100),
79    food!(strawberry,      32,  0.7, 100),
80    food!(sugar,          385,  0.0, 100),
81    food!(tuna,           282, 39.0, 198),
82    food!(tofu,            94, 10.0, 124),
83    food!(thigh,          149, 18.6, 100),
84    food!(tomato,          22,  0.7, 100),
85    food!(turkey,          64,  7.7,  57),
86    food!(veg,             20,  1.5,  57),
87    food!(whiskey,        250,  0.0, 100),
88];
89
90/// Parses foods in the format C,P/Z.
91/// * C is the number of kilocalories per serving
92/// * P is the number of grams of protein per serving
93/// * Z is the serving size
94fn parse_custom(s: &str) -> Option<Food> {
95    let (cp, z) = s.split_once('/')?;
96    let (c, p) = cp.split_once(',')?;
97
98    let kcal: f64 = c.parse().ok()?;
99    let protein: f64 = p.parse().ok()?;
100    let hundreds: f64 = z.parse::<Portion>().ok()?.convert_to(Unit::Gram).number / 100.0;
101
102    Some(Food {
103        kcal: kcal / hundreds,
104        protein: protein / hundreds,
105    })
106}
107
108/// TODO: Support multiple plurals per singular.
109fn pluralize(s: &str) -> String {
110    if let Some(base) = s.strip_suffix('y') {
111        base.to_owned() + "ies"
112    } else if s.ends_with('o') {
113        s.to_owned() + "es"
114    } else {
115        s.to_owned() + "s"
116    }
117}
118
119impl FromStr for Food {
120    type Err = BadFood;
121    fn from_str(s: &str) -> Result<Self, Self::Err> {
122        if let Some(food) = FOODS
123            .iter()
124            .find_map(|(slug, food)| (*slug == s).then_some(food))
125        {
126            Ok(food.clone())
127        } else if let Some(food) = FOODS
128            .iter()
129            .find_map(|(slug, food)| (pluralize(slug) == s).then_some(food))
130        {
131            Ok(food.clone())
132        } else if let Some(food) = parse_custom(s) {
133            Ok(food)
134        } else {
135            Err(BadFood(s.to_string()))
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn pluralize_works() {
146        for (singular, want) in [
147            ("blueberry", "blueberries"),
148            ("tomato", "tomatoes"),
149            ("fig", "figs"),
150        ] {
151            assert_eq!(pluralize(singular), want);
152        }
153    }
154}