1use std::{
16 fmt::Debug,
17 io, iter, mem,
18 str::FromStr,
19 time::{Duration, SystemTime},
20};
21
22use derive_more::with_trait::From;
23use either::Either;
24use itertools::Itertools as _;
25use serde::Serialize;
26
27use crate::{
28 Event, World, Writer, WriterExt as _, cli,
29 event::{self, Retries},
30 parser,
31 writer::{
32 self, Arbitrary, Normalize, Summarize,
33 basic::{coerce_error, trim_path},
34 out::WriteStrExt as _,
35 },
36};
37
38#[derive(Clone, Debug, Default, clap::Args)]
40#[group(skip)]
41pub struct Cli {
42 #[arg(long, value_name = "json")]
44 pub format: Option<Format>,
45
46 #[arg(long)]
49 pub show_output: bool,
50
51 #[arg(long, value_name = "plain|colored", default_missing_value = "plain")]
53 pub report_time: Option<ReportTime>,
54
55 #[arg(short = 'Z')]
57 pub nightly: Option<String>,
58}
59
60#[derive(Clone, Copy, Debug)]
64pub enum Format {
65 Json,
69}
70
71impl FromStr for Format {
72 type Err = String;
73
74 fn from_str(s: &str) -> Result<Self, Self::Err> {
75 match s.to_lowercase().as_str() {
76 "json" => Ok(Self::Json),
77 s @ ("pretty" | "terse" | "junit") => {
78 Err(format!("`{s}` option is not supported yet"))
79 }
80 s => Err(format!(
81 "Unknown option `{s}`, expected `pretty` or `json`",
82 )),
83 }
84 }
85}
86
87#[derive(Clone, Copy, Debug)]
89pub enum ReportTime {
90 Plain,
92
93 Colored,
95}
96
97impl FromStr for ReportTime {
98 type Err = String;
99
100 fn from_str(s: &str) -> Result<Self, Self::Err> {
101 match s.to_lowercase().as_str() {
102 "plain" => Ok(Self::Plain),
103 "colored" => Ok(Self::Colored),
104 s => Err(format!(
105 "Unknown option `{s}`, expected `plain` or `colored`",
106 )),
107 }
108 }
109}
110
111#[derive(Debug)]
130pub struct Libtest<W, Out: io::Write = io::Stdout> {
131 output: Out,
133
134 events: Vec<parser::Result<Event<event::Cucumber<W>>>>,
144
145 parsed_all: bool,
149
150 passed: usize,
154
155 failed: usize,
159
160 retried: usize,
164
165 ignored: usize,
169
170 parsing_errors: usize,
174
175 hook_errors: usize,
179
180 features_without_path: usize,
188
189 started_at: Option<SystemTime>,
193
194 step_started_at: Option<SystemTime>,
200}
201
202impl<World, Out: Clone + io::Write> Clone for Libtest<World, Out> {
205 fn clone(&self) -> Self {
206 Self {
207 output: self.output.clone(),
208 events: self.events.clone(),
209 parsed_all: self.parsed_all,
210 passed: self.passed,
211 failed: self.failed,
212 retried: self.retried,
213 ignored: self.ignored,
214 parsing_errors: self.parsing_errors,
215 hook_errors: self.hook_errors,
216 features_without_path: self.features_without_path,
217 started_at: self.started_at,
218 step_started_at: self.step_started_at,
219 }
220 }
221}
222
223impl<W: World + Debug, Out: io::Write> Writer<W> for Libtest<W, Out> {
224 type Cli = Cli;
225
226 async fn handle_event(
227 &mut self,
228 event: parser::Result<Event<event::Cucumber<W>>>,
229 cli: &Self::Cli,
230 ) {
231 self.handle_cucumber_event(event, cli);
232 }
233}
234
235pub type Or<W, Wr> = writer::Or<
237 Wr,
238 Normalize<W, Libtest<W, io::Stdout>>,
239 fn(
240 &parser::Result<Event<event::Cucumber<W>>>,
241 &cli::Compose<<Wr as Writer<W>>::Cli, Cli>,
242 ) -> bool,
243>;
244
245pub type OrBasic<W> = Or<W, Summarize<Normalize<W, writer::Basic>>>;
247
248impl<W: Debug + World> Libtest<W, io::Stdout> {
249 #[must_use]
254 pub fn stdout() -> Normalize<W, Self> {
255 Self::new(io::stdout())
256 }
257
258 #[must_use]
264 pub fn or<AnotherWriter: Writer<W>>(
265 writer: AnotherWriter,
266 ) -> Or<W, AnotherWriter> {
267 Or::new(writer, Self::stdout(), |_, cli| {
268 !matches!(cli.right.format, Some(Format::Json))
269 })
270 }
271
272 #[must_use]
279 pub fn or_basic() -> OrBasic<W> {
280 Self::or(writer::Basic::stdout().summarized())
281 }
282}
283
284impl<W: Debug + World, Out: io::Write> Libtest<W, Out> {
285 #[must_use]
298 pub fn new(output: Out) -> Normalize<W, Self> {
299 Self::raw(output).normalized()
300 }
301
302 #[must_use]
315 pub const fn raw(output: Out) -> Self {
316 Self {
317 output,
318 events: Vec::new(),
319 parsed_all: false,
320 passed: 0,
321 failed: 0,
322 retried: 0,
323 parsing_errors: 0,
324 hook_errors: 0,
325 ignored: 0,
326 features_without_path: 0,
327 started_at: None,
328 step_started_at: None,
329 }
330 }
331
332 fn handle_cucumber_event(
342 &mut self,
343 event: parser::Result<Event<event::Cucumber<W>>>,
344 cli: &Cli,
345 ) {
346 use event::{Cucumber, Metadata};
347
348 let unite = |ev: Result<(Cucumber<W>, Metadata), _>| {
349 ev.map(|(e, m)| m.insert(e))
350 };
351
352 match (event.map(Event::split), self.parsed_all) {
353 (event @ Ok((Cucumber::ParsingFinished { .. }, _)), false) => {
354 self.parsed_all = true;
355
356 let all_events =
357 iter::once(unite(event)).chain(mem::take(&mut self.events));
358 for ev in all_events {
359 self.output_event(ev, cli);
360 }
361 }
362 (event, false) => self.events.push(unite(event)),
363 (event, true) => self.output_event(unite(event), cli),
364 }
365 }
366
367 fn output_event(
369 &mut self,
370 event: parser::Result<Event<event::Cucumber<W>>>,
371 cli: &Cli,
372 ) {
373 for ev in self.expand_cucumber_event(event, cli) {
374 self.output
375 .write_line(serde_json::to_string(&ev).unwrap_or_else(|e| {
376 panic!("Failed to serialize `LibTestJsonEvent`: {e}")
377 }))
378 .unwrap_or_else(|e| panic!("Failed to write: {e}"));
379 }
380 }
381
382 fn expand_cucumber_event(
384 &mut self,
385 event: parser::Result<Event<event::Cucumber<W>>>,
386 cli: &Cli,
387 ) -> Vec<LibTestJsonEvent> {
388 use event::Cucumber;
389
390 match event.map(Event::split) {
391 Ok((Cucumber::Started, meta)) => {
392 self.started_at = Some(meta.at);
393 Vec::new()
394 }
395 Ok((Cucumber::ParsingFinished { steps, parser_errors, .. }, _)) => {
396 vec![
397 SuiteEvent::Started { test_count: steps + parser_errors }
398 .into(),
399 ]
400 }
401 Ok((Cucumber::Finished, meta)) => {
402 let exec_time = self
403 .started_at
404 .and_then(|started| meta.at.duration_since(started).ok())
405 .as_ref()
406 .map(Duration::as_secs_f64);
407
408 let failed =
409 self.failed + self.parsing_errors + self.hook_errors;
410 let results = SuiteResults {
411 passed: self.passed,
412 failed,
413 ignored: self.ignored,
414 measured: 0,
415 filtered_out: 0,
416 exec_time,
417 };
418 let ev = if failed == 0 {
419 SuiteEvent::Ok { results }
420 } else {
421 SuiteEvent::Failed { results }
422 }
423 .into();
424
425 vec![ev]
426 }
427 Ok((Cucumber::Feature(feature, ev), meta)) => {
428 self.expand_feature_event(&feature, ev, meta, cli)
429 }
430 Err(e) => {
431 self.parsing_errors += 1;
432
433 let path = match &e {
434 parser::Error::Parsing(e) => match &**e {
435 gherkin::ParseFileError::Parsing { path, .. }
436 | gherkin::ParseFileError::Reading { path, .. } => {
437 Some(path)
438 }
439 },
440 parser::Error::ExampleExpansion(e) => e.path.as_ref(),
441 };
442 let name = path.and_then(|p| p.to_str()).map_or_else(
443 || self.parsing_errors.to_string(),
444 |p| p.escape_default().to_string(),
445 );
446 let name = format!("Feature: Parsing {name}");
447
448 vec![
449 TestEvent::started(name.clone()).into(),
450 TestEvent::failed(name, None)
451 .with_stdout(e.to_string())
452 .into(),
453 ]
454 }
455 }
456 }
457
458 fn expand_feature_event(
460 &mut self,
461 feature: &gherkin::Feature,
462 ev: event::Feature<W>,
463 meta: event::Metadata,
464 cli: &Cli,
465 ) -> Vec<LibTestJsonEvent> {
466 use event::{Feature, Rule};
467
468 match ev {
469 Feature::Started
470 | Feature::Finished
471 | Feature::Rule(_, Rule::Started | Rule::Finished) => Vec::new(),
472 Feature::Rule(rule, Rule::Scenario(scenario, ev)) => self
473 .expand_scenario_event(
474 feature,
475 Some(&rule),
476 &scenario,
477 ev,
478 meta,
479 cli,
480 ),
481 Feature::Scenario(scenario, ev) => self
482 .expand_scenario_event(feature, None, &scenario, ev, meta, cli),
483 }
484 }
485
486 fn expand_scenario_event(
488 &mut self,
489 feature: &gherkin::Feature,
490 rule: Option<&gherkin::Rule>,
491 scenario: &gherkin::Scenario,
492 ev: event::RetryableScenario<W>,
493 meta: event::Metadata,
494 cli: &Cli,
495 ) -> Vec<LibTestJsonEvent> {
496 use event::Scenario;
497
498 let retries = ev.retries;
499 match ev.event {
500 Scenario::Started | Scenario::Finished => Vec::new(),
501 Scenario::Hook(ty, ev) => self.expand_hook_event(
502 feature, rule, scenario, ty, ev, retries, meta, cli,
503 ),
504 Scenario::Background(step, ev) => self.expand_step_event(
505 feature, rule, scenario, &step, ev, retries, true, meta, cli,
506 ),
507 Scenario::Step(step, ev) => self.expand_step_event(
508 feature, rule, scenario, &step, ev, retries, false, meta, cli,
509 ),
510 #[expect( clippy::print_stdout,
517 reason = "supporting `libtest` output capturing properly"
518 )]
519 Scenario::Log(msg) => {
520 print!("{msg}");
521 vec![]
522 }
523 }
524 }
525
526 #[expect(clippy::too_many_arguments, reason = "needs refactoring")]
529 fn expand_hook_event(
530 &mut self,
531 feature: &gherkin::Feature,
532 rule: Option<&gherkin::Rule>,
533 scenario: &gherkin::Scenario,
534 hook: event::HookType,
535 ev: event::Hook<W>,
536 retries: Option<Retries>,
537 meta: event::Metadata,
538 cli: &Cli,
539 ) -> Vec<LibTestJsonEvent> {
540 match ev {
541 event::Hook::Started => {
542 self.step_started_at(meta, cli);
543 Vec::new()
544 }
545 event::Hook::Passed => Vec::new(),
546 event::Hook::Failed(world, info) => {
547 self.hook_errors += 1;
548
549 let name = self.test_case_name(
550 feature,
551 rule,
552 scenario,
553 Either::Left(hook),
554 retries,
555 );
556
557 vec![
558 TestEvent::started(name.clone()).into(),
559 TestEvent::failed(name, self.step_exec_time(meta, cli))
560 .with_stdout(format!(
561 "{}{}",
562 coerce_error(&info),
563 world
564 .map(|w| format!("\n{w:#?}"))
565 .unwrap_or_default(),
566 ))
567 .into(),
568 ]
569 }
570 }
571 }
572
573 #[expect(clippy::too_many_arguments, reason = "needs refactoring")]
576 fn expand_step_event(
577 &mut self,
578 feature: &gherkin::Feature,
579 rule: Option<&gherkin::Rule>,
580 scenario: &gherkin::Scenario,
581 step: &gherkin::Step,
582 ev: event::Step<W>,
583 retries: Option<Retries>,
584 is_background: bool,
585 meta: event::Metadata,
586 cli: &Cli,
587 ) -> Vec<LibTestJsonEvent> {
588 use event::Step;
589
590 let name = self.test_case_name(
591 feature,
592 rule,
593 scenario,
594 Either::Right((step, is_background)),
595 retries,
596 );
597
598 let ev = match ev {
599 Step::Started => {
600 self.step_started_at(meta, cli);
601 TestEvent::started(name)
602 }
603 Step::Passed(_, loc) => {
604 self.passed += 1;
605
606 let event = TestEvent::ok(name, self.step_exec_time(meta, cli));
607 if cli.show_output {
608 event.with_stdout(format!(
609 "{}:{}:{} (defined){}",
610 feature
611 .path
612 .as_ref()
613 .and_then(|p| p.to_str().map(trim_path))
614 .unwrap_or(&feature.name),
615 step.position.line,
616 step.position.col,
617 loc.map(|l| format!(
618 "\n{}:{}:{} (matched)",
619 l.path, l.line, l.column,
620 ))
621 .unwrap_or_default()
622 ))
623 } else {
624 event
625 }
626 }
627 Step::Skipped => {
628 self.ignored += 1;
629
630 let event =
631 TestEvent::ignored(name, self.step_exec_time(meta, cli));
632 if cli.show_output {
633 event.with_stdout(format!(
634 "{}:{}:{} (defined)",
635 feature
636 .path
637 .as_ref()
638 .and_then(|p| p.to_str().map(trim_path))
639 .unwrap_or(&feature.name),
640 step.position.line,
641 step.position.col,
642 ))
643 } else {
644 event
645 }
646 }
647 Step::Failed(_, loc, world, err) => {
648 if retries.is_some_and(|r| {
649 r.left > 0 && !matches!(err, event::StepError::NotFound)
650 }) {
651 self.retried += 1;
652 } else {
653 self.failed += 1;
654 }
655
656 TestEvent::failed(name, self.step_exec_time(meta, cli))
657 .with_stdout(format!(
658 "{}:{}:{} (defined){}\n{err}{}",
659 feature
660 .path
661 .as_ref()
662 .and_then(|p| p.to_str().map(trim_path))
663 .unwrap_or(&feature.name),
664 step.position.line,
665 step.position.col,
666 loc.map(|l| format!(
667 "\n{}:{}:{} (matched)",
668 l.path, l.line, l.column,
669 ))
670 .unwrap_or_default(),
671 world.map(|w| format!("\n{w:#?}")).unwrap_or_default(),
672 ))
673 }
674 };
675
676 vec![ev.into()]
677 }
678
679 fn test_case_name(
681 &mut self,
682 feature: &gherkin::Feature,
683 rule: Option<&gherkin::Rule>,
684 scenario: &gherkin::Scenario,
685 step: Either<event::HookType, (&gherkin::Step, IsBackground)>,
686 retries: Option<Retries>,
687 ) -> String {
688 let feature_name = format!(
689 "{}: {} {}",
690 feature.keyword,
691 feature.name,
692 feature
693 .path
694 .as_ref()
695 .and_then(|p| p.to_str().map(trim_path))
696 .map_or_else(
697 || {
698 self.features_without_path += 1;
699 self.features_without_path.to_string()
700 },
701 |s| s.escape_default().to_string()
702 ),
703 );
704 let rule_name = rule
705 .as_ref()
706 .map(|r| format!("{}: {}: {}", r.position.line, r.keyword, r.name));
707 let scenario_name = format!(
708 "{}: {}: {}{}",
709 scenario.position.line,
710 scenario.keyword,
711 scenario.name,
712 retries
713 .filter(|r| r.current > 0)
714 .map(|r| format!(
715 " | Retry attempt {}/{}",
716 r.current,
717 r.current + r.left,
718 ))
719 .unwrap_or_default(),
720 );
721 let step_name = match step {
722 Either::Left(hook) => format!("{hook} hook"),
723 Either::Right((step, is_bg)) => format!(
724 "{}: {} {}{}",
725 step.position.line,
726 if is_bg {
727 feature
728 .background
729 .as_ref()
730 .map_or("Background", |bg| bg.keyword.as_str())
731 } else {
732 ""
733 },
734 step.keyword,
735 step.value,
736 ),
737 };
738
739 [Some(feature_name), rule_name, Some(scenario_name), Some(step_name)]
740 .into_iter()
741 .flatten()
742 .join("::")
743 }
744
745 fn step_started_at(&mut self, meta: event::Metadata, cli: &Cli) {
749 self.step_started_at =
750 Some(meta.at).filter(|_| cli.report_time.is_some());
751 }
752
753 fn step_exec_time(
756 &mut self,
757 meta: event::Metadata,
758 cli: &Cli,
759 ) -> Option<Duration> {
760 let started = self.step_started_at.take()?;
761 meta.at
762 .duration_since(started)
763 .ok()
764 .filter(|_| cli.report_time.is_some())
765 }
766}
767
768type IsBackground = bool;
773
774impl<W, O: io::Write> writer::NonTransforming for Libtest<W, O> {}
775
776impl<W, O> writer::Stats<W> for Libtest<W, O>
777where
778 O: io::Write,
779 Self: Writer<W>,
780{
781 fn passed_steps(&self) -> usize {
782 self.passed
783 }
784
785 fn skipped_steps(&self) -> usize {
786 self.ignored
787 }
788
789 fn failed_steps(&self) -> usize {
790 self.failed
791 }
792
793 fn retried_steps(&self) -> usize {
794 self.retried
795 }
796
797 fn parsing_errors(&self) -> usize {
798 self.parsing_errors
799 }
800
801 fn hook_errors(&self) -> usize {
802 self.hook_errors
803 }
804}
805
806impl<W, Val, Out> Arbitrary<W, Val> for Libtest<W, Out>
807where
808 W: World + Debug,
809 Val: AsRef<str>,
810 Out: io::Write,
811{
812 async fn write(&mut self, val: Val) {
813 self.output
814 .write_line(val.as_ref())
815 .unwrap_or_else(|e| panic!("failed to write: {e}"));
816 }
817}
818
819#[derive(Clone, Debug, From, Serialize)]
826#[serde(tag = "type", rename_all = "snake_case")]
827enum LibTestJsonEvent {
828 Suite {
830 #[serde(flatten)]
832 event: SuiteEvent,
833 },
834
835 Test {
837 #[serde(flatten)]
839 event: TestEvent,
840 },
841}
842
843#[derive(Clone, Debug, Serialize)]
845#[serde(tag = "event", rename_all = "snake_case")]
846enum SuiteEvent {
847 Started {
849 test_count: usize,
855 },
856
857 Ok {
859 #[serde(flatten)]
861 results: SuiteResults,
862 },
863
864 Failed {
866 #[serde(flatten)]
868 results: SuiteResults,
869 },
870}
871
872#[derive(Clone, Copy, Debug, Serialize)]
874struct SuiteResults {
875 passed: usize,
877
878 failed: usize,
880
881 ignored: usize,
883
884 measured: usize,
886
887 filtered_out: usize,
890
891 #[serde(skip_serializing_if = "Option::is_none")]
893 exec_time: Option<f64>,
894}
895
896#[derive(Clone, Debug, Serialize)]
898#[serde(tag = "event", rename_all = "snake_case")]
899enum TestEvent {
900 Started(TestEventInner),
902
903 Ok(TestEventInner),
905
906 Failed(TestEventInner),
908
909 Ignored(TestEventInner),
911
912 Timeout(TestEventInner),
914}
915
916impl TestEvent {
917 const fn started(name: String) -> Self {
919 Self::Started(TestEventInner::new(name))
920 }
921
922 fn ok(name: String, exec_time: Option<Duration>) -> Self {
924 Self::Ok(TestEventInner::new(name).with_exec_time(exec_time))
925 }
926
927 fn failed(name: String, exec_time: Option<Duration>) -> Self {
929 Self::Failed(TestEventInner::new(name).with_exec_time(exec_time))
930 }
931
932 fn ignored(name: String, exec_time: Option<Duration>) -> Self {
934 Self::Ignored(TestEventInner::new(name).with_exec_time(exec_time))
935 }
936
937 #[expect(dead_code, reason = "API uniformity")]
939 fn timeout(name: String, exec_time: Option<Duration>) -> Self {
940 Self::Timeout(TestEventInner::new(name).with_exec_time(exec_time))
941 }
942
943 fn with_stdout(self, mut stdout: String) -> Self {
945 if !stdout.ends_with('\n') {
946 stdout.push('\n');
947 }
948
949 match self {
950 Self::Started(inner) => Self::Started(inner.with_stdout(stdout)),
951 Self::Ok(inner) => Self::Ok(inner.with_stdout(stdout)),
952 Self::Failed(inner) => Self::Failed(inner.with_stdout(stdout)),
953 Self::Ignored(inner) => Self::Ignored(inner.with_stdout(stdout)),
954 Self::Timeout(inner) => Self::Timeout(inner.with_stdout(stdout)),
955 }
956 }
957}
958
959#[derive(Clone, Debug, Serialize)]
961struct TestEventInner {
962 name: String,
964
965 #[serde(skip_serializing_if = "Option::is_none")]
969 stdout: Option<String>,
970
971 #[serde(skip_serializing_if = "Option::is_none")]
978 stderr: Option<String>,
979
980 #[serde(skip_serializing_if = "Option::is_none")]
982 exec_time: Option<f64>,
983}
984
985impl TestEventInner {
986 const fn new(name: String) -> Self {
988 Self { name, stdout: None, stderr: None, exec_time: None }
989 }
990
991 fn with_exec_time(mut self, exec_time: Option<Duration>) -> Self {
993 self.exec_time = exec_time.as_ref().map(Duration::as_secs_f64);
994 self
995 }
996
997 fn with_stdout(mut self, stdout: String) -> Self {
999 self.stdout = Some(stdout);
1000 self
1001 }
1002}