cooklang_to_human/
lib.rs

1//! Format a recipe for humans to read
2//!
3//! This will always write ansi colours. Use something like
4//! [`anstream`](https://docs.rs/anstream) to remove them if needed.
5
6use 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) = &section.name {
307            writeln!(w, "{}:", name.paint(styles().section_name))?;
308        }
309
310        for content in &section.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 = "     "; // 5
318                        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    // contains the ingredient and index (if any) in the line under
345    // the step that shows the ingredients
346    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!(), // guaranteed in parsing
392                }
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    // This is only for the line where ingredients are placed
407
408    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 = &section.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    // contain all ingredients used in the step (the names), the vec
462    // contains the exact indices used
463    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 each name only keep entries that provide information:
472    // - if it has a quantity
473    // - if it's an intermediate reference
474    // - at least one if it's empty
475    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}