1#[cfg(test)]
6mod tests;
7
8use std::{borrow::Cow, fmt::Display};
9
10trait SplitTwice<'a> {
11 fn split_twice(self, delim: &'a str) -> Option<(&'a str, &'a str, &'a str)>;
12}
13
14impl<'a> SplitTwice<'a> for &'a str {
15 fn split_twice(self: &'a str, delim: &'a str) -> Option<(&'a str, &'a str, &'a str)> {
16 self.split_once(delim)
17 .and_then(|(a, b)| b.split_once(delim).map(|(b, c)| (a, b, c)))
18 }
19}
20
21#[derive(Debug, Clone)]
22pub struct Recipe<'a> {
23 pub preface: Cow<'a, str>,
24 pub ingredients: Vec<Ingredient<'a>>,
25 pub instructions: Cow<'a, str>,
26}
27
28impl<'a> Recipe<'a> {
29 pub fn into_static(self) -> Recipe<'static> {
30 let Self {
31 preface,
32 ingredients,
33 instructions,
34 } = self;
35 Recipe {
36 preface: preface.to_string().into(),
37 ingredients: ingredients.into_iter().map(|i| i.into_static()).collect(),
38 instructions: instructions.to_string().into(),
39 }
40 }
41}
42
43impl Display for Recipe<'_> {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 write!(f, "{}", self.preface)?;
46 for ingredient in &self.ingredients {
47 write!(f, "{ingredient}")?;
48 }
49 write!(f, "{}", self.instructions)
50 }
51}
52
53#[derive(Debug, Clone)]
54pub struct Ingredient<'a> {
55 pub indent: Cow<'a, str>,
56 pub quantity: Quantity,
57 pub name: Cow<'a, str>,
58}
59
60impl Display for Ingredient<'_> {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 write!(f, "{}- ", self.indent)?;
63 match &self.quantity {
64 Quantity::Simple(q) => write!(f, "{q} ")?,
65 Quantity::Volume(v) => write!(f, "{v} ")?,
66 _ => (),
67 };
68 write!(f, "{}", self.name)?;
69 Ok(())
70 }
71}
72
73#[derive(Debug, Clone)]
74pub enum Quantity {
75 None,
76 Simple(f32),
77 Volume(Volume),
78}
79
80#[derive(Debug, Clone)]
81pub struct Volume {
82 quarter_teaspoons: f32,
83}
84
85impl Volume {
86 pub fn scale(&self, factor: f32) -> Self {
87 Volume {
88 quarter_teaspoons: self.quarter_teaspoons * factor,
89 }
90 }
91}
92
93impl Display for Volume {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 use quarter_teaspoons::*;
96 let mut qtr_tsps = self.quarter_teaspoons;
97 let mut out = String::new();
98 let mut plural = false;
100 let cups = qtr_tsps.div_euclid(CUP);
101 qtr_tsps = qtr_tsps.rem_euclid(CUP);
102 if cups > 0.0 {
103 out.push_str(&cups.to_string());
104 out.push(' ');
105 }
106 if qtr_tsps >= THREE_QUARTER_CUP {
108 if !out.is_empty() {
109 out.push_str("+ ");
110 plural = true;
111 }
112 out.push_str("3/4 ");
113 qtr_tsps -= THREE_QUARTER_CUP;
114 }
115 if qtr_tsps >= TWO_THIRDS_CUP {
117 if !out.is_empty() {
118 out.push_str("+ ");
119 plural = true;
120 }
121 out.push_str("2/3 ");
122 qtr_tsps -= TWO_THIRDS_CUP;
123 }
124 if qtr_tsps >= HALF_CUP {
126 if !out.is_empty() {
127 out.push_str("+ ");
128 plural = true;
129 }
130 out.push_str("1/2 ");
131 qtr_tsps -= HALF_CUP;
132 }
133 if qtr_tsps >= THIRD_CUP {
135 if !out.is_empty() {
136 out.push_str("+ ");
137 plural = true;
138 }
139 out.push_str("1/3 ");
140 qtr_tsps -= THIRD_CUP;
141 }
142 if qtr_tsps >= QUARTER_CUP {
144 if !out.is_empty() {
145 out.push_str("+ ");
146 plural = true;
147 }
148 out.push_str("1/4 ");
149 qtr_tsps -= QUARTER_CUP;
150 }
151 if cups > 1.0 || plural {
153 out.push_str("cups");
154 } else if !out.is_empty() {
155 out.push_str("cup");
156 }
157
158 let mut has_tablespoons = false;
160 let mut plural = false;
161 let tablespoons = qtr_tsps.div_euclid(TABLESPOON);
162 qtr_tsps = qtr_tsps.rem_euclid(TABLESPOON);
163 if tablespoons > 0.0 {
164 has_tablespoons = true;
165 if !out.is_empty() {
166 out.push_str("+ ");
167 }
168 out.push_str(&format!("{tablespoons} "));
169 }
170 if (HALF_TABLESPOON..2.0 * TEASPOON).contains(&qtr_tsps) {
173 if !out.is_empty() {
174 out.push_str("+ ");
175 }
176 plural = has_tablespoons;
177 has_tablespoons = true;
178 out.push_str("1/2 ");
179 qtr_tsps -= HALF_TABLESPOON;
180 }
181 if tablespoons > 1.0 || plural {
182 out.push_str("tbsps");
183 } else if has_tablespoons {
184 out.push_str("tbsp")
185 }
186
187 let mut has_teaspoons = false;
189 let mut plural = false;
190 let teaspoons = qtr_tsps.div_euclid(TEASPOON);
191 qtr_tsps = qtr_tsps.rem_euclid(TEASPOON);
192 if teaspoons > 0.0 {
193 has_teaspoons = true;
194 if !out.is_empty() {
195 out.push_str("+ ");
196 }
197 out.push_str(&format!("{teaspoons} "));
198 }
199 if qtr_tsps >= HALF_TEASPOON {
200 plural = has_teaspoons;
201 has_teaspoons = true;
202 if !out.is_empty() {
203 out.push_str("+ ");
204 }
205 out.push_str("1/2 ");
206 qtr_tsps -= HALF_TEASPOON;
207 }
208 if qtr_tsps >= QUARTER_TEASPOON {
209 plural = has_teaspoons;
210 has_teaspoons = true;
211 if !out.is_empty() {
212 out.push_str("+ ");
213 }
214 out.push_str("1/4 ");
215 qtr_tsps -= QUARTER_TEASPOON;
216 }
217 if qtr_tsps > 0.0 {
218 plural = has_teaspoons;
219 has_teaspoons = true;
220 if !out.is_empty() {
221 out.push_str("+ ");
222 }
223 let tsps = qtr_tsps / 4.0;
224 match tsps {
225 0.0625 => out.push_str("1/16 "),
226 0.125 => out.push_str("1/8 "),
227 tsps => out.push_str(&format!("{tsps} ")),
228 }
229 }
230 if teaspoons > 1.0 || plural {
231 out.push_str("tsps");
232 } else if has_teaspoons {
233 out.push_str("tsp")
234 }
235 write!(f, "{out}")
241 }
242}
243
244mod quarter_teaspoons {
245 pub const CUP: f32 = 16.0 * 3.0 * 4.0;
246 pub const THREE_QUARTER_CUP: f32 = 3.0 / 4.0 * CUP;
247 pub const TWO_THIRDS_CUP: f32 = 2.0 / 3.0 * CUP;
248 pub const HALF_CUP: f32 = 0.5 * CUP;
249 pub const THIRD_CUP: f32 = 1.0 / 3.0 * CUP;
250 pub const QUARTER_CUP: f32 = 1.0 / 4.0 * CUP;
251 pub const TABLESPOON: f32 = 3.0 * 4.0;
252 pub const HALF_TABLESPOON: f32 = 0.5 * TABLESPOON;
253 pub const TEASPOON: f32 = 4.0;
254 pub const HALF_TEASPOON: f32 = 2.0;
255 pub const QUARTER_TEASPOON: f32 = 1.0;
256}
257
258impl Volume {
259 fn parse(amount: &str, unit: &str) -> Option<Self> {
260 let amount = parse_f32(amount).ok()?;
261 let unit_quarter_teaspoons: f32 = match unit.to_lowercase().as_str() {
262 "cups" | "cup" => 16.0 * 3.0 * 4.0,
263 "tablespoon" | "tablespoons" | "tb" | "tbs" | "tbsp" | "tbsps" => 3.0 * 4.0,
264 "teaspoon" | "teaspoons" | "tsp" | "tsps" => 4.0,
265 _ => return None,
266 };
267 Some(Self {
268 quarter_teaspoons: amount * unit_quarter_teaspoons,
269 })
270 }
271}
272
273impl<'a> Recipe<'a> {
274 pub fn scale(&self, factor: f32) -> Self {
275 Recipe {
276 preface: self.preface.clone(),
277 ingredients: self.ingredients.iter().map(|i| i.scale(factor)).collect(),
278 instructions: self.instructions.clone(),
279 }
280 }
281 pub fn parse(src: &'a str) -> Self {
282 const INGREDIENTS: &str = "\n## Ingredients\n\n";
284 let Some(mut ingredients_start) = src.find(INGREDIENTS) else {
285 return Recipe {
286 preface: Cow::Borrowed(src),
287 ingredients: vec![],
288 instructions: Cow::Borrowed(""),
289 };
290 };
291 ingredients_start += INGREDIENTS.len();
292 let (preface, src) = src.split_at(ingredients_start);
294 let (ingredients, instructions) = match src.find("\n##") {
295 Some(ingredients_end) => src.split_at(ingredients_end),
296 None => (src, ""),
297 };
298 let ingredients = Ingredients(ingredients).map(Ingredient::parse).collect();
300
301 Recipe {
303 preface: preface.into(),
304 ingredients,
305 instructions: instructions.into(),
306 }
307 }
308}
309
310fn parse_f32(num: &str) -> Result<f32, std::num::ParseFloatError> {
311 if let Some((a, b)) = num.split_once("/") {
312 Ok(a.parse::<f32>()? / b.parse::<f32>()?)
313 } else {
314 num.parse::<f32>()
315 }
316}
317
318impl<'a> Ingredient<'a> {
319 fn into_static(self) -> Ingredient<'static> {
320 let Self {
321 indent,
322 quantity,
323 name,
324 } = self;
325 Ingredient {
326 indent: indent.to_string().into(),
327 quantity,
328 name: name.to_string().into(),
329 }
330 }
331 fn scale(&self, factor: f32) -> Self {
332 let quantity = match &self.quantity {
333 Quantity::None => Quantity::None,
334 Quantity::Simple(q) => Quantity::Simple(q * factor),
335 Quantity::Volume(volume) => Quantity::Volume(volume.scale(factor)),
336 };
337 Self {
338 indent: self.indent.clone(),
339 quantity,
340 name: self.name.clone(),
341 }
342 }
343 fn parse(src: &'a str) -> Self {
344 let (indent, tail) = src
345 .split_once("- ")
346 .expect("Attempted to parse a non-ingredient string.");
347 let (quantity, name) = 'parse_quantity: {
348 if let Some((amount, unit, name)) = tail.split_twice(" ")
350 && let Some(volume) = Volume::parse(amount, unit)
351 {
352 break 'parse_quantity (Quantity::Volume(volume), name);
353 };
354 if let Some((amount, name)) = tail.split_once(" ")
356 && let Ok(simple) = parse_f32(amount)
357 {
358 break 'parse_quantity (Quantity::Simple(simple), name);
359 }
360 (Quantity::None, tail)
362 };
363 Self {
364 indent: indent.into(),
365 quantity,
366 name: name.into(),
367 }
368 }
369}
370
371struct Ingredients<'a>(&'a str);
372
373impl<'a> Iterator for Ingredients<'a> {
374 type Item = &'a str;
375
376 fn next(&mut self) -> Option<Self::Item> {
377 let src = self.0;
379 let (_, tail) = src.split_once("- ")?;
381 for line in tail.split("\n") {
383 if line.trim_start().starts_with("-") {
384 let end = line.as_ptr() as usize;
385 let len = end - src.as_ptr() as usize;
386 let (next, src) = src.split_at(len);
387 self.0 = src;
388 return Some(next);
389 }
390 }
391 self.0 = "";
393 Some(src)
394 }
395}
396
397#[test]
398fn pizza() {
399 let pizza_src = include_str!("tests/pizza.md"); let recipe = Recipe::parse(pizza_src);
401 let scaled = recipe.scale(0.5);
402 println!("{scaled}");
403 assert_eq!(pizza_src, format!("{recipe}"));
404}