1use std::{fmt::Debug, io, mem, time::SystemTime};
16
17use junit_report::{
18 Duration, Report, TestCase, TestCaseBuilder, TestSuite, TestSuiteBuilder,
19};
20
21use crate::{
22 Event, World, Writer, event, parser,
23 writer::{
24 self, Ext as _, Verbosity,
25 basic::{Coloring, coerce_error, trim_path},
26 discard,
27 out::WritableString,
28 },
29};
30
31const WRAP_ADVICE: &str = "Consider wrapping `Writer` into `writer::Normalize`";
35
36#[derive(Clone, Copy, Debug, Default, clap::Args)]
38#[group(skip)]
39pub struct Cli {
40 #[arg(id = "junit-v", long = "junit-v", value_name = "0|1", global = true)]
45 pub verbose: Option<u8>,
46}
47
48#[derive(Debug)]
60pub struct JUnit<W, Out: io::Write> {
61 output: Out,
63
64 report: Report,
68
69 suit: Option<TestSuite>,
73
74 scenario_started_at: Option<SystemTime>,
78
79 events: Vec<event::RetryableScenario<W>>,
84
85 verbosity: Verbosity,
87}
88
89impl<World, Out: Clone + io::Write> Clone for JUnit<World, Out> {
92 fn clone(&self) -> Self {
93 Self {
94 output: self.output.clone(),
95 report: self.report.clone(),
96 suit: self.suit.clone(),
97 scenario_started_at: self.scenario_started_at,
98 events: self.events.clone(),
99 verbosity: self.verbosity,
100 }
101 }
102}
103
104impl<W, Out> Writer<W> for JUnit<W, Out>
105where
106 W: World + Debug,
107 Out: io::Write,
108{
109 type Cli = Cli;
110
111 async fn handle_event(
112 &mut self,
113 event: parser::Result<Event<event::Cucumber<W>>>,
114 cli: &Self::Cli,
115 ) {
116 use event::{Cucumber, Feature, Rule};
117
118 self.apply_cli(*cli);
119
120 match event.map(Event::split) {
121 Err(err) => self.handle_error(&err),
122 Ok((Cucumber::Started | Cucumber::ParsingFinished { .. }, _)) => {}
123 Ok((Cucumber::Feature(feat, ev), meta)) => match ev {
124 Feature::Started => {
125 self.suit = Some(
126 TestSuiteBuilder::new(&format!(
127 "Feature: {}{}",
128 &feat.name,
129 feat.path
130 .as_deref()
131 .and_then(|p| p.to_str().map(trim_path))
132 .map(|path| format!(": {path}"))
133 .unwrap_or_default(),
134 ))
135 .set_timestamp(meta.at.into())
136 .build(),
137 );
138 }
139 Feature::Rule(_, Rule::Started | Rule::Finished) => {}
140 Feature::Rule(r, Rule::Scenario(sc, ev)) => {
141 self.handle_scenario_event(&feat, Some(&r), &sc, ev, meta);
142 }
143 Feature::Scenario(sc, ev) => {
144 self.handle_scenario_event(&feat, None, &sc, ev, meta);
145 }
146 Feature::Finished => {
147 let suite = self.suit.take().unwrap_or_else(|| {
148 panic!(
149 "no `TestSuit` for `Feature` \"{}\"\n{WRAP_ADVICE}",
150 feat.name,
151 )
152 });
153 self.report.add_testsuite(suite);
154 }
155 },
156 Ok((Cucumber::Finished, _)) => {
157 self.report
158 .write_xml(&mut self.output)
159 .unwrap_or_else(|e| panic!("failed to write XML: {e}"));
160 }
161 }
162 }
163}
164
165impl<W, O: io::Write> writer::NonTransforming for JUnit<W, O> {}
166
167impl<W: Debug, Out: io::Write> JUnit<W, Out> {
168 #[must_use]
173 pub fn new(
174 output: Out,
175 verbosity: impl Into<Verbosity>,
176 ) -> writer::Normalize<W, Self> {
177 Self::raw(output, verbosity).normalized()
178 }
179
180 #[must_use]
188 pub fn for_tee(
189 output: Out,
190 verbosity: impl Into<Verbosity>,
191 ) -> discard::Arbitrary<discard::Stats<Self>> {
192 Self::raw(output, verbosity)
193 .discard_stats_writes()
194 .discard_arbitrary_writes()
195 }
196
197 #[must_use]
208 pub fn raw(output: Out, verbosity: impl Into<Verbosity>) -> Self {
209 Self {
210 output,
211 report: Report::new(),
212 suit: None,
213 scenario_started_at: None,
214 events: vec![],
215 verbosity: verbosity.into(),
216 }
217 }
218
219 pub const fn apply_cli(&mut self, cli: Cli) {
221 match cli.verbose {
222 None => {}
223 Some(0) => self.verbosity = Verbosity::Default,
224 _ => self.verbosity = Verbosity::ShowWorld,
225 }
226 }
227
228 fn handle_error(&mut self, err: &parser::Error) {
230 let (name, ty) = match err {
231 parser::Error::Parsing(err) => {
232 let path = match err.as_ref() {
233 gherkin::ParseFileError::Reading { path, .. }
234 | gherkin::ParseFileError::Parsing { path, .. } => path,
235 };
236 (
237 format!(
238 "Feature{}",
239 path.to_str()
240 .map(|p| format!(": {}", trim_path(p)))
241 .unwrap_or_default(),
242 ),
243 "Parser Error",
244 )
245 }
246 parser::Error::ExampleExpansion(err) => (
247 format!(
248 "Feature: {}{}:{}",
249 err.path
250 .as_deref()
251 .and_then(|p| p.to_str().map(trim_path))
252 .map(|p| format!("{p}:"))
253 .unwrap_or_default(),
254 err.pos.line,
255 err.pos.col,
256 ),
257 "Example Expansion Error",
258 ),
259 };
260
261 self.report.add_testsuite(
262 TestSuiteBuilder::new("Errors")
263 .add_testcase(TestCase::failure(
264 &name,
265 Duration::ZERO,
266 ty,
267 &err.to_string(),
268 ))
269 .build(),
270 );
271 }
272
273 fn handle_scenario_event(
275 &mut self,
276 feat: &gherkin::Feature,
277 rule: Option<&gherkin::Rule>,
278 sc: &gherkin::Scenario,
279 ev: event::RetryableScenario<W>,
280 meta: Event<()>,
281 ) {
282 use event::Scenario;
283
284 match &ev.event {
285 Scenario::Started => {
286 self.scenario_started_at = Some(meta.at);
287 self.events.push(ev);
288 }
289 Scenario::Log(_)
290 | Scenario::Hook(..)
291 | Scenario::Background(..)
292 | Scenario::Step(..) => {
293 self.events.push(ev);
294 }
295 Scenario::Finished => {
296 let dur = self.scenario_duration(meta.at, sc);
297 let events = mem::take(&mut self.events);
298 let case = self.test_case(feat, rule, sc, &events, dur);
299
300 self.suit
301 .as_mut()
302 .unwrap_or_else(|| {
303 panic!(
304 "no `TestSuit` for `Scenario` \"{}\"\n\
305 {WRAP_ADVICE}",
306 sc.name,
307 )
308 })
309 .add_testcase(case);
310 }
311 }
312 }
313
314 fn test_case(
316 &self,
317 feat: &gherkin::Feature,
318 rule: Option<&gherkin::Rule>,
319 sc: &gherkin::Scenario,
320 events: &[event::RetryableScenario<W>],
321 duration: Duration,
322 ) -> TestCase {
323 use event::{Hook, HookType, Scenario, Step};
324
325 let last_event = events
326 .iter()
327 .rev()
328 .find(|ev| {
329 !matches!(
330 ev.event,
331 Scenario::Log(_)
332 | Scenario::Hook(
333 HookType::After,
334 Hook::Passed | Hook::Started,
335 ),
336 )
337 })
338 .unwrap_or_else(|| {
339 panic!(
340 "no events for `Scenario` \"{}\"\n{WRAP_ADVICE}",
341 sc.name,
342 )
343 });
344
345 let case_name = format!(
346 "{}Scenario: {}: {}{}:{}",
347 rule.map(|r| format!("Rule: {}: ", r.name)).unwrap_or_default(),
348 sc.name,
349 feat.path
350 .as_ref()
351 .and_then(|p| p.to_str().map(trim_path))
352 .map(|path| format!("{path}:"))
353 .unwrap_or_default(),
354 sc.position.line,
355 sc.position.col,
356 );
357
358 let mut case = match &last_event.event {
359 Scenario::Started
360 | Scenario::Log(_)
361 | Scenario::Hook(_, Hook::Started | Hook::Passed)
362 | Scenario::Background(_, Step::Started | Step::Passed(_, _))
363 | Scenario::Step(_, Step::Started | Step::Passed(_, _)) => {
364 TestCaseBuilder::success(&case_name, duration).build()
365 }
366 Scenario::Background(_, Step::Skipped)
367 | Scenario::Step(_, Step::Skipped) => {
368 TestCaseBuilder::skipped(&case_name).build()
369 }
370 Scenario::Hook(_, Hook::Failed(_, e)) => TestCaseBuilder::failure(
371 &case_name,
372 duration,
373 "Hook Panicked",
374 coerce_error(e).as_ref(),
375 )
376 .build(),
377 Scenario::Background(_, Step::Failed(_, _, _, e))
378 | Scenario::Step(_, Step::Failed(_, _, _, e)) => {
379 TestCaseBuilder::failure(
380 &case_name,
381 duration,
382 "Step Panicked",
383 &e.to_string(),
384 )
385 .build()
386 }
387 Scenario::Finished => {
388 panic!(
389 "Duplicated `Finished` event for `Scenario`: \"{}\"\n\
390 {WRAP_ADVICE}",
391 sc.name,
392 );
393 }
394 };
395
396 let mut basic_wr = writer::Basic::raw(
399 WritableString(String::new()),
400 Coloring::Never,
401 self.verbosity,
402 );
403 let output = events
404 .iter()
405 .map(|ev| {
406 basic_wr.scenario(feat, sc, ev)?;
407 Ok(mem::take(&mut **basic_wr))
408 })
409 .collect::<io::Result<String>>()
410 .unwrap_or_else(|e| {
411 panic!("Failed to write with `writer::Basic`: {e}")
412 });
413
414 case.set_system_out(&output);
415
416 case
417 }
418
419 fn scenario_duration(
423 &mut self,
424 ended: SystemTime,
425 sc: &gherkin::Scenario,
426 ) -> Duration {
427 let started_at = self.scenario_started_at.take().unwrap_or_else(|| {
428 panic!(
429 "no `Started` event for `Scenario` \"{}\"\n{WRAP_ADVICE}",
430 sc.name,
431 )
432 });
433 Duration::try_from(ended.duration_since(started_at).unwrap_or_else(
434 |e| {
435 panic!(
436 "failed to compute duration between {ended:?} and \
437 {started_at:?}: {e}",
438 )
439 },
440 ))
441 .unwrap_or_else(|e| {
442 panic!(
443 "cannot covert `std::time::Duration` to `time::Duration`: {e}",
444 )
445 })
446 }
447}