use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub const APP_NAME: &'static str = env!("CARGO_PKG_NAME");
fn float0(f: &f32) -> String {
format!("{:.0}", f)
}
fn float1(f: &f32) -> String {
format!("{:.1}", f)
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, tabled::Tabled)]
#[cfg_attr(test, derive(PartialEq))]
pub struct Nutrients {
#[tabled(display_with = "float1")]
pub carb: f32,
#[tabled(display_with = "float1")]
pub fat: f32,
#[tabled(display_with = "float1")]
pub protein: f32,
#[tabled(display_with = "float0")]
pub kcal: f32,
}
impl Nutrients {
pub fn maybe_compute_kcal(self) -> Nutrients {
Nutrients {
kcal: if self.kcal > 0.0 {
self.kcal
} else {
self.carb * 4.0 + self.fat * 9.0 + self.protein * 4.0
},
..self
}
}
}
impl std::ops::Add<Nutrients> for Nutrients {
type Output = Nutrients;
fn add(self, rhs: Nutrients) -> Self::Output {
Nutrients {
carb: self.carb + rhs.carb,
fat: self.fat + rhs.fat,
protein: self.protein + rhs.protein,
kcal: self.kcal + rhs.kcal,
}
}
}
impl std::ops::Mul<f32> for Nutrients {
type Output = Nutrients;
fn mul(self, rhs: f32) -> Self::Output {
Nutrients {
carb: self.carb * rhs,
fat: self.fat * rhs,
protein: self.protein * rhs,
kcal: self.kcal * rhs,
}
}
}
#[test]
fn test_nutrient_mult() {
let nut = Nutrients {
carb: 1.2,
fat: 2.3,
protein: 3.1,
kcal: 124.5,
} * 2.0;
assert_eq!(nut.carb, 2.4);
assert_eq!(nut.fat, 4.6);
assert_eq!(nut.protein, 6.2);
assert_eq!(nut.kcal, 249.0);
}
#[test]
fn test_nutrient_kcal_computation() {
let nut = Nutrients {
carb: 1.2,
fat: 2.3,
protein: 3.1,
kcal: 0.0,
}
.maybe_compute_kcal();
assert_eq!(nut.carb, 1.2);
assert_eq!(nut.fat, 2.3);
assert_eq!(nut.protein, 3.1);
assert_eq!(nut.kcal, 37.9);
}
#[derive(Serialize, Deserialize, Debug, Default, tabled::Tabled)]
#[cfg_attr(test, derive(PartialEq))]
pub struct Food {
pub name: String,
#[tabled(inline)]
pub nutrients: Nutrients,
#[tabled(skip)]
pub servings: HashMap<String, f32>,
}
#[derive(Serialize, Deserialize, Debug, Default)]
#[cfg_attr(test, derive(PartialEq))]
pub struct Journal(pub HashMap<String, f32>);
#[derive(Debug)]
pub struct Data {
food_dir: PathBuf,
recipe_dir: PathBuf,
journal_dir: PathBuf,
}
impl Data {
pub fn new(root_dir: &Path) -> Data {
Data {
food_dir: root_dir.join("food"),
recipe_dir: root_dir.join("recipe"),
journal_dir: root_dir.join("journal"),
}
}
fn read<T: serde::de::DeserializeOwned>(&self, path: &Path) -> Result<Option<T>> {
log::trace!("Reading {path:?}");
match fs::read_to_string(&path) {
Ok(s) => Ok(Some(toml::from_str(&s)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| "Opening {path:?}"),
}
}
fn write<T: serde::Serialize + std::fmt::Debug>(&self, path: &Path, obj: &T) -> Result<()> {
log::trace!("Writing to {path:?}: {obj:?}");
fs::create_dir_all(
path.parent()
.ok_or(anyhow!("Missing parent dir: {path:?}"))?,
)?;
Ok(fs::write(&path, toml::to_string_pretty(obj)?)?)
}
pub fn food(&self, key: &str) -> Result<Option<Food>> {
self.read(&self.food_dir.join(key).with_extension("toml"))
}
pub fn write_food(&self, key: &str, food: &Food) -> Result<()> {
self.write(&self.food_dir.join(key).with_extension("toml"), food)
}
fn journal_path(&self, date: &impl chrono::Datelike) -> PathBuf {
self.journal_dir
.join(format!("{:04}", date.year()))
.join(format!("{:02}", date.month()))
.join(format!("{:02}", date.day()))
.with_extension("toml")
}
pub fn journal(&self, date: &impl chrono::Datelike) -> Result<Option<Journal>> {
self.read(&self.journal_path(date))
}
pub fn write_journal(&self, date: &impl chrono::Datelike, journal: &Journal) -> Result<()> {
self.write(&self.journal_path(date), journal)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_food_data() {
let tmp = tempfile::tempdir().unwrap();
let data = Data::new(tmp.path());
let expected = Food {
name: "Oats".into(),
nutrients: Nutrients {
carb: 68.7,
fat: 5.89,
protein: 13.5,
kcal: 382.0,
},
servings: HashMap::from([("g".into(), 100.0)]),
};
data.write_food("oats", &expected).unwrap();
let actual = data.food("oats").unwrap().unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_journal_data() {
let tmp = tempfile::tempdir().unwrap();
let data = Data::new(tmp.path());
let expected = Journal(HashMap::from([
("banana".to_string(), 1.0),
("oats".to_string(), 2.0),
("peanut_butter".to_string(), 1.5),
]));
let date = &chrono::NaiveDate::from_ymd_opt(2024, 04, 02).unwrap();
data.write_journal(&date.clone(), &expected).unwrap();
let actual = data.journal(date).unwrap().unwrap();
assert_eq!(expected, actual);
}
}