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 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        // Take out as many cups as you can.
99        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        // Check if 3/4 cup remains
107        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        // Check if 2/3 Cup remains
116        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        // Check if 1/2 Cup remains
125        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        // Check if 1/3 Cup remains
134        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        // Check if 1/4 Cup remains
143        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        // Add 'cups' or 'cup'
152        if cups > 1.0 || plural {
153            out.push_str("cups");
154        } else if !out.is_empty() {
155            out.push_str("cup");
156        }
157
158        // Adding tablespoons
159        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        // As two teaspoons is more than half a tablespoon, we only
171        // do this one if we have less than two teaspoons
172        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        // Adding teaspoons
188        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        // TODO
236
237        // Adding teaspoons
238        // - Check if what's left is greater or equal to two teaspoons
239        // - Do the rest
240        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        // Find where the ingredients start
283        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        // Seperate the preface, ingredients, and instructions
293        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        // Parse the ingredients
299        let ingredients = Ingredients(ingredients).map(Ingredient::parse).collect();
300
301        // Return the recipe
302        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            // Try to parse as a volume
349            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            // Try to parse as a simple
355            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            // Resort to a none
361            (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        // Store the current tail
378        let src = self.0;
379        // Skip past the start of the md item
380        let (_, tail) = src.split_once("- ")?;
381        // Find the start of the next item
382        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        // If we can't, return everything
392        self.0 = "";
393        Some(src)
394    }
395}
396
397#[test]
398fn pizza() {
399    let pizza_src = include_str!("tests/pizza.md"); // Lol 'pizza_src'
400    let recipe = Recipe::parse(pizza_src);
401    let scaled = recipe.scale(0.5);
402    println!("{scaled}");
403    assert_eq!(pizza_src, format!("{recipe}"));
404}