sweet-potator 0.7.10

Static recipe site generator
Documentation
use std::{collections::HashMap, fmt};

use serde::Serialize;

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

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

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

impl ParseFromStr for Yield {
    fn parse_from_str(s: &str) -> ParseResult<Self> {
        let (value, unit) = s.split_once(' ').map_or((s, None), |(value, unit)| {
            (value, Some(unit.trim_start().into()))
        });
        if let Ok(value) = value.parse() {
            if value == 0 {
                Err(format!(
                    "metadata value for key '{}' must be greater than zero",
                    Metadata::YIELD_KEY,
                )
                .into())
            } else {
                Ok(Yield { value, unit })
            }
        } else {
            Err(format!(
                "metadata value for key '{}' must start with a number",
                Metadata::YIELD_KEY
            )
            .into())
        }
    }
}

#[derive(Debug, Serialize)]
pub struct Duration {
    pub hours: u32,
    pub minutes: u32,
}

impl Duration {
    fn parse_unit(text: &str, unit: &str) -> ParseResult<u32> {
        text.strip_suffix(unit)
            .and_then(|value| value.parse().ok())
            .ok_or_else(|| "invalid recipe duration".into())
    }
}

impl fmt::Display for Duration {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut duration: Vec<String> = Vec::new();
        if self.hours > 0 {
            duration.push(self.hours.to_string() + "h");
        }
        if self.minutes > 0 {
            duration.push(self.minutes.to_string() + "m");
        }
        write!(f, "{}", duration.join(" "))
    }
}

impl ParseFromStr for Duration {
    fn parse_from_str(s: &str) -> ParseResult<Self> {
        let (mut hours, mut minutes) = if let Some((h, m)) = s.split_once(' ') {
            (
                Self::parse_unit(h, "h")?,
                Self::parse_unit(m.trim_start(), "m")?,
            )
        } else if let Ok(h) = Self::parse_unit(s, "h") {
            (h, 0)
        } else {
            (0, Self::parse_unit(s, "m")?)
        };
        if hours == 0 && minutes == 0 {
            return Err("recipe duration must be greater than zero".into());
        }
        hours += minutes / 60;
        minutes %= 60;
        Ok(Duration { hours, minutes })
    }
}

#[derive(Debug, Serialize)]
pub struct Link {
    pub name: String,
    pub url: String,
}

impl fmt::Display for Link {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} > {}", self.name, self.url)
    }
}

impl ParseFromStr for Link {
    fn parse_from_str(s: &str) -> ParseResult<Self> {
        let (name, url) = s.split_once(" > ").ok_or("missing link separator ' > '")?;
        let name = name.trim_end().to_string();
        let url = url.trim_start().to_string();
        if name.is_empty() {
            return Err(ParseError::empty("link name"));
        }
        if url.is_empty() {
            return Err(ParseError::empty("link url"));
        }
        Ok(Self { name, url })
    }
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Source {
    Author(String),
    Book(String),
    Link(Link),
}

impl Source {
    const AUTHOR_KEY: &'static str = "Author";
    const BOOK_KEY: &'static str = "Book";
    const LINK_KEY: &'static str = "Link";
}

impl fmt::Display for Source {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Author(author) => write!(f, "{}: {}", Self::AUTHOR_KEY, author),
            Self::Book(book) => write!(f, "{}: {}", Self::BOOK_KEY, book),
            Self::Link(link) => write!(f, "{}: {}", Self::LINK_KEY, link),
        }
    }
}

#[derive(Debug, Serialize)]
pub struct Metadata {
    pub duration: Option<Duration>,
    #[serde(rename = "yield")]
    pub yields: Yield,
    pub source: Option<Source>,
    pub tags: Vec<String>,
}

impl Metadata {
    const DURATION_KEY: &'static str = "Time";
    const YIELD_KEY: &'static str = "Yield";
    const TAGS_KEY: &'static str = "Tags";
}

impl fmt::Display for Metadata {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "{}: {}", Self::YIELD_KEY, self.yields)?;
        if let Some(duration) = &self.duration {
            writeln!(f, "{}: {}", Self::DURATION_KEY, duration)?;
        }
        if let Some(source) = &self.source {
            writeln!(f, "{source}")?;
        }
        if !self.tags.is_empty() {
            writeln!(f, "{}: {}", Self::TAGS_KEY, self.tags.join(", "))?;
        }
        Ok(())
    }
}

impl TryFrom<Vec<String>> for Metadata {
    type Error = ParseError;

    fn try_from(lines: Vec<String>) -> Result<Self, Self::Error> {
        let mut map = HashMap::new();
        for line in lines {
            let (key, value) = parse_mapping(&line)?;
            if map.insert(key.into(), value.into()).is_some() {
                return Err(format!("duplicate metadata key '{key}'").into());
            }
        }
        map.try_into()
    }
}

impl TryFrom<HashMap<String, String>> for Metadata {
    type Error = ParseError;

    fn try_from(mut map: HashMap<String, String>) -> Result<Self, Self::Error> {
        let yields = map
            .remove(Self::YIELD_KEY)
            .ok_or_else(|| format!("missing metadata key '{}'", Self::YIELD_KEY))?;
        let yields = Yield::parse_from_str(&yields)?;
        let duration = map
            .remove(Self::DURATION_KEY)
            .as_deref()
            .map(Duration::parse_from_str)
            .transpose()?;
        let source = if let Some(value) = map.remove(Source::LINK_KEY) {
            Some(Source::Link(Link::parse_from_str(&value)?))
        } else {
            map.remove(Source::AUTHOR_KEY)
                .map(Source::Author)
                .or_else(|| map.remove(Source::BOOK_KEY).map(Source::Book))
        };
        let tags = map.remove(Self::TAGS_KEY).map_or_else(Vec::new, |value| {
            value.split(", ").map(|s| s.trim().into()).collect()
        });
        if let Some(key) = map.keys().next() {
            return Err(format!("unknown metadata key '{key}'").into());
        }
        let metadata = Self {
            duration,
            yields,
            source,
            tags,
        };
        Ok(metadata)
    }
}

fn parse_mapping(line: &str) -> ParseResult<(&str, &str)> {
    let (key, value) = line
        .trim()
        .split_once(": ")
        .ok_or("missing key-value separator ': ' in metadata")?;
    let key = key.trim_end();
    let value = value.trim_start();
    let mapping = if key.is_empty() {
        Err(ParseError::empty("metadata key"))
    } else if value.is_empty() {
        Err(ParseError::empty("metadata value"))
    } else {
        Ok((key, value))
    }?;
    Ok(mapping)
}

#[cfg(test)]
mod tests {

    use super::*;

    #[test]
    fn test_display_duration() {
        let mut duration = Duration {
            hours: 1,
            minutes: 0,
        };
        assert_eq!(duration.to_string(), "1h");
        duration.minutes = 30;
        assert_eq!(duration.to_string(), "1h 30m");
        duration.hours = 0;
        assert_eq!(duration.to_string(), "30m");
    }

    #[test]
    fn test_parse_duration() {
        let duration = Duration::parse_from_str("1h").unwrap();
        assert_eq!(duration.hours, 1);
        assert_eq!(duration.minutes, 0);
        let duration = Duration::parse_from_str("1m").unwrap();
        assert_eq!(duration.hours, 0);
        assert_eq!(duration.minutes, 1);
        let duration = Duration::parse_from_str("2h  30m").unwrap();
        assert_eq!(duration.hours, 2);
        assert_eq!(duration.minutes, 30);
        let duration = Duration::parse_from_str("0h  60m").unwrap();
        assert_eq!(duration.hours, 1);
        assert_eq!(duration.minutes, 0);
    }

    #[test]
    fn test_display_link() {
        let link = Link {
            name: "name".into(),
            url: "url".into(),
        };
        assert_eq!(link.to_string(), "name > url");
    }

    #[test]
    fn test_parse_link() {
        let link = Link::parse_from_str("name > url").unwrap();
        assert_eq!(link.name, "name");
        assert_eq!(link.url, "url");
        let link = Link::parse_from_str("name with space  >  url with space").unwrap();
        assert_eq!(link.name, "name with space");
        assert_eq!(link.url, "url with space");
    }

    #[test]
    fn test_display_source() {
        let link = Link {
            name: "name".into(),
            url: "url".into(),
        };
        let source = Source::Link(link);
        assert_eq!(source.to_string(), "Link: name > url");
        let source = Source::Author("name".into());
        assert_eq!(source.to_string(), "Author: name");
        let source = Source::Book("name".into());
        assert_eq!(source.to_string(), "Book: name");
    }

    #[test]
    fn test_metadata() {
        let mut map = HashMap::new();
        map.insert("Yield".into(), "1  unit".into());
        map.insert("Link".into(), "name> > >url".into());
        map.insert("Tags".into(), "tag1 ,  tag2".into());
        let metadata: Metadata = map.try_into().unwrap();
        assert_eq!(metadata.yields.value, 1);
        assert_eq!(metadata.yields.unit.as_deref(), Some("unit"));
        assert!(
            matches!(&metadata.source, Some(Source::Link(link)) if link.name =="name>" && link.url == ">url")
        );
        assert_eq!(&metadata.tags, &["tag1", "tag2"]);
        assert_eq!(
            metadata.to_string(),
            "Yield: 1 unit\nLink: name> > >url\nTags: tag1, tag2\n"
        );
    }

    #[test]

    fn test_metadata_empty() {
        assert!(Metadata::try_from(HashMap::new()).is_err());
    }

    #[test]
    fn test_metadata_missing_entries() {
        let mut map = HashMap::new();
        map.insert("Yield".into(), "1".into());
        let metadata: Metadata = map.try_into().unwrap();
        assert_eq!(metadata.yields.value, 1);
        assert_eq!(metadata.to_string(), "Yield: 1\n");
    }

    #[test]
    fn test_metadata_unkown_entries() {
        let mut map = HashMap::new();
        map.insert("Yield".into(), "1".into());
        map.insert("Unknown".into(), "unknown".into());
        assert!(Metadata::try_from(map).is_err());
    }
}