use std::{borrow::Cow, collections::HashMap};
use derive_more::with_trait::Deref;
use itertools::Itertools as _;
use crate::{
Event, World, Writer,
cli::Colored,
event::{self, Retries, Source},
parser,
writer::{self, out::Styles},
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Stats {
pub passed: usize,
pub skipped: usize,
pub failed: usize,
pub retried: usize,
}
impl Stats {
#[must_use]
pub const fn total(&self) -> usize {
self.passed + self.skipped + self.failed
}
}
pub type SkipFn =
fn(&gherkin::Feature, Option<&gherkin::Rule>, &gherkin::Scenario) -> bool;
#[derive(Clone, Copy, Debug)]
enum Indicator {
Failed,
Skipped,
Retried,
}
#[derive(Clone, Copy, Debug)]
enum State {
InProgress,
FinishedButNotOutput,
FinishedAndOutput,
}
#[derive(Clone, Debug, Deref)]
pub struct Summarize<Writer> {
#[deref]
writer: Writer,
features: usize,
rules: usize,
scenarios: Stats,
steps: Stats,
parsing_errors: usize,
failed_hooks: usize,
state: State,
handled_scenarios: HandledScenarios,
}
type HandledScenarios = HashMap<
(
Source<gherkin::Feature>,
Option<Source<gherkin::Rule>>,
Source<gherkin::Scenario>,
),
Indicator,
>;
impl<W, Wr> Writer<W> for Summarize<Wr>
where
W: World,
Wr: writer::Arbitrary<W, String> + Summarizable,
Wr::Cli: Colored,
{
type Cli = Wr::Cli;
async fn handle_event(
&mut self,
event: parser::Result<Event<event::Cucumber<W>>>,
cli: &Self::Cli,
) {
use event::{Cucumber, Feature, Rule};
if matches!(self.state, State::InProgress) {
match event.as_deref() {
Err(_) => self.parsing_errors += 1,
Ok(Cucumber::Feature(feat, ev)) => match ev {
Feature::Started => self.features += 1,
Feature::Rule(_, Rule::Started) => {
self.rules += 1;
}
Feature::Rule(rule, Rule::Scenario(sc, ev)) => {
self.handle_scenario(
feat.clone(),
Some(rule.clone()),
sc.clone(),
ev,
);
}
Feature::Scenario(sc, ev) => {
self.handle_scenario(
feat.clone(),
None,
sc.clone(),
ev,
);
}
Feature::Finished | Feature::Rule(..) => {}
},
Ok(Cucumber::Finished) => {
self.state = State::FinishedButNotOutput;
}
Ok(Cucumber::Started | Cucumber::ParsingFinished { .. }) => {}
}
}
self.writer.handle_event(event, cli).await;
if matches!(self.state, State::FinishedButNotOutput) {
self.state = State::FinishedAndOutput;
let mut styles = Styles::new();
styles.apply_coloring(cli.coloring());
self.writer.write(styles.summary(self)).await;
}
}
}
#[warn(clippy::missing_trait_methods)]
impl<W, Wr, Val> writer::Arbitrary<W, Val> for Summarize<Wr>
where
W: World,
Self: Writer<W>,
Wr: writer::Arbitrary<W, Val>,
{
async fn write(&mut self, val: Val) {
self.writer.write(val).await;
}
}
impl<W, Wr> writer::Stats<W> for Summarize<Wr>
where
W: World,
Self: Writer<W>,
{
fn passed_steps(&self) -> usize {
self.steps.passed
}
fn skipped_steps(&self) -> usize {
self.steps.skipped
}
fn failed_steps(&self) -> usize {
self.steps.failed
}
fn retried_steps(&self) -> usize {
self.steps.retried
}
fn parsing_errors(&self) -> usize {
self.parsing_errors
}
fn hook_errors(&self) -> usize {
self.failed_hooks
}
}
#[warn(clippy::missing_trait_methods)]
impl<Wr: writer::Normalized> writer::Normalized for Summarize<Wr> {}
#[warn(clippy::missing_trait_methods)]
impl<Wr: writer::NonTransforming> writer::NonTransforming for Summarize<Wr> {}
impl<Writer> From<Writer> for Summarize<Writer> {
fn from(writer: Writer) -> Self {
Self {
writer,
features: 0,
rules: 0,
scenarios: Stats { passed: 0, skipped: 0, failed: 0, retried: 0 },
steps: Stats { passed: 0, skipped: 0, failed: 0, retried: 0 },
parsing_errors: 0,
failed_hooks: 0,
state: State::InProgress,
handled_scenarios: HashMap::new(),
}
}
}
impl<Writer> Summarize<Writer> {
#[must_use]
pub fn new(writer: Writer) -> Self {
Self::from(writer)
}
#[must_use]
pub const fn inner_writer(&self) -> &Writer {
&self.writer
}
#[must_use]
pub const fn scenarios_stats(&self) -> &Stats {
&self.scenarios
}
#[must_use]
pub const fn steps_stats(&self) -> &Stats {
&self.steps
}
fn handle_step<W>(
&mut self,
feature: Source<gherkin::Feature>,
rule: Option<Source<gherkin::Rule>>,
scenario: Source<gherkin::Scenario>,
step: &gherkin::Step,
ev: &event::Step<W>,
retries: Option<Retries>,
) {
use self::{
Indicator::{Failed, Retried, Skipped},
event::Step,
};
match ev {
Step::Started => {}
Step::Passed(..) => {
self.steps.passed += 1;
if scenario.steps.last().as_ref().is_some_and(|s| *s == step) {
_ = self
.handled_scenarios
.remove(&(feature, rule, scenario));
}
}
Step::Skipped => {
self.steps.skipped += 1;
self.scenarios.skipped += 1;
_ = self
.handled_scenarios
.insert((feature, rule, scenario), Skipped);
}
Step::Failed(_, _, _, err) => {
if retries.as_ref().is_some_and(|r| {
r.left > 0 && !matches!(err, event::StepError::NotFound)
}) {
self.steps.retried += 1;
let inserted_before = self
.handled_scenarios
.insert((feature, rule, scenario), Retried);
if inserted_before.is_none() {
self.scenarios.retried += 1;
}
} else {
self.steps.failed += 1;
self.scenarios.failed += 1;
_ = self
.handled_scenarios
.insert((feature, rule, scenario), Failed);
}
}
}
}
fn handle_scenario<W>(
&mut self,
feature: Source<gherkin::Feature>,
rule: Option<Source<gherkin::Rule>>,
scenario: Source<gherkin::Scenario>,
ev: &event::RetryableScenario<W>,
) {
use event::{Hook, Scenario};
let path = (feature, rule, scenario);
let ret = ev.retries;
match &ev.event {
Scenario::Started
| Scenario::Hook(_, Hook::Passed | Hook::Started)
| Scenario::Log(_) => {}
Scenario::Hook(_, Hook::Failed(..)) => {
match self.handled_scenarios.get(&path) {
Some(Indicator::Failed | Indicator::Retried) => {}
Some(Indicator::Skipped) => {
self.scenarios.skipped -= 1;
self.scenarios.failed += 1;
}
None => {
self.scenarios.failed += 1;
_ = self
.handled_scenarios
.insert(path, Indicator::Failed);
}
}
self.failed_hooks += 1;
}
Scenario::Background(st, ev) | Scenario::Step(st, ev) => {
self.handle_step(path.0, path.1, path.2, st.as_ref(), ev, ret);
}
Scenario::Finished => {
let is_retried = self
.handled_scenarios
.get(&path)
.is_some_and(|ind| matches!(ind, Indicator::Retried));
if !is_retried && self.handled_scenarios.remove(&path).is_none()
{
self.scenarios.passed += 1;
}
}
}
}
}
pub trait Summarizable {}
impl<T: writer::NonTransforming> Summarizable for T {}
#[expect( // related to summarization only
clippy::multiple_inherent_impl,
reason = "related to summarization only"
)]
impl Styles {
#[must_use]
pub fn summary<W>(&self, summary: &Summarize<W>) -> String {
let features = self.maybe_plural("feature", summary.features);
let rules = if summary.rules > 0 {
format!("{}\n", self.maybe_plural("rule", summary.rules))
} else {
String::new()
};
let scenarios =
self.maybe_plural("scenario", summary.scenarios.total());
let scenarios_stats = self.format_stats(summary.scenarios);
let steps = self.maybe_plural("step", summary.steps.total());
let steps_stats = self.format_stats(summary.steps);
let parsing_errors = if summary.parsing_errors > 0 {
self.err(self.maybe_plural("parsing error", summary.parsing_errors))
} else {
"".into()
};
let hook_errors = if summary.failed_hooks > 0 {
self.err(self.maybe_plural("hook error", summary.failed_hooks))
} else {
"".into()
};
let comma = if !parsing_errors.is_empty() && !hook_errors.is_empty() {
self.err(", ")
} else {
"".into()
};
format!(
"{summary}\n{features}\n{rules}{scenarios}{scenarios_stats}\n\
{steps}{steps_stats}\n{parsing_errors}{comma}{hook_errors}",
summary = self.bold(self.header("[Summary]")),
)
.trim_end_matches('\n')
.to_owned()
}
#[must_use]
pub fn format_stats(&self, stats: Stats) -> Cow<'static, str> {
let mut formatted = [
if stats.passed > 0 {
self.bold(self.ok(format!("{} passed", stats.passed)))
} else {
"".into()
},
if stats.skipped > 0 {
self.bold(self.skipped(format!("{} skipped", stats.skipped)))
} else {
"".into()
},
if stats.failed > 0 {
self.bold(self.err(format!("{} failed", stats.failed)))
} else {
"".into()
},
]
.into_iter()
.filter(|s| !s.is_empty())
.join(&self.bold(", "));
if stats.retried > 0 {
formatted.push_str(" with ");
formatted.push_str(&self.bold(self.retry(format!(
"{} retr{}",
stats.retried,
if stats.retried == 1 { "y" } else { "ies" },
))));
}
if formatted.is_empty() {
"".into()
} else {
self.bold(format!(
" {}{formatted}{}",
self.bold("("),
self.bold(")"),
))
}
}
fn maybe_plural(
&self,
singular: impl Into<Cow<'static, str>>,
num: usize,
) -> Cow<'static, str> {
self.bold(format!(
"{num} {}{}",
singular.into(),
if num == 1 { "" } else { "s" },
))
}
}