1use std::{collections::HashMap, io, time::Duration};
7
8use cooklang::{
9 convert::Converter,
10 ingredient_list::GroupedIngredient,
11 metadata::CooklangValueExt,
12 model::{Ingredient, IngredientReferenceTarget, Item},
13 quantity::Quantity,
14 scale::ScaleOutcome,
15 ScaledRecipe, Section, Step,
16};
17use std::fmt::Write;
18use tabular::{Row, Table};
19use yansi::Paint;
20
21mod style;
22use style::styles;
23pub use style::{set_styles, CookStyles};
24
25pub type Result<T = ()> = std::result::Result<T, io::Error>;
26
27pub fn print_human(
28 recipe: &ScaledRecipe,
29 name: &str,
30 converter: &Converter,
31 mut writer: impl std::io::Write,
32) -> Result {
33 let w = &mut writer;
34
35 header(w, recipe, name)?;
36 metadata(w, recipe, converter)?;
37 ingredients(w, recipe, converter)?;
38 cookware(w, recipe)?;
39 steps(w, recipe)?;
40
41 Ok(())
42}
43
44fn header(w: &mut impl io::Write, recipe: &ScaledRecipe, name: &str) -> Result {
45 let title_text = format!(
46 " {}{} ",
47 recipe
48 .metadata
49 .get("emoji")
50 .and_then(|v| v.as_str())
51 .map(|s| format!("{s} "))
52 .unwrap_or_default(),
53 name
54 );
55 writeln!(w, "{}", title_text.paint(styles().title))?;
56 if let Some(tags) = recipe.metadata.tags() {
57 let mut tags_str = String::new();
58 for tag in tags {
59 let color = tag_color(&tag);
60 write!(&mut tags_str, "{} ", format!("#{tag}").paint(color)).unwrap();
61 }
62 print_wrapped(w, &tags_str)?;
63 }
64 writeln!(w)
65}
66
67fn tag_color(tag: &str) -> yansi::Color {
68 let hash = tag
69 .chars()
70 .enumerate()
71 .map(|(i, c)| c as usize * i)
72 .reduce(usize::wrapping_add)
73 .map(|h| (h % 7))
74 .unwrap_or_default();
75 match hash {
76 0 => yansi::Color::Red,
77 1 => yansi::Color::Blue,
78 2 => yansi::Color::Cyan,
79 3 => yansi::Color::Yellow,
80 4 => yansi::Color::Green,
81 5 => yansi::Color::Magenta,
82 6 => yansi::Color::White,
83 _ => unreachable!(),
84 }
85}
86
87fn metadata(w: &mut impl io::Write, recipe: &ScaledRecipe, converter: &Converter) -> Result {
88 if let Some(desc) = recipe.metadata.description() {
89 print_wrapped_with_options(w, desc, |o| {
90 o.initial_indent("\u{2502} ").subsequent_indent("\u{2502}")
91 })?;
92 writeln!(w)?;
93 }
94
95 let mut meta_fmt =
96 |name: &str, value: &str| writeln!(w, "{}: {}", name.paint(styles().meta_key), value);
97 if let Some(author) = recipe.metadata.author() {
98 let text = author.name().or(author.url()).unwrap_or("-");
99 meta_fmt("author", text)?;
100 }
101 if let Some(source) = recipe.metadata.source() {
102 let text = source.name().or(source.url()).unwrap_or("-");
103 meta_fmt("source", text)?;
104 }
105 if let Some(time) = recipe.metadata.time(converter) {
106 let time_fmt = |t: u32| {
107 format!(
108 "{}",
109 humantime::format_duration(Duration::from_secs(t as u64 * 60))
110 )
111 };
112 match time {
113 cooklang::metadata::RecipeTime::Total(t) => meta_fmt("time", &time_fmt(t))?,
114 cooklang::metadata::RecipeTime::Composed {
115 prep_time,
116 cook_time,
117 } => {
118 if let Some(p) = prep_time {
119 meta_fmt("prep time", &time_fmt(p))?
120 }
121 if let Some(c) = cook_time {
122 meta_fmt("cook time", &time_fmt(c))?;
123 }
124 meta_fmt("total time", &time_fmt(time.total()))?;
125 }
126 }
127 }
128 if let Some(servings) = recipe.metadata.servings() {
129 let index = recipe
130 .scaled_data()
131 .and_then(|d| d.target.index())
132 .or_else(|| recipe.is_default_scaled().then_some(0));
133 let mut text = servings
134 .iter()
135 .enumerate()
136 .map(|(i, s)| {
137 if Some(i) == index {
138 format!("[{s}]")
139 .paint(styles().selected_servings)
140 .to_string()
141 } else {
142 s.to_string()
143 }
144 })
145 .reduce(|a, b| format!("{a}|{b}"))
146 .unwrap_or_default();
147 if let Some(data) = recipe.scaled_data() {
148 if data.target.index().is_none() {
149 text = format!(
150 "{} {} {}",
151 text.strike().dim(),
152 "\u{2192}".red(),
153 data.target.target_servings().red()
154 );
155 }
156 }
157 meta_fmt("servings", &text)?;
158 }
159 for (key, value) in recipe.metadata.map.iter().filter_map(|(key, value)| {
160 let key = key.as_str_like()?;
161 match key.as_ref() {
162 "name" | "title" | "description" | "tags" | "author" | "source" | "emoji" | "time"
163 | "prep time" | "cook time" | "servings" => return None,
164 _ => {}
165 }
166 let value = value.as_str_like()?;
167 Some((key, value))
168 }) {
169 meta_fmt(&key, &value)?;
170 }
171 if !recipe.metadata.map.is_empty() {
172 writeln!(w)?;
173 }
174 Ok(())
175}
176
177fn ingredients(w: &mut impl io::Write, recipe: &ScaledRecipe, converter: &Converter) -> Result {
178 if recipe.ingredients.is_empty() {
179 return Ok(());
180 }
181 writeln!(w, "Ingredients:")?;
182 let mut table = Table::new(" {:<} {:<} {:<} {:<}");
183 let mut there_is_fixed = false;
184 let mut there_is_err = false;
185 let trinagle = " \u{26a0}";
186 let octagon = " \u{2BC3}";
187 for entry in recipe.group_ingredients(converter) {
188 let GroupedIngredient {
189 ingredient: igr,
190 quantity,
191 outcome,
192 ..
193 } = entry;
194 if !igr.modifiers().should_be_listed() {
195 continue;
196 }
197 let mut is_fixed = false;
198 let mut is_err = false;
199 let (outcome_style, outcome_char) = outcome
200 .map(|outcome| match outcome {
201 ScaleOutcome::Fixed => {
202 there_is_fixed = true;
203 is_fixed = true;
204 (yansi::Style::new().yellow(), trinagle)
205 }
206 ScaleOutcome::Error(_) => {
207 there_is_err = true;
208 is_err = true;
209 (yansi::Style::new().red(), octagon)
210 }
211 ScaleOutcome::Scaled | ScaleOutcome::NoQuantity => (yansi::Style::new(), ""),
212 })
213 .unwrap_or_default();
214 let mut row = Row::new().with_cell(igr.display_name());
215 if igr.modifiers().is_optional() {
216 row.add_ansi_cell("(optional)".paint(styles().opt_marker));
217 } else {
218 row.add_cell("");
219 }
220 let content = quantity
221 .iter()
222 .map(|q| quantity_fmt(q).paint(outcome_style).to_string())
223 .reduce(|s, q| format!("{s}, {q}"))
224 .unwrap_or_default();
225 row.add_ansi_cell(format!("{content}{}", outcome_char.paint(outcome_style)));
226
227 if let Some(note) = &igr.note {
228 row.add_cell(format!("({note})"));
229 } else {
230 row.add_cell("");
231 }
232 table.add_row(row);
233 }
234 write!(w, "{table}")?;
235 if there_is_fixed || there_is_err {
236 writeln!(w)?;
237 if there_is_fixed {
238 write!(w, "{} {}", trinagle.trim().yellow(), "fixed value".yellow())?;
239 }
240 if there_is_err {
241 if there_is_fixed {
242 write!(w, " | ")?;
243 }
244 write!(w, "{} {}", octagon.trim().red(), "error scaling".red())?;
245 }
246 writeln!(w)?;
247 }
248 writeln!(w)
249}
250
251fn cookware(w: &mut impl io::Write, recipe: &ScaledRecipe) -> Result {
252 if recipe.cookware.is_empty() {
253 return Ok(());
254 }
255 writeln!(w, "Cookware:")?;
256 let mut table = Table::new(" {:<} {:<} {:<} {:<}");
257 for item in recipe
258 .cookware
259 .iter()
260 .filter(|cw| cw.modifiers().should_be_listed())
261 {
262 let mut row = Row::new().with_cell(item.display_name()).with_cell(
263 if item.modifiers().is_optional() {
264 "(optional)"
265 } else {
266 ""
267 },
268 );
269
270 let amount = item.group_amounts(&recipe.cookware);
271 if amount.is_empty() {
272 row.add_cell("");
273 } else {
274 let t = amount
275 .iter()
276 .map(|q| q.to_string())
277 .reduce(|s, q| format!("{s}, {q}"))
278 .unwrap();
279 row.add_ansi_cell(t);
280 }
281
282 if let Some(note) = &item.note {
283 row.add_cell(format!("({note})"));
284 } else {
285 row.add_cell("");
286 }
287
288 table.add_row(row);
289 }
290 writeln!(w, "{table}")?;
291 Ok(())
292}
293
294fn steps(w: &mut impl io::Write, recipe: &ScaledRecipe) -> Result {
295 writeln!(w, "Steps:")?;
296 for (section_index, section) in recipe.sections.iter().enumerate() {
297 if recipe.sections.len() > 1 {
298 writeln!(
299 w,
300 "{: ^width$}",
301 format!("─── § {} ───", section_index + 1),
302 width = TERM_WIDTH
303 )?;
304 }
305
306 if let Some(name) = §ion.name {
307 writeln!(w, "{}:", name.paint(styles().section_name))?;
308 }
309
310 for content in §ion.content {
311 match content {
312 cooklang::Content::Step(step) => {
313 let (step_text, step_ingredients) = step_text(recipe, section, step);
314 let step_text = format!("{:>2}. {}", step.number, step_text.trim());
315 print_wrapped_with_options(w, &step_text, |o| o.subsequent_indent(" "))?;
316 print_wrapped_with_options(w, &step_ingredients, |o| {
317 let indent = " "; o.initial_indent(indent)
319 .subsequent_indent(indent)
320 .word_separator(textwrap::WordSeparator::Custom(|s| {
321 Box::new(
322 s.split_inclusive(", ")
323 .map(|part| textwrap::core::Word::from(part)),
324 )
325 }))
326 })?;
327 }
328 cooklang::Content::Text(t) => {
329 writeln!(w)?;
330 print_wrapped_with_options(w, t.trim(), |o| o.initial_indent(" "))?;
331 writeln!(w)?;
332 }
333 }
334 }
335 }
336 Ok(())
337}
338
339fn step_text(recipe: &ScaledRecipe, section: &Section, step: &Step) -> (String, String) {
340 let mut step_text = String::new();
341
342 let step_igrs_dedup = build_step_igrs_dedup(step, recipe);
343
344 let mut step_igrs_line: Vec<(&Ingredient, Option<usize>)> = Vec::new();
347
348 for item in &step.items {
349 match item {
350 Item::Text { value } => step_text += value,
351 &Item::Ingredient { index } => {
352 let igr = &recipe.ingredients[index];
353 write!(
354 &mut step_text,
355 "{}",
356 igr.display_name().paint(styles().ingredient)
357 )
358 .unwrap();
359 let pos = write_igr_count(&mut step_text, &step_igrs_dedup, index, &igr.name);
360 if step_igrs_dedup[igr.name.as_str()].contains(&index) {
361 step_igrs_line.push((igr, pos));
362 }
363 }
364 &Item::Cookware { index } => {
365 let cookware = &recipe.cookware[index];
366 write!(&mut step_text, "{}", cookware.name.paint(styles().cookware)).unwrap();
367 }
368 &Item::Timer { index } => {
369 let timer = &recipe.timers[index];
370
371 match (&timer.quantity, &timer.name) {
372 (Some(quantity), Some(name)) => {
373 let s = format!(
374 "{} ({})",
375 quantity_fmt(quantity).paint(styles().timer),
376 name.paint(styles().timer),
377 );
378 write!(&mut step_text, "{}", s).unwrap();
379 }
380 (Some(quantity), None) => {
381 write!(
382 &mut step_text,
383 "{}",
384 quantity_fmt(quantity).paint(styles().timer)
385 )
386 .unwrap();
387 }
388 (None, Some(name)) => {
389 write!(&mut step_text, "{}", name.paint(styles().timer)).unwrap();
390 }
391 (None, None) => unreachable!(), }
393 }
394 &Item::InlineQuantity { index } => {
395 let q = &recipe.inline_quantities[index];
396 write!(
397 &mut step_text,
398 "{}",
399 quantity_fmt(q).paint(styles().inline_quantity)
400 )
401 .unwrap()
402 }
403 }
404 }
405
406 if step_igrs_line.is_empty() {
409 return (step_text, "[-]".into());
410 }
411 let mut igrs_text = String::from("[");
412 for (i, (igr, pos)) in step_igrs_line.iter().enumerate() {
413 write!(&mut igrs_text, "{}", igr.display_name()).unwrap();
414 if let Some(pos) = pos {
415 write_subscript(&mut igrs_text, &pos.to_string());
416 }
417 if igr.modifiers().is_optional() {
418 write!(&mut igrs_text, "{}", " (opt)".paint(styles().opt_marker)).unwrap();
419 }
420 if let Some(source) = inter_ref_text(igr, section) {
421 write!(
422 &mut igrs_text,
423 "{}",
424 format!(" from {source}").paint(styles().intermediate_ref)
425 )
426 .unwrap();
427 }
428 if let Some(q) = &igr.quantity {
429 write!(
430 &mut igrs_text,
431 ": {}",
432 quantity_fmt(q).paint(styles().step_igr_quantity)
433 )
434 .unwrap();
435 }
436 if i != step_igrs_line.len() - 1 {
437 igrs_text += ", ";
438 }
439 }
440 igrs_text += "]";
441 (step_text, igrs_text)
442}
443
444fn inter_ref_text(igr: &Ingredient, section: &Section) -> Option<String> {
445 match igr.relation.references_to() {
446 Some((target_sect, IngredientReferenceTarget::Section)) => {
447 Some(format!("section {}", target_sect + 1))
448 }
449 Some((target_step, IngredientReferenceTarget::Step)) => {
450 let step = §ion.content[target_step].unwrap_step();
451 Some(format!("step {}", step.number))
452 }
453 _ => None,
454 }
455}
456
457fn build_step_igrs_dedup<'a>(
458 step: &'a Step,
459 recipe: &'a ScaledRecipe,
460) -> HashMap<&'a str, Vec<usize>> {
461 let mut step_igrs_dedup: HashMap<&str, Vec<usize>> = HashMap::new();
464 for item in &step.items {
465 if let Item::Ingredient { index } = item {
466 let igr = &recipe.ingredients[*index];
467 step_igrs_dedup.entry(&igr.name).or_default().push(*index);
468 }
469 }
470
471 for group in step_igrs_dedup.values_mut() {
476 let first = group.first().copied().unwrap();
477 group.retain(|&i| {
478 let igr = &recipe.ingredients[i];
479 igr.quantity.is_some() || igr.relation.is_intermediate_reference()
480 });
481 if group.is_empty() {
482 group.push(first);
483 }
484 }
485 step_igrs_dedup
486}
487
488fn write_igr_count(
489 buffer: &mut String,
490 step_igrs: &HashMap<&str, Vec<usize>>,
491 index: usize,
492 name: &str,
493) -> Option<usize> {
494 let entries = &step_igrs[name];
495 if entries.len() <= 1 {
496 return None;
497 }
498 if let Some(mut pos) = entries.iter().position(|&i| i == index) {
499 pos += 1;
500 write_subscript(buffer, &pos.to_string());
501 Some(pos)
502 } else {
503 None
504 }
505}
506
507fn quantity_fmt(qty: &Quantity) -> String {
508 if let Some(unit) = qty.unit() {
509 format!("{} {}", qty.value(), unit.italic())
510 } else {
511 format!("{}", qty.value())
512 }
513}
514
515fn write_subscript(buffer: &mut String, s: &str) {
516 buffer.reserve(s.len());
517 s.chars()
518 .map(|c| match c {
519 '0' => '₀',
520 '1' => '₁',
521 '2' => '₂',
522 '3' => '₃',
523 '4' => '₄',
524 '5' => '₅',
525 '6' => '₆',
526 '7' => '₇',
527 '8' => '₈',
528 '9' => '₉',
529 _ => c,
530 })
531 .for_each(|c| buffer.push(c))
532}
533
534fn print_wrapped(w: &mut impl io::Write, text: &str) -> Result {
535 print_wrapped_with_options(w, text, |o| o)
536}
537
538static TERM_WIDTH: std::sync::LazyLock<usize> =
539 std::sync::LazyLock::new(|| textwrap::termwidth().min(80));
540
541fn print_wrapped_with_options<F>(w: &mut impl io::Write, text: &str, f: F) -> Result
542where
543 F: FnOnce(textwrap::Options) -> textwrap::Options,
544{
545 let options = f(textwrap::Options::new(*TERM_WIDTH));
546 let lines = textwrap::wrap(text, options);
547 for line in lines {
548 writeln!(w, "{}", line)?;
549 }
550 Ok(())
551}