cucumber/writer/
basic.rs

1// Copyright (c) 2018-2025  Brendan Molloy <brendan@bbqsrc.net>,
2//                          Ilya Solovyiov <ilya.solovyiov@gmail.com>,
3//                          Kai Ren <tyranron@gmail.com>
4//
5// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
8// option. This file may not be copied, modified, or distributed
9// except according to those terms.
10
11//! Default [`Writer`] implementation.
12
13use std::{
14    borrow::Cow,
15    cmp, env,
16    fmt::{Debug, Display},
17    io,
18    str::FromStr,
19    sync::LazyLock,
20};
21
22use derive_more::with_trait::{Deref, DerefMut};
23use itertools::Itertools as _;
24use regex::CaptureLocations;
25use smart_default::SmartDefault;
26
27use crate::{
28    Event, World, Writer,
29    cli::Colored,
30    event::{self, Info, Retries},
31    parser, step,
32    writer::{
33        self, Ext as _, Verbosity,
34        out::{Styles, WriteStrExt as _},
35    },
36};
37
38/// CLI options of a [`Basic`] [`Writer`].
39#[derive(Clone, Copy, Debug, SmartDefault, clap::Args)]
40#[group(skip)]
41pub struct Cli {
42    /// Verbosity of an output.
43    ///
44    /// `-v` is default verbosity, `-vv` additionally outputs world on failed
45    /// steps, `-vvv` additionally outputs step's doc string (if present).
46    #[arg(short, action = clap::ArgAction::Count, global = true)]
47    pub verbose: u8,
48
49    /// Coloring policy for a console output.
50    #[arg(
51        long,
52        value_name = "auto|always|never",
53        default_value = "auto",
54        global = true
55    )]
56    #[default(Coloring::Auto)]
57    pub color: Coloring,
58}
59
60impl Colored for Cli {
61    fn coloring(&self) -> Coloring {
62        self.color
63    }
64}
65
66/// Possible policies of a [`console`] output coloring.
67#[derive(Clone, Copy, Debug)]
68pub enum Coloring {
69    /// Letting [`console::colors_enabled()`] to decide, whether output should
70    /// be colored.
71    Auto,
72
73    /// Forcing of a colored output.
74    Always,
75
76    /// Forcing of a non-colored output.
77    Never,
78}
79
80impl FromStr for Coloring {
81    type Err = &'static str;
82
83    fn from_str(s: &str) -> Result<Self, Self::Err> {
84        match s.to_ascii_lowercase().as_str() {
85            "auto" => Ok(Self::Auto),
86            "always" => Ok(Self::Always),
87            "never" => Ok(Self::Never),
88            _ => Err("possible options: auto, always, never"),
89        }
90    }
91}
92
93/// Default [`Writer`] implementation outputting to an [`io::Write`] implementor
94/// ([`io::Stdout`] by default).
95///
96/// Pretty-prints with colors if terminal was successfully detected, otherwise
97/// has simple output. Useful for running tests with CI tools.
98///
99/// # Ordering
100///
101/// This [`Writer`] isn't [`Normalized`] by itself, so should be wrapped into
102/// a [`writer::Normalize`], otherwise will produce output [`Event`]s in a
103/// broken order.
104///
105/// [`Normalized`]: writer::Normalized
106/// [`Runner`]: crate::runner::Runner
107/// [`Scenario`]: gherkin::Scenario
108#[derive(Clone, Debug, Deref, DerefMut)]
109pub struct Basic<Out: io::Write = io::Stdout> {
110    /// [`io::Write`] implementor to write the output into.
111    #[deref]
112    #[deref_mut]
113    output: Out,
114
115    /// [`Styles`] for terminal output.
116    styles: Styles,
117
118    /// Current indentation that events are outputted with.
119    indent: usize,
120
121    /// Number of lines to clear.
122    lines_to_clear: usize,
123
124    /// Buffer to be re-output after [`clear_last_lines_if_term_present()`][0].
125    ///
126    /// [0]: Self::clear_last_lines_if_term_present
127    re_output_after_clear: String,
128
129    /// [`Verbosity`] of this [`Writer`].
130    verbosity: Verbosity,
131}
132
133impl<W, Out> Writer<W> for Basic<Out>
134where
135    W: World + Debug,
136    Out: io::Write,
137{
138    type Cli = Cli;
139
140    async fn handle_event(
141        &mut self,
142        event: parser::Result<Event<event::Cucumber<W>>>,
143        cli: &Self::Cli,
144    ) {
145        use event::{Cucumber, Feature};
146
147        self.apply_cli(*cli);
148
149        match event.map(Event::into_inner) {
150            Err(err) => self.parsing_failed(&err),
151            Ok(
152                Cucumber::Started
153                | Cucumber::ParsingFinished { .. }
154                | Cucumber::Finished,
155            ) => Ok(()),
156            Ok(Cucumber::Feature(f, ev)) => match ev {
157                Feature::Started => self.feature_started(&f),
158                Feature::Scenario(sc, ev) => self.scenario(&f, &sc, &ev),
159                Feature::Rule(r, ev) => self.rule(&f, &r, ev),
160                Feature::Finished => Ok(()),
161            },
162        }
163        .unwrap_or_else(|e| panic!("failed to write into terminal: {e}"));
164    }
165}
166
167impl<W, Val, Out> writer::Arbitrary<W, Val> for Basic<Out>
168where
169    W: World + Debug,
170    Val: AsRef<str>,
171    Out: io::Write,
172{
173    async fn write(&mut self, val: Val) {
174        self.write_line(val.as_ref())
175            .unwrap_or_else(|e| panic!("failed to write: {e}"));
176    }
177}
178
179impl<O: io::Write> writer::NonTransforming for Basic<O> {}
180
181impl Basic {
182    /// Creates a new [`Normalized`] [`Basic`] [`Writer`] outputting to
183    /// [`io::Stdout`].
184    ///
185    /// [`Normalized`]: writer::Normalized
186    #[must_use]
187    pub fn stdout<W>() -> writer::Normalize<W, Self> {
188        Self::new(io::stdout(), Coloring::Auto, Verbosity::Default)
189    }
190}
191
192impl<Out: io::Write> Basic<Out> {
193    /// Creates a new [`Normalized`] [`Basic`] [`Writer`] outputting to the
194    /// given `output`.
195    ///
196    /// [`Normalized`]: writer::Normalized
197    #[must_use]
198    pub fn new<W>(
199        output: Out,
200        color: Coloring,
201        verbosity: impl Into<Verbosity>,
202    ) -> writer::Normalize<W, Self> {
203        Self::raw(output, color, verbosity).normalized()
204    }
205
206    /// Creates a new non-[`Normalized`] [`Basic`] [`Writer`] outputting to the
207    /// given `output`.
208    ///
209    /// Use it only if you know what you're doing. Otherwise, consider using
210    /// [`Basic::new()`] which creates an already [`Normalized`] version of a
211    /// [`Basic`] [`Writer`].
212    ///
213    /// [`Normalized`]: writer::Normalized
214    #[must_use]
215    pub fn raw(
216        output: Out,
217        color: Coloring,
218        verbosity: impl Into<Verbosity>,
219    ) -> Self {
220        let mut basic = Self {
221            output,
222            styles: Styles::new(),
223            indent: 0,
224            lines_to_clear: 0,
225            re_output_after_clear: String::new(),
226            verbosity: verbosity.into(),
227        };
228        basic.apply_cli(Cli { verbose: u8::from(basic.verbosity) + 1, color });
229        basic
230    }
231
232    /// Applies the given [`Cli`] options to this [`Basic`] [`Writer`].
233    pub fn apply_cli(&mut self, cli: Cli) {
234        match cli.verbose {
235            0 => {}
236            1 => self.verbosity = Verbosity::Default,
237            2 => self.verbosity = Verbosity::ShowWorld,
238            _ => self.verbosity = Verbosity::ShowWorldAndDocString,
239        }
240        self.styles.apply_coloring(cli.color);
241    }
242
243    /// Clears last `n` lines if [`Coloring`] is enabled.
244    fn clear_last_lines_if_term_present(&mut self) -> io::Result<()> {
245        if self.styles.is_present && self.lines_to_clear > 0 {
246            self.output.clear_last_lines(self.lines_to_clear)?;
247            self.output.write_str(&self.re_output_after_clear)?;
248            self.re_output_after_clear.clear();
249            self.lines_to_clear = 0;
250        }
251        Ok(())
252    }
253
254    /// Outputs the parsing `error` encountered while parsing some [`Feature`].
255    ///
256    /// [`Feature`]: gherkin::Feature
257    pub(crate) fn parsing_failed(
258        &mut self,
259        error: impl Display,
260    ) -> io::Result<()> {
261        self.output
262            .write_line(self.styles.err(format!("Failed to parse: {error}")))
263    }
264
265    /// Outputs the [started] [`Feature`].
266    ///
267    /// [started]: event::Feature::Started
268    /// [`Feature`]: gherkin::Feature
269    pub(crate) fn feature_started(
270        &mut self,
271        feature: &gherkin::Feature,
272    ) -> io::Result<()> {
273        let out = format!("{}: {}", feature.keyword, feature.name);
274        self.output.write_line(self.styles.ok(out))
275    }
276
277    /// Outputs the [`Rule`]'s [started]/[scenario]/[finished] event.
278    ///
279    /// [finished]: event::Rule::Finished
280    /// [scenario]: event::Rule::Scenario
281    /// [started]: event::Rule::Started
282    /// [`Rule`]: gherkin::Rule
283    pub(crate) fn rule<W: Debug>(
284        &mut self,
285        feat: &gherkin::Feature,
286        rule: &gherkin::Rule,
287        ev: event::Rule<W>,
288    ) -> io::Result<()> {
289        use event::Rule;
290
291        match ev {
292            Rule::Started => {
293                self.rule_started(rule)?;
294            }
295            Rule::Scenario(sc, ev) => {
296                self.scenario(feat, &sc, &ev)?;
297            }
298            Rule::Finished => {
299                self.indent = self.indent.saturating_sub(2);
300            }
301        }
302        Ok(())
303    }
304
305    /// Outputs the [started] [`Rule`].
306    ///
307    /// [started]: event::Rule::Started
308    /// [`Rule`]: gherkin::Rule
309    pub(crate) fn rule_started(
310        &mut self,
311        rule: &gherkin::Rule,
312    ) -> io::Result<()> {
313        let out = format!(
314            "{indent}{}: {}",
315            rule.keyword,
316            rule.name,
317            indent = " ".repeat(self.indent)
318        );
319        self.indent += 2;
320        self.output.write_line(self.styles.ok(out))
321    }
322
323    /// Outputs the [`Scenario`]'s [started]/[background]/[step] event.
324    ///
325    /// [background]: event::Scenario::Background
326    /// [started]: event::Scenario::Started
327    /// [step]: event::Step
328    /// [`Scenario`]: gherkin::Scenario
329    pub(crate) fn scenario<W: Debug>(
330        &mut self,
331        feat: &gherkin::Feature,
332        scenario: &gherkin::Scenario,
333        ev: &event::RetryableScenario<W>,
334    ) -> io::Result<()> {
335        use event::{Hook, Scenario};
336
337        let retries = ev.retries;
338        match &ev.event {
339            Scenario::Started => {
340                self.scenario_started(scenario, retries)?;
341            }
342            Scenario::Hook(_, Hook::Started) => {
343                self.indent += 4;
344            }
345            Scenario::Hook(which, Hook::Failed(world, info)) => {
346                self.hook_failed(
347                    feat,
348                    scenario,
349                    *which,
350                    retries,
351                    world.as_ref(),
352                    info,
353                )?;
354                self.indent = self.indent.saturating_sub(4);
355            }
356            Scenario::Hook(_, Hook::Passed) => {
357                self.indent = self.indent.saturating_sub(4);
358            }
359            Scenario::Background(bg, ev) => {
360                self.background(feat, scenario, bg, ev, retries)?;
361            }
362            Scenario::Step(st, ev) => {
363                self.step(feat, scenario, st, ev, retries)?;
364            }
365            Scenario::Finished => {
366                self.indent = self.indent.saturating_sub(2);
367            }
368            Scenario::Log(msg) => self.emit_log(msg)?,
369        }
370        Ok(())
371    }
372
373    /// Outputs the [`event::Scenario::Log`].
374    pub(crate) fn emit_log(&mut self, msg: impl AsRef<str>) -> io::Result<()> {
375        self.lines_to_clear += self.styles.lines_count(msg.as_ref());
376        self.re_output_after_clear.push_str(msg.as_ref());
377        self.output.write_str(msg)
378    }
379
380    /// Outputs the [failed] [`Scenario`]'s hook.
381    ///
382    /// [failed]: event::Hook::Failed
383    /// [`Scenario`]: gherkin::Scenario
384    pub(crate) fn hook_failed<W: Debug>(
385        &mut self,
386        feat: &gherkin::Feature,
387        sc: &gherkin::Scenario,
388        which: event::HookType,
389        retries: Option<Retries>,
390        world: Option<&W>,
391        info: &Info,
392    ) -> io::Result<()> {
393        self.clear_last_lines_if_term_present()?;
394
395        let style = |s| {
396            if retries.filter(|r| r.left > 0).is_some() {
397                self.styles.bright().retry(s)
398            } else {
399                self.styles.err(s)
400            }
401        };
402
403        self.output.write_line(style(format!(
404            "{indent}✘  Scenario's {which} hook failed {}:{}:{}\n\
405             {indent}   Captured output: {}{}",
406            feat.path
407                .as_ref()
408                .and_then(|p| p.to_str().map(trim_path))
409                .unwrap_or(&feat.name),
410            sc.position.line,
411            sc.position.col,
412            format_str_with_indent(
413                coerce_error(info),
414                self.indent.saturating_sub(3) + 3
415            ),
416            world
417                .map(|w| format_str_with_indent(
418                    format!("{w:#?}"),
419                    self.indent.saturating_sub(3) + 3,
420                ))
421                .filter(|_| self.verbosity.shows_world())
422                .unwrap_or_default(),
423            indent = " ".repeat(self.indent.saturating_sub(3)),
424        )))
425    }
426
427    /// Outputs the [started] [`Scenario`].
428    ///
429    /// [started]: event::Scenario::Started
430    /// [`Scenario`]: gherkin::Scenario
431    pub(crate) fn scenario_started(
432        &mut self,
433        scenario: &gherkin::Scenario,
434        retries: Option<Retries>,
435    ) -> io::Result<()> {
436        self.indent += 2;
437
438        if let Some(retries) = retries.filter(|r| r.current > 0) {
439            let out = format!(
440                "{}{}: {} | Retry attempt: {}/{}",
441                " ".repeat(self.indent),
442                scenario.keyword,
443                scenario.name,
444                retries.current,
445                retries.left + retries.current,
446            );
447            self.output.write_line(self.styles.retry(out))
448        } else {
449            let out = format!(
450                "{}{}: {}",
451                " ".repeat(self.indent),
452                scenario.keyword,
453                scenario.name,
454            );
455            self.output.write_line(self.styles.ok(out))
456        }
457    }
458
459    /// Outputs the [`Step`]'s [started]/[passed]/[skipped]/[failed] event.
460    ///
461    /// [failed]: event::Step::Failed
462    /// [passed]: event::Step::Passed
463    /// [skipped]: event::Step::Skipped
464    /// [started]: event::Step::Started
465    /// [`Step`]: gherkin::Step
466    pub(crate) fn step<W: Debug>(
467        &mut self,
468        feat: &gherkin::Feature,
469        sc: &gherkin::Scenario,
470        step: &gherkin::Step,
471        ev: &event::Step<W>,
472        retries: Option<Retries>,
473    ) -> io::Result<()> {
474        use event::Step;
475
476        match ev {
477            Step::Started => {
478                self.step_started(step)?;
479            }
480            Step::Passed(captures, _) => {
481                self.step_passed(sc, step, captures, retries)?;
482                self.indent = self.indent.saturating_sub(4);
483            }
484            Step::Skipped => {
485                self.step_skipped(feat, step)?;
486                self.indent = self.indent.saturating_sub(4);
487            }
488            Step::Failed(c, loc, w, i) => {
489                self.step_failed(
490                    feat,
491                    step,
492                    c.as_ref(),
493                    *loc,
494                    retries,
495                    w.as_ref(),
496                    i,
497                )?;
498                self.indent = self.indent.saturating_sub(4);
499            }
500        }
501        Ok(())
502    }
503
504    /// Outputs the [started] [`Step`].
505    ///
506    /// The [`Step`] is printed only if [`Coloring`] is enabled and gets
507    /// overwritten by later [passed]/[skipped]/[failed] events.
508    ///
509    /// [failed]: event::Step::Failed
510    /// [passed]: event::Step::Passed
511    /// [skipped]: event::Step::Skipped
512    /// [started]: event::Step::Started
513    /// [`Step`]: gherkin::Step
514    pub(crate) fn step_started(
515        &mut self,
516        step: &gherkin::Step,
517    ) -> io::Result<()> {
518        self.indent += 4;
519        if self.styles.is_present {
520            let out = format!(
521                "{indent}{}{}{}{}",
522                step.keyword,
523                step.value,
524                step.docstring
525                    .as_ref()
526                    .and_then(|doc| self.verbosity.shows_docstring().then(
527                        || {
528                            format_str_with_indent(
529                                doc,
530                                self.indent.saturating_sub(3) + 3,
531                            )
532                        }
533                    ))
534                    .unwrap_or_default(),
535                step.table
536                    .as_ref()
537                    .map(|t| format_table(t, self.indent))
538                    .unwrap_or_default(),
539                indent = " ".repeat(self.indent),
540            );
541            self.lines_to_clear += self.styles.lines_count(&out);
542            self.output.write_line(&out)?;
543        }
544        Ok(())
545    }
546
547    /// Outputs the [passed] [`Step`].
548    ///
549    /// [passed]: event::Step::Passed
550    /// [`Step`]: gherkin::Step
551    pub(crate) fn step_passed(
552        &mut self,
553        scenario: &gherkin::Scenario,
554        step: &gherkin::Step,
555        captures: &CaptureLocations,
556        retries: Option<Retries>,
557    ) -> io::Result<()> {
558        self.clear_last_lines_if_term_present()?;
559
560        let style = |s| {
561            if retries.filter(|r| r.current > 0).is_some()
562                && scenario.steps.last().filter(|st| *st != step).is_some()
563            {
564                self.styles.retry(s)
565            } else {
566                self.styles.ok(s)
567            }
568        };
569
570        let step_keyword = style(format!("✔  {}", step.keyword));
571        let step_value = format_captures(
572            &step.value,
573            captures,
574            |v| style(v.to_owned()),
575            |v| style(self.styles.bold(v).to_string()),
576        );
577        let doc_str = style(
578            step.docstring
579                .as_ref()
580                .and_then(|doc| {
581                    self.verbosity.shows_docstring().then(|| {
582                        format_str_with_indent(
583                            doc,
584                            self.indent.saturating_sub(3) + 3,
585                        )
586                    })
587                })
588                .unwrap_or_default(),
589        );
590        let step_table = style(
591            step.table
592                .as_ref()
593                .map(|t| format_table(t, self.indent))
594                .unwrap_or_default(),
595        );
596
597        self.output.write_line(style(format!(
598            "{indent}{step_keyword}{step_value}{doc_str}{step_table}",
599            indent = " ".repeat(self.indent.saturating_sub(3)),
600        )))
601    }
602
603    /// Outputs the [skipped] [`Step`].
604    ///
605    /// [skipped]: event::Step::Skipped
606    /// [`Step`]: gherkin::Step
607    pub(crate) fn step_skipped(
608        &mut self,
609        feat: &gherkin::Feature,
610        step: &gherkin::Step,
611    ) -> io::Result<()> {
612        self.clear_last_lines_if_term_present()?;
613        self.output.write_line(self.styles.skipped(format!(
614            "{indent}?  {}{}{}{}\n\
615             {indent}   Step skipped: {}:{}:{}",
616            step.keyword,
617            step.value,
618            step.docstring
619                .as_ref()
620                .and_then(|doc| self.verbosity.shows_docstring().then(|| {
621                    format_str_with_indent(
622                        doc,
623                        self.indent.saturating_sub(3) + 3,
624                    )
625                }))
626                .unwrap_or_default(),
627            step.table
628                .as_ref()
629                .map(|t| format_table(t, self.indent))
630                .unwrap_or_default(),
631            feat.path
632                .as_ref()
633                .and_then(|p| p.to_str().map(trim_path))
634                .unwrap_or(&feat.name),
635            step.position.line,
636            step.position.col,
637            indent = " ".repeat(self.indent.saturating_sub(3)),
638        )))
639    }
640
641    /// Outputs the [failed] [`Step`].
642    ///
643    /// [failed]: event::Step::Failed
644    /// [`Step`]: gherkin::Step
645    // TODO: Needs refactoring.
646    #[expect(clippy::too_many_arguments, reason = "needs refactoring")]
647    pub(crate) fn step_failed<W: Debug>(
648        &mut self,
649        feat: &gherkin::Feature,
650        step: &gherkin::Step,
651        captures: Option<&CaptureLocations>,
652        loc: Option<step::Location>,
653        retries: Option<Retries>,
654        world: Option<&W>,
655        err: &event::StepError,
656    ) -> io::Result<()> {
657        self.clear_last_lines_if_term_present()?;
658
659        let style = |s| {
660            if retries
661                .filter(|r| {
662                    r.left > 0 && !matches!(err, event::StepError::NotFound)
663                })
664                .is_some()
665            {
666                self.styles.bright().retry(s)
667            } else {
668                self.styles.err(s)
669            }
670        };
671
672        let indent = " ".repeat(self.indent.saturating_sub(3));
673
674        let step_keyword = style(format!("{indent}✘  {}", step.keyword));
675        let step_value = captures.map_or_else(
676            || style(step.value.clone()),
677            |capts| {
678                format_captures(
679                    &step.value,
680                    capts,
681                    |v| style(v.to_owned()),
682                    |v| style(self.styles.bold(v).to_string()),
683                )
684                .into()
685            },
686        );
687
688        let diagnostics = style(format!(
689            "{}{}\n\
690             {indent}   Step failed:\n\
691             {indent}   Defined: {}:{}:{}{}{}{}",
692            step.docstring
693                .as_ref()
694                .and_then(|doc| self.verbosity.shows_docstring().then(|| {
695                    format_str_with_indent(
696                        doc,
697                        self.indent.saturating_sub(3) + 3,
698                    )
699                }))
700                .unwrap_or_default(),
701            step.table
702                .as_ref()
703                .map(|t| format_table(t, self.indent))
704                .unwrap_or_default(),
705            feat.path
706                .as_ref()
707                .and_then(|p| p.to_str().map(trim_path))
708                .unwrap_or(&feat.name),
709            step.position.line,
710            step.position.col,
711            loc.map(|l| format!(
712                "\n{indent}   Matched: {}:{}:{}",
713                l.path, l.line, l.column,
714            ))
715            .unwrap_or_default(),
716            format_str_with_indent(
717                err.to_string(),
718                self.indent.saturating_sub(3) + 3,
719            ),
720            world
721                .map(|w| format_str_with_indent(
722                    format!("{w:#?}"),
723                    self.indent.saturating_sub(3) + 3,
724                ))
725                .filter(|_| self.verbosity.shows_world())
726                .unwrap_or_default(),
727        ));
728
729        self.output
730            .write_line(format!("{step_keyword}{step_value}{diagnostics}"))
731    }
732
733    /// Outputs the [`Background`] [`Step`]'s
734    /// [started]/[passed]/[skipped]/[failed] event.
735    ///
736    /// [failed]: event::Step::Failed
737    /// [passed]: event::Step::Passed
738    /// [skipped]: event::Step::Skipped
739    /// [started]: event::Step::Started
740    /// [`Background`]: gherkin::Background
741    /// [`Step`]: gherkin::Step
742    pub(crate) fn background<W: Debug>(
743        &mut self,
744        feat: &gherkin::Feature,
745        sc: &gherkin::Scenario,
746        bg: &gherkin::Step,
747        ev: &event::Step<W>,
748        retries: Option<Retries>,
749    ) -> io::Result<()> {
750        use event::Step;
751
752        match ev {
753            Step::Started => {
754                self.bg_step_started(bg)?;
755            }
756            Step::Passed(captures, _) => {
757                self.bg_step_passed(sc, bg, captures, retries)?;
758                self.indent = self.indent.saturating_sub(4);
759            }
760            Step::Skipped => {
761                self.bg_step_skipped(feat, bg)?;
762                self.indent = self.indent.saturating_sub(4);
763            }
764            Step::Failed(c, loc, w, i) => {
765                self.bg_step_failed(
766                    feat,
767                    bg,
768                    c.as_ref(),
769                    *loc,
770                    retries,
771                    w.as_ref(),
772                    i,
773                )?;
774                self.indent = self.indent.saturating_sub(4);
775            }
776        }
777        Ok(())
778    }
779
780    /// Outputs the [started] [`Background`] [`Step`].
781    ///
782    /// The [`Step`] is printed only if [`Coloring`] is enabled and gets
783    /// overwritten by later [passed]/[skipped]/[failed] events.
784    ///
785    /// [failed]: event::Step::Failed
786    /// [passed]: event::Step::Passed
787    /// [skipped]: event::Step::Skipped
788    /// [started]: event::Step::Started
789    /// [`Background`]: gherkin::Background
790    /// [`Step`]: gherkin::Step
791    pub(crate) fn bg_step_started(
792        &mut self,
793        step: &gherkin::Step,
794    ) -> io::Result<()> {
795        self.indent += 4;
796        if self.styles.is_present {
797            let out = format!(
798                "{indent}> {}{}{}{}",
799                step.keyword,
800                step.value,
801                step.docstring
802                    .as_ref()
803                    .and_then(|doc| self.verbosity.shows_docstring().then(
804                        || {
805                            format_str_with_indent(
806                                doc,
807                                self.indent.saturating_sub(3) + 3,
808                            )
809                        }
810                    ))
811                    .unwrap_or_default(),
812                step.table
813                    .as_ref()
814                    .map(|t| format_table(t, self.indent))
815                    .unwrap_or_default(),
816                indent = " ".repeat(self.indent.saturating_sub(2)),
817            );
818            self.lines_to_clear += self.styles.lines_count(&out);
819            self.output.write_line(&out)?;
820        }
821        Ok(())
822    }
823
824    /// Outputs the [passed] [`Background`] [`Step`].
825    ///
826    /// [passed]: event::Step::Passed
827    /// [`Background`]: gherkin::Background
828    /// [`Step`]: gherkin::Step
829    pub(crate) fn bg_step_passed(
830        &mut self,
831        scenario: &gherkin::Scenario,
832        step: &gherkin::Step,
833        captures: &CaptureLocations,
834        retries: Option<Retries>,
835    ) -> io::Result<()> {
836        self.clear_last_lines_if_term_present()?;
837
838        let style = |s| {
839            if retries.filter(|r| r.current > 0).is_some()
840                && scenario.steps.last().filter(|st| *st != step).is_some()
841            {
842                self.styles.retry(s)
843            } else {
844                self.styles.ok(s)
845            }
846        };
847
848        let indent = " ".repeat(self.indent.saturating_sub(3));
849
850        let step_keyword = style(format!("{indent}✔> {}", step.keyword));
851        let step_value = format_captures(
852            &step.value,
853            captures,
854            |v| style(v.to_owned()),
855            |v| style(self.styles.bold(v).to_string()),
856        );
857        let doc_str = style(
858            step.docstring
859                .as_ref()
860                .and_then(|doc| {
861                    self.verbosity.shows_docstring().then(|| {
862                        format_str_with_indent(
863                            doc,
864                            self.indent.saturating_sub(3) + 3,
865                        )
866                    })
867                })
868                .unwrap_or_default(),
869        );
870        let step_table = style(
871            step.table
872                .as_ref()
873                .map(|t| format_table(t, self.indent))
874                .unwrap_or_default(),
875        );
876
877        self.output.write_line(style(format!(
878            "{step_keyword}{step_value}{doc_str}{step_table}",
879        )))
880    }
881
882    /// Outputs the [skipped] [`Background`] [`Step`].
883    ///
884    /// [skipped]: event::Step::Skipped
885    /// [`Background`]: gherkin::Background
886    /// [`Step`]: gherkin::Step
887    pub(crate) fn bg_step_skipped(
888        &mut self,
889        feat: &gherkin::Feature,
890        step: &gherkin::Step,
891    ) -> io::Result<()> {
892        self.clear_last_lines_if_term_present()?;
893        self.output.write_line(self.styles.skipped(format!(
894            "{indent}?> {}{}{}{}\n\
895             {indent}   Background step failed: {}:{}:{}",
896            step.keyword,
897            step.value,
898            step.docstring
899                .as_ref()
900                .and_then(|doc| self.verbosity.shows_docstring().then(|| {
901                    format_str_with_indent(
902                        doc,
903                        self.indent.saturating_sub(3) + 3,
904                    )
905                }))
906                .unwrap_or_default(),
907            step.table
908                .as_ref()
909                .map(|t| format_table(t, self.indent))
910                .unwrap_or_default(),
911            feat.path
912                .as_ref()
913                .and_then(|p| p.to_str().map(trim_path))
914                .unwrap_or(&feat.name),
915            step.position.line,
916            step.position.col,
917            indent = " ".repeat(self.indent.saturating_sub(3)),
918        )))
919    }
920
921    /// Outputs the [failed] [`Background`] [`Step`].
922    ///
923    /// [failed]: event::Step::Failed
924    /// [`Background`]: gherkin::Background
925    /// [`Step`]: gherkin::Step
926    // TODO: Needs refactoring.
927    #[expect(clippy::too_many_arguments, reason = "needs refactoring")]
928    pub(crate) fn bg_step_failed<W: Debug>(
929        &mut self,
930        feat: &gherkin::Feature,
931        step: &gherkin::Step,
932        captures: Option<&CaptureLocations>,
933        loc: Option<step::Location>,
934        retries: Option<Retries>,
935        world: Option<&W>,
936        err: &event::StepError,
937    ) -> io::Result<()> {
938        self.clear_last_lines_if_term_present()?;
939
940        let style = |s| {
941            if retries
942                .filter(|r| {
943                    r.left > 0 && !matches!(err, event::StepError::NotFound)
944                })
945                .is_some()
946            {
947                self.styles.bright().retry(s)
948            } else {
949                self.styles.err(s)
950            }
951        };
952
953        let indent = " ".repeat(self.indent.saturating_sub(3));
954        let step_keyword = style(format!("{indent}✘> {}", step.keyword));
955        let step_value = captures.map_or_else(
956            || style(step.value.clone()),
957            |capts| {
958                format_captures(
959                    &step.value,
960                    capts,
961                    |v| style(v.to_owned()),
962                    |v| style(self.styles.bold(v).to_string()),
963                )
964                .into()
965            },
966        );
967
968        let diagnostics = style(format!(
969            "{}{}\n\
970             {indent}   Step failed:\n\
971             {indent}   Defined: {}:{}:{}{}{}{}",
972            step.docstring
973                .as_ref()
974                .and_then(|doc| self.verbosity.shows_docstring().then(|| {
975                    format_str_with_indent(
976                        doc,
977                        self.indent.saturating_sub(3) + 3,
978                    )
979                }))
980                .unwrap_or_default(),
981            step.table
982                .as_ref()
983                .map(|t| format_table(t, self.indent))
984                .unwrap_or_default(),
985            feat.path
986                .as_ref()
987                .and_then(|p| p.to_str().map(trim_path))
988                .unwrap_or(&feat.name),
989            step.position.line,
990            step.position.col,
991            loc.map(|l| format!(
992                "\n{indent}   Matched: {}:{}:{}",
993                l.path, l.line, l.column,
994            ))
995            .unwrap_or_default(),
996            format_str_with_indent(
997                err.to_string(),
998                self.indent.saturating_sub(3) + 3,
999            ),
1000            world
1001                .map(|w| format_str_with_indent(
1002                    format!("{w:#?}"),
1003                    self.indent.saturating_sub(3) + 3,
1004                ))
1005                .filter(|_| self.verbosity.shows_world())
1006                .unwrap_or_default(),
1007        ));
1008
1009        self.output
1010            .write_line(format!("{step_keyword}{step_value}{diagnostics}"))
1011    }
1012}
1013
1014/// Tries to coerce [`catch_unwind()`] output to [`String`].
1015///
1016/// [`catch_unwind()`]: std::panic::catch_unwind()
1017#[must_use]
1018pub(crate) fn coerce_error(err: &Info) -> Cow<'static, str> {
1019    (**err)
1020        .downcast_ref::<String>()
1021        .map(|s| s.clone().into())
1022        .or_else(|| (**err).downcast_ref::<&str>().map(|s| s.to_owned().into()))
1023        .unwrap_or_else(|| "(Could not resolve panic payload)".into())
1024}
1025
1026/// Formats the given [`str`] by adding `indent`s to each line to prettify the
1027/// output.
1028fn format_str_with_indent(str: impl AsRef<str>, indent: usize) -> String {
1029    let str = str
1030        .as_ref()
1031        .lines()
1032        .map(|line| format!("{}{line}", " ".repeat(indent)))
1033        .join("\n");
1034    if str.is_empty() { String::new() } else { format!("\n{str}") }
1035}
1036
1037/// Formats the given [`gherkin::Table`] and adds `indent`s to each line to
1038/// prettify the output.
1039fn format_table(table: &gherkin::Table, indent: usize) -> String {
1040    use std::fmt::Write as _;
1041
1042    let max_row_len = table
1043        .rows
1044        .iter()
1045        .fold(None, |mut acc: Option<Vec<_>>, row| {
1046            if let Some(existing_len) = acc.as_mut() {
1047                for (cell, max_len) in row.iter().zip(existing_len) {
1048                    *max_len = cmp::max(*max_len, cell.len());
1049                }
1050            } else {
1051                acc = Some(row.iter().map(String::len).collect::<Vec<_>>());
1052            }
1053            acc
1054        })
1055        .unwrap_or_default();
1056
1057    let mut table = table
1058        .rows
1059        .iter()
1060        .map(|row| {
1061            row.iter().zip(&max_row_len).fold(
1062                String::new(),
1063                |mut out, (cell, len)| {
1064                    _ = write!(out, "| {cell:len$} ");
1065                    out
1066                },
1067            )
1068        })
1069        .map(|row| format!("{}{row}", " ".repeat(indent + 1)))
1070        .join("|\n");
1071
1072    if !table.is_empty() {
1073        table.insert(0, '\n');
1074        table.push('|');
1075    }
1076
1077    table
1078}
1079
1080/// Formats `value`s in the given `captures` with the provided `accent` style
1081/// and with the `default` style anything else.
1082fn format_captures<D, A>(
1083    value: impl AsRef<str>,
1084    captures: &CaptureLocations,
1085    default: D,
1086    accent: A,
1087) -> String
1088where
1089    D: for<'a> Fn(&'a str) -> Cow<'a, str>,
1090    A: for<'a> Fn(&'a str) -> Cow<'a, str>,
1091{
1092    #![expect( // intentional
1093        clippy::string_slice,
1094        reason = "all indices are obtained from the source string"
1095    )]
1096
1097    let value = value.as_ref();
1098
1099    let (mut formatted, end) =
1100        (1..captures.len()).filter_map(|group| captures.get(group)).fold(
1101            (String::with_capacity(value.len()), 0),
1102            |(mut str, old), (start, end)| {
1103                // Ignore nested groups.
1104                if old > start {
1105                    return (str, old);
1106                }
1107
1108                str.push_str(&default(&value[old..start]));
1109                str.push_str(&accent(&value[start..end]));
1110                (str, end)
1111            },
1112        );
1113    formatted.push_str(&default(&value[end..value.len()]));
1114
1115    formatted
1116}
1117
1118/// Trims start of the path if it matches the current project directory.
1119pub(crate) fn trim_path(path: &str) -> &str {
1120    /// Path of the current project directory.
1121    static CURRENT_DIR: LazyLock<String> = LazyLock::new(|| {
1122        env::var("CARGO_WORKSPACE_DIR")
1123            .or_else(|_| env::var("CARGO_MANIFEST_DIR"))
1124            .unwrap_or_else(|_| {
1125                env::current_dir()
1126                    .map(|path| path.display().to_string())
1127                    .unwrap_or_default()
1128            })
1129    });
1130
1131    path.trim_start_matches(&**CURRENT_DIR)
1132        .trim_start_matches('/')
1133        .trim_start_matches('\\')
1134}