Skip to main content

cooklang_format/
human.rs

1// This file includes a substantial portion of code from
2// https://github.com/Zheoni/cooklang-chef
3//
4// The original code is licensed under the MIT License, a copy of which
5// is provided below in addition to our project's license.
6//
7//
8
9// MIT License
10
11// Copyright (c) 2023 Francisco J. Sanchez
12
13// Permission is hereby granted, free of charge, to any person obtaining a copy
14// of this software and associated documentation files (the "Software"), to deal
15// in the Software without restriction, including without limitation the rights
16// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17// copies of the Software, and to permit persons to whom the Software is
18// furnished to do so, subject to the following conditions:
19
20// The above copyright notice and this permission notice shall be included in all
21// copies or substantial portions of the Software.
22
23// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29// SOFTWARE.
30
31//! Format a recipe for humans to read
32//!
33//! Colour is chosen by the [`Style`] passed to [`print_human`].
34//! The rendering code always paints; when the caller asks for [`Style::Plain`]
35//! the escape codes are removed at the writer, so the two outputs differ only
36//! by the escapes.
37
38use 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    // macro magic to generate 2 struct CookStyles and OwoStyles same fields, but
111    // when Style is used here, CookStyles will have anstyle::Style and OwoStyles
112    // owo_colors::Style for internal use. Also, OwoStyles impl From<CookStyles>
113
114    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
138/// Write a recipe as the text `cook recipe` prints.
139///
140/// `name` is the title to show, `scale` is appended to it when it is not
141/// `1.0`, and `converter` supplies the units used to group quantities.
142///
143/// `style` decides whether ANSI escape codes survive. The rendering below
144/// always paints, so `Style::Plain` is `Style::Ansi` with the escapes
145/// removed, character for character.
146pub 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    // Strip at the writer rather than reaching for yansi's global switch: a
155    // library must not mutate process-wide state that its callers share.
156    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
184/// A writer that drops ANSI escape sequences on their way through.
185///
186/// `anstream::StripStream` would do this, but it only accepts inner writers
187/// from a sealed list, which a type parameter cannot join. This wraps
188/// anstream's escape-sequence state machine directly instead, so a sequence
189/// split across two `write` calls is still removed — see the tests below.
190struct 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        // The whole buffer was consumed: what was not forwarded was escapes.
210        // Reporting less would make `write_all` retry bytes already handled.
211        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    // Group ingredients by display name and merge quantities
340    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    // Sort by name for consistent display
361    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        // Show reference if any ingredient has one
372        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        // Mark as optional only if ALL occurrences are optional
388        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        // Combine notes from all ingredients
404        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) = &section.name {
476            writeln!(w, "{}:", name.paint(styles().section_name))?;
477        }
478
479        for content in &section.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 = "     "; // 5
496                        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                    // Check if this is a list bullet item
508                    if t.trim() == "-" {
509                        // Don't print anything for isolated dash, it will be handled as a newline before the next item
510                        writeln!(w)?;
511                    } else {
512                        writeln!(w)?;
513                        // Format as a note with a visual indicator
514                        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    // contains the ingredient and index (if any) in the line under
545    // the step that shows the ingredients
546    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                // Check if this is a list bullet and add a newline before it for better formatting
552                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!(), // guaranteed in parsing
599                }
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    // This is only for the line where ingredients are placed
614
615    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    // contain all ingredients used in the step (the names), the vec
646    // contains the exact indices used
647    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 each name only keep entries that provide information:
656    // - if it has a quantity
657    // - at least one if it's empty
658    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    /// Feed `chunks` through a [`StripWriter`] as separate `write` calls.
742    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    /// The reason this type exists rather than a one-shot `strip_bytes`: the
776    /// formatter writes in whatever chunks `writeln!` produces, so an escape
777    /// sequence can straddle two calls. Splitting mid-sequence must not leak
778    /// the tail as literal text.
779    #[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        // Every split point, including ones inside both escape sequences.
784        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    /// A sequence that is still incomplete when the writer is dropped must not
795    /// have been forwarded either.
796    #[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}