Skip to main content

maddi_recipe/
lib.rs

1// SPDX-FileCopyrightText: 2025 Madeline Baggins <madeline@baggins.family>
2//
3// SPDX-License-Identifier: GPL-3.0-only
4
5#[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        // Take out as many cups as you can.
120        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        // Check if 3/4 cup remains
128        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        // Check if 2/3 Cup remains
137        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        // Check if 1/2 Cup remains
146        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        // Check if 1/3 Cup remains
155        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        // Check if 1/4 Cup remains
164        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        // Add 'cups' or 'cup'
173        if cups > 1.0 || plural {
174            out.push_str("cups");
175        } else if !out.is_empty() {
176            out.push_str("cup");
177        }
178
179        // Adding tablespoons
180        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        // As two teaspoons is more than half a tablespoon, we only
192        // do this one if we have less than two teaspoons
193        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        // Adding teaspoons
209        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        // TODO
257
258        // Adding teaspoons
259        // - Check if what's left is greater or equal to two teaspoons
260        // - Do the rest
261        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        // Find where the ingredients start
304        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        // Seperate the preface, ingredients, and instructions
314        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        // Parse the ingredients
320        let ingredients = Ingredients(ingredients).map(Ingredient::parse).collect();
321
322        // Return the recipe
323        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            // Try to parse as a volume
370            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            // Try to parse as a simple
376            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            // Resort to a none
382            (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        // Store the current tail
399        let src = self.0;
400        // Skip past the start of the md item
401        let (_, tail) = src.split_once("- ")?;
402        // Find the start of the next item
403        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        // If we can't, return everything
413        self.0 = "";
414        Some(src)
415    }
416}
417
418#[test]
419fn pizza() {
420    let pizza_src = include_str!("tests/pizza.md"); // Lol 'pizza_src'
421    let recipe = Recipe::parse(pizza_src);
422    let scaled = recipe.scale(0.5);
423    println!("{scaled}");
424    assert_eq!(pizza_src, format!("{recipe}"));
425}