Skip to main content

cucumber/writer/
libtest.rs

1// Copyright (c) 2018-2026  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//! [Rust `libtest`][1] compatible [`Writer`] implementation.
12//!
13//! [1]: https://doc.rust-lang.org/rustc/tests/index.html
14
15use std::{
16    fmt::Debug,
17    io, iter, mem,
18    str::FromStr,
19    time::{Duration, SystemTime},
20};
21
22use derive_more::with_trait::From;
23use either::Either;
24use itertools::Itertools as _;
25use serde::Serialize;
26
27use crate::{
28    Event, World, Writer, WriterExt as _, cli,
29    event::{self, Retries},
30    parser,
31    writer::{
32        self, Arbitrary, Normalize, Summarize,
33        basic::{coerce_error, trim_path},
34        out::WriteStrExt as _,
35    },
36};
37
38/// CLI options of a [`Libtest`] [`Writer`].
39#[derive(Clone, Debug, Default, clap::Args)]
40#[group(skip)]
41pub struct Cli {
42    /// Formatting of the output.
43    #[arg(long, value_name = "json")]
44    pub format: Option<Format>,
45
46    /// Show captured stdout of successful tests. Currently, outputs only step
47    /// function location.
48    #[arg(long)]
49    pub show_output: bool,
50
51    /// Show execution time of each test.
52    #[arg(long, value_name = "plain|colored", default_missing_value = "plain")]
53    pub report_time: Option<ReportTime>,
54
55    /// Enable nightly-only flags.
56    #[arg(short = 'Z')]
57    pub nightly: Option<String>,
58}
59
60/// Output formats.
61///
62/// Currently, supports only JSON.
63#[derive(Clone, Copy, Debug)]
64pub enum Format {
65    /// [`libtest`][1]'s JSON format.
66    ///
67    /// [1]: https://doc.rust-lang.org/rustc/tests/index.html
68    Json,
69}
70
71impl FromStr for Format {
72    type Err = String;
73
74    fn from_str(s: &str) -> Result<Self, Self::Err> {
75        match s.to_lowercase().as_str() {
76            "json" => Ok(Self::Json),
77            s @ ("pretty" | "terse" | "junit") => {
78                Err(format!("`{s}` option is not supported yet"))
79            }
80            s => Err(format!(
81                "Unknown option `{s}`, expected `pretty` or `json`",
82            )),
83        }
84    }
85}
86
87/// Format of reporting time.
88#[derive(Clone, Copy, Debug)]
89pub enum ReportTime {
90    /// Plain time reporting.
91    Plain,
92
93    /// Colored time reporting.
94    Colored,
95}
96
97impl FromStr for ReportTime {
98    type Err = String;
99
100    fn from_str(s: &str) -> Result<Self, Self::Err> {
101        match s.to_lowercase().as_str() {
102            "plain" => Ok(Self::Plain),
103            "colored" => Ok(Self::Colored),
104            s => Err(format!(
105                "Unknown option `{s}`, expected `plain` or `colored`",
106            )),
107        }
108    }
109}
110
111/// [`libtest`][1] compatible [`Writer`].
112///
113/// Currently used only to support `--format=json` option.
114///
115/// # Ordering
116///
117/// This [`Writer`] isn't [`Normalized`] by itself, so should be wrapped into a
118/// [`writer::Normalize`], otherwise will produce output [`Event`]s in a broken
119/// order.
120///
121/// Ideally, we shouldn't wrap this into a [`writer::Normalize`] and leave this
122/// to tools, parsing JSON output. Unfortunately, not all tools can do that (ex.
123/// [`IntelliJ Rust`][2]), so it's still recommended to wrap this into
124/// [`writer::Normalize`] even if it can mess up timing reports.
125///
126/// [`Normalized`]: writer::Normalized
127/// [1]: https://doc.rust-lang.org/rustc/tests/index.html
128/// [2]: https://github.com/intellij-rust/intellij-rust/issues/9041
129#[derive(Debug)]
130pub struct Libtest<W, Out: io::Write = io::Stdout> {
131    /// [`io::Write`] implementor to output into.
132    output: Out,
133
134    /// Collection of events before [`ParsingFinished`] is received.
135    ///
136    /// Until a [`ParsingFinished`] is received, all the events are stored
137    /// inside [`Libtest::events`] and outputted only after that event is
138    /// received. This is done, because [`libtest`][1]'s first event must
139    /// contain number of executed test cases.
140    ///
141    /// [`ParsingFinished`]: event::Cucumber::ParsingFinished
142    /// [1]: https://doc.rust-lang.org/rustc/tests/index.html
143    events: Vec<parser::Result<Event<event::Cucumber<W>>>>,
144
145    /// Indicates whether a [`ParsingFinished`] event was received.
146    ///
147    /// [`ParsingFinished`]: event::Cucumber::ParsingFinished
148    parsed_all: bool,
149
150    /// Number of passed [`Step`]s.
151    ///
152    /// [`Step`]: gherkin::Step
153    passed: usize,
154
155    /// Number of failed [`Step`]s.
156    ///
157    /// [`Step`]: gherkin::Step
158    failed: usize,
159
160    /// Number of retried [`Step`]s.
161    ///
162    /// [`Step`]: gherkin::Step
163    retried: usize,
164
165    /// Number of skipped [`Step`]s.
166    ///
167    /// [`Step`]: gherkin::Step
168    ignored: usize,
169
170    /// Number of [`Parser`] errors.
171    ///
172    /// [`Parser`]: crate::Parser
173    parsing_errors: usize,
174
175    /// Number of [`Hook`] errors.
176    ///
177    /// [`Hook`]: event::Hook
178    hook_errors: usize,
179
180    /// Number of [`Feature`]s with [`path`] set to [`None`].
181    ///
182    /// This value is used to generate a unique name for each [`Feature`] to
183    /// avoid name collisions.
184    ///
185    /// [`Feature`]: gherkin::Feature
186    /// [`path`]: gherkin::Feature::path
187    features_without_path: usize,
188
189    /// [`SystemTime`] when the [`Started`] event was received.
190    ///
191    /// [`Started`]: event::Cucumber::Started
192    started_at: Option<SystemTime>,
193
194    /// [`SystemTime`] when the [`Step::Started`]/[`Hook::Started`] event was
195    /// received.
196    ///
197    /// [`Hook::Started`]: event::Hook::Started
198    /// [`Step::Started`]: event::Step::Started
199    step_started_at: Option<SystemTime>,
200}
201
202// Implemented manually to omit redundant `World: Clone` trait bound, imposed by
203// `#[derive(Clone)]`.
204impl<World, Out: Clone + io::Write> Clone for Libtest<World, Out> {
205    fn clone(&self) -> Self {
206        Self {
207            output: self.output.clone(),
208            events: self.events.clone(),
209            parsed_all: self.parsed_all,
210            passed: self.passed,
211            failed: self.failed,
212            retried: self.retried,
213            ignored: self.ignored,
214            parsing_errors: self.parsing_errors,
215            hook_errors: self.hook_errors,
216            features_without_path: self.features_without_path,
217            started_at: self.started_at,
218            step_started_at: self.step_started_at,
219        }
220    }
221}
222
223impl<W: World + Debug, Out: io::Write> Writer<W> for Libtest<W, Out> {
224    type Cli = Cli;
225
226    async fn handle_event(
227        &mut self,
228        event: parser::Result<Event<event::Cucumber<W>>>,
229        cli: &Self::Cli,
230    ) {
231        self.handle_cucumber_event(event, cli);
232    }
233}
234
235/// Shortcut of a [`Libtest::or()`] return type.
236pub type Or<W, Wr> = writer::Or<
237    Wr,
238    Normalize<W, Libtest<W, io::Stdout>>,
239    fn(
240        &parser::Result<Event<event::Cucumber<W>>>,
241        &cli::Compose<<Wr as Writer<W>>::Cli, Cli>,
242    ) -> bool,
243>;
244
245/// Shortcut of a [`Libtest::or_basic()`] return type.
246pub type OrBasic<W> = Or<W, Summarize<Normalize<W, writer::Basic>>>;
247
248impl<W: Debug + World> Libtest<W, io::Stdout> {
249    /// Creates a new [`Normalized`] [`Libtest`] [`Writer`] outputting into the
250    /// [`io::Stdout`].
251    ///
252    /// [`Normalized`]: writer::Normalized
253    #[must_use]
254    pub fn stdout() -> Normalize<W, Self> {
255        Self::new(io::stdout())
256    }
257
258    /// Creates a new [`Writer`] which uses a [`Normalized`] [`Libtest`] in case
259    /// [`Cli::format`] is set to [`Json`], or provided the `writer` otherwise.
260    ///
261    /// [`Json`]: Format::Json
262    /// [`Normalized`]: writer::Normalized
263    #[must_use]
264    pub fn or<AnotherWriter: Writer<W>>(
265        writer: AnotherWriter,
266    ) -> Or<W, AnotherWriter> {
267        Or::new(writer, Self::stdout(), |_, cli| {
268            !matches!(cli.right.format, Some(Format::Json))
269        })
270    }
271
272    /// Creates a new [`Writer`] which uses a [`Normalized`] [`Libtest`] in case
273    /// [`Cli::format`] is set to [`Json`], or a [`Normalized`]
274    /// [`writer::Basic`] otherwise.
275    ///
276    /// [`Json`]: Format::Json
277    /// [`Normalized`]: writer::Normalized
278    #[must_use]
279    pub fn or_basic() -> OrBasic<W> {
280        Self::or(writer::Basic::stdout().summarized())
281    }
282}
283
284impl<W: Debug + World, Out: io::Write> Libtest<W, Out> {
285    /// Creates a new [`Normalized`] [`Libtest`] [`Writer`] outputting into the
286    /// provided `output`.
287    ///
288    /// Theoretically, normalization should be done by the tool that's consuming
289    /// the output og this [`Writer`]. But lack of clear specification of the
290    /// [`libtest`][1]'s JSON output leads to some tools [struggling][2] to
291    /// interpret it. So, we recommend using a [`Normalized`] [`Libtest::new()`]
292    /// rather than a non-[`Normalized`] [`Libtest::raw()`].
293    ///
294    /// [`Normalized`]: writer::Normalized
295    /// [1]: https://doc.rust-lang.org/rustc/tests/index.html
296    /// [2]: https://github.com/intellij-rust/intellij-rust/issues/9041
297    #[must_use]
298    pub fn new(output: Out) -> Normalize<W, Self> {
299        Self::raw(output).normalized()
300    }
301
302    /// Creates a new non-[`Normalized`] [`Libtest`] [`Writer`] outputting into
303    /// the provided `output`.
304    ///
305    /// Theoretically, normalization should be done by the tool that's consuming
306    /// the output og this [`Writer`]. But lack of clear specification of the
307    /// [`libtest`][1]'s JSON output leads to some tools [struggling][2] to
308    /// interpret it. So, we recommend using a [`Normalized`] [`Libtest::new()`]
309    /// rather than a non-[`Normalized`] [`Libtest::raw()`].
310    ///
311    /// [`Normalized`]: writer::Normalized
312    /// [1]: https://doc.rust-lang.org/rustc/tests/index.html
313    /// [2]: https://github.com/intellij-rust/intellij-rust/issues/9041
314    #[must_use]
315    pub const fn raw(output: Out) -> Self {
316        Self {
317            output,
318            events: Vec::new(),
319            parsed_all: false,
320            passed: 0,
321            failed: 0,
322            retried: 0,
323            parsing_errors: 0,
324            hook_errors: 0,
325            ignored: 0,
326            features_without_path: 0,
327            started_at: None,
328            step_started_at: None,
329        }
330    }
331
332    /// Handles the provided [`event::Cucumber`].
333    ///
334    /// Until [`ParsingFinished`] is received, all the events are stored inside
335    /// [`Libtest::events`] and outputted only after that event is received.
336    /// This is done, because [`libtest`][1]'s first event must contain number
337    /// of executed test cases.
338    ///
339    /// [1]: https://doc.rust-lang.org/rustc/tests/index.html
340    /// [`ParsingFinished`]: event::Cucumber::ParsingFinished
341    fn handle_cucumber_event(
342        &mut self,
343        event: parser::Result<Event<event::Cucumber<W>>>,
344        cli: &Cli,
345    ) {
346        use event::{Cucumber, Metadata};
347
348        let unite = |ev: Result<(Cucumber<W>, Metadata), _>| {
349            ev.map(|(e, m)| m.insert(e))
350        };
351
352        match (event.map(Event::split), self.parsed_all) {
353            (event @ Ok((Cucumber::ParsingFinished { .. }, _)), false) => {
354                self.parsed_all = true;
355
356                let all_events =
357                    iter::once(unite(event)).chain(mem::take(&mut self.events));
358                for ev in all_events {
359                    self.output_event(ev, cli);
360                }
361            }
362            (event, false) => self.events.push(unite(event)),
363            (event, true) => self.output_event(unite(event), cli),
364        }
365    }
366
367    /// Outputs the provided [`event::Cucumber`].
368    fn output_event(
369        &mut self,
370        event: parser::Result<Event<event::Cucumber<W>>>,
371        cli: &Cli,
372    ) {
373        for ev in self.expand_cucumber_event(event, cli) {
374            self.output
375                .write_line(serde_json::to_string(&ev).unwrap_or_else(|e| {
376                    panic!("Failed to serialize `LibTestJsonEvent`: {e}")
377                }))
378                .unwrap_or_else(|e| panic!("Failed to write: {e}"));
379        }
380    }
381
382    /// Converts the provided [`event::Cucumber`] into [`LibTestJsonEvent`]s.
383    fn expand_cucumber_event(
384        &mut self,
385        event: parser::Result<Event<event::Cucumber<W>>>,
386        cli: &Cli,
387    ) -> Vec<LibTestJsonEvent> {
388        use event::Cucumber;
389
390        match event.map(Event::split) {
391            Ok((Cucumber::Started, meta)) => {
392                self.started_at = Some(meta.at);
393                Vec::new()
394            }
395            Ok((Cucumber::ParsingFinished { steps, parser_errors, .. }, _)) => {
396                vec![
397                    SuiteEvent::Started { test_count: steps + parser_errors }
398                        .into(),
399                ]
400            }
401            Ok((Cucumber::Finished, meta)) => {
402                let exec_time = self
403                    .started_at
404                    .and_then(|started| meta.at.duration_since(started).ok())
405                    .as_ref()
406                    .map(Duration::as_secs_f64);
407
408                let failed =
409                    self.failed + self.parsing_errors + self.hook_errors;
410                let results = SuiteResults {
411                    passed: self.passed,
412                    failed,
413                    ignored: self.ignored,
414                    measured: 0,
415                    filtered_out: 0,
416                    exec_time,
417                };
418                let ev = if failed == 0 {
419                    SuiteEvent::Ok { results }
420                } else {
421                    SuiteEvent::Failed { results }
422                }
423                .into();
424
425                vec![ev]
426            }
427            Ok((Cucumber::Feature(feature, ev), meta)) => {
428                self.expand_feature_event(&feature, ev, meta, cli)
429            }
430            Err(e) => {
431                self.parsing_errors += 1;
432
433                let path = match &e {
434                    parser::Error::Parsing(e) => match &**e {
435                        gherkin::ParseFileError::Parsing { path, .. }
436                        | gherkin::ParseFileError::Reading { path, .. } => {
437                            Some(path)
438                        }
439                    },
440                    parser::Error::ExampleExpansion(e) => e.path.as_ref(),
441                };
442                let name = path.and_then(|p| p.to_str()).map_or_else(
443                    || self.parsing_errors.to_string(),
444                    |p| p.escape_default().to_string(),
445                );
446                let name = format!("Feature: Parsing {name}");
447
448                vec![
449                    TestEvent::started(name.clone()).into(),
450                    TestEvent::failed(name, None)
451                        .with_stdout(e.to_string())
452                        .into(),
453                ]
454            }
455        }
456    }
457
458    /// Converts the provided [`event::Feature`] into [`LibTestJsonEvent`]s.
459    fn expand_feature_event(
460        &mut self,
461        feature: &gherkin::Feature,
462        ev: event::Feature<W>,
463        meta: event::Metadata,
464        cli: &Cli,
465    ) -> Vec<LibTestJsonEvent> {
466        use event::{Feature, Rule};
467
468        match ev {
469            Feature::Started
470            | Feature::Finished
471            | Feature::Rule(_, Rule::Started | Rule::Finished) => Vec::new(),
472            Feature::Rule(rule, Rule::Scenario(scenario, ev)) => self
473                .expand_scenario_event(
474                    feature,
475                    Some(&rule),
476                    &scenario,
477                    ev,
478                    meta,
479                    cli,
480                ),
481            Feature::Scenario(scenario, ev) => self
482                .expand_scenario_event(feature, None, &scenario, ev, meta, cli),
483        }
484    }
485
486    /// Converts the provided [`event::Scenario`] into [`LibTestJsonEvent`]s.
487    fn expand_scenario_event(
488        &mut self,
489        feature: &gherkin::Feature,
490        rule: Option<&gherkin::Rule>,
491        scenario: &gherkin::Scenario,
492        ev: event::RetryableScenario<W>,
493        meta: event::Metadata,
494        cli: &Cli,
495    ) -> Vec<LibTestJsonEvent> {
496        use event::Scenario;
497
498        let retries = ev.retries;
499        match ev.event {
500            Scenario::Started | Scenario::Finished => Vec::new(),
501            Scenario::Hook(ty, ev) => self.expand_hook_event(
502                feature, rule, scenario, ty, ev, retries, meta, cli,
503            ),
504            Scenario::Background(step, ev) => self.expand_step_event(
505                feature, rule, scenario, &step, ev, retries, true, meta, cli,
506            ),
507            Scenario::Step(step, ev) => self.expand_step_event(
508                feature, rule, scenario, &step, ev, retries, false, meta, cli,
509            ),
510            // We do use `print!()` intentionally here to support `libtest`
511            // output capturing properly, which can only capture output from
512            // the standard library’s `print!()` macro.
513            // This is the same as `tracing_subscriber::fmt::TestWriter` does
514            // (check its documentation for details).
515            #[expect( // intentional
516                clippy::print_stdout,
517                reason = "supporting `libtest` output capturing properly"
518            )]
519            Scenario::Log(msg) => {
520                print!("{msg}");
521                vec![]
522            }
523        }
524    }
525
526    /// Converts the provided [`event::Hook`] into [`LibTestJsonEvent`]s.
527    // TODO: Needs refactoring.
528    #[expect(clippy::too_many_arguments, reason = "needs refactoring")]
529    fn expand_hook_event(
530        &mut self,
531        feature: &gherkin::Feature,
532        rule: Option<&gherkin::Rule>,
533        scenario: &gherkin::Scenario,
534        hook: event::HookType,
535        ev: event::Hook<W>,
536        retries: Option<Retries>,
537        meta: event::Metadata,
538        cli: &Cli,
539    ) -> Vec<LibTestJsonEvent> {
540        match ev {
541            event::Hook::Started => {
542                self.step_started_at(meta, cli);
543                Vec::new()
544            }
545            event::Hook::Passed => Vec::new(),
546            event::Hook::Failed(world, info) => {
547                self.hook_errors += 1;
548
549                let name = self.test_case_name(
550                    feature,
551                    rule,
552                    scenario,
553                    Either::Left(hook),
554                    retries,
555                );
556
557                vec![
558                    TestEvent::started(name.clone()).into(),
559                    TestEvent::failed(name, self.step_exec_time(meta, cli))
560                        .with_stdout(format!(
561                            "{}{}",
562                            coerce_error(&info),
563                            world
564                                .map(|w| format!("\n{w:#?}"))
565                                .unwrap_or_default(),
566                        ))
567                        .into(),
568                ]
569            }
570        }
571    }
572
573    /// Converts the provided [`event::Step`] into [`LibTestJsonEvent`]s.
574    // TODO: Needs refactoring.
575    #[expect(clippy::too_many_arguments, reason = "needs refactoring")]
576    fn expand_step_event(
577        &mut self,
578        feature: &gherkin::Feature,
579        rule: Option<&gherkin::Rule>,
580        scenario: &gherkin::Scenario,
581        step: &gherkin::Step,
582        ev: event::Step<W>,
583        retries: Option<Retries>,
584        is_background: bool,
585        meta: event::Metadata,
586        cli: &Cli,
587    ) -> Vec<LibTestJsonEvent> {
588        use event::Step;
589
590        let name = self.test_case_name(
591            feature,
592            rule,
593            scenario,
594            Either::Right((step, is_background)),
595            retries,
596        );
597
598        let ev = match ev {
599            Step::Started => {
600                self.step_started_at(meta, cli);
601                TestEvent::started(name)
602            }
603            Step::Passed(_, loc) => {
604                self.passed += 1;
605
606                let event = TestEvent::ok(name, self.step_exec_time(meta, cli));
607                if cli.show_output {
608                    event.with_stdout(format!(
609                        "{}:{}:{} (defined){}",
610                        feature
611                            .path
612                            .as_ref()
613                            .and_then(|p| p.to_str().map(trim_path))
614                            .unwrap_or(&feature.name),
615                        step.position.line,
616                        step.position.col,
617                        loc.map(|l| format!(
618                            "\n{}:{}:{} (matched)",
619                            l.path, l.line, l.column,
620                        ))
621                        .unwrap_or_default()
622                    ))
623                } else {
624                    event
625                }
626            }
627            Step::Skipped => {
628                self.ignored += 1;
629
630                let event =
631                    TestEvent::ignored(name, self.step_exec_time(meta, cli));
632                if cli.show_output {
633                    event.with_stdout(format!(
634                        "{}:{}:{} (defined)",
635                        feature
636                            .path
637                            .as_ref()
638                            .and_then(|p| p.to_str().map(trim_path))
639                            .unwrap_or(&feature.name),
640                        step.position.line,
641                        step.position.col,
642                    ))
643                } else {
644                    event
645                }
646            }
647            Step::Failed(_, loc, world, err) => {
648                if retries.is_some_and(|r| {
649                    r.left > 0 && !matches!(err, event::StepError::NotFound)
650                }) {
651                    self.retried += 1;
652                } else {
653                    self.failed += 1;
654                }
655
656                TestEvent::failed(name, self.step_exec_time(meta, cli))
657                    .with_stdout(format!(
658                        "{}:{}:{} (defined){}\n{err}{}",
659                        feature
660                            .path
661                            .as_ref()
662                            .and_then(|p| p.to_str().map(trim_path))
663                            .unwrap_or(&feature.name),
664                        step.position.line,
665                        step.position.col,
666                        loc.map(|l| format!(
667                            "\n{}:{}:{} (matched)",
668                            l.path, l.line, l.column,
669                        ))
670                        .unwrap_or_default(),
671                        world.map(|w| format!("\n{w:#?}")).unwrap_or_default(),
672                    ))
673            }
674        };
675
676        vec![ev.into()]
677    }
678
679    /// Generates test case name.
680    fn test_case_name(
681        &mut self,
682        feature: &gherkin::Feature,
683        rule: Option<&gherkin::Rule>,
684        scenario: &gherkin::Scenario,
685        step: Either<event::HookType, (&gherkin::Step, IsBackground)>,
686        retries: Option<Retries>,
687    ) -> String {
688        let feature_name = format!(
689            "{}: {} {}",
690            feature.keyword,
691            feature.name,
692            feature
693                .path
694                .as_ref()
695                .and_then(|p| p.to_str().map(trim_path))
696                .map_or_else(
697                    || {
698                        self.features_without_path += 1;
699                        self.features_without_path.to_string()
700                    },
701                    |s| s.escape_default().to_string()
702                ),
703        );
704        let rule_name = rule
705            .as_ref()
706            .map(|r| format!("{}: {}: {}", r.position.line, r.keyword, r.name));
707        let scenario_name = format!(
708            "{}: {}: {}{}",
709            scenario.position.line,
710            scenario.keyword,
711            scenario.name,
712            retries
713                .filter(|r| r.current > 0)
714                .map(|r| format!(
715                    " | Retry attempt {}/{}",
716                    r.current,
717                    r.current + r.left,
718                ))
719                .unwrap_or_default(),
720        );
721        let step_name = match step {
722            Either::Left(hook) => format!("{hook} hook"),
723            Either::Right((step, is_bg)) => format!(
724                "{}: {} {}{}",
725                step.position.line,
726                if is_bg {
727                    feature
728                        .background
729                        .as_ref()
730                        .map_or("Background", |bg| bg.keyword.as_str())
731                } else {
732                    ""
733                },
734                step.keyword,
735                step.value,
736            ),
737        };
738
739        [Some(feature_name), rule_name, Some(scenario_name), Some(step_name)]
740            .into_iter()
741            .flatten()
742            .join("::")
743    }
744
745    /// Saves [`Step`] starting [`SystemTime`].
746    ///
747    /// [`Step`]: gherkin::Step
748    fn step_started_at(&mut self, meta: event::Metadata, cli: &Cli) {
749        self.step_started_at =
750            Some(meta.at).filter(|_| cli.report_time.is_some());
751    }
752
753    /// Retrieves [`Duration`] since the last [`Libtest::step_started_at()`]
754    /// call.
755    fn step_exec_time(
756        &mut self,
757        meta: event::Metadata,
758        cli: &Cli,
759    ) -> Option<Duration> {
760        let started = self.step_started_at.take()?;
761        meta.at
762            .duration_since(started)
763            .ok()
764            .filter(|_| cli.report_time.is_some())
765    }
766}
767
768/// Indicator, whether a [`Step`] is [`Background`] or not.
769///
770/// [`Background`]: event::Scenario::Background
771/// [`Step`]: gherkin::Step
772type IsBackground = bool;
773
774impl<W, O: io::Write> writer::NonTransforming for Libtest<W, O> {}
775
776impl<W, O> writer::Stats<W> for Libtest<W, O>
777where
778    O: io::Write,
779    Self: Writer<W>,
780{
781    fn passed_steps(&self) -> usize {
782        self.passed
783    }
784
785    fn skipped_steps(&self) -> usize {
786        self.ignored
787    }
788
789    fn failed_steps(&self) -> usize {
790        self.failed
791    }
792
793    fn retried_steps(&self) -> usize {
794        self.retried
795    }
796
797    fn parsing_errors(&self) -> usize {
798        self.parsing_errors
799    }
800
801    fn hook_errors(&self) -> usize {
802        self.hook_errors
803    }
804}
805
806impl<W, Val, Out> Arbitrary<W, Val> for Libtest<W, Out>
807where
808    W: World + Debug,
809    Val: AsRef<str>,
810    Out: io::Write,
811{
812    async fn write(&mut self, val: Val) {
813        self.output
814            .write_line(val.as_ref())
815            .unwrap_or_else(|e| panic!("failed to write: {e}"));
816    }
817}
818
819/// [`libtest`][1]'s JSON event.
820///
821/// This format isn't stable, so this implementation uses [implementation][1] as
822/// a reference point.
823///
824/// [1]: https://bit.ly/3PrLtKC
825#[derive(Clone, Debug, From, Serialize)]
826#[serde(tag = "type", rename_all = "snake_case")]
827enum LibTestJsonEvent {
828    /// Event of test suite.
829    Suite {
830        /// [`SuiteEvent`].
831        #[serde(flatten)]
832        event: SuiteEvent,
833    },
834
835    /// Event of the test case.
836    Test {
837        /// [`TestEvent`].
838        #[serde(flatten)]
839        event: TestEvent,
840    },
841}
842
843/// Test suite event.
844#[derive(Clone, Debug, Serialize)]
845#[serde(tag = "event", rename_all = "snake_case")]
846enum SuiteEvent {
847    /// Test suite started.
848    Started {
849        /// Number of test cases. In our case, this is number of parsed
850        /// [`Step`]s and [`Parser`] errors.
851        ///
852        /// [`Parser`]: crate::Parser
853        /// [`Step`]: gherkin::Step
854        test_count: usize,
855    },
856
857    /// Test suite finished without errors.
858    Ok {
859        /// Execution results.
860        #[serde(flatten)]
861        results: SuiteResults,
862    },
863
864    /// Test suite encountered errors during the execution.
865    Failed {
866        /// Execution results.
867        #[serde(flatten)]
868        results: SuiteResults,
869    },
870}
871
872/// Test suite execution results.
873#[derive(Clone, Copy, Debug, Serialize)]
874struct SuiteResults {
875    /// Number of passed test cases.
876    passed: usize,
877
878    /// Number of failed test cases.
879    failed: usize,
880
881    /// Number of ignored test cases.
882    ignored: usize,
883
884    /// Number of measured benches.
885    measured: usize,
886
887    // TODO: Figure out a way to actually report this.
888    /// Number of filtered out test cases.
889    filtered_out: usize,
890
891    /// Test suite execution time.
892    #[serde(skip_serializing_if = "Option::is_none")]
893    exec_time: Option<f64>,
894}
895
896/// Test case event.
897#[derive(Clone, Debug, Serialize)]
898#[serde(tag = "event", rename_all = "snake_case")]
899enum TestEvent {
900    /// Test case started.
901    Started(TestEventInner),
902
903    /// Test case finished successfully.
904    Ok(TestEventInner),
905
906    /// Test case failed.
907    Failed(TestEventInner),
908
909    /// Test case ignored.
910    Ignored(TestEventInner),
911
912    /// Test case timed out.
913    Timeout(TestEventInner),
914}
915
916impl TestEvent {
917    /// Creates a new [`TestEvent::Started`].
918    const fn started(name: String) -> Self {
919        Self::Started(TestEventInner::new(name))
920    }
921
922    /// Creates a new [`TestEvent::Ok`].
923    fn ok(name: String, exec_time: Option<Duration>) -> Self {
924        Self::Ok(TestEventInner::new(name).with_exec_time(exec_time))
925    }
926
927    /// Creates a new [`TestEvent::Failed`].
928    fn failed(name: String, exec_time: Option<Duration>) -> Self {
929        Self::Failed(TestEventInner::new(name).with_exec_time(exec_time))
930    }
931
932    /// Creates a new [`TestEvent::Ignored`].
933    fn ignored(name: String, exec_time: Option<Duration>) -> Self {
934        Self::Ignored(TestEventInner::new(name).with_exec_time(exec_time))
935    }
936
937    /// Creates a new [`TestEvent::Timeout`].
938    #[expect(dead_code, reason = "API uniformity")]
939    fn timeout(name: String, exec_time: Option<Duration>) -> Self {
940        Self::Timeout(TestEventInner::new(name).with_exec_time(exec_time))
941    }
942
943    /// Adds a [`TestEventInner::stdout`].
944    fn with_stdout(self, mut stdout: String) -> Self {
945        if !stdout.ends_with('\n') {
946            stdout.push('\n');
947        }
948
949        match self {
950            Self::Started(inner) => Self::Started(inner.with_stdout(stdout)),
951            Self::Ok(inner) => Self::Ok(inner.with_stdout(stdout)),
952            Self::Failed(inner) => Self::Failed(inner.with_stdout(stdout)),
953            Self::Ignored(inner) => Self::Ignored(inner.with_stdout(stdout)),
954            Self::Timeout(inner) => Self::Timeout(inner.with_stdout(stdout)),
955        }
956    }
957}
958
959/// Inner value of a [`TestEvent`].
960#[derive(Clone, Debug, Serialize)]
961struct TestEventInner {
962    /// Name of this test case.
963    name: String,
964
965    /// [`Stdout`] of this test case.
966    ///
967    /// [`Stdout`]: io::Stdout
968    #[serde(skip_serializing_if = "Option::is_none")]
969    stdout: Option<String>,
970
971    /// [`Stderr`] of this test case.
972    ///
973    /// Isn't actually used, as [IntelliJ Rust][1] ignores it.
974    ///
975    /// [1]: https://github.com/intellij-rust/intellij-rust/issues/9041
976    /// [`Stderr`]: io::Stderr
977    #[serde(skip_serializing_if = "Option::is_none")]
978    stderr: Option<String>,
979
980    /// Test case execution time.
981    #[serde(skip_serializing_if = "Option::is_none")]
982    exec_time: Option<f64>,
983}
984
985impl TestEventInner {
986    /// Creates a new [`TestEventInner`].
987    const fn new(name: String) -> Self {
988        Self { name, stdout: None, stderr: None, exec_time: None }
989    }
990
991    /// Adds a [`TestEventInner::exec_time`].
992    fn with_exec_time(mut self, exec_time: Option<Duration>) -> Self {
993        self.exec_time = exec_time.as_ref().map(Duration::as_secs_f64);
994        self
995    }
996
997    /// Adds a [`TestEventInner::stdout`].
998    fn with_stdout(mut self, stdout: String) -> Self {
999        self.stdout = Some(stdout);
1000        self
1001    }
1002}