1use crate::{
39 quantity::{grouped_quantity_fmt, ordered_components},
40 Style,
41};
42use std::{collections::HashMap, io, time::Duration};
43
44use cooklang::{
45 convert::Converter,
46 ingredient_list::GroupedIngredient,
47 metadata::CooklangValueExt,
48 model::{Ingredient, Item},
49 quantity::Quantity,
50 Recipe, Section, Step,
51};
52use std::fmt::Write;
53use tabular::{Row, Table};
54use yansi::Paint;
55
56mod style {
57 use anstyle::Style;
58
59 macro_rules! map_style_type {
60 (Style) => {
61 yansi::Style
62 };
63 ($other:ty) => {
64 $other
65 };
66 }
67
68 macro_rules! map_style_func {
69 ($s:ident, $name:ident, Style) => {
70 anstyle_yansi::to_yansi_style($s.$name)
71 };
72 ($s:ident, $name:ident, $type:ty) => {
73 $s.$name
74 };
75 }
76
77 macro_rules! generate_styles_struct {
78 ($($v:vis $field_name:ident : $field_type:tt = $default:expr),+ $(,)?) => {
79 #[derive(Debug, Clone)]
80 #[non_exhaustive]
81 pub struct CookStyles { $($v $field_name: $field_type),+ }
82
83 #[derive(Debug, Clone)]
84 pub(crate) struct OwoStyles { $(pub $field_name: map_style_type!($field_type)),* }
85
86 impl From<CookStyles> for OwoStyles {
87 fn from(s: CookStyles) -> OwoStyles {
88 OwoStyles {
89 $($field_name: map_style_func!(s, $field_name, $field_type)),+
90 }
91 }
92 }
93
94 impl CookStyles {
95 pub const fn default_styles() -> Self {
96 Self {
97 $($field_name: $default),+
98 }
99 }
100 }
101 };
102 }
103
104 macro_rules! color {
105 ($color:ident) => {
106 Some(anstyle::Color::Ansi(anstyle::AnsiColor::$color))
107 };
108 }
109
110 generate_styles_struct! {
115 pub title: Style = Style::new().fg_color(color!(White)).bg_color(color!(Magenta)).bold(),
116 pub meta_key: Style = Style::new().fg_color(color!(BrightGreen)).bold(),
117 pub ingredient: Style = Style::new().fg_color(color!(Green)),
118 pub cookware: Style = Style::new().fg_color(color!(Yellow)),
119 pub timer: Style = Style::new().fg_color(color!(Cyan)),
120 pub inline_quantity: Style = Style::new().fg_color(color!(BrightRed)),
121 pub opt_marker: Style = Style::new().fg_color(color!(BrightCyan)).italic(),
122 pub reference_marker: Style = Style::new().fg_color(color!(Blue)).italic(),
123 pub section_name: Style = Style::new().bold().underline(),
124 pub step_igr_quantity: Style = Style::new().dimmed(),
125 }
126
127 static STYLE: std::sync::OnceLock<OwoStyles> = std::sync::OnceLock::new();
128
129 #[inline]
130 pub(crate) fn styles() -> &'static OwoStyles {
131 STYLE.get_or_init(|| CookStyles::default_styles().into())
132 }
133}
134use style::styles;
135
136type Result<T = ()> = std::result::Result<T, io::Error>;
137
138pub fn print_human(
147 recipe: &Recipe,
148 name: &str,
149 scale: f64,
150 converter: &Converter,
151 style: Style,
152 writer: &mut impl io::Write,
153) -> io::Result<()> {
154 if style.is_ansi() {
157 write_recipe(writer, recipe, name, scale, converter)
158 } else {
159 write_recipe(
160 &mut StripWriter::new(writer),
161 recipe,
162 name,
163 scale,
164 converter,
165 )
166 }
167}
168
169fn write_recipe(
170 w: &mut impl io::Write,
171 recipe: &Recipe,
172 name: &str,
173 scale: f64,
174 converter: &Converter,
175) -> Result {
176 header(w, recipe, name, scale)?;
177 metadata(w, recipe, converter)?;
178 ingredients(w, recipe, converter)?;
179 cookware(w, recipe, converter)?;
180 steps(w, recipe)?;
181 Ok(())
182}
183
184struct StripWriter<W: io::Write> {
191 inner: W,
192 state: anstream::adapter::StripBytes,
193}
194
195impl<W: io::Write> StripWriter<W> {
196 fn new(inner: W) -> Self {
197 Self {
198 inner,
199 state: anstream::adapter::StripBytes::default(),
200 }
201 }
202}
203
204impl<W: io::Write> io::Write for StripWriter<W> {
205 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
206 for printable in self.state.strip_next(buf) {
207 self.inner.write_all(printable)?;
208 }
209 Ok(buf.len())
212 }
213
214 fn flush(&mut self) -> io::Result<()> {
215 self.inner.flush()
216 }
217}
218
219fn header(w: &mut impl io::Write, recipe: &Recipe, name: &str, scale: f64) -> Result {
220 let title_text = format!(
221 " {}{}{} ",
222 recipe
223 .metadata
224 .get("emoji")
225 .and_then(|v| v.as_str())
226 .map(|s| format!("{s} "))
227 .unwrap_or_default(),
228 name,
229 if scale != 1.0 {
230 format!(" @ {scale}")
231 } else {
232 "".to_string()
233 }
234 );
235
236 writeln!(w, "{}", title_text.paint(styles().title))?;
237
238 if let Some(tags) = recipe.metadata.tags() {
239 let mut tags_str = String::new();
240 for tag in tags {
241 let color = tag_color(&tag);
242 write!(&mut tags_str, "{} ", format!("#{tag}").paint(color)).unwrap();
243 }
244 print_wrapped(w, &tags_str)?;
245 }
246 writeln!(w)
247}
248
249fn tag_color(tag: &str) -> yansi::Color {
250 let hash = tag
251 .chars()
252 .enumerate()
253 .map(|(i, c)| c as usize * i)
254 .reduce(usize::wrapping_add)
255 .map(|h| h % 7)
256 .unwrap_or_default();
257 match hash {
258 0 => yansi::Color::Red,
259 1 => yansi::Color::Blue,
260 2 => yansi::Color::Cyan,
261 3 => yansi::Color::Yellow,
262 4 => yansi::Color::Green,
263 5 => yansi::Color::Magenta,
264 6 => yansi::Color::White,
265 _ => unreachable!(),
266 }
267}
268
269fn metadata(w: &mut impl io::Write, recipe: &Recipe, converter: &Converter) -> Result {
270 if let Some(desc) = recipe.metadata.description() {
271 print_wrapped_with_options(w, desc, |o| {
272 o.initial_indent("\u{2502} ").subsequent_indent("\u{2502}")
273 })?;
274 writeln!(w)?;
275 }
276
277 let mut meta_fmt =
278 |name: &str, value: &str| writeln!(w, "{}: {}", name.paint(styles().meta_key), value);
279 if let Some(author) = recipe.metadata.author() {
280 let text = author.name().or(author.url()).unwrap_or("-");
281 meta_fmt("author", text)?;
282 }
283 if let Some(source) = recipe.metadata.source() {
284 let text = source.name().or(source.url()).unwrap_or("-");
285 meta_fmt("source", text)?;
286 }
287 if let Some(time) = recipe.metadata.time(converter) {
288 let time_fmt = |t: u32| {
289 format!(
290 "{}",
291 humantime::format_duration(Duration::from_secs(t as u64 * 60))
292 )
293 };
294 match time {
295 cooklang::metadata::RecipeTime::Total(t) => meta_fmt("time", &time_fmt(t))?,
296 cooklang::metadata::RecipeTime::Composed {
297 prep_time,
298 cook_time,
299 } => {
300 if let Some(p) = prep_time {
301 meta_fmt("prep time", &time_fmt(p))?
302 }
303 if let Some(c) = cook_time {
304 meta_fmt("cook time", &time_fmt(c))?;
305 }
306 meta_fmt("total time", &time_fmt(time.total()))?;
307 }
308 }
309 }
310
311 if let Some(servings) = recipe.metadata.servings() {
312 meta_fmt("servings", &servings.to_string())?;
313 }
314
315 for (key, value) in recipe.metadata.map.iter().filter_map(|(key, value)| {
316 let key = key.as_str_like()?;
317 match key.as_ref() {
318 "name" | "title" | "description" | "tags" | "author" | "source" | "emoji" | "time"
319 | "prep time" | "cook time" | "servings" => return None,
320 _ => {}
321 }
322 let value = value.as_str_like()?;
323 Some((key, value))
324 }) {
325 meta_fmt(&key, &value)?;
326 }
327 if !recipe.metadata.map.is_empty() {
328 writeln!(w)?;
329 }
330 Ok(())
331}
332
333fn ingredients(w: &mut impl io::Write, recipe: &Recipe, converter: &Converter) -> Result {
334 if recipe.ingredients.is_empty() {
335 return Ok(());
336 }
337 writeln!(w, "Ingredients:")?;
338
339 let mut grouped: HashMap<String, (cooklang::quantity::GroupedQuantity, Vec<&Ingredient>)> =
341 HashMap::new();
342
343 for entry in recipe.group_ingredients(converter) {
344 let GroupedIngredient {
345 ingredient: igr,
346 quantity,
347 ..
348 } = entry;
349
350 let display_name = igr.display_name().to_string();
351 grouped
352 .entry(display_name)
353 .and_modify(|(merged_qty, igrs)| {
354 merged_qty.merge(&quantity, converter);
355 igrs.push(igr);
356 })
357 .or_insert_with(|| (quantity.clone(), vec![igr]));
358 }
359
360 let mut sorted_ingredients: Vec<_> = grouped.into_iter().collect();
362 sorted_ingredients.sort_by(|a, b| a.0.cmp(&b.0));
363
364 let mut table = Table::new(" {:<} {:<} {:<} {:<} {:<}");
365 for (display_name, (quantity, ingredients)) in sorted_ingredients {
366 let outcome_style = yansi::Style::new();
367 let outcome_char = "";
368
369 let mut row = Row::new().with_cell(display_name);
370
371 let has_reference = ingredients.iter().any(|igr| igr.reference.is_some());
373 if has_reference {
374 let igr = ingredients
375 .iter()
376 .find(|igr| igr.reference.is_some())
377 .unwrap();
378 let sep = crate::REFERENCE_SEPARATOR;
379 let path = igr.reference.as_ref().unwrap().components.join(sep);
380 row.add_ansi_cell(
381 format!("(recipe: {}{}{})", path, sep, igr.name).paint(styles().reference_marker),
382 );
383 } else {
384 row.add_cell("");
385 }
386
387 let all_optional = ingredients.iter().all(|igr| igr.modifiers().is_optional());
389 if all_optional {
390 row.add_ansi_cell("(optional)".paint(styles().opt_marker));
391 } else {
392 row.add_cell("");
393 }
394
395 let content = ordered_components(&quantity)
396 .into_iter()
397 .map(|q| quantity_fmt(q).paint(outcome_style).to_string())
398 .reduce(|s, q| format!("{s}, {q}"))
399 .unwrap_or_default();
400
401 row.add_ansi_cell(format!("{content}{}", outcome_char.paint(outcome_style)));
402
403 let notes: Vec<_> = ingredients
405 .iter()
406 .filter_map(|igr| igr.note.as_ref())
407 .collect();
408 if !notes.is_empty() {
409 let combined_notes = notes
410 .iter()
411 .map(|n| n.as_str())
412 .collect::<Vec<_>>()
413 .join(", ");
414 row.add_cell(format!("({combined_notes})"));
415 } else {
416 row.add_cell("");
417 }
418
419 table.add_row(row);
420 }
421 write!(w, "{table}")?;
422 writeln!(w)
423}
424
425fn cookware(w: &mut impl io::Write, recipe: &Recipe, converter: &Converter) -> Result {
426 if recipe.cookware.is_empty() {
427 return Ok(());
428 }
429 writeln!(w, "Cookware:")?;
430 let mut table = Table::new(" {:<} {:<} {:<} {:<}");
431 for item in recipe
432 .cookware
433 .iter()
434 .filter(|cw| cw.modifiers().should_be_listed())
435 {
436 let mut row = Row::new().with_cell(item.display_name()).with_cell(
437 if item.modifiers().is_optional() {
438 "(optional)"
439 } else {
440 ""
441 },
442 );
443
444 let amount = item.group_quantities(&recipe.cookware, converter);
445 if amount.is_empty() {
446 row.add_cell("");
447 } else {
448 row.add_ansi_cell(grouped_quantity_fmt(&amount));
449 }
450
451 if let Some(note) = &item.note {
452 row.add_cell(format!("({note})"));
453 } else {
454 row.add_cell("");
455 }
456
457 table.add_row(row);
458 }
459 writeln!(w, "{table}")?;
460 Ok(())
461}
462
463fn steps(w: &mut impl io::Write, recipe: &Recipe) -> Result {
464 writeln!(w, "Steps:")?;
465 for (section_index, section) in recipe.sections.iter().enumerate() {
466 if recipe.sections.len() > 1 {
467 writeln!(
468 w,
469 "{: ^width$}",
470 format!("─── § {} ───", section_index + 1),
471 width = TERM_WIDTH
472 )?;
473 }
474
475 if let Some(name) = §ion.name {
476 writeln!(w, "{}:", name.paint(styles().section_name))?;
477 }
478
479 for content in §ion.content {
480 match content {
481 cooklang::Content::Step(step) => {
482 let (step_text, step_ingredients) = step_text(recipe, section, step);
483 let paragraphs: Vec<&str> = step_text.trim().split('\n').collect();
484 for (i, paragraph) in paragraphs.iter().enumerate() {
485 if i == 0 {
486 let first = format!("{:>2}. {}", step.number, paragraph.trim_start());
487 print_wrapped_with_options(w, &first, |o| o.subsequent_indent(" "))?;
488 } else {
489 print_wrapped_with_options(w, paragraph.trim_start(), |o| {
490 o.initial_indent(" ").subsequent_indent(" ")
491 })?;
492 }
493 }
494 print_wrapped_with_options(w, &step_ingredients, |o| {
495 let indent = " "; o.initial_indent(indent)
497 .subsequent_indent(indent)
498 .word_separator(textwrap::WordSeparator::Custom(|s| {
499 Box::new(
500 s.split_inclusive(", ")
501 .map(|part| textwrap::core::Word::from(part)),
502 )
503 }))
504 })?;
505 }
506 cooklang::Content::Text(t) => {
507 if t.trim() == "-" {
509 writeln!(w)?;
511 } else {
512 writeln!(w)?;
513 let note_style = yansi::Style::new().italic().fg(yansi::Color::Blue);
515 let note_prefix = "📝 Note: ".paint(note_style);
516 let note_indent = " ";
517 let paragraphs: Vec<&str> = t.trim().split('\n').collect();
518 for (i, paragraph) in paragraphs.iter().enumerate() {
519 if i == 0 {
520 write!(w, " {note_prefix}")?;
521 print_wrapped_with_options(w, paragraph.trim(), |o| {
522 o.initial_indent("").subsequent_indent(note_indent)
523 })?;
524 } else {
525 print_wrapped_with_options(w, paragraph.trim(), |o| {
526 o.initial_indent(note_indent).subsequent_indent(note_indent)
527 })?;
528 }
529 }
530 writeln!(w)?;
531 }
532 }
533 }
534 }
535 }
536 Ok(())
537}
538
539fn step_text(recipe: &Recipe, _section: &Section, step: &Step) -> (String, String) {
540 let mut step_text = String::new();
541
542 let step_igrs_dedup = build_step_igrs_dedup(step, recipe);
543
544 let mut step_igrs_line: Vec<(&Ingredient, Option<usize>)> = Vec::new();
547
548 for item in &step.items {
549 match item {
550 Item::Text { value } => {
551 if value.trim() == "-" {
553 step_text += "\n • ";
554 } else {
555 step_text += value;
556 }
557 }
558 &Item::Ingredient { index } => {
559 let igr = &recipe.ingredients[index];
560 write!(
561 &mut step_text,
562 "{}",
563 igr.display_name().paint(styles().ingredient)
564 )
565 .unwrap();
566 let pos = write_igr_count(&mut step_text, &step_igrs_dedup, index, &igr.name);
567 if step_igrs_dedup[igr.name.as_str()].contains(&index) {
568 step_igrs_line.push((igr, pos));
569 }
570 }
571 &Item::Cookware { index } => {
572 let cookware = &recipe.cookware[index];
573 write!(&mut step_text, "{}", cookware.name.paint(styles().cookware)).unwrap();
574 }
575 &Item::Timer { index } => {
576 let timer = &recipe.timers[index];
577
578 match (&timer.quantity, &timer.name) {
579 (Some(quantity), Some(name)) => {
580 let s = format!(
581 "{} ({})",
582 quantity_fmt(quantity).paint(styles().timer),
583 name.paint(styles().timer),
584 );
585 write!(&mut step_text, "{s}").unwrap();
586 }
587 (Some(quantity), None) => {
588 write!(
589 &mut step_text,
590 "{}",
591 quantity_fmt(quantity).paint(styles().timer)
592 )
593 .unwrap();
594 }
595 (None, Some(name)) => {
596 write!(&mut step_text, "{}", name.paint(styles().timer)).unwrap();
597 }
598 (None, None) => unreachable!(), }
600 }
601 &Item::InlineQuantity { index } => {
602 let q = &recipe.inline_quantities[index];
603 write!(
604 &mut step_text,
605 "{}",
606 quantity_fmt(q).paint(styles().inline_quantity)
607 )
608 .unwrap()
609 }
610 }
611 }
612
613 if step_igrs_line.is_empty() {
616 return (step_text, "[-]".into());
617 }
618 let mut igrs_text = String::from("[");
619 for (i, (igr, pos)) in step_igrs_line.iter().enumerate() {
620 write!(&mut igrs_text, "{}", igr.display_name()).unwrap();
621 if let Some(pos) = pos {
622 write_subscript(&mut igrs_text, &pos.to_string());
623 }
624 if igr.modifiers().is_optional() {
625 write!(&mut igrs_text, "{}", " (opt)".paint(styles().opt_marker)).unwrap();
626 }
627
628 if let Some(q) = &igr.quantity {
629 write!(
630 &mut igrs_text,
631 ": {}",
632 quantity_fmt(q).paint(styles().step_igr_quantity)
633 )
634 .unwrap();
635 }
636 if i != step_igrs_line.len() - 1 {
637 igrs_text += ", ";
638 }
639 }
640 igrs_text += "]";
641 (step_text, igrs_text)
642}
643
644fn build_step_igrs_dedup<'a>(step: &'a Step, recipe: &'a Recipe) -> HashMap<&'a str, Vec<usize>> {
645 let mut step_igrs_dedup: HashMap<&str, Vec<usize>> = HashMap::new();
648 for item in &step.items {
649 if let Item::Ingredient { index } = item {
650 let igr = &recipe.ingredients[*index];
651 step_igrs_dedup.entry(&igr.name).or_default().push(*index);
652 }
653 }
654
655 for group in step_igrs_dedup.values_mut() {
659 let first = group.first().copied().unwrap();
660 group.retain(|&i| {
661 let igr = &recipe.ingredients[i];
662 igr.quantity.is_some()
663 });
664 if group.is_empty() {
665 group.push(first);
666 }
667 }
668 step_igrs_dedup
669}
670
671fn write_igr_count(
672 buffer: &mut String,
673 step_igrs: &HashMap<&str, Vec<usize>>,
674 index: usize,
675 name: &str,
676) -> Option<usize> {
677 let entries = &step_igrs[name];
678 if entries.len() <= 1 {
679 return None;
680 }
681 if let Some(mut pos) = entries.iter().position(|&i| i == index) {
682 pos += 1;
683 write_subscript(buffer, &pos.to_string());
684 Some(pos)
685 } else {
686 None
687 }
688}
689
690fn quantity_fmt(qty: &Quantity) -> String {
691 if let Some(unit) = qty.unit() {
692 format!("{} {}", qty.value(), unit.italic())
693 } else {
694 format!("{}", qty.value())
695 }
696}
697
698fn write_subscript(buffer: &mut String, s: &str) {
699 buffer.reserve(s.len());
700 s.chars()
701 .map(|c| match c {
702 '0' => '₀',
703 '1' => '₁',
704 '2' => '₂',
705 '3' => '₃',
706 '4' => '₄',
707 '5' => '₅',
708 '6' => '₆',
709 '7' => '₇',
710 '8' => '₈',
711 '9' => '₉',
712 _ => c,
713 })
714 .for_each(|c| buffer.push(c))
715}
716
717fn print_wrapped(w: &mut impl io::Write, text: &str) -> Result {
718 print_wrapped_with_options(w, text, |o| o)
719}
720
721static TERM_WIDTH: std::sync::LazyLock<usize> =
722 std::sync::LazyLock::new(|| textwrap::termwidth().min(80));
723
724fn print_wrapped_with_options<F>(w: &mut impl io::Write, text: &str, f: F) -> Result
725where
726 F: FnOnce(textwrap::Options) -> textwrap::Options,
727{
728 let options = f(textwrap::Options::new(*TERM_WIDTH));
729 let lines = textwrap::wrap(text, options);
730 for line in lines {
731 writeln!(w, "{line}")?;
732 }
733 Ok(())
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739 use std::io::Write as _;
740
741 fn strip_in_chunks(chunks: &[&[u8]]) -> Vec<u8> {
743 let mut out = Vec::new();
744 {
745 let mut w = StripWriter::new(&mut out);
746 for chunk in chunks {
747 let n = w.write(chunk).expect("write");
748 assert_eq!(
749 n,
750 chunk.len(),
751 "write must report the whole buffer consumed, or write_all loops"
752 );
753 }
754 w.flush().expect("flush");
755 }
756 out
757 }
758
759 #[test]
760 fn strip_writer_passes_plain_bytes_through_untouched() {
761 assert_eq!(
762 strip_in_chunks(&[b"Ingredients:\n water 2 c\n"]),
763 b"Ingredients:\n water 2 c\n"
764 );
765 }
766
767 #[test]
768 fn strip_writer_removes_escape_sequences() {
769 assert_eq!(
770 strip_in_chunks(&["\u{1b}[1;45;37m Tea \u{1b}[0m\n".as_bytes()]),
771 b" Tea \n"
772 );
773 }
774
775 #[test]
780 fn strip_writer_removes_an_escape_split_across_writes() {
781 let whole = "a\u{1b}[32mb\u{1b}[0mc";
782 let bytes = whole.as_bytes();
783 for at in 0..=bytes.len() {
785 let (head, tail) = bytes.split_at(at);
786 assert_eq!(
787 strip_in_chunks(&[head, tail]),
788 b"abc",
789 "split at byte {at} of {whole:?} leaked escape bytes"
790 );
791 }
792 }
793
794 #[test]
797 fn strip_writer_holds_back_an_unterminated_escape() {
798 assert_eq!(strip_in_chunks(&[b"x\x1b[3"]), b"x");
799 }
800
801 #[test]
802 fn strip_writer_preserves_non_ascii_text() {
803 assert_eq!(
804 strip_in_chunks(&["\u{1b}[3mSauté 180°C — ½\u{1b}[0m".as_bytes()]),
805 "Sauté 180°C — ½".as_bytes()
806 );
807 }
808}