Skip to main content

sweet_potator/recipe/
mod.rs

1pub mod directory;
2pub mod errors;
3pub mod ingredient;
4pub mod list;
5pub mod metadata;
6mod reader;
7
8use std::{fmt, io};
9
10use serde::Serialize;
11
12use crate::error::Result;
13
14use self::{
15    errors::{ParseError, ParseResult},
16    ingredient::Ingredient,
17    list::List,
18    metadata::Metadata,
19    reader::Reader,
20};
21
22pub trait ParseFromStr: Sized {
23    fn parse_from_str(s: &str) -> ParseResult<Self>;
24}
25
26impl ParseFromStr for String {
27    fn parse_from_str(s: &str) -> ParseResult<Self> {
28        Ok(s.into())
29    }
30}
31
32#[derive(Debug, Serialize)]
33pub struct Recipe {
34    pub title: String,
35    pub metadata: Metadata,
36    pub ingredients: List<Ingredient>,
37    pub instructions: List<String>,
38    pub notes: Vec<String>,
39}
40
41impl Recipe {
42    pub fn parse_from(reader: impl io::Read) -> Result<Self> {
43        let mut reader = Reader::new(reader, true);
44        Ok(Self {
45            title: reader
46                .next_block()?
47                .ok_or_else(|| ParseError::from("missing title"))
48                .and_then(Self::parse_title)?,
49            metadata: reader
50                .next_block()?
51                .ok_or_else(|| ParseError::from("missing metadata"))
52                .and_then(Metadata::try_from)?,
53            ingredients: reader
54                .next_block()?
55                .ok_or_else(|| ParseError::from("missing ingredients"))
56                .and_then(Self::parse_ingredients)?,
57            instructions: reader
58                .next_block()?
59                .ok_or_else(|| ParseError::from("missing instructions"))
60                .and_then(Self::parse_instructions)?,
61            notes: reader
62                .next_block()?
63                .map_or_else(|| Ok(Vec::new()), Self::parse_notes)?,
64        })
65    }
66
67    fn parse_title(mut lines: Vec<String>) -> ParseResult<String> {
68        if lines.len() > 1 {
69            return Err("missing empty line after title line".into());
70        }
71        lines.pop().ok_or_else(|| ParseError::empty("title line"))
72    }
73
74    fn parse_ingredients(mut lines: Vec<String>) -> ParseResult<List<Ingredient>> {
75        if !matches!(lines.first(), Some(line) if line == "Ingredients") {
76            return Err("missing headline 'Ingredients'".into());
77        }
78        lines.remove(0);
79        lines.try_into()
80    }
81
82    fn parse_instructions(mut lines: Vec<String>) -> ParseResult<List<String>> {
83        if !matches!(lines.first(), Some(line) if line == "Instructions") {
84            return Err("missing headline 'Instructions'".into());
85        }
86        lines.remove(0);
87        lines.try_into()
88    }
89
90    fn parse_notes(mut lines: Vec<String>) -> ParseResult<Vec<String>> {
91        if !matches!(lines.first(), Some(line) if line == "Notes") {
92            return Err("expected headline 'Notes'".into());
93        }
94        lines.remove(0);
95        List::<String>::parse_basic(&lines)
96    }
97}
98
99impl fmt::Display for Recipe {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        let indentation = " ".repeat(2);
102        writeln!(f, "{}\n", self.title)?;
103        writeln!(f, "{}", self.metadata)?;
104        writeln!(f, "Ingredients")?;
105        self.ingredients.format(f, &indentation)?;
106        writeln!(f, "\nInstructions")?;
107        self.instructions.format(f, &indentation)?;
108        if !self.notes.is_empty() {
109            writeln!(f, "\nNotes")?;
110            list::format_items(&self.notes, f, &indentation)?;
111        }
112        Ok(())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118
119    use super::{
120        list::Section,
121        metadata::{Duration, Link, Source, Yield},
122        *,
123    };
124
125    const RECIPE_TO_PARSE: &str = concat!(
126        " \n",
127        " the title \n",
128        " \n",
129        " Yield :  10  u n i t \n",
130        " Time: 1h  30m\n",
131        " Link: the name  >  the url\n",
132        " Tags: tag 1 ,  tag 2\n",
133        " \n",
134        " Ingredients \n",
135        " section 1 \n",
136        " -  name 1 ,  kind 1 :  10  1 unit  ( note 1 ) \n",
137        " -  name 2 \n",
138        " section 2 \n",
139        "   - name \n",
140        " \n",
141        " Instructions \n",
142        " section 1 \n",
143        " - instruction 1 \n",
144        "   - instruction 2 \n",
145        " \n",
146        " Notes \n",
147        "  - note",
148        " \n"
149    );
150
151    const RECIPE_TO_DISPLAY: &str = concat!(
152        "title\n",
153        "\n",
154        "Yield: 1 unit\n",
155        "Time: 1h 30m\n",
156        "Link: name > url\n",
157        "Tags: tag1, tag2\n",
158        "\n",
159        "Ingredients\n",
160        "  section\n",
161        "    - name\n",
162        "\n",
163        "Instructions\n",
164        "  section\n",
165        "    - instruction\n",
166        "\n",
167        "Notes\n",
168        "  - note\n"
169    );
170
171    #[test]
172    fn test_display() {
173        let recipe = Recipe {
174            title: "title".into(),
175            metadata: Metadata {
176                duration: Some(Duration {
177                    hours: 1,
178                    minutes: 30,
179                }),
180                yields: Yield {
181                    value: 1,
182                    unit: Some("unit".into()),
183                },
184                source: Some(Source::Link(Link {
185                    name: "name".into(),
186                    url: "url".into(),
187                })),
188                tags: vec!["tag1".into(), "tag2".into()],
189            },
190            ingredients: List::Sectioned(vec![Section::new(
191                "section".into(),
192                vec![Ingredient {
193                    name: "name".into(),
194                    kind: None,
195                    quantity: None,
196                }],
197            )]),
198            instructions: List::Sectioned(vec![Section::new(
199                "section".into(),
200                vec!["instruction".into()],
201            )]),
202            notes: vec!["note".into()],
203        };
204        assert_eq!(recipe.to_string(), RECIPE_TO_DISPLAY)
205    }
206
207    #[test]
208    fn test_parse() {
209        let reader = io::Cursor::new(RECIPE_TO_PARSE);
210        let recipe = Recipe::parse_from(reader).unwrap();
211
212        // title
213        assert_eq!(recipe.title, "the title");
214
215        // metadata
216        let metadata = recipe.metadata;
217
218        assert_eq!(metadata.yields.value, 10);
219        assert_eq!(metadata.yields.unit.as_deref(), Some("u n i t"));
220        assert!(metadata.duration.is_some());
221        if let Some(duration) = metadata.duration {
222            assert_eq!(duration.hours, 1);
223            assert_eq!(duration.minutes, 30);
224        }
225        assert!(matches!(metadata.source, Some(Source::Link(_))));
226        if let Some(Source::Link(link)) = metadata.source {
227            assert_eq!(link.name, "the name");
228            assert_eq!(link.url, "the url");
229        }
230        assert_eq!(metadata.tags, ["tag 1", "tag 2"]);
231
232        // ingredients
233        assert!(matches!(recipe.ingredients, List::Sectioned(_)));
234        if let List::Sectioned(sections) = recipe.ingredients {
235            assert_eq!(sections.len(), 2);
236
237            // section 1
238            let section = sections.get(0).unwrap();
239            assert_eq!(section.name, "section 1");
240            assert_eq!(section.items.len(), 2);
241            let item = section.items.get(0).unwrap();
242            assert_eq!(item.name, "name 1");
243            assert_eq!(item.kind, Some("kind 1".into()));
244            let quantity = item.quantity.as_ref().unwrap();
245            assert_eq!(quantity.value.to_string(), "10");
246            assert_eq!(quantity.unit, Some("1 unit".into()));
247            assert_eq!(quantity.note, Some("note 1".into()));
248            let item = section.items.get(1).unwrap();
249            assert_eq!(item.name, "name 2");
250            assert_eq!(item.kind, None);
251            assert!(item.quantity.is_none());
252
253            // section 2
254            let section = sections.get(1).unwrap();
255            assert_eq!(section.name, "section 2");
256            assert_eq!(section.items.len(), 1);
257            let item = section.items.get(0).unwrap();
258            assert_eq!(item.name, "name");
259            assert_eq!(item.kind, None);
260            assert!(item.quantity.is_none());
261        }
262
263        // instructions
264        assert!(matches!(recipe.instructions, List::Sectioned(_)));
265        if let List::Sectioned(sections) = recipe.instructions {
266            assert_eq!(sections.len(), 1);
267
268            // section 1
269            let section = sections.get(0).unwrap();
270            assert_eq!(section.name, "section 1");
271            assert_eq!(section.items.len(), 2);
272            assert_eq!(section.items.get(0).unwrap(), "instruction 1");
273            assert_eq!(section.items.get(1).unwrap(), "instruction 2");
274        }
275    }
276
277    #[test]
278    fn test_parse_empty() {
279        let reader = io::Cursor::new("");
280        assert!(Recipe::parse_from(reader).is_err());
281    }
282
283    #[test]
284    fn test_parse_missing() {
285        let reader = io::Cursor::new("");
286        assert!(Recipe::parse_from(reader).is_err());
287        let reader = io::Cursor::new("title\n\nYield: 1\n\nIngredients\nNothing");
288        assert!(Recipe::parse_from(reader).is_err());
289    }
290}