sweet-potator 0.5.0

Static recipe site generator
Documentation
use std::{fmt, ops::Not};

use serde::Serialize;

use super::{
    errors::{ParseError, ParseResult},
    ParseFromStr,
};

#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum QuantityValue {
    Integer(u32),
    Decimal { int: u32, frac: u32 },
    Fraction { numer: u8, denom: u8 },
}

#[derive(Debug, Serialize)]
pub struct Quantity {
    pub value: QuantityValue,
    pub unit: Option<String>,
    pub note: Option<String>,
}

impl fmt::Display for QuantityValue {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Integer(value) => {
                write!(f, "{}", value)
            }
            Self::Decimal { int, frac } => {
                write!(f, "{}.{}", int, frac)
            }
            Self::Fraction { numer, denom } => {
                write!(f, "{}/{}", numer, denom)
            }
        }
    }
}

impl fmt::Display for Quantity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.value)?;
        if let Some(unit) = &self.unit {
            write!(f, " {}", unit)?;
        }
        if let Some(note) = &self.note {
            write!(f, " ({})", note)?;
        }
        Ok(())
    }
}

impl ParseFromStr for Quantity {
    fn parse_from_str(s: &str) -> ParseResult<Self> {
        let (value, rest) = s
            .split_once(" ")
            .map_or((s, ""), |(value, rest)| (value, rest));
        let value = if let Some((int, frac)) = value.split_once('.') {
            if let (Ok(int), Ok(frac)) = (int.parse(), frac.parse()) {
                Some(QuantityValue::Decimal { int, frac })
            } else {
                None
            }
        } else if let Some((numer, denom)) = value.split_once('/') {
            if let (Ok(numer), Ok(denom)) = (numer.parse(), denom.parse()) {
                Some(QuantityValue::Fraction { numer, denom })
            } else {
                None
            }
        } else if let Ok(value) = value.parse() {
            Some(QuantityValue::Integer(value))
        } else {
            None
        }
        .ok_or_else(|| {
            ParseError::from("ingredient quantity must start with a number or fraction")
        })?;
        let (unit, note) = if let Some((unit, note)) = rest.split_once(" (") {
            let unit = unit.trim().to_string();
            let note = note
                .strip_suffix(')')
                .ok_or("missing closing parenthesis of quantity note")?
                .trim();
            (unit, note.is_empty().not().then(|| note.into()))
        } else {
            (rest.trim_start().into(), None)
        };
        let unit = unit.is_empty().not().then(|| unit);
        Ok(Self { value, unit, note })
    }
}

#[derive(Debug, Serialize)]
pub struct Ingredient {
    pub name: String,
    pub kind: Option<String>,
    pub quantity: Option<Quantity>,
}

impl fmt::Display for Ingredient {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.name)?;
        if let Some(kind) = &self.kind {
            write!(f, ", {}", kind)?;
        }
        if let Some(quantity) = &self.quantity {
            write!(f, ": {}", quantity)?;
        }
        Ok(())
    }
}

impl ParseFromStr for Ingredient {
    fn parse_from_str(s: &str) -> ParseResult<Self> {
        let (name, quantity) = s.split_once(": ").map_or((s, ""), |(name, quantity)| {
            (name.trim_end(), quantity.trim_start())
        });
        let (name, kind) = name.split_once(", ").map_or((name, ""), |(name, kind)| {
            (name.trim_end(), kind.trim_start())
        });
        if name.is_empty() {
            return Err(ParseError::empty("ingredient name"));
        }
        let kind = kind.is_empty().not().then(|| kind.into());
        let quantity = if quantity.is_empty() {
            None
        } else {
            Some(Quantity::parse_from_str(quantity)?)
        };
        Ok(Self {
            name: name.into(),
            kind,
            quantity,
        })
    }
}

#[cfg(test)]
mod tests {

    use super::*;

    #[test]
    fn test_display_quantity() {
        let mut quantity = Quantity {
            value: QuantityValue::Decimal { int: 0, frac: 5 },
            unit: None,
            note: None,
        };
        assert_eq!(quantity.to_string(), "0.5");
        quantity.value = QuantityValue::Fraction { numer: 1, denom: 2 };
        assert_eq!(quantity.to_string(), "1/2");
        quantity.value = QuantityValue::Integer(1);
        assert_eq!(quantity.to_string(), "1");
        quantity.unit = Some("unit".into());
        assert_eq!(quantity.to_string(), "1 unit");
        quantity.note = Some("note".into());
        assert_eq!(quantity.to_string(), "1 unit (note)");
        quantity.unit = None;
        assert_eq!(quantity.to_string(), "1 (note)");
    }

    #[test]
    fn test_parse_quantity() {
        let quantity = Quantity::parse_from_str("1").unwrap();
        assert!(matches!(quantity.value, QuantityValue::Integer(1)));
        assert_eq!(quantity.unit, None);
        assert_eq!(quantity.note, None);
        let quantity = Quantity::parse_from_str("0.5").unwrap();
        assert!(matches!(
            quantity.value,
            QuantityValue::Decimal { int: 0, frac: 5 }
        ));
        let quantity = Quantity::parse_from_str("1/2").unwrap();
        assert!(matches!(
            quantity.value,
            QuantityValue::Fraction { numer: 1, denom: 2 }
        ));
        let quantity = Quantity::parse_from_str("1  a unit").unwrap();
        assert!(matches!(quantity.value, QuantityValue::Integer(1)));
        assert_eq!(quantity.unit, Some("a unit".into()));
        assert_eq!(quantity.note, None);
        let quantity = Quantity::parse_from_str("1  ( a note )").unwrap();
        assert!(matches!(quantity.value, QuantityValue::Integer(1)));
        assert_eq!(quantity.unit, None);
        assert_eq!(quantity.note, Some("a note".into()));
        let quantity = Quantity::parse_from_str("10 1 unit  ( a note )").unwrap();
        assert!(matches!(quantity.value, QuantityValue::Integer(10)));
        assert_eq!(quantity.unit, Some("1 unit".into()));
        assert_eq!(quantity.note, Some("a note".into()));
    }

    #[test]
    fn test_display_ingredient() {
        let quantity = Quantity {
            value: QuantityValue::Integer(1),
            unit: None,
            note: None,
        };
        let mut ingredient = Ingredient {
            name: "name".into(),
            kind: None,
            quantity: None,
        };
        assert_eq!(ingredient.to_string(), "name");
        ingredient.kind = Some("kind".into());
        assert_eq!(ingredient.to_string(), "name, kind");
        ingredient.quantity = Some(quantity);
        assert_eq!(ingredient.to_string(), "name, kind: 1");
        ingredient.kind = None;
        assert_eq!(ingredient.to_string(), "name: 1");
    }

    #[test]
    fn test_parse_ingredient() {
        let ingredient = Ingredient::parse_from_str("a name").unwrap();
        assert_eq!(ingredient.name, "a name");
        assert_eq!(ingredient.kind, None);
        assert!(ingredient.quantity.is_none());
        let ingredient = Ingredient::parse_from_str("a name ,  a kind").unwrap();
        assert_eq!(ingredient.name, "a name");
        assert_eq!(ingredient.kind, Some("a kind".into()));
        assert!(ingredient.quantity.is_none());
        let ingredient = Ingredient::parse_from_str("a name :  1 unit (note)").unwrap();
        assert_eq!(ingredient.name, "a name");
        assert_eq!(ingredient.kind, None);
        assert_eq!(ingredient.quantity.unwrap().to_string(), "1 unit (note)");
        let ingredient = Ingredient::parse_from_str("a name ,  a kind :  1 unit (note)").unwrap();
        assert_eq!(ingredient.name, "a name");
        assert_eq!(ingredient.kind, Some("a kind".into()));
        assert_eq!(ingredient.quantity.unwrap().to_string(), "1 unit (note)");
    }
}