nosh/
lib.rs

1use anyhow::{anyhow, Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7pub const APP_NAME: &'static str = env!("CARGO_PKG_NAME");
8
9fn float0(f: &f32) -> String {
10    format!("{:.0}", f)
11}
12
13fn float1(f: &f32) -> String {
14    format!("{:.1}", f)
15}
16
17// The macronutrients of a food.
18#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, tabled::Tabled)]
19#[cfg_attr(test, derive(PartialEq))]
20pub struct Nutrients {
21    #[tabled(display_with = "float1")]
22    pub carb: f32,
23    #[tabled(display_with = "float1")]
24    pub fat: f32,
25    #[tabled(display_with = "float1")]
26    pub protein: f32,
27    #[tabled(display_with = "float0")]
28    pub kcal: f32,
29}
30
31impl Nutrients {
32    // If kcal is 0, compute it using the Atwater General Calculation:
33    // 4*carb + 4*protein + 9*fat.
34    // Note that there is a newer system that uses food-specific multipliers:
35    // See https://en.wikipedia.org/wiki/Atwater_system#Modified_system.
36    pub fn maybe_compute_kcal(self) -> Nutrients {
37        Nutrients {
38            kcal: if self.kcal > 0.0 {
39                self.kcal
40            } else {
41                self.carb * 4.0 + self.fat * 9.0 + self.protein * 4.0
42            },
43            ..self
44        }
45    }
46}
47impl std::ops::Add<Nutrients> for Nutrients {
48    type Output = Nutrients;
49
50    fn add(self, rhs: Nutrients) -> Self::Output {
51        Nutrients {
52            carb: self.carb + rhs.carb,
53            fat: self.fat + rhs.fat,
54            protein: self.protein + rhs.protein,
55            kcal: self.kcal + rhs.kcal,
56        }
57    }
58}
59
60impl std::ops::Mul<f32> for Nutrients {
61    type Output = Nutrients;
62
63    fn mul(self, rhs: f32) -> Self::Output {
64        Nutrients {
65            carb: self.carb * rhs,
66            fat: self.fat * rhs,
67            protein: self.protein * rhs,
68            kcal: self.kcal * rhs,
69        }
70    }
71}
72
73#[test]
74fn test_nutrient_mult() {
75    let nut = Nutrients {
76        carb: 1.2,
77        fat: 2.3,
78        protein: 3.1,
79        kcal: 124.5,
80    } * 2.0;
81
82    assert_eq!(nut.carb, 2.4);
83    assert_eq!(nut.fat, 4.6);
84    assert_eq!(nut.protein, 6.2);
85    assert_eq!(nut.kcal, 249.0);
86}
87
88#[test]
89fn test_nutrient_kcal_computation() {
90    let nut = Nutrients {
91        carb: 1.2,
92        fat: 2.3,
93        protein: 3.1,
94        kcal: 0.0,
95    }
96    .maybe_compute_kcal();
97
98    assert_eq!(nut.carb, 1.2);
99    assert_eq!(nut.fat, 2.3);
100    assert_eq!(nut.protein, 3.1);
101    assert_eq!(nut.kcal, 37.9);
102}
103
104// Food describes a single food item.
105#[derive(Serialize, Deserialize, Debug, Default, tabled::Tabled)]
106#[cfg_attr(test, derive(PartialEq))]
107pub struct Food {
108    // The display name of the food. This is shown in the UI.
109    // Data files will reference the food by it's filename, not display name.
110    pub name: String,
111
112    // The macronutrients of this food item.
113    #[tabled(inline)]
114    pub nutrients: Nutrients,
115
116    // Ways of describing a single serving of this food.
117    // For example, the following says that 1 serving is 100g or "14 chips":
118    // ```
119    // [portions]
120    // g = 100.0
121    // chips = 14
122    // ```
123    #[tabled(skip)]
124    pub servings: HashMap<String, f32>,
125}
126
127// Journal is a record of food consumed during a day.
128#[derive(Serialize, Deserialize, Debug, Default)]
129#[cfg_attr(test, derive(PartialEq))]
130pub struct Journal(pub HashMap<String, f32>);
131
132// Data provides access to the nosh "database".
133// Nosh stores all of it's data as TOML files using a particular directory structure:
134// - $root/ (typically XDG_DATA_HOME)
135//
136//   - food/
137//     - apple.toml
138//     - banana.toml
139//
140//   - recipe/
141//     - cake.toml
142//     - pie.toml
143//
144//   - journal/
145//     - 2024/
146//       - 01/
147//         - 01.toml
148//         - 02.toml
149//     - 2023/
150//       - 12/
151//         - 30.toml
152//         - 31.toml
153#[derive(Debug)]
154pub struct Data {
155    food_dir: PathBuf,
156    recipe_dir: PathBuf,
157    journal_dir: PathBuf,
158}
159
160impl Data {
161    // Create a new database from the given root directory.
162    pub fn new(root_dir: &Path) -> Data {
163        Data {
164            food_dir: root_dir.join("food"),
165            recipe_dir: root_dir.join("recipe"),
166            journal_dir: root_dir.join("journal"),
167        }
168    }
169
170    fn read<T: serde::de::DeserializeOwned>(&self, path: &Path) -> Result<Option<T>> {
171        log::trace!("Reading {path:?}");
172        match fs::read_to_string(&path) {
173            Ok(s) => Ok(Some(toml::from_str(&s)?)),
174            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
175            Err(e) => Err(e).with_context(|| "Opening {path:?}"),
176        }
177    }
178
179    fn write<T: serde::Serialize + std::fmt::Debug>(&self, path: &Path, obj: &T) -> Result<()> {
180        log::trace!("Writing to {path:?}: {obj:?}");
181        fs::create_dir_all(
182            path.parent()
183                .ok_or(anyhow!("Missing parent dir: {path:?}"))?,
184        )?;
185        Ok(fs::write(&path, toml::to_string_pretty(obj)?)?)
186    }
187
188    pub fn food(&self, key: &str) -> Result<Option<Food>> {
189        self.read(&self.food_dir.join(key).with_extension("toml"))
190    }
191
192    pub fn write_food(&self, key: &str, food: &Food) -> Result<()> {
193        self.write(&self.food_dir.join(key).with_extension("toml"), food)
194    }
195
196    fn journal_path(&self, date: &impl chrono::Datelike) -> PathBuf {
197        self.journal_dir
198            .join(format!("{:04}", date.year()))
199            .join(format!("{:02}", date.month()))
200            .join(format!("{:02}", date.day()))
201            .with_extension("toml")
202    }
203
204    // Fetch the journal for the given date.
205    // Returns None if there is no journal for that date.
206    pub fn journal(&self, date: &impl chrono::Datelike) -> Result<Option<Journal>> {
207        self.read(&self.journal_path(date))
208    }
209
210    pub fn write_journal(&self, date: &impl chrono::Datelike, journal: &Journal) -> Result<()> {
211        self.write(&self.journal_path(date), journal)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_food_data() {
221        let tmp = tempfile::tempdir().unwrap();
222        let data = Data::new(tmp.path());
223
224        let expected = Food {
225            name: "Oats".into(),
226            nutrients: Nutrients {
227                carb: 68.7,
228                fat: 5.89,
229                protein: 13.5,
230                kcal: 382.0,
231            },
232            servings: HashMap::from([("g".into(), 100.0)]),
233        };
234
235        data.write_food("oats", &expected).unwrap();
236        let actual = data.food("oats").unwrap().unwrap();
237        assert_eq!(expected, actual);
238    }
239
240    #[test]
241    fn test_journal_data() {
242        let tmp = tempfile::tempdir().unwrap();
243        let data = Data::new(tmp.path());
244
245        let expected = Journal(HashMap::from([
246            ("banana".to_string(), 1.0),
247            ("oats".to_string(), 2.0),
248            ("peanut_butter".to_string(), 1.5),
249        ]));
250
251        let date = &chrono::NaiveDate::from_ymd_opt(2024, 04, 02).unwrap();
252        data.write_journal(&date.clone(), &expected).unwrap();
253        let actual = data.journal(date).unwrap().unwrap();
254        assert_eq!(expected, actual);
255    }
256}