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
8pub 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 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 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 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 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 §ion.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}