use std::{
borrow::Cow,
cmp, env,
fmt::{Debug, Display},
io,
str::FromStr,
};
use async_trait::async_trait;
use derive_more::{Deref, DerefMut};
use itertools::Itertools as _;
use once_cell::sync::Lazy;
use regex::CaptureLocations;
use smart_default::SmartDefault;
use crate::{
cli::Colored,
event::{self, Info, Retries},
parser, step,
writer::{
self,
out::{Styles, WriteStrExt as _},
Ext as _, Verbosity,
},
Event, World, Writer,
};
#[derive(clap::Args, Clone, Copy, Debug, SmartDefault)]
#[group(skip)]
pub struct Cli {
#[arg(short, action = clap::ArgAction::Count, global = true)]
pub verbose: u8,
#[arg(
long,
value_name = "auto|always|never",
default_value = "auto",
global = true
)]
#[default(Coloring::Auto)]
pub color: Coloring,
}
impl Colored for Cli {
fn coloring(&self) -> Coloring {
self.color
}
}
#[derive(Clone, Copy, Debug)]
pub enum Coloring {
Auto,
Always,
Never,
}
impl FromStr for Coloring {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"always" => Ok(Self::Always),
"never" => Ok(Self::Never),
_ => Err("possible options: auto, always, never"),
}
}
}
#[derive(Clone, Debug, Deref, DerefMut)]
pub struct Basic<Out: io::Write = io::Stdout> {
#[deref]
#[deref_mut]
output: Out,
styles: Styles,
indent: usize,
lines_to_clear: usize,
verbosity: Verbosity,
}
#[async_trait(?Send)]
impl<W, Out> Writer<W> for Basic<Out>
where
W: World + Debug,
Out: io::Write,
{
type Cli = Cli;
async fn handle_event(
&mut self,
ev: parser::Result<Event<event::Cucumber<W>>>,
opts: &Self::Cli,
) {
use event::{Cucumber, Feature};
self.apply_cli(*opts);
match ev.map(Event::into_inner) {
Err(err) => self.parsing_failed(&err),
Ok(
Cucumber::Started
| Cucumber::ParsingFinished { .. }
| Cucumber::Finished,
) => Ok(()),
Ok(Cucumber::Feature(f, ev)) => match ev {
Feature::Started => self.feature_started(&f),
Feature::Scenario(sc, ev) => self.scenario(&f, &sc, &ev),
Feature::Rule(r, ev) => self.rule(&f, &r, ev),
Feature::Finished => Ok(()),
},
}
.unwrap_or_else(|e| panic!("Failed to write into terminal: {e}"));
}
}
#[async_trait(?Send)]
impl<'val, W, Val, Out> writer::Arbitrary<'val, W, Val> for Basic<Out>
where
W: World + Debug,
Val: AsRef<str> + 'val,
Out: io::Write,
{
async fn write(&mut self, val: Val)
where
'val: 'async_trait,
{
self.write_line(val.as_ref())
.unwrap_or_else(|e| panic!("Failed to write: {e}"));
}
}
impl<O: io::Write> writer::NonTransforming for Basic<O> {}
impl Basic {
#[must_use]
pub fn stdout<W>() -> writer::Normalize<W, Self> {
Self::new(io::stdout(), Coloring::Auto, Verbosity::Default)
}
}
impl<Out: io::Write> Basic<Out> {
#[must_use]
pub fn new<W>(
output: Out,
color: Coloring,
verbosity: impl Into<Verbosity>,
) -> writer::Normalize<W, Self> {
Self::raw(output, color, verbosity).normalized()
}
#[must_use]
pub fn raw(
output: Out,
color: Coloring,
verbosity: impl Into<Verbosity>,
) -> Self {
let mut basic = Self {
output,
styles: Styles::new(),
indent: 0,
lines_to_clear: 0,
verbosity: verbosity.into(),
};
basic.apply_cli(Cli {
verbose: u8::from(basic.verbosity) + 1,
color,
});
basic
}
pub fn apply_cli(&mut self, cli: Cli) {
match cli.verbose {
0 => {}
1 => self.verbosity = Verbosity::Default,
2 => self.verbosity = Verbosity::ShowWorld,
_ => self.verbosity = Verbosity::ShowWorldAndDocString,
};
self.styles.apply_coloring(cli.color);
}
fn clear_last_lines_if_term_present(&mut self) -> io::Result<()> {
if self.styles.is_present && self.lines_to_clear > 0 {
self.output.clear_last_lines(self.lines_to_clear)?;
self.lines_to_clear = 0;
}
Ok(())
}
pub(crate) fn parsing_failed(
&mut self,
error: impl Display,
) -> io::Result<()> {
self.output
.write_line(&self.styles.err(format!("Failed to parse: {error}")))
}
pub(crate) fn feature_started(
&mut self,
feature: &gherkin::Feature,
) -> io::Result<()> {
self.lines_to_clear = 1;
self.output.write_line(
&self
.styles
.ok(format!("{}: {}", feature.keyword, feature.name)),
)
}
pub(crate) fn rule<W: Debug>(
&mut self,
feat: &gherkin::Feature,
rule: &gherkin::Rule,
ev: event::Rule<W>,
) -> io::Result<()> {
use event::Rule;
match ev {
Rule::Started => {
self.rule_started(rule)?;
}
Rule::Scenario(sc, ev) => {
self.scenario(feat, &sc, &ev)?;
}
Rule::Finished => {
self.indent = self.indent.saturating_sub(2);
}
}
Ok(())
}
pub(crate) fn rule_started(
&mut self,
rule: &gherkin::Rule,
) -> io::Result<()> {
self.lines_to_clear = 1;
self.indent += 2;
self.output.write_line(&self.styles.ok(format!(
"{indent}{}: {}",
rule.keyword,
rule.name,
indent = " ".repeat(self.indent)
)))
}
pub(crate) fn scenario<W: Debug>(
&mut self,
feat: &gherkin::Feature,
scenario: &gherkin::Scenario,
ev: &event::RetryableScenario<W>,
) -> io::Result<()> {
use event::{Hook, Scenario};
let retries = ev.retries;
match &ev.event {
Scenario::Started => {
self.scenario_started(scenario, retries)?;
}
Scenario::Hook(_, Hook::Started) => {
self.indent += 4;
}
Scenario::Hook(which, Hook::Failed(world, info)) => {
self.hook_failed(
feat,
scenario,
*which,
retries,
world.as_ref(),
info,
)?;
self.indent = self.indent.saturating_sub(4);
}
Scenario::Hook(_, Hook::Passed) => {
self.indent = self.indent.saturating_sub(4);
}
Scenario::Background(bg, ev) => {
self.background(feat, scenario, bg, ev, retries)?;
}
Scenario::Step(st, ev) => {
self.step(feat, scenario, st, ev, retries)?;
}
Scenario::Finished => {
self.indent = self.indent.saturating_sub(2);
}
}
Ok(())
}
pub(crate) fn hook_failed<W: Debug>(
&mut self,
feat: &gherkin::Feature,
sc: &gherkin::Scenario,
which: event::HookType,
retries: Option<Retries>,
world: Option<&W>,
info: &Info,
) -> io::Result<()> {
self.clear_last_lines_if_term_present()?;
let style = |s| {
if retries.filter(|r| r.left > 0).is_some() {
self.styles.bright().retry(s)
} else {
self.styles.err(s)
}
};
self.output.write_line(&style(format!(
"{indent}✘ Scenario's {which} hook failed {}:{}:{}\n\
{indent} Captured output: {}{}",
feat.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or(&feat.name),
sc.position.line,
sc.position.col,
format_str_with_indent(
coerce_error(info),
self.indent.saturating_sub(3) + 3
),
world
.map(|w| format_str_with_indent(
format!("{w:#?}"),
self.indent.saturating_sub(3) + 3,
))
.unwrap_or_default(),
indent = " ".repeat(self.indent.saturating_sub(3)),
)))
}
pub(crate) fn scenario_started(
&mut self,
scenario: &gherkin::Scenario,
retries: Option<Retries>,
) -> io::Result<()> {
self.lines_to_clear = 1;
self.indent += 2;
if let Some(retries) = retries.filter(|r| r.current > 0) {
self.output.write_line(&self.styles.retry(format!(
"{}{}: {} | Retry attempt: {}/{}",
" ".repeat(self.indent),
scenario.keyword,
scenario.name,
retries.current,
retries.left + retries.current,
)))
} else {
self.output.write_line(&self.styles.ok(format!(
"{}{}: {}",
" ".repeat(self.indent),
scenario.keyword,
scenario.name,
)))
}
}
pub(crate) fn step<W: Debug>(
&mut self,
feat: &gherkin::Feature,
sc: &gherkin::Scenario,
step: &gherkin::Step,
ev: &event::Step<W>,
retries: Option<Retries>,
) -> io::Result<()> {
use event::Step;
match ev {
Step::Started => {
self.step_started(step)?;
}
Step::Passed(captures, _) => {
self.step_passed(sc, step, captures, retries)?;
self.indent = self.indent.saturating_sub(4);
}
Step::Skipped => {
self.step_skipped(feat, step)?;
self.indent = self.indent.saturating_sub(4);
}
Step::Failed(c, loc, w, i) => {
self.step_failed(
feat,
step,
c.as_ref(),
*loc,
retries,
w.as_ref(),
i,
)?;
self.indent = self.indent.saturating_sub(4);
}
}
Ok(())
}
pub(crate) fn step_started(
&mut self,
step: &gherkin::Step,
) -> io::Result<()> {
self.indent += 4;
if self.styles.is_present {
let output = format!(
"{indent}{}{}{}{}",
step.keyword,
step.value,
step.docstring
.as_ref()
.and_then(|doc| self.verbosity.shows_docstring().then(
|| {
format_str_with_indent(
doc,
self.indent.saturating_sub(3) + 3,
)
}
))
.unwrap_or_default(),
step.table
.as_ref()
.map(|t| format_table(t, self.indent))
.unwrap_or_default(),
indent = " ".repeat(self.indent),
);
self.lines_to_clear = output.lines().count();
self.write_line(&output)?;
}
Ok(())
}
pub(crate) fn step_passed(
&mut self,
scenario: &gherkin::Scenario,
step: &gherkin::Step,
captures: &CaptureLocations,
retries: Option<Retries>,
) -> io::Result<()> {
self.clear_last_lines_if_term_present()?;
let style = |s| {
if retries.filter(|r| r.current > 0).is_some()
&& scenario.steps.last().filter(|st| *st != step).is_some()
{
self.styles.retry(s)
} else {
self.styles.ok(s)
}
};
let step_keyword = style(format!("✔ {}", step.keyword));
let step_value = format_captures(
&step.value,
captures,
|v| style(v.to_owned()),
|v| style(self.styles.bold(v).to_string()),
);
let doc_str = style(
step.docstring
.as_ref()
.and_then(|doc| {
self.verbosity.shows_docstring().then(|| {
format_str_with_indent(
doc,
self.indent.saturating_sub(3) + 3,
)
})
})
.unwrap_or_default(),
);
let step_table = style(
step.table
.as_ref()
.map(|t| format_table(t, self.indent))
.unwrap_or_default(),
);
self.output.write_line(&style(format!(
"{indent}{step_keyword}{step_value}{doc_str}{step_table}",
indent = " ".repeat(self.indent.saturating_sub(3)),
)))
}
pub(crate) fn step_skipped(
&mut self,
feat: &gherkin::Feature,
step: &gherkin::Step,
) -> io::Result<()> {
self.clear_last_lines_if_term_present()?;
self.output.write_line(&self.styles.skipped(format!(
"{indent}? {}{}{}{}\n\
{indent} Step skipped: {}:{}:{}",
step.keyword,
step.value,
step.docstring
.as_ref()
.and_then(|doc| self.verbosity.shows_docstring().then(|| {
format_str_with_indent(
doc,
self.indent.saturating_sub(3) + 3,
)
}))
.unwrap_or_default(),
step.table
.as_ref()
.map(|t| format_table(t, self.indent))
.unwrap_or_default(),
feat.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or(&feat.name),
step.position.line,
step.position.col,
indent = " ".repeat(self.indent.saturating_sub(3)),
)))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn step_failed<W: Debug>(
&mut self,
feat: &gherkin::Feature,
step: &gherkin::Step,
captures: Option<&CaptureLocations>,
loc: Option<step::Location>,
retries: Option<Retries>,
world: Option<&W>,
err: &event::StepError,
) -> io::Result<()> {
self.clear_last_lines_if_term_present()?;
let style = |s| {
if retries
.filter(|r| {
r.left > 0 && !matches!(err, event::StepError::NotFound)
})
.is_some()
{
self.styles.bright().retry(s)
} else {
self.styles.err(s)
}
};
let indent = " ".repeat(self.indent.saturating_sub(3));
let step_keyword = style(format!("{indent}✘ {}", step.keyword));
let step_value = captures.map_or_else(
|| style(step.value.clone()),
|capts| {
format_captures(
&step.value,
capts,
|v| style(v.to_owned()),
|v| style(self.styles.bold(v).to_string()),
)
.into()
},
);
let diagnostics = style(format!(
"{}{}\n\
{indent} Step failed:\n\
{indent} Defined: {}:{}:{}{}{}{}",
step.docstring
.as_ref()
.and_then(|doc| self.verbosity.shows_docstring().then(|| {
format_str_with_indent(
doc,
self.indent.saturating_sub(3) + 3,
)
}))
.unwrap_or_default(),
step.table
.as_ref()
.map(|t| format_table(t, self.indent))
.unwrap_or_default(),
feat.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or(&feat.name),
step.position.line,
step.position.col,
loc.map(|l| format!(
"\n{indent} Matched: {}:{}:{}",
l.path, l.line, l.column,
))
.unwrap_or_default(),
format_str_with_indent(
err.to_string(),
self.indent.saturating_sub(3) + 3,
),
world
.map(|w| format_str_with_indent(
format!("{w:#?}"),
self.indent.saturating_sub(3) + 3,
))
.filter(|_| self.verbosity.shows_world())
.unwrap_or_default(),
));
self.write_line(&format!("{step_keyword}{step_value}{diagnostics}"))
}
pub(crate) fn background<W: Debug>(
&mut self,
feat: &gherkin::Feature,
sc: &gherkin::Scenario,
bg: &gherkin::Step,
ev: &event::Step<W>,
retries: Option<Retries>,
) -> io::Result<()> {
use event::Step;
match ev {
Step::Started => {
self.bg_step_started(bg)?;
}
Step::Passed(captures, _) => {
self.bg_step_passed(sc, bg, captures, retries)?;
self.indent = self.indent.saturating_sub(4);
}
Step::Skipped => {
self.bg_step_skipped(feat, bg)?;
self.indent = self.indent.saturating_sub(4);
}
Step::Failed(c, loc, w, i) => {
self.bg_step_failed(
feat,
bg,
c.as_ref(),
*loc,
retries,
w.as_ref(),
i,
)?;
self.indent = self.indent.saturating_sub(4);
}
}
Ok(())
}
pub(crate) fn bg_step_started(
&mut self,
step: &gherkin::Step,
) -> io::Result<()> {
self.indent += 4;
if self.styles.is_present {
let output = format!(
"{indent}> {}{}{}{}",
step.keyword,
step.value,
step.docstring
.as_ref()
.and_then(|doc| self.verbosity.shows_docstring().then(
|| {
format_str_with_indent(
doc,
self.indent.saturating_sub(3) + 3,
)
}
))
.unwrap_or_default(),
step.table
.as_ref()
.map(|t| format_table(t, self.indent))
.unwrap_or_default(),
indent = " ".repeat(self.indent.saturating_sub(2)),
);
self.lines_to_clear = output.lines().count();
self.write_line(&output)?;
}
Ok(())
}
pub(crate) fn bg_step_passed(
&mut self,
scenario: &gherkin::Scenario,
step: &gherkin::Step,
captures: &CaptureLocations,
retries: Option<Retries>,
) -> io::Result<()> {
self.clear_last_lines_if_term_present()?;
let style = |s| {
if retries.filter(|r| r.current > 0).is_some()
&& scenario.steps.last().filter(|st| *st != step).is_some()
{
self.styles.retry(s)
} else {
self.styles.ok(s)
}
};
let indent = " ".repeat(self.indent.saturating_sub(3));
let step_keyword = style(format!("{indent}✔> {}", step.keyword));
let step_value = format_captures(
&step.value,
captures,
|v| style(v.to_owned()),
|v| style(self.styles.bold(v).to_string()),
);
let doc_str = style(
step.docstring
.as_ref()
.and_then(|doc| {
self.verbosity.shows_docstring().then(|| {
format_str_with_indent(
doc,
self.indent.saturating_sub(3) + 3,
)
})
})
.unwrap_or_default(),
);
let step_table = style(
step.table
.as_ref()
.map(|t| format_table(t, self.indent))
.unwrap_or_default(),
);
self.output.write_line(&style(format!(
"{step_keyword}{step_value}{doc_str}{step_table}",
)))
}
pub(crate) fn bg_step_skipped(
&mut self,
feat: &gherkin::Feature,
step: &gherkin::Step,
) -> io::Result<()> {
self.clear_last_lines_if_term_present()?;
self.output.write_line(&self.styles.skipped(format!(
"{indent}?> {}{}{}{}\n\
{indent} Background step failed: {}:{}:{}",
step.keyword,
step.value,
step.docstring
.as_ref()
.and_then(|doc| self.verbosity.shows_docstring().then(|| {
format_str_with_indent(
doc,
self.indent.saturating_sub(3) + 3,
)
}))
.unwrap_or_default(),
step.table
.as_ref()
.map(|t| format_table(t, self.indent))
.unwrap_or_default(),
feat.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or(&feat.name),
step.position.line,
step.position.col,
indent = " ".repeat(self.indent.saturating_sub(3)),
)))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn bg_step_failed<W: Debug>(
&mut self,
feat: &gherkin::Feature,
step: &gherkin::Step,
captures: Option<&CaptureLocations>,
loc: Option<step::Location>,
retries: Option<Retries>,
world: Option<&W>,
err: &event::StepError,
) -> io::Result<()> {
self.clear_last_lines_if_term_present()?;
let style = |s| {
if retries
.filter(|r| {
r.left > 0 && !matches!(err, event::StepError::NotFound)
})
.is_some()
{
self.styles.bright().retry(s)
} else {
self.styles.err(s)
}
};
let indent = " ".repeat(self.indent.saturating_sub(3));
let step_keyword = style(format!("{indent}✘> {}", step.keyword));
let step_value = captures.map_or_else(
|| style(step.value.clone()),
|capts| {
format_captures(
&step.value,
capts,
|v| style(v.to_owned()),
|v| style(self.styles.bold(v).to_string()),
)
.into()
},
);
let diagnostics = style(format!(
"{}{}\n\
{indent} Step failed:\n\
{indent} Defined: {}:{}:{}{}{}{}",
step.docstring
.as_ref()
.and_then(|doc| self.verbosity.shows_docstring().then(|| {
format_str_with_indent(
doc,
self.indent.saturating_sub(3) + 3,
)
}))
.unwrap_or_default(),
step.table
.as_ref()
.map(|t| format_table(t, self.indent))
.unwrap_or_default(),
feat.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or(&feat.name),
step.position.line,
step.position.col,
loc.map(|l| format!(
"\n{indent} Matched: {}:{}:{}",
l.path, l.line, l.column,
))
.unwrap_or_default(),
format_str_with_indent(
err.to_string(),
self.indent.saturating_sub(3) + 3,
),
world
.map(|w| format_str_with_indent(
format!("{w:#?}"),
self.indent.saturating_sub(3) + 3,
))
.unwrap_or_default(),
));
self.write_line(&format!("{step_keyword}{step_value}{diagnostics}"))
}
}
#[must_use]
pub(crate) fn coerce_error(err: &Info) -> Cow<'static, str> {
err.downcast_ref::<String>()
.map(|s| s.clone().into())
.or_else(|| err.downcast_ref::<&str>().map(|s| s.to_owned().into()))
.unwrap_or_else(|| "(Could not resolve panic payload)".into())
}
fn format_str_with_indent(str: impl AsRef<str>, indent: usize) -> String {
let str = str
.as_ref()
.lines()
.map(|line| format!("{}{line}", " ".repeat(indent)))
.join("\n");
(!str.is_empty())
.then(|| format!("\n{str}"))
.unwrap_or_default()
}
fn format_table(table: &gherkin::Table, indent: usize) -> String {
let max_row_len = table
.rows
.iter()
.fold(None, |mut acc: Option<Vec<_>>, row| {
#[allow(clippy::option_if_let_else)]
if let Some(existing_len) = acc.as_mut() {
for (cell, max_len) in row.iter().zip(existing_len) {
*max_len = cmp::max(*max_len, cell.len());
}
} else {
acc = Some(row.iter().map(String::len).collect::<Vec<_>>());
}
acc
})
.unwrap_or_default();
let mut table = table
.rows
.iter()
.map(|row| {
row.iter()
.zip(&max_row_len)
.map(|(cell, len)| format!("| {cell:len$} "))
.collect::<String>()
})
.map(|row| format!("{}{row}", " ".repeat(indent + 1)))
.join("|\n");
if !table.is_empty() {
table.insert(0, '\n');
table.push('|');
}
table
}
fn format_captures<D, A>(
value: impl AsRef<str>,
captures: &CaptureLocations,
default: D,
accent: A,
) -> String
where
D: for<'a> Fn(&'a str) -> Cow<'a, str>,
A: for<'a> Fn(&'a str) -> Cow<'a, str>,
{
#![allow(clippy::string_slice)]
let value = value.as_ref();
let (mut formatted, end) = (1..captures.len())
.filter_map(|group| captures.get(group))
.fold(
(String::with_capacity(value.len()), 0),
|(mut str, old), (start, end)| {
if old > start {
return (str, old);
}
str.push_str(&default(&value[old..start]));
str.push_str(&accent(&value[start..end]));
(str, end)
},
);
formatted.push_str(&default(&value[end..value.len()]));
formatted
}
pub(crate) fn trim_path(path: &str) -> &str {
static CURRENT_DIR: Lazy<String> = Lazy::new(|| {
env::var("CARGO_WORKSPACE_DIR")
.or_else(|_| env::var("CARGO_MANIFEST_DIR"))
.unwrap_or_else(|_| {
env::current_dir()
.map(|path| path.display().to_string())
.unwrap_or_default()
})
});
path.trim_start_matches(&**CURRENT_DIR)
.trim_start_matches('/')
.trim_start_matches('\\')
}