use std::{fmt::Debug, io, mem, sync::LazyLock, time::SystemTime};
use base64::Engine as _;
use derive_more::with_trait::Display;
use inflector::Inflector as _;
use mime::Mime;
use serde::Serialize;
use serde_with::{DisplayFromStr, serde_as};
use crate::{
Event, World, Writer, cli, event,
feature::ExpandExamplesError,
parser,
writer::{
self, Ext as _,
basic::{coerce_error, trim_path},
discard,
},
};
#[derive(Clone, Debug)]
pub struct Json<Out: io::Write> {
output: Out,
features: Vec<Feature>,
started: Option<SystemTime>,
logs: Vec<String>,
}
impl<W: World + Debug, Out: io::Write> Writer<W> for Json<Out> {
type Cli = cli::Empty;
async fn handle_event(
&mut self,
event: parser::Result<Event<event::Cucumber<W>>>,
_: &Self::Cli,
) {
use event::{Cucumber, Rule};
match event.map(Event::split) {
Err(parser::Error::Parsing(e)) => {
let feature = Feature::parsing_err(&e);
self.features.push(feature);
}
Err(parser::Error::ExampleExpansion(e)) => {
let feature = Feature::example_expansion_err(&e);
self.features.push(feature);
}
Ok((
Cucumber::Feature(f, event::Feature::Scenario(sc, ev)),
meta,
)) => {
self.handle_scenario_event(&f, None, &sc, ev.event, meta);
}
Ok((
Cucumber::Feature(
f,
event::Feature::Rule(r, Rule::Scenario(sc, ev)),
),
meta,
)) => {
self.handle_scenario_event(&f, Some(&r), &sc, ev.event, meta);
}
Ok((Cucumber::Finished, _)) => {
self.output
.write_all(
serde_json::to_string(&self.features)
.unwrap_or_else(|e| {
panic!("Failed to serialize JSON: {e}")
})
.as_bytes(),
)
.unwrap_or_else(|e| panic!("Failed to write JSON: {e}"));
}
_ => {}
}
}
}
impl<O: io::Write> writer::NonTransforming for Json<O> {}
impl<Out: io::Write> Json<Out> {
#[must_use]
pub fn new<W: Debug + World>(output: Out) -> writer::Normalize<W, Self> {
Self::raw(output).normalized()
}
#[must_use]
pub fn for_tee(output: Out) -> discard::Arbitrary<discard::Stats<Self>> {
Self::raw(output).discard_stats_writes().discard_arbitrary_writes()
}
#[must_use]
pub const fn raw(output: Out) -> Self {
Self { output, features: vec![], started: None, logs: vec![] }
}
fn handle_scenario_event<W>(
&mut self,
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
ev: event::Scenario<W>,
meta: event::Metadata,
) {
use event::Scenario;
match ev {
Scenario::Started => {}
Scenario::Hook(ty, ev) => {
self.handle_hook_event(feature, rule, scenario, ty, ev, meta);
}
Scenario::Background(st, ev) => {
self.handle_step_event(
feature,
rule,
scenario,
"background",
&st,
ev,
meta,
);
}
Scenario::Step(st, ev) => {
self.handle_step_event(
feature, rule, scenario, "scenario", &st, ev, meta,
);
}
Scenario::Log(msg) => {
self.logs.push(msg);
}
Scenario::Finished => {
self.logs.clear();
}
}
}
fn handle_hook_event<W>(
&mut self,
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
hook_ty: event::HookType,
event: event::Hook<W>,
meta: event::Metadata,
) {
use event::{Hook, HookType};
let mut duration = || {
let started = self.started.take().unwrap_or_else(|| {
panic!("no `Started` event for `{hook_ty} Hook`")
});
meta.at
.duration_since(started)
.unwrap_or_else(|e| {
panic!(
"Failed to compute duration between {:?} and \
{started:?}: {e}",
meta.at,
);
})
.as_nanos()
};
let res = match event {
Hook::Started => {
self.started = Some(meta.at);
return;
}
Hook::Passed => HookResult {
result: RunResult {
status: Status::Passed,
duration: duration(),
error_message: None,
},
embeddings: mem::take(&mut self.logs)
.into_iter()
.map(Embedding::from_log)
.collect(),
},
Hook::Failed(_, info) => HookResult {
result: RunResult {
status: Status::Failed,
duration: duration(),
error_message: Some(coerce_error(&info).into_owned()),
},
embeddings: mem::take(&mut self.logs)
.into_iter()
.map(Embedding::from_log)
.collect(),
},
};
let el =
self.mut_or_insert_element(feature, rule, scenario, "scenario");
match hook_ty {
HookType::Before => el.before.push(res),
HookType::After => el.after.push(res),
}
}
#[expect(clippy::too_many_arguments, reason = "needs refactoring")]
fn handle_step_event<W>(
&mut self,
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
ty: &'static str,
step: &gherkin::Step,
event: event::Step<W>,
meta: event::Metadata,
) {
let mut duration = || {
let started = self.started.take().unwrap_or_else(|| {
panic!("no `Started` event for `Step` '{}'", step.value)
});
meta.at
.duration_since(started)
.unwrap_or_else(|e| {
panic!(
"failed to compute duration between {:?} and \
{started:?}: {e}",
meta.at,
);
})
.as_nanos()
};
let result = match event {
event::Step::Started => {
self.started = Some(meta.at);
_ = self.mut_or_insert_element(feature, rule, scenario, ty);
return;
}
event::Step::Passed(..) => RunResult {
status: Status::Passed,
duration: duration(),
error_message: None,
},
event::Step::Failed(_, loc, _, err) => {
let status = match &err {
event::StepError::NotFound => Status::Undefined,
event::StepError::AmbiguousMatch(..) => Status::Ambiguous,
event::StepError::Panic(..) => Status::Failed,
};
RunResult {
status,
duration: duration(),
error_message: Some(format!(
"{}{err}",
loc.map(|l| format!(
"Matched: {}:{}:{}\n",
l.path, l.line, l.column,
))
.unwrap_or_default(),
)),
}
}
event::Step::Skipped => RunResult {
status: Status::Skipped,
duration: duration(),
error_message: None,
},
};
let step = Step {
keyword: step.keyword.clone(),
line: step.position.line,
name: step.value.clone(),
hidden: false,
result,
embeddings: mem::take(&mut self.logs)
.into_iter()
.map(Embedding::from_log)
.collect(),
};
let el = self.mut_or_insert_element(feature, rule, scenario, ty);
el.steps.push(step);
}
fn mut_or_insert_element(
&mut self,
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
ty: &'static str,
) -> &mut Element {
let f_pos =
self.features.iter().position(|f| f == feature).unwrap_or_else(
|| {
self.features.push(Feature::new(feature));
self.features.len() - 1
},
);
let f = self.features.get_mut(f_pos).unwrap_or_else(|| unreachable!());
let el_pos = f
.elements
.iter()
.position(|el| {
el.name
== format!(
"{}{}",
rule.map(|r| format!("{} ", r.name))
.unwrap_or_default(),
scenario.name,
)
&& el.line == scenario.position.line
&& el.r#type == ty
})
.unwrap_or_else(|| {
f.elements.push(Element::new(feature, rule, scenario, ty));
f.elements.len() - 1
});
f.elements.get_mut(el_pos).unwrap_or_else(|| unreachable!())
}
}
#[derive(Clone, Debug, Display, Serialize)]
#[serde(transparent)]
pub struct Base64(String);
impl Base64 {
const ENGINE: base64::engine::GeneralPurpose =
base64::engine::general_purpose::STANDARD;
#[must_use]
pub fn encode(bytes: impl AsRef<[u8]>) -> Self {
Self(Self::ENGINE.encode(bytes))
}
#[must_use]
pub fn decode(&self) -> Vec<u8> {
Self::ENGINE.decode(&self.0).unwrap_or_else(|_| {
unreachable!(
"the only way to construct this type is `Base64::encode`, so \
should contain a valid `base64` encoded `String`",
)
})
}
}
#[serde_as]
#[derive(Clone, Debug, Serialize)]
pub struct Embedding {
pub data: Base64,
#[serde_as(as = "DisplayFromStr")]
pub mime_type: Mime,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Embedding {
fn from_log(msg: impl AsRef<str>) -> Self {
static LOG_MIME: LazyLock<Mime> = LazyLock::new(|| {
"text/x.cucumber.log+plain"
.parse()
.unwrap_or_else(|_| unreachable!("valid MIME"))
});
Self {
data: Base64::encode(msg.as_ref()),
mime_type: LOG_MIME.clone(),
name: None,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Tag {
pub name: String,
pub line: usize,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Passed,
Failed,
Skipped,
Ambiguous,
Undefined,
Pending,
}
#[derive(Clone, Debug, Serialize)]
pub struct RunResult {
pub status: Status,
pub duration: u128,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct Step {
pub keyword: String,
pub line: usize,
pub name: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub hidden: bool,
pub result: RunResult,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub embeddings: Vec<Embedding>,
}
#[derive(Clone, Debug, Serialize)]
pub struct HookResult {
pub result: RunResult,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub embeddings: Vec<Embedding>,
}
#[derive(Clone, Debug, Serialize)]
pub struct Element {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub after: Vec<HookResult>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub before: Vec<HookResult>,
pub keyword: String,
pub r#type: &'static str,
pub id: String,
pub line: usize,
pub name: String,
pub tags: Vec<Tag>,
pub steps: Vec<Step>,
}
impl Element {
fn new(
feature: &gherkin::Feature,
rule: Option<&gherkin::Rule>,
scenario: &gherkin::Scenario,
ty: &'static str,
) -> Self {
Self {
after: vec![],
before: vec![],
keyword: (ty == "background")
.then(|| feature.background.as_ref().map(|bg| &bg.keyword))
.flatten()
.unwrap_or(&scenario.keyword)
.clone(),
r#type: ty,
id: format!(
"{}{}/{}",
feature.name.to_kebab_case(),
rule.map(|r| format!("/{}", r.name.to_kebab_case()))
.unwrap_or_default(),
scenario.name.to_kebab_case(),
),
line: scenario.position.line,
name: format!(
"{}{}",
rule.map(|r| format!("{} ", r.name)).unwrap_or_default(),
scenario.name.clone(),
),
tags: scenario
.tags
.iter()
.map(|t| Tag { name: t.clone(), line: scenario.position.line })
.collect(),
steps: vec![],
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Feature {
pub uri: Option<String>,
pub keyword: String,
pub name: String,
pub tags: Vec<Tag>,
pub elements: Vec<Element>,
}
impl Feature {
fn new(feature: &gherkin::Feature) -> Self {
Self {
uri: feature
.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.map(str::to_owned),
keyword: feature.keyword.clone(),
name: feature.name.clone(),
tags: feature
.tags
.iter()
.map(|tag| Tag {
name: tag.clone(),
line: feature.position.line,
})
.collect(),
elements: vec![],
}
}
fn example_expansion_err(err: &ExpandExamplesError) -> Self {
Self {
uri: err
.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.map(str::to_owned),
keyword: String::new(),
name: String::new(),
tags: vec![],
elements: vec![Element {
after: vec![],
before: vec![],
keyword: String::new(),
r#type: "scenario",
id: format!(
"failed-to-expand-examples{}",
err.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.unwrap_or_default(),
),
line: 0,
name: String::new(),
tags: vec![],
steps: vec![Step {
keyword: String::new(),
line: err.pos.line,
name: "scenario".into(),
hidden: false,
result: RunResult {
status: Status::Failed,
duration: 0,
error_message: Some(err.to_string()),
},
embeddings: vec![],
}],
}],
}
}
fn parsing_err(err: &gherkin::ParseFileError) -> Self {
let path = match err {
gherkin::ParseFileError::Reading { path, .. }
| gherkin::ParseFileError::Parsing { path, .. } => path,
}
.to_str()
.map(trim_path)
.map(str::to_owned);
Self {
uri: path.clone(),
keyword: String::new(),
name: String::new(),
tags: vec![],
elements: vec![Element {
after: vec![],
before: vec![],
keyword: String::new(),
r#type: "scenario",
id: format!(
"failed-to-parse{}",
path.as_deref().unwrap_or_default(),
),
line: 0,
name: String::new(),
tags: vec![],
steps: vec![Step {
keyword: String::new(),
line: 0,
name: "scenario".into(),
hidden: false,
result: RunResult {
status: Status::Failed,
duration: 0,
error_message: Some(err.to_string()),
},
embeddings: vec![],
}],
}],
}
}
}
impl PartialEq<gherkin::Feature> for Feature {
fn eq(&self, other: &gherkin::Feature) -> bool {
self.uri
.as_ref()
.and_then(|uri| {
other
.path
.as_ref()
.and_then(|p| p.to_str().map(trim_path))
.map(|path| uri == path)
})
.unwrap_or_default()
&& self.name == other.name
}
}