sweet-potator 0.7.10

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

use serde::Serialize;

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

#[derive(Debug, Serialize)]
pub struct Section<D> {
    pub name: String,
    pub items: Vec<D>,
}

impl<D> Section<D> {
    pub fn new(name: String, items: Vec<D>) -> Self {
        Self { name, items }
    }
}

impl<P: ParseFromStr> Section<P> {
    fn parse_items<'a>(
        iter: &'a mut slice::Iter<String>,
    ) -> ParseResult<(Vec<P>, Option<&'a String>)> {
        let mut items = Vec::new();
        for line in iter {
            if let Ok(line) = strip_prefix(line) {
                items.push(P::parse_from_str(line)?);
            } else {
                return Ok((items, Some(line)));
            }
        }
        Ok((items, None))
    }
}

impl<D: fmt::Display> Section<D> {
    fn format(&self, f: &mut fmt::Formatter<'_>, indentation: &str) -> fmt::Result {
        writeln!(f, "{}{}", indentation, self.name)?;
        format_items(&self.items, f, &format!("{indentation}{indentation}"))
    }
}

#[derive(Debug)]
pub enum List<D> {
    Basic(Vec<D>),
    Sectioned(Vec<Section<D>>),
}

impl<D> List<D> {
    pub fn count(&self) -> usize {
        match self {
            Self::Basic(items) => items.len(),
            Self::Sectioned(sections) => sections
                .iter()
                .fold(0, |acc, section| acc + section.items.len()),
        }
    }
}

impl<D: Serialize> Serialize for List<D> {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        let count = self.count();
        match self {
            Self::Basic(items) => {
                #[derive(Serialize)]
                struct Extended<'a, D> {
                    items: &'a Vec<D>,
                    count: usize,
                }
                let ext = Extended { items, count };
                Ok(ext.serialize(serializer)?)
            }
            Self::Sectioned(sections) => {
                #[derive(Serialize)]
                struct Extended<'a, D> {
                    sections: &'a Vec<Section<D>>,
                    count: usize,
                }
                let ext = Extended { sections, count };
                Ok(ext.serialize(serializer)?)
            }
        }
    }
}

impl<D: fmt::Display> List<D> {
    pub fn format(&self, f: &mut fmt::Formatter<'_>, indentation: &str) -> fmt::Result {
        match self {
            Self::Basic(items) => {
                format_items(items, f, indentation)?;
            }
            Self::Sectioned(sections) => {
                for section in sections {
                    section.format(f, indentation)?;
                }
            }
        }
        Ok(())
    }
}

impl<P: ParseFromStr> List<P> {
    pub fn parse_basic<S: ParseFromStr>(lines: &[String]) -> ParseResult<Vec<S>> {
        lines
            .iter()
            .map(|line| strip_prefix(line).and_then(S::parse_from_str))
            .collect()
    }

    fn parse_sectioned(lines: &[String]) -> ParseResult<Vec<Section<P>>> {
        let mut sections = Vec::new();
        let mut lines = lines.iter();
        let mut section_name = if let Some(line) = lines.next() {
            line.to_string()
        } else {
            return Ok(sections);
        };
        loop {
            if section_name.is_empty() {
                return Err(ParseError::empty("list section name"));
            }
            let (items, new_section_name) = Section::parse_items(&mut lines)?;
            sections.push(Section::new(section_name, items));
            if let Some(name) = new_section_name {
                section_name = name.into();
            } else {
                break;
            }
        }
        Ok(sections)
    }
}

impl<D: ParseFromStr> TryFrom<Vec<String>> for List<D> {
    type Error = ParseError;

    fn try_from(lines: Vec<String>) -> Result<Self, Self::Error> {
        if lines.first().map_or(true, |line| line.starts_with("- ")) {
            Ok(Self::Basic(Self::parse_basic(&lines)?))
        } else {
            Ok(Self::Sectioned(Self::parse_sectioned(&lines)?))
        }
    }
}

pub fn format_items<D: fmt::Display>(
    items: &[D],
    f: &mut fmt::Formatter<'_>,
    indentation: &str,
) -> fmt::Result {
    for item in items {
        writeln!(f, "{indentation}- {item}")?;
    }
    Ok(())
}

fn strip_prefix(line: &str) -> ParseResult<&str> {
    line.strip_prefix("- ")
        .ok_or_else(|| "list item must start with '- '".into())
        .map(str::trim_start)
}

#[cfg(test)]
mod tests {

    use super::*;

    struct DisplayTest<'a, D> {
        list: &'a List<D>,
        indentation: String,
    }

    impl<'a, D> DisplayTest<'a, D> {
        pub fn new(list: &'a List<D>, indent_size: usize) -> Self {
            let indentation = " ".repeat(indent_size);
            Self { list, indentation }
        }
    }

    impl<'a, D: fmt::Display> fmt::Display for DisplayTest<'a, D> {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            self.list.format(f, &self.indentation)
        }
    }

    const SECTIONED_LIST: &str = concat!(
        "section 1\n",
        "- item 1.1\n",
        "- item 1.2\n",
        "section 2\n",
        "- item 2.1\n",
        "- item 2.2\n",
    );

    const TWO_INDENTED_SECTIONED_LIST: &str = concat!(
        "  section 1\n",
        "    - item 1.1\n",
        "    - item 1.2\n",
        "  section 2\n",
        "    - item 2.1\n",
        "    - item 2.2\n",
    );

    #[test]
    fn test_count() {
        let list = List::Basic(vec!["item 1", "item 2"]);
        assert_eq!(list.count(), 2);
        let section1 = Section {
            name: "section 1".into(),
            items: vec!["item 1.1", "item 1.2"],
        };
        let section2 = Section {
            name: "section 2".into(),
            items: vec!["item 2.1"],
        };
        let list = List::Sectioned(vec![section1, section2]);
        assert_eq!(list.count(), 3);
    }

    #[test]
    fn test_display_basic() {
        let list = List::Basic(vec!["item 1", "item 2"]);
        let test = DisplayTest::new(&list, 0);
        assert_eq!(test.to_string(), "- item 1\n- item 2\n");
        let test = DisplayTest::new(&list, 2);
        assert_eq!(test.to_string(), "  - item 1\n  - item 2\n");
    }

    #[test]
    fn test_display_sectioned() {
        let section1 = Section {
            name: "section 1".into(),
            items: vec!["item 1.1", "item 1.2"],
        };
        let section2 = Section {
            name: "section 2".into(),
            items: vec!["item 2.1", "item 2.2"],
        };
        let list = List::Sectioned(vec![section1, section2]);
        let test = DisplayTest::new(&list, 0);
        assert_eq!(test.to_string(), SECTIONED_LIST);
        let test = DisplayTest::new(&list, 2);
        assert_eq!(test.to_string(), TWO_INDENTED_SECTIONED_LIST);
    }

    #[test]
    fn test_parse_basic() {
        let list: List<String> = vec!["-  item 1".into(), "-  item 2".into()]
            .try_into()
            .unwrap();
        assert!(matches!(list, List::Basic(items) if items == vec!("item 1", "item 2")))
    }

    #[test]
    fn test_parse_basic_fail() {
        assert!(List::<String>::try_from(vec![
            "- item 1".into(),
            "- item 2".into(),
            String::new()
        ])
        .is_err());
    }

    #[test]
    fn test_parse_basic_empty() {
        assert!(List::<String>::try_from(vec![String::new()]).is_err());
    }

    #[test]
    fn test_parse_sectioned() {
        let list: List<String> = SECTIONED_LIST
            .trim()
            .split('\n')
            .map(Into::into)
            .collect::<Vec<_>>()
            .try_into()
            .unwrap();
        assert!(matches!(list, List::Sectioned(_)));
        if let List::Sectioned(sections) = list {
            assert_eq!(sections.len(), 2);
            let section = sections.get(0).unwrap();
            assert_eq!(section.name, "section 1");
            assert_eq!(section.items, vec!("item 1.1", "item 1.2"));
            let section = sections.get(1).unwrap();
            assert_eq!(section.name, "section 2");
            assert_eq!(section.items, vec!("item 2.1", "item 2.2"));
        }
    }
}