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#[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 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#[derive(Serialize, Deserialize, Debug, Default, tabled::Tabled)]
106#[cfg_attr(test, derive(PartialEq))]
107pub struct Food {
108 pub name: String,
111
112 #[tabled(inline)]
114 pub nutrients: Nutrients,
115
116 #[tabled(skip)]
124 pub servings: HashMap<String, f32>,
125}
126
127#[derive(Serialize, Deserialize, Debug, Default)]
129#[cfg_attr(test, derive(PartialEq))]
130pub struct Journal(pub HashMap<String, f32>);
131
132#[derive(Debug)]
154pub struct Data {
155 food_dir: PathBuf,
156 recipe_dir: PathBuf,
157 journal_dir: PathBuf,
158}
159
160impl Data {
161 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 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}