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