Skip to main content

fond_domain/
parser.rs

1use chrono::Utc;
2use cooklang::{CooklangParser, Extensions};
3
4use crate::error::DomainError;
5use crate::recipe::{Cookware, Recipe, RecipeIngredient, Step, Timer};
6use crate::slug::{slugify, title_from_stem};
7
8/// Parse a `.cook` file's content into a domain [`Recipe`].
9///
10/// The original source is preserved in [`Recipe::raw_source`] so that
11/// user-authored files can be written back without data loss.
12///
13/// If the file lacks a `title` metadata key, the title is derived
14/// from `file_stem` (e.g., `"chicken-adobo"` → `"Chicken Adobo"`).
15pub fn parse_cook(content: &str, file_stem: &str) -> Result<Recipe, DomainError> {
16    let parser = CooklangParser::new(Extensions::all(), Default::default());
17    let result = parser.parse(content);
18    let scaled = result
19        .into_output()
20        .ok_or_else(|| DomainError::ParseCooklang {
21            message: "failed to produce a valid recipe".into(),
22        })?;
23
24    let meta = &scaled.metadata;
25    let get = |key: &str| -> Option<String> {
26        meta.map
27            .get(key)
28            .and_then(|v| {
29                v.as_str()
30                    .map(|s| s.to_string())
31                    .or_else(|| v.as_u64().map(|n| n.to_string()))
32                    .or_else(|| v.as_f64().map(|n| n.to_string()))
33            })
34            .filter(|s| !s.is_empty())
35    };
36
37    let title = get("title").unwrap_or_else(|| title_from_stem(file_stem));
38    let slug = slugify(&title);
39
40    // Extract prep_time with fallback key
41    let prep_time = get("prep time").or_else(|| get("prep_time"));
42    let cook_time = get("cook time").or_else(|| get("cook_time"));
43    let total_time = get("total time").or_else(|| get("total_time"));
44
45    let tags: Vec<String> = meta
46        .tags()
47        .map(|ts| ts.into_iter().map(|s| s.to_string()).collect())
48        .unwrap_or_default();
49
50    // Extract ingredients
51    let ingredients: Vec<RecipeIngredient> = scaled
52        .ingredients
53        .iter()
54        .map(|ing| {
55            let (quantity, unit) = match &ing.quantity {
56                Some(q) => {
57                    let qty_str = format!("{}", q.value());
58                    let unit_str = q.unit().map(|u| u.to_string());
59                    (
60                        if qty_str.is_empty() {
61                            None
62                        } else {
63                            Some(qty_str)
64                        },
65                        unit_str,
66                    )
67                }
68                None => (None, None),
69            };
70            RecipeIngredient {
71                name: ing.name.clone(),
72                quantity,
73                unit,
74                note: ing.note.clone(),
75                optional: false,
76            }
77        })
78        .collect();
79
80    // Extract cookware
81    let cookware: Vec<Cookware> = scaled
82        .cookware
83        .iter()
84        .map(|cw| Cookware {
85            name: cw.name.clone(),
86            quantity: cw.quantity.as_ref().map(|q| format!("{}", q.value())),
87        })
88        .collect();
89
90    // Extract steps with timers
91    let mut steps = Vec::new();
92    let mut order = 0u32;
93    for section in &scaled.sections {
94        let section_name = section.name.clone();
95        for item in &section.content {
96            match item {
97                cooklang::Content::Step(step) => {
98                    let mut body = String::new();
99                    let mut timers = Vec::new();
100
101                    for si in &step.items {
102                        match si {
103                            cooklang::Item::Text { value } => body.push_str(value),
104                            cooklang::Item::Ingredient { index } => {
105                                if let Some(ing) = scaled.ingredients.get(*index) {
106                                    body.push_str(&ing.name);
107                                }
108                            }
109                            cooklang::Item::Cookware { index } => {
110                                if let Some(cw) = scaled.cookware.get(*index) {
111                                    body.push_str(&cw.name);
112                                }
113                            }
114                            cooklang::Item::Timer { index } => {
115                                if let Some(t) = scaled.timers.get(*index) {
116                                    let duration = t.quantity.as_ref().map(|q| format!("{q}"));
117                                    let name = t.name.clone();
118                                    if let Some(d) = &duration {
119                                        body.push_str(d);
120                                    } else if let Some(n) = &name {
121                                        body.push_str(n);
122                                    }
123                                    timers.push(Timer { name, duration });
124                                }
125                            }
126                            _ => {}
127                        }
128                    }
129
130                    steps.push(Step {
131                        section: section_name.clone(),
132                        body,
133                        timers,
134                        order,
135                    });
136                    order += 1;
137                }
138                cooklang::Content::Text(text) => {
139                    steps.push(Step {
140                        section: section_name.clone(),
141                        body: text.clone(),
142                        timers: Vec::new(),
143                        order,
144                    });
145                    order += 1;
146                }
147            }
148        }
149    }
150
151    let now = Utc::now();
152
153    Ok(Recipe {
154        slug,
155        title,
156        source: get("source"),
157        source_url: get("source_url").or_else(|| get("source url")),
158        description: get("description"),
159        recipe_yield: get("yield"),
160        prep_time,
161        cook_time,
162        total_time,
163        servings: get("servings"),
164        ingredients,
165        steps,
166        cookware,
167        tags,
168        created_at: now,
169        updated_at: now,
170        raw_source: Some(content.to_string()),
171    })
172}