nosh 0.1.0

CLI nutrition tracker
Documentation
use std::{io::Write, os::unix::fs::OpenOptionsExt};

use assert_cmd::Command;
use nosh::{Food, Nutrients, APP_NAME};
use predicates::prelude::*;

struct CLI {
    data_dir: tempfile::TempDir,
}

impl CLI {
    fn new() -> Self {
        Self {
            data_dir: tempfile::tempdir().unwrap(),
        }
    }

    fn cmd(&self) -> Command {
        let mut cmd = Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap();
        cmd.env("XDG_DATA_HOME", self.data_dir.path());
        cmd
    }

    fn editor(&self, content: &str) -> Command {
        let editor = format!("#!/bin/sh\necho -e {content:?} > $1");
        let path = self.data_dir.path().join("editor");
        std::fs::OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(true)
            .mode(0o770)
            .open(&path)
            .unwrap()
            .write_all(editor.as_bytes())
            .unwrap();
        log::debug!("Test wrote fake editor to {path:?}:\n{editor:?}");
        let mut cmd = self.cmd();
        cmd.env("EDITOR", path);
        cmd
    }

    fn add_food(&self, key: &str, food: &Food) {
        let path = self.data_dir.path().join(APP_NAME);
        log::info!("Test staging food to {path:?}: {food:?}");
        nosh::Data::new(&path).write_food(key, food).unwrap()
    }
}

fn matches(pattern: &str) -> predicates::str::RegexPredicate {
    predicates::str::is_match(pattern).unwrap()
}

#[test]
fn test_show_food_missing() {
    let cli = CLI::new();

    cli.cmd()
        .args(["food", "show", "nope"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("Error: No food with key \"nope\""));
}

#[test]
fn test_edit_food() {
    let cli = CLI::new();

    // Edit a new food
    cli.editor(
        r#"
name = "Oats"
[nutrients]
carb = 68.7
fat = 5.89
protein = 13.5
kcal = 382
[servings]
g = 100.0
        "#,
    )
    .args(["food", "edit", "oats"])
    .assert()
    .success();

    cli.cmd()
        .args(["food", "show", "oats"])
        .assert()
        .success()
        .stdout(matches("carb.*68.7"))
        .stdout(matches("fat.*5.89"))
        .stdout(matches("protein.*13.5"))
        .stdout(matches("kcal.*382"))
        .stdout(matches("g.*100"));

    // Edit the existing food
    cli.editor(
        r#"
name = "Oats"
[nutrients]
carb = 40.0
fat = 7.10
protein = 14.0
kcal = 240
[servings]
g = 100.0
cups = 0.5
        "#,
    )
    .args(["food", "edit", "oats"])
    .assert()
    .success();

    cli.cmd()
        .args(["food", "show", "oats"])
        .assert()
        .success()
        .stdout(matches("carb.*40.0"))
        .stdout(matches("fat.*7.1"))
        .stdout(matches("protein.*14.0"))
        .stdout(matches("kcal.*240"))
        .stdout(matches("g.*100"))
        .stdout(matches("cups.*0.5"));
}

#[test]
fn test_eat_food_missing() {
    let cli = CLI::new();

    cli.cmd()
        .args(["eat", "nope"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("Error: No food with key \"nope\""));
}

#[test]
fn test_eat() {
    let cli = CLI::new();

    cli.add_food(
        "oats",
        &Food {
            name: "Oats".into(),
            nutrients: Nutrients {
                carb: 68.7,
                fat: 5.89,
                protein: 13.5,
                kcal: 382.0,
            },
            servings: [("g".into(), 100.0)].into(),
        },
    );

    cli.add_food(
        "banana",
        &Food {
            name: "Banana".into(),
            nutrients: Nutrients {
                carb: 23.0,
                fat: 0.20,
                protein: 0.74,
                kcal: 98.0,
            },
            servings: [("g".into(), 100.0)].into(),
        },
    );

    // Add one serving
    cli.cmd().args(["eat", "oats"]).assert().success();
    cli.cmd()
        .args(["journal", "show"])
        .assert()
        .success()
        .stdout(matches("Oats.*1.*68.7.*5.9.*13.5.*382"))
        .stdout(matches("Total.*1.*68.7.*5.9.*13.5.*382"));

    // Add 2.5 servings
    cli.cmd().args(["eat", "oats", "2.5"]).assert().success();
    cli.cmd()
        .args(["journal", "show"])
        .assert()
        .success()
        .stdout(matches("Oats.*3.5.*240.4.*20.6.*47.2.*1337"))
        .stdout(matches("Total.*240.4.*20.6.*47.2.*1337"));

    // Add one serving of banana
    cli.cmd().args(["eat", "banana"]).assert().success();
    cli.cmd()
        .args(["journal", "show"])
        .assert()
        .success()
        .stdout(matches("Oats.*3.5.*240.4.*20.6.*47.2.*1337"))
        .stdout(matches("Banana.*1.*23.0.*0.2.*0.7.*98"))
        .stdout(matches("Total.*263.4.*20.8.*48.0.*1435"));
}