use std::{
fmt::Debug,
io, iter, mem,
str::FromStr,
time::{Duration, SystemTime},
};
use derive_more::with_trait::From;
use either::Either;
use itertools::Itertools as _;
use serde::Serialize;
use crate::{
Event, World, Writer, WriterExt as _, cli,
event::{self, Retries},
parser,
writer::{
self, Arbitrary, Normalize, Summarize,
basic::{coerce_error, trim_path},
out::WriteStrExt as _,
},
};
#[derive(Clone, Debug, Default, clap::Args)]
#[group(skip)]
pub struct Cli {
#[arg(long, value_name = "json")]
pub format: Option<Format>,
#[arg(long)]
pub show_output: bool,
#[arg(long, value_name = "plain|colored", default_missing_value = "plain")]
pub report_time: Option<ReportTime>,
#[arg(short = 'Z')]
pub nightly: Option<String>,
}
#[derive(Clone, Copy, Debug)]
pub enum Format {
Json,
}
impl FromStr for Format {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
s @ ("pretty" | "terse" | "junit") => {
Err(format!("`{s}` option is not supported yet"))
}
s => Err(format!(
"Unknown option `{s}`, expected `pretty` or `json`",
)),
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum ReportTime {
Plain,
Colored,
}
impl FromStr for ReportTime {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"plain" => Ok(Self::Plain),
"colored" => Ok(Self::Colored),
s => Err(format!(
"Unknown option `{s}`, expected `plain` or `colored`",
)),
}
}
}
#[derive(Debug)]
pub struct Libtest<W, Out: io::Write = io::Stdout> {
output: Out,
events: Vec<parser::Result<Event<event::Cucumber<W>>>>,
parsed_all: bool,
passed: usize,
failed: usize,
retried: usize,
ignored: usize,
parsing_errors: usize,
hook_errors: usize,
features_without_path: usize,
started_at: Option<SystemTime>,
step_started_at: Option<SystemTime>,
}
impl<World, Out: Clone + io::Write> Clone for Libtest<World, Out> {
fn clone(&self) -> Self {
Self {
output: self.output.clone(),
events: self.events.clone(),
parsed_all: self.parsed_all,
passed: self.passed,
failed: self.failed,
retried: self.retried,
ignored: self.ignored,
parsing_errors: self.parsing_errors,
hook_errors: self.hook_errors,
features_without_path: self.features_without_path,
started_at: self.started_at,
step_started_at: self.step_started_at,
}
}
}
impl<W: World + Debug, Out: io::Write> Writer<W> for Libtest<W, Out> {
type Cli = Cli;
async fn handle_event(
&mut self,
event: parser::Result<Event<event::Cucumber<W>>>,
cli: &Self::Cli,
) {
self.handle_cucumber_event(event, cli);
}
}
pub type Or<W, Wr> = writer::Or<
Wr,
Normalize<W, Libtest<W, io::Stdout>>,
fn(
&parser::Result<Event<event::Cucumber<W>>>,
&cli::Compose<<Wr as Writer<W>>::Cli, Cli>,
) -> bool,
>;
pub type OrBasic<W> = Or<W, Summarize<Normalize<W, writer::Basic>>>;
impl<W: Debug + World> Libtest<W, io::Stdout> {
#[must_use]
pub fn stdout() -> Normalize<W, Self> {
Self::new(io::stdout())
}
#[must_use]
pub fn or<AnotherWriter: Writer<W>>(
writer: AnotherWriter,
) -> Or<W, AnotherWriter> {
Or::new(writer, Self::stdout(), |_, cli| {
!matches!(cli.right.format, Some(Format::Json))
})
}
#[must_use]
pub fn or_basic() -> OrBasic<W> {
Self::or(writer::Basic::stdout().summarized())
}
}
impl<W: Debug + World, Out: io::Write> Libtest<W, Out> {
#[must_use]
pub fn new(output: Out) -> Normalize<W, Self> {
Self::raw(output).normalized()
}
#[must_use]
pub const fn raw(output: Out) -> Self {
Self {
output,
events: Vec::new(),
parsed_all: false,
passed: 0,
failed: 0,
retried: 0,
parsing_errors: 0,
hook_errors: 0,
ignored: 0,
features_without_path: 0,
started_at: None,
step_started_at: None,
}
}
fn handle_cucumber_event(
&mut self,
event: parser::Result<Event<event::Cucumber<W>>>,
cli: &Cli,
) {
use event::{Cucumber, Metadata};
let unite = |ev: Result<(Cucumber<W>, Metadata), _>| {
ev.map(|(e, m)| m.insert(e))
};
match (event.map(Event::split), self.parsed_all) {
(event @ Ok((Cucumber::ParsingFinished { .. }, _)), false) => {
self.parsed_all = true;
let all_events =
iter::once(unite(event)).chain(mem::take(&mut self.events));
for ev in all_events {
self.output_event(ev, cli);
}
}
(event, false) => self.events.push(unite(event)),
(event, true) => self.output_event(unite(event), cli),
}
}
fn output_event(
&mut self,
event: parser::Result<Event<event::Cucumber<W>>>,
cli: &Cli,
) {
for ev in self.expand_cucumber_event(event, cli) {
self.output
.write_line(serde_json::to_string(&ev).unwrap_or_else(|e| {
panic!("Failed to serialize `LibTestJsonEvent`: {e}")
}))
.unwrap_or_else(|e| panic!("Failed to write: {e}"));
}
}
fn expand_cucumber_event(
&mut self,
event: parser::Result<Event<event::Cucumber<W>>>,
cli: &Cli,
) -> Vec<LibTestJsonEvent> {
use event::Cucumber;
match event.map(Event::split) {
Ok((Cucumber::Started, meta)) => {
self.started_at = Some(meta.at);
Vec::new()
}
Ok((Cucumber::ParsingFinished { steps, parser_errors, .. }, _)) => {
vec![
SuiteEvent::Started { test_count: steps + parser_errors }
.into(),
]
}
Ok((Cucumber::Finished, meta)) => {
let exec_time = self
.started_at
.and_then(|started| meta.at.duration_since(started).ok())
.as_ref()
.map(Duration::as_secs_f64);
let failed =
self.failed + self.parsing_errors + self.hook_errors;
let results = SuiteResults {
passed: self.passed,
failed,
ignored: self.ignored,
measured: 0,
filtered_out: 0,
exec_time,
};
let ev = if failed == 0 {
SuiteEvent::Ok { results }
} else {
SuiteEvent::Failed { results }
}
.into();
vec![ev]
}
Ok((Cucumber::Feature(feature, ev), meta)) => {
self.expand_feature_event(&feature, ev, meta, cli)
}
Err(e) => {
self.parsing_errors += 1;
let path = match &e {
parser::Error::Parsing(e) => match &**e {
gherkin::ParseFileError::Parsing { path, .. }
| gherkin::ParseFileError::Reading { path, .. } => {
Some(path)
}
},
parser::Error::ExampleExpansion(e) => e.path.as_ref(),
};
let name = path.and_then(|p| p.to_str()).map_or_else(
|| self.parsing_errors.to_string(),
|p| p.escape_default().to_string(),
);
let name = format!("Feature: Parsing {name}");
vec![
TestEvent::started(name.clone()).into(),
TestEvent::failed(name, None)
.with_stdout(e.to_string())
.into(),
]
}
}
}
fn expand_feature_event(
&mut self,
feature: &gherkin::Feature,
ev: event::Feature<W>,
meta: event::Metadata,
cli: &Cli,
) -> Vec<LibTestJsonEvent> {
use event::{Feature, Rule};
match ev {
Feature::Started
| Feature::Finished
| Feature::Rule(_, Rule::Started | Rule::Finished) => Vec::new(),
Feature::Rule(rule, Rule::Scenario(scenario, ev)) => self
.expand_scenario_event(
feature,
Some(&rule),
&scenario,
ev,
meta,
cli,
),
Feature::Scenario(scenario, ev) => self
.expand_scenario_event(feature, None, &scenario, ev, meta, cli),
}
}
fn expand_scenario_event(
&mut self,
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
ev: event::RetryableScenario<W>,
meta: event::Metadata,
cli: &Cli,
) -> Vec<LibTestJsonEvent> {
use event::Scenario;
let retries = ev.retries;
match ev.event {
Scenario::Started | Scenario::Finished => Vec::new(),
Scenario::Hook(ty, ev) => self.expand_hook_event(
feature, rule, scenario, ty, ev, retries, meta, cli,
),
Scenario::Background(step, ev) => self.expand_step_event(
feature, rule, scenario, &step, ev, retries, true, meta, cli,
),
Scenario::Step(step, ev) => self.expand_step_event(
feature, rule, scenario, &step, ev, retries, false, meta, cli,
),
#[expect( // intentional
clippy::print_stdout,
reason = "supporting `libtest` output capturing properly"
)]
Scenario::Log(msg) => {
print!("{msg}");
vec![]
}
}
}
#[expect(clippy::too_many_arguments, reason = "needs refactoring")]
fn expand_hook_event(
&mut self,
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
hook: event::HookType,
ev: event::Hook<W>,
retries: Option<Retries>,
meta: event::Metadata,
cli: &Cli,
) -> Vec<LibTestJsonEvent> {
match ev {
event::Hook::Started => {
self.step_started_at(meta, cli);
Vec::new()
}
event::Hook::Passed => Vec::new(),
event::Hook::Failed(world, info) => {
self.hook_errors += 1;
let name = self.test_case_name(
feature,
rule,
scenario,
Either::Left(hook),
retries,
);
vec![
TestEvent::started(name.clone()).into(),
TestEvent::failed(name, self.step_exec_time(meta, cli))
.with_stdout(format!(
"{}{}",
coerce_error(&info),
world
.map(|w| format!("\n{w:#?}"))
.unwrap_or_default(),
))
.into(),
]
}
}
}
#[expect(clippy::too_many_arguments, reason = "needs refactoring")]
fn expand_step_event(
&mut self,
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
step: &gherkin::Step,
ev: event::Step<W>,
retries: Option<Retries>,
is_background: bool,
meta: event::Metadata,
cli: &Cli,
) -> Vec<LibTestJsonEvent> {
use event::Step;
let name = self.test_case_name(
feature,
rule,
scenario,
Either::Right((step, is_background)),
retries,
);
let ev = match ev {
Step::Started => {
self.step_started_at(meta, cli);
TestEvent::started(name)
}
Step::Passed(_, loc) => {
self.passed += 1;
let event = TestEvent::ok(name, self.step_exec_time(meta, cli));
if cli.show_output {
event.with_stdout(format!(
"{}:{}:{} (defined){}",
feature
.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or(&feature.name),
step.position.line,
step.position.col,
loc.map(|l| format!(
"\n{}:{}:{} (matched)",
l.path, l.line, l.column,
))
.unwrap_or_default()
))
} else {
event
}
}
Step::Skipped => {
self.ignored += 1;
let event =
TestEvent::ignored(name, self.step_exec_time(meta, cli));
if cli.show_output {
event.with_stdout(format!(
"{}:{}:{} (defined)",
feature
.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or(&feature.name),
step.position.line,
step.position.col,
))
} else {
event
}
}
Step::Failed(_, loc, world, err) => {
if retries.is_some_and(|r| {
r.left > 0 && !matches!(err, event::StepError::NotFound)
}) {
self.retried += 1;
} else {
self.failed += 1;
}
TestEvent::failed(name, self.step_exec_time(meta, cli))
.with_stdout(format!(
"{}:{}:{} (defined){}\n{err}{}",
feature
.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or(&feature.name),
step.position.line,
step.position.col,
loc.map(|l| format!(
"\n{}:{}:{} (matched)",
l.path, l.line, l.column,
))
.unwrap_or_default(),
world.map(|w| format!("\n{w:#?}")).unwrap_or_default(),
))
}
};
vec![ev.into()]
}
fn test_case_name(
&mut self,
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
step: Either<event::HookType, (&gherkin::Step, IsBackground)>,
retries: Option<Retries>,
) -> String {
let feature_name = format!(
"{}: {} {}",
feature.keyword,
feature.name,
feature
.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.map_or_else(
|| {
self.features_without_path += 1;
self.features_without_path.to_string()
},
|s| s.escape_default().to_string()
),
);
let rule_name = rule
.as_ref()
.map(|r| format!("{}: {}: {}", r.position.line, r.keyword, r.name));
let scenario_name = format!(
"{}: {}: {}{}",
scenario.position.line,
scenario.keyword,
scenario.name,
retries
.filter(|r| r.current > 0)
.map(|r| format!(
" | Retry attempt {}/{}",
r.current,
r.current + r.left,
))
.unwrap_or_default(),
);
let step_name = match step {
Either::Left(hook) => format!("{hook} hook"),
Either::Right((step, is_bg)) => format!(
"{}: {} {}{}",
step.position.line,
if is_bg {
feature
.background
.as_ref()
.map_or("Background", |bg| bg.keyword.as_str())
} else {
""
},
step.keyword,
step.value,
),
};
[Some(feature_name), rule_name, Some(scenario_name), Some(step_name)]
.into_iter()
.flatten()
.join("::")
}
fn step_started_at(&mut self, meta: event::Metadata, cli: &Cli) {
self.step_started_at =
Some(meta.at).filter(|_| cli.report_time.is_some());
}
fn step_exec_time(
&mut self,
meta: event::Metadata,
cli: &Cli,
) -> Option<Duration> {
let started = self.step_started_at.take()?;
meta.at
.duration_since(started)
.ok()
.filter(|_| cli.report_time.is_some())
}
}
type IsBackground = bool;
impl<W, O: io::Write> writer::NonTransforming for Libtest<W, O> {}
impl<W, O> writer::Stats<W> for Libtest<W, O>
where
O: io::Write,
Self: Writer<W>,
{
fn passed_steps(&self) -> usize {
self.passed
}
fn skipped_steps(&self) -> usize {
self.ignored
}
fn failed_steps(&self) -> usize {
self.failed
}
fn retried_steps(&self) -> usize {
self.retried
}
fn parsing_errors(&self) -> usize {
self.parsing_errors
}
fn hook_errors(&self) -> usize {
self.hook_errors
}
}
impl<W, Val, Out> Arbitrary<W, Val> for Libtest<W, Out>
where
W: World + Debug,
Val: AsRef<str>,
Out: io::Write,
{
async fn write(&mut self, val: Val) {
self.output
.write_line(val.as_ref())
.unwrap_or_else(|e| panic!("failed to write: {e}"));
}
}
#[derive(Clone, Debug, From, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum LibTestJsonEvent {
Suite {
#[serde(flatten)]
event: SuiteEvent,
},
Test {
#[serde(flatten)]
event: TestEvent,
},
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
enum SuiteEvent {
Started {
test_count: usize,
},
Ok {
#[serde(flatten)]
results: SuiteResults,
},
Failed {
#[serde(flatten)]
results: SuiteResults,
},
}
#[derive(Clone, Copy, Debug, Serialize)]
struct SuiteResults {
passed: usize,
failed: usize,
ignored: usize,
measured: usize,
filtered_out: usize,
#[serde(skip_serializing_if = "Option::is_none")]
exec_time: Option<f64>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
enum TestEvent {
Started(TestEventInner),
Ok(TestEventInner),
Failed(TestEventInner),
Ignored(TestEventInner),
Timeout(TestEventInner),
}
impl TestEvent {
const fn started(name: String) -> Self {
Self::Started(TestEventInner::new(name))
}
fn ok(name: String, exec_time: Option<Duration>) -> Self {
Self::Ok(TestEventInner::new(name).with_exec_time(exec_time))
}
fn failed(name: String, exec_time: Option<Duration>) -> Self {
Self::Failed(TestEventInner::new(name).with_exec_time(exec_time))
}
fn ignored(name: String, exec_time: Option<Duration>) -> Self {
Self::Ignored(TestEventInner::new(name).with_exec_time(exec_time))
}
#[expect(dead_code, reason = "API uniformity")]
fn timeout(name: String, exec_time: Option<Duration>) -> Self {
Self::Timeout(TestEventInner::new(name).with_exec_time(exec_time))
}
fn with_stdout(self, mut stdout: String) -> Self {
if !stdout.ends_with('\n') {
stdout.push('\n');
}
match self {
Self::Started(inner) => Self::Started(inner.with_stdout(stdout)),
Self::Ok(inner) => Self::Ok(inner.with_stdout(stdout)),
Self::Failed(inner) => Self::Failed(inner.with_stdout(stdout)),
Self::Ignored(inner) => Self::Ignored(inner.with_stdout(stdout)),
Self::Timeout(inner) => Self::Timeout(inner.with_stdout(stdout)),
}
}
}
#[derive(Clone, Debug, Serialize)]
struct TestEventInner {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
stdout: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stderr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
exec_time: Option<f64>,
}
impl TestEventInner {
const fn new(name: String) -> Self {
Self { name, stdout: None, stderr: None, exec_time: None }
}
fn with_exec_time(mut self, exec_time: Option<Duration>) -> Self {
self.exec_time = exec_time.as_ref().map(Duration::as_secs_f64);
self
}
fn with_stdout(mut self, stdout: String) -> Self {
self.stdout = Some(stdout);
self
}
}