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 assert_eq!(recipe.title, "the title");
214
215 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 assert!(matches!(recipe.ingredients, List::Sectioned(_)));
234 if let List::Sectioned(sections) = recipe.ingredients {
235 assert_eq!(sections.len(), 2);
236
237 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 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 assert!(matches!(recipe.instructions, List::Sectioned(_)));
265 if let List::Sectioned(sections) = recipe.instructions {
266 assert_eq!(sections.len(), 1);
267
268 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}