1use std::collections::{HashMap, HashSet};
2
3use serde::Serialize;
4
5use crate::collector;
6use crate::doc_entry::DocEntry;
7use crate::types::{RawAttachment, RawStepEvent, RawTestCase, StoryMeta, StoryStep};
8
9#[derive(Clone)]
11pub struct StepDoc(pub(crate) DocEntry);
12
13impl StepDoc {
14 #[must_use]
15 pub fn note(text: &str) -> Self {
16 StepDoc(DocEntry::note(text))
17 }
18 #[must_use]
19 pub fn tag(names: &[&str]) -> Self {
20 StepDoc(DocEntry::tag(names))
21 }
22 #[must_use]
23 pub fn kv(label: &str, value: serde_json::Value) -> Self {
24 StepDoc(DocEntry::kv(label, value))
25 }
26 #[must_use]
27 pub fn json_doc(label: &str, value: &impl Serialize) -> Self {
28 StepDoc(DocEntry::json_doc(label, value))
29 }
30 #[must_use]
31 pub fn code(label: &str, content: &str, lang: Option<&str>) -> Self {
32 StepDoc(DocEntry::code(label, content, lang))
33 }
34 #[must_use]
35 pub fn table(label: &str, columns: &[&str], rows: &[&[&str]]) -> Self {
36 StepDoc(DocEntry::table(label, columns, rows))
37 }
38 #[must_use]
39 pub fn link(label: &str, url: &str) -> Self {
40 StepDoc(DocEntry::link(label, url))
41 }
42 #[must_use]
43 pub fn section(title: &str, markdown: &str) -> Self {
44 StepDoc(DocEntry::section(title, markdown))
45 }
46 #[must_use]
47 pub fn mermaid(code: &str, title: Option<&str>) -> Self {
48 StepDoc(DocEntry::mermaid(code, title))
49 }
50 #[must_use]
51 pub fn screenshot(path: &str, alt: Option<&str>) -> Self {
52 StepDoc(DocEntry::screenshot(path, alt))
53 }
54 #[must_use]
55 pub fn custom(type_name: &str, data: serde_json::Value) -> Self {
56 StepDoc(DocEntry::custom(type_name, data))
57 }
58 fn apply(self, step: &mut StoryStep) {
59 step.docs.get_or_insert_with(Vec::new).push(self.0);
60 }
61}
62
63struct TimerEntry {
64 start: std::time::Instant,
65 step_index: Option<usize>,
66 step_id: Option<String>,
67 consumed: bool,
68}
69
70pub struct Story {
75 scenario: String,
76 steps: Vec<StoryStep>,
77 tags: Option<Vec<String>>,
78 tickets: Option<Vec<crate::types::Ticket>>,
79 meta: Option<serde_json::Value>,
80 trace_url_template: Option<String>,
81 docs: Vec<DocEntry>,
82 current_step_index: Option<usize>,
83 seen_primary: HashSet<String>,
84 source_order: Option<u32>,
85 start_time: std::time::Instant,
86 passed: bool,
87 step_counter: usize,
88 attachments: Vec<RawAttachment>,
89 active_timers: HashMap<usize, TimerEntry>,
90 timer_counter: usize,
91 otel_spans: Option<Vec<serde_json::Value>>,
92}
93
94impl Story {
95 #[must_use]
97 pub fn new(scenario: &str) -> Self {
98 let mut story = Story {
99 scenario: scenario.to_string(),
100 steps: Vec::new(),
101 tags: None,
102 tickets: None,
103 meta: None,
104 trace_url_template: None,
105 docs: Vec::new(),
106 current_step_index: None,
107 seen_primary: HashSet::new(),
108 source_order: Some(collector::next_order()),
109 start_time: std::time::Instant::now(),
110 passed: false,
111 step_counter: 0,
112 attachments: Vec::new(),
113 active_timers: HashMap::new(),
114 timer_counter: 0,
115 otel_spans: None,
116 };
117 story.bridge_otel();
118 story
119 }
120
121 #[must_use]
123 pub fn with_tags(mut self, tags: &[&str]) -> Self {
124 self.tags = Some(tags.iter().map(std::string::ToString::to_string).collect());
125 self
126 }
127
128 #[must_use]
130 pub fn with_tickets(mut self, tickets: &[&str]) -> Self {
131 self.tickets = Some(tickets.iter().map(|t| crate::types::Ticket { id: (*t).to_string(), url: None }).collect());
132 self
133 }
134
135 #[must_use]
137 pub fn with_ticket_url(mut self, id: &str, url: &str) -> Self {
138 let ticket = crate::types::Ticket { id: id.to_string(), url: Some(url.to_string()) };
139 match &mut self.tickets {
140 Some(tickets) => tickets.push(ticket),
141 None => self.tickets = Some(vec![ticket]),
142 }
143 self
144 }
145
146 #[must_use]
149 pub fn with_trace_url_template(mut self, template: &str) -> Self {
150 self.trace_url_template = Some(template.to_string());
151 self
152 }
153
154 fn bridge_otel(&mut self) {
157 #[cfg(feature = "otel")]
158 {
159 #[allow(unused_imports)]
160 use opentelemetry::trace::{Span, TraceContextExt};
161 let cx = opentelemetry::Context::current();
162 let span_ref = cx.span();
163 let span_ctx = span_ref.span_context();
164
165 if !span_ctx.trace_id().to_string().chars().any(|c| c != '0') {
166 return;
167 }
168
169 let trace_id = span_ctx.trace_id().to_string();
170 let span_id = span_ctx.span_id().to_string();
171
172 self.meta = Some(serde_json::json!({
174 "otel": { "traceId": &trace_id, "spanId": &span_id }
175 }));
176
177 self.docs.push(DocEntry::kv("Trace ID", serde_json::json!(trace_id.clone())));
179
180 let template = self
181 .trace_url_template
182 .clone()
183 .or_else(|| std::env::var("OTEL_TRACE_URL_TEMPLATE").ok());
184 if let Some(tmpl) = template {
185 let url = tmpl.replace("{traceId}", &trace_id);
186 self.docs.push(DocEntry::link("View Trace", &url));
187 }
188
189 let span = cx.span();
191 span.set_attribute(opentelemetry::KeyValue::new(
192 "story.scenario",
193 self.scenario.clone(),
194 ));
195 if let Some(ref tags) = self.tags {
196 span.set_attribute(opentelemetry::KeyValue::new("story.tags", format!("{tags:?}")));
197 }
198 if let Some(ref tickets) = self.tickets {
199 span.set_attribute(opentelemetry::KeyValue::new(
200 "story.tickets",
201 format!("{tickets:?}"),
202 ));
203 }
204 }
205 }
206
207 fn add_step(&mut self, keyword: &str, text: &str) -> &mut Self {
210 let effective = match keyword {
212 "Given" | "When" | "Then" => {
213 if self.seen_primary.insert(keyword.to_string()) {
214 keyword
215 } else {
216 "And"
217 }
218 }
219 _ => keyword,
220 };
221 let id = format!("step-{}", self.step_counter);
222 self.step_counter += 1;
223 self.steps.push(StoryStep {
224 keyword: effective.to_string(),
225 text: text.to_string(),
226 id: Some(id),
227 mode: None,
228 wrapped: None,
229 duration_ms: None,
230 docs: None,
231 });
232 self.current_step_index = Some(self.steps.len() - 1);
233 self
234 }
235
236 pub fn given(&mut self, text: &str) -> &mut Self {
238 self.add_step("Given", text)
239 }
240
241 pub fn when(&mut self, text: &str) -> &mut Self {
243 self.add_step("When", text)
244 }
245
246 pub fn then(&mut self, text: &str) -> &mut Self {
248 self.add_step("Then", text)
249 }
250
251 pub fn and(&mut self, text: &str) -> &mut Self {
253 self.add_step("And", text)
254 }
255
256 pub fn but(&mut self, text: &str) -> &mut Self {
258 self.add_step("But", text)
259 }
260
261 pub fn arrange(&mut self, text: &str) -> &mut Self {
265 self.add_step("Given", text)
266 }
267
268 pub fn act(&mut self, text: &str) -> &mut Self {
270 self.add_step("When", text)
271 }
272
273 pub fn assert_that(&mut self, text: &str) -> &mut Self {
275 self.add_step("Then", text)
276 }
277
278 pub fn setup(&mut self, text: &str) -> &mut Self {
282 self.add_step("Given", text)
283 }
284
285 pub fn context(&mut self, text: &str) -> &mut Self {
287 self.add_step("Given", text)
288 }
289
290 pub fn execute(&mut self, text: &str) -> &mut Self {
292 self.add_step("When", text)
293 }
294
295 pub fn action(&mut self, text: &str) -> &mut Self {
297 self.add_step("When", text)
298 }
299
300 pub fn verify(&mut self, text: &str) -> &mut Self {
302 self.add_step("Then", text)
303 }
304
305 pub fn with_docs(&mut self, docs: Vec<StepDoc>) -> &mut Self {
307 if let Some(idx) = self.current_step_index {
308 let step = &mut self.steps[idx];
309 for doc in docs {
310 doc.apply(step);
311 }
312 }
313 self
314 }
315
316 pub fn start_timer(&mut self) -> usize {
320 let token = self.timer_counter;
321 self.timer_counter += 1;
322 let entry = TimerEntry {
323 start: std::time::Instant::now(),
324 step_index: self.current_step_index,
325 step_id: self.current_step_id(),
326 consumed: false,
327 };
328 self.active_timers.insert(token, entry);
329 token
330 }
331
332 pub fn end_timer(&mut self, token: usize) {
334 let entry = match self.active_timers.get_mut(&token) {
335 Some(e) if !e.consumed => e,
336 _ => return,
337 };
338 entry.consumed = true;
339 let duration_ms = entry.start.elapsed().as_secs_f64() * 1000.0;
340 let step_index = entry.step_index;
341 let step_id = entry.step_id.clone();
342
343 if let Some(idx) = step_index {
344 if let Some(step) = self.steps.get_mut(idx) {
345 step.duration_ms = Some(duration_ms);
346 }
347 } else if let Some(id) = step_id {
348 if let Some(step) = self.steps.iter_mut().find(|s| s.id.as_deref() == Some(id.as_str()))
349 {
350 step.duration_ms = Some(duration_ms);
351 }
352 }
353 }
354
355 pub fn fn_step<F, T>(&mut self, keyword: &str, text: &str, body: F) -> T
361 where
362 F: FnOnce() -> T,
363 {
364 self.add_step(keyword, text);
365 let idx = self.steps.len() - 1;
366 self.steps[idx].wrapped = Some(true);
367
368 let start = std::time::Instant::now();
369 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
370 self.steps[idx].duration_ms = Some(start.elapsed().as_secs_f64() * 1000.0);
371
372 match result {
373 Ok(val) => val,
374 Err(panic) => std::panic::resume_unwind(panic),
375 }
376 }
377
378 pub fn expect_step<F, T>(&mut self, text: &str, body: F) -> T
380 where
381 F: FnOnce() -> T,
382 {
383 self.fn_step("Then", text, body)
384 }
385
386 fn attach_doc(&mut self, entry: DocEntry) -> &mut Self {
390 if let Some(idx) = self.current_step_index {
391 let step = &mut self.steps[idx];
392 let docs = step.docs.get_or_insert_with(Vec::new);
393 docs.push(entry);
394 } else {
395 self.docs.push(entry);
396 }
397 self
398 }
399
400 pub fn note(&mut self, text: &str) -> &mut Self {
402 self.attach_doc(DocEntry::note(text))
403 }
404
405 pub fn tag(&mut self, names: &[&str]) -> &mut Self {
407 self.attach_doc(DocEntry::tag(names))
408 }
409
410 pub fn kv(&mut self, label: &str, value: serde_json::Value) -> &mut Self {
412 self.attach_doc(DocEntry::kv(label, value))
413 }
414
415 pub fn json(&mut self, label: &str, value: &impl Serialize) -> &mut Self {
417 self.attach_doc(DocEntry::json_doc(label, value))
418 }
419
420 pub fn code(&mut self, label: &str, content: &str, lang: Option<&str>) -> &mut Self {
422 self.attach_doc(DocEntry::code(label, content, lang))
423 }
424
425 pub fn table(&mut self, label: &str, columns: &[&str], rows: &[&[&str]]) -> &mut Self {
427 self.attach_doc(DocEntry::table(label, columns, rows))
428 }
429
430 pub fn link(&mut self, label: &str, url: &str) -> &mut Self {
432 self.attach_doc(DocEntry::link(label, url))
433 }
434
435 pub fn section(&mut self, title: &str, markdown: &str) -> &mut Self {
437 self.attach_doc(DocEntry::section(title, markdown))
438 }
439
440 pub fn mermaid(&mut self, code: &str, title: Option<&str>) -> &mut Self {
442 self.attach_doc(DocEntry::mermaid(code, title))
443 }
444
445 pub fn screenshot(&mut self, path: &str, alt: Option<&str>) -> &mut Self {
447 self.attach_doc(DocEntry::screenshot(path, alt))
448 }
449
450 pub fn custom(&mut self, type_name: &str, data: serde_json::Value) -> &mut Self {
452 self.attach_doc(DocEntry::custom(type_name, data))
453 }
454
455 fn current_step_id(&self) -> Option<String> {
459 self.steps.last().and_then(|s| s.id.clone())
460 }
461
462 pub fn attach(&mut self, name: &str, media_type: &str, path: &str) -> &mut Self {
464 let mut a = RawAttachment {
465 name: name.to_string(),
466 media_type: media_type.to_string(),
467 path: if path.is_empty() { None } else { Some(path.to_string()) },
468 body: None,
469 encoding: None,
470 charset: None,
471 file_name: None,
472 byte_length: None,
473 step_index: None,
474 step_id: None,
475 };
476 if let Some(ref current) = self.current_step_id() {
477 a.step_index = Some(self.steps.len().saturating_sub(1));
478 a.step_id = Some(current.clone());
479 }
480 self.attachments.push(a);
481 self
482 }
483
484 pub fn attach_inline(
486 &mut self,
487 name: &str,
488 media_type: &str,
489 body: &str,
490 encoding: &str,
491 ) -> &mut Self {
492 let mut a = RawAttachment {
493 name: name.to_string(),
494 media_type: media_type.to_string(),
495 path: None,
496 body: Some(body.to_string()),
497 encoding: Some(encoding.to_string()),
498 charset: None,
499 file_name: None,
500 byte_length: None,
501 step_index: None,
502 step_id: None,
503 };
504 if let Some(ref current) = self.current_step_id() {
505 a.step_index = Some(self.steps.len().saturating_sub(1));
506 a.step_id = Some(current.clone());
507 }
508 self.attachments.push(a);
509 self
510 }
511
512 pub fn attach_spans(&mut self, spans: Vec<serde_json::Value>) -> &mut Self {
514 self.otel_spans = Some(spans);
515 self
516 }
517
518 pub fn pass(&mut self) {
520 self.passed = true;
521 }
522}
523
524impl Drop for Story {
525 fn drop(&mut self) {
526 let status = if self.passed { "pass" } else { "fail" };
527 let duration = self.start_time.elapsed().as_secs_f64() * 1000.0;
528
529 let step_events: Vec<RawStepEvent> = self
530 .steps
531 .iter()
532 .enumerate()
533 .filter_map(|(i, step)| {
534 step.duration_ms.map(|d| RawStepEvent {
535 index: Some(i),
536 title: Some(step.text.clone()),
537 status: None,
538 duration_ms: Some(d),
539 })
540 })
541 .collect();
542
543 let tc = RawTestCase {
544 status: status.to_string(),
545 title: Some(self.scenario.clone()),
546 story: Some(StoryMeta {
547 scenario: self.scenario.clone(),
548 steps: self.steps.clone(),
549 tags: self.tags.clone(),
550 tickets: self.tickets.clone(),
551 meta: self.meta.clone(),
552 docs: if self.docs.is_empty() { None } else { Some(self.docs.clone()) },
553 source_order: self.source_order,
554 otel_spans: self.otel_spans.clone(),
555 }),
556 duration_ms: Some(duration),
557 retry: 0,
558 retries: 0,
559 attachments: if self.attachments.is_empty() {
560 None
561 } else {
562 Some(self.attachments.clone())
563 },
564 step_events: if step_events.is_empty() { None } else { Some(step_events) },
565 ..Default::default()
566 };
567 collector::record(tc);
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use std::thread;
575 use std::time::Duration;
576
577 #[test]
578 fn auto_and_on_repeated_primary_keyword() {
579 let mut story = Story::new("auto-and across story");
580 story.given("first given");
581 story.when("a when in between");
582 story.given("second given");
583
584 assert_eq!(story.steps[0].keyword, "Given");
585 assert_eq!(story.steps[1].keyword, "When");
586 assert_eq!(story.steps[2].keyword, "And");
587 }
588
589 #[test]
590 fn aaa_aliases_produce_correct_keywords() {
591 let mut story = Story::new("AAA story");
592 story.arrange("precondition");
593 story.act("action");
594 story.assert_that("outcome");
595
596 assert_eq!(story.steps[0].keyword, "Given");
597 assert_eq!(story.steps[1].keyword, "When");
598 assert_eq!(story.steps[2].keyword, "Then");
599 }
600
601 #[test]
602 fn extra_aliases_produce_correct_keywords() {
603 let mut story = Story::new("Extra aliases");
604 story.setup("setup");
605 story.context("context");
606 story.execute("execute");
607 story.action("action");
608 story.verify("verify");
609
610 assert_eq!(story.steps[0].keyword, "Given");
611 assert_eq!(story.steps[1].keyword, "And");
612 assert_eq!(story.steps[2].keyword, "When");
613 assert_eq!(story.steps[3].keyword, "And");
614 assert_eq!(story.steps[4].keyword, "Then");
615 }
616
617 #[test]
618 fn with_docs_attaches_to_step() {
619 let mut story = Story::new("With docs");
620 story.given("a step").with_docs(vec![StepDoc::note("a note"), StepDoc::tag(&["smoke"])]);
621
622 assert_eq!(story.steps.len(), 1);
623 let docs = story.steps[0].docs.as_ref().unwrap();
624 assert_eq!(docs.len(), 2);
625 let note = serde_json::to_value(&docs[0]).unwrap();
626 assert_eq!(note["kind"], "note");
627 assert_eq!(note["text"], "a note");
628 let tag = serde_json::to_value(&docs[1]).unwrap();
629 assert_eq!(tag["kind"], "tag");
630 }
631
632 #[test]
633 fn step_timing() {
634 let mut story = Story::new("Timed step");
635 story.given("slow step");
636 let token = story.start_timer();
637 thread::sleep(Duration::from_millis(15));
638 story.end_timer(token);
639 story.pass();
640
641 assert_eq!(story.steps.len(), 1);
642 let d = story.steps[0].duration_ms.unwrap();
643 assert!(d >= 10.0, "duration_ms should be >= 10, got {d}");
644 }
645
646 #[test]
647 fn double_end_timer_is_noop() {
648 let mut story = Story::new("Double end");
649 story.given("step");
650 let token = story.start_timer();
651 story.end_timer(token);
652 story.end_timer(token); story.pass();
654
655 assert_eq!(story.steps.len(), 1);
656 assert!(story.steps[0].duration_ms.is_some());
657 }
658
659 #[test]
660 fn fn_step_creates_wrapped_step() {
661 let mut story = Story::new("fn_step test");
662 let mut called = false;
663 story.fn_step("Given", "a wrapped precondition", || {
664 called = true;
665 });
666 story.pass();
667
668 assert!(called);
669 assert_eq!(story.steps.len(), 1);
670 assert_eq!(story.steps[0].keyword, "Given");
671 assert_eq!(story.steps[0].text, "a wrapped precondition");
672 assert_eq!(story.steps[0].wrapped, Some(true));
673 assert!(story.steps[0].duration_ms.is_some());
674 }
675
676 #[test]
677 fn fn_step_records_duration() {
678 let mut story = Story::new("fn_step duration");
679 story.fn_step("When", "I wait briefly", || {
680 thread::sleep(Duration::from_millis(15));
681 });
682 story.pass();
683
684 let d = story.steps[0].duration_ms.unwrap();
685 assert!(d >= 10.0, "duration_ms should be >= 10, got {d}");
686 }
687
688 #[test]
689 fn fn_step_returns_result() {
690 let mut story = Story::new("fn_step return");
691 let result = story.fn_step("When", "I compute", || 42);
692 story.pass();
693
694 assert_eq!(result, 42);
695 }
696
697 #[test]
698 #[should_panic(expected = "boom")]
699 fn fn_step_propagates_panics() {
700 let mut story = Story::new("fn_step panic");
701 story.fn_step("Then", "it panics", || {
702 panic!("boom");
703 });
704 story.pass();
705 }
706
707 #[test]
708 fn fn_step_auto_and_conversion() {
709 let mut story = Story::new("fn_step auto-and");
710 story.given("a text-only step");
711 story.fn_step("Given", "a wrapped step", || {});
712 story.pass();
713
714 assert_eq!(story.steps[0].keyword, "Given");
715 assert_eq!(story.steps[1].keyword, "And");
716 assert_eq!(story.steps[1].wrapped, Some(true));
717 }
718
719 #[test]
720 fn fn_step_integration_with_markers() {
721 let mut story = Story::new("fn_step + markers");
722 story.given("a text-only precondition");
723 story.fn_step("When", "I perform action", || {});
724 story.then("a text-only assertion");
725 story.fn_step("Then", "the wrapped assertion", || {});
726 story.pass();
727
728 assert_eq!(story.steps.len(), 4);
729 assert_eq!(story.steps[0].wrapped, None);
730 assert_eq!(story.steps[1].wrapped, Some(true));
731 assert_eq!(story.steps[2].wrapped, None);
732 assert_eq!(story.steps[3].wrapped, Some(true));
733 }
734
735 #[test]
736 fn expect_step_creates_wrapped_then_step() {
737 let mut story = Story::new("expect_step test");
738 let mut called = false;
739 story.expect_step("the result is correct", || {
740 called = true;
741 });
742 story.pass();
743
744 assert!(called);
745 assert_eq!(story.steps[0].keyword, "Then");
746 assert_eq!(story.steps[0].text, "the result is correct");
747 assert_eq!(story.steps[0].wrapped, Some(true));
748 assert!(story.steps[0].duration_ms.is_some());
749 }
750
751 #[test]
752 fn expect_step_returns_result() {
753 let mut story = Story::new("expect_step return");
754 let result = story.expect_step("check value", || true);
755 story.pass();
756
757 assert!(result);
758 }
759}