Skip to main content

allure_rust_commons/
lifecycle.rs

1//! Low-level lifecycle owner for mutable Allure test state.
2
3use std::{
4    cell::RefCell,
5    cmp,
6    collections::HashMap,
7    sync::{
8        atomic::{AtomicU64, Ordering},
9        Arc, Mutex,
10    },
11    time::{SystemTime, UNIX_EPOCH},
12};
13
14use crate::{
15    config,
16    http_exchange::{HttpExchange, HTTP_EXCHANGE_ATTACHMENT_MIME, HTTP_EXCHANGE_ATTACHMENT_NAME},
17    md5::md5_hex,
18    model::{
19        Attachment, FixtureResult, GlobalAttachment, GlobalError, Globals, Label, Link, Parameter,
20        ParameterMode, Stage, Status, StatusDetails, StepResult, TestResult, TestResultContainer,
21    },
22    writer::FileSystemResultsWriter,
23};
24
25thread_local! {
26    static ACTIVE_TEST_ROOT: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
27    static ACTIVE_SCOPE_ROOT: RefCell<Option<String>> = const { RefCell::new(None) };
28}
29
30static ID_COUNTER: AtomicU64 = AtomicU64::new(1);
31
32fn now_millis() -> i64 {
33    SystemTime::now()
34        .duration_since(UNIX_EPOCH)
35        .map(|d| d.as_millis() as i64)
36        .unwrap_or_default()
37}
38
39fn next_id() -> String {
40    format!(
41        "{}-{}",
42        now_millis(),
43        ID_COUNTER.fetch_add(1, Ordering::Relaxed)
44    )
45}
46
47fn round_millis(value: f64) -> i64 {
48    value.round() as i64
49}
50
51fn normalize_times(
52    start: Option<i64>,
53    stop: Option<i64>,
54    duration: Option<f64>,
55    fallback_stop: i64,
56) -> (Option<i64>, Option<i64>) {
57    let rounded_duration = duration.map(round_millis).map(|value| cmp::max(value, 0));
58
59    let (start, stop) = match (start, stop, rounded_duration) {
60        (Some(start), Some(stop), _) => (start, cmp::max(stop, start)),
61        (Some(start), None, Some(duration)) => (start, start.saturating_add(duration)),
62        (None, Some(stop), Some(duration)) => (stop.saturating_sub(duration), stop),
63        (Some(start), None, None) => (start, cmp::max(fallback_stop, start)),
64        (None, Some(stop), None) => (stop, stop),
65        (None, None, Some(duration)) => {
66            let stop = fallback_stop;
67            (stop.saturating_sub(duration), stop)
68        }
69        (None, None, None) => (fallback_stop, fallback_stop),
70    };
71
72    (Some(start), Some(stop))
73}
74
75fn normalize_step_result(step: &mut StepResult, fallback_stop: i64) {
76    (step.start, step.stop) = normalize_times(step.start, step.stop, None, fallback_stop);
77    for nested in &mut step.steps {
78        normalize_step_result(nested, step.stop.unwrap_or(fallback_stop));
79    }
80}
81
82fn normalize_fixture_result(fixture: &mut FixtureResult, fallback_stop: i64) {
83    (fixture.start, fixture.stop) =
84        normalize_times(fixture.start, fixture.stop, None, fallback_stop);
85    let fixture_stop = fixture.stop.unwrap_or(fallback_stop);
86    for step in &mut fixture.steps {
87        normalize_step_result(step, fixture_stop);
88    }
89}
90
91fn normalize_test_result(test: &mut TestResult, fallback_stop: i64) {
92    (test.start, test.stop) = normalize_times(test.start, test.stop, None, fallback_stop);
93    let test_stop = test.stop.unwrap_or(fallback_stop);
94    for step in &mut test.steps {
95        normalize_step_result(step, test_stop);
96    }
97}
98
99fn normalize_container_times(container: &mut TestResultContainer, fallback_stop: i64) {
100    (container.start, container.stop) =
101        normalize_times(container.start, container.stop, None, fallback_stop);
102    let container_stop = container.stop.unwrap_or(fallback_stop);
103    for fixture in &mut container.befores {
104        normalize_fixture_result(fixture, container_stop);
105    }
106    for fixture in &mut container.afters {
107        normalize_fixture_result(fixture, container_stop);
108    }
109}
110
111fn derive_test_case_id(test: &TestResult) -> Option<String> {
112    test.test_case_id
113        .clone()
114        .or_else(|| test.full_name.clone().map(|full_name| md5_hex(&full_name)))
115}
116
117fn derive_history_id(test: &TestResult) -> Option<String> {
118    let base = test
119        .test_case_id
120        .as_ref()
121        .or(test.full_name.as_ref())
122        .or(Some(&test.name))?;
123
124    let mut parameters = test
125        .parameters
126        .iter()
127        .filter(|parameter| parameter.excluded != Some(true))
128        .map(|parameter| format!("{}:{}", parameter.name, parameter.value))
129        .collect::<Vec<_>>();
130    parameters.sort();
131    let parameter_hash = md5_hex(&parameters.join(","));
132
133    Some(md5_hex(&format!("{base}:{parameter_hash}")))
134}
135
136/// Factory for lifecycle instances that share a results writer.
137#[derive(Clone)]
138pub struct AllureRuntime {
139    writer: Arc<FileSystemResultsWriter>,
140}
141
142impl AllureRuntime {
143    /// Creates a runtime backed by the given filesystem writer.
144    pub fn new(writer: FileSystemResultsWriter) -> Self {
145        Self {
146            writer: Arc::new(writer),
147        }
148    }
149
150    /// Creates an independent lifecycle owner that writes through this runtime.
151    pub fn lifecycle(&self) -> AllureLifecycle {
152        AllureLifecycle {
153            writer: Arc::clone(&self.writer),
154            state: Arc::new(Mutex::new(LifecycleState::default())),
155        }
156    }
157}
158
159/// Owns mutable in-progress Allure lifecycle state.
160#[derive(Clone)]
161pub struct AllureLifecycle {
162    writer: Arc<FileSystemResultsWriter>,
163    state: Arc<Mutex<LifecycleState>>,
164}
165
166/// Parameters used to start a test case.
167#[derive(Debug, Clone, Default)]
168pub struct StartTestCaseParams {
169    /// Optional explicit test UUID.
170    pub uuid: Option<String>,
171    /// Test display name.
172    pub name: String,
173    /// Stable fully qualified test name.
174    pub full_name: Option<String>,
175    /// Optional explicit history identifier.
176    pub history_id: Option<String>,
177    /// Optional explicit logical test case identifier.
178    pub test_case_id: Option<String>,
179    /// Markdown description.
180    pub description: Option<String>,
181    /// HTML description.
182    pub description_html: Option<String>,
183    /// Initial status.
184    pub status: Option<Status>,
185    /// Initial status details.
186    pub status_details: Option<StatusDetails>,
187    /// Initial lifecycle stage.
188    pub stage: Option<Stage>,
189    /// Initial labels.
190    pub labels: Vec<Label>,
191    /// Initial links.
192    pub links: Vec<Link>,
193    /// Initial parameters.
194    pub parameters: Vec<Parameter>,
195    /// Initial steps.
196    pub steps: Vec<StepResult>,
197    /// Initial attachments.
198    pub attachments: Vec<Attachment>,
199    /// Optional title path.
200    pub title_path: Option<Vec<String>>,
201    /// Optional start timestamp in milliseconds since the Unix epoch.
202    pub start: Option<i64>,
203    /// Optional stop timestamp in milliseconds since the Unix epoch.
204    pub stop: Option<i64>,
205}
206
207impl StartTestCaseParams {
208    /// Creates start parameters with a display name.
209    pub fn new(name: impl Into<String>) -> Self {
210        Self {
211            name: name.into(),
212            ..Default::default()
213        }
214    }
215
216    /// Sets the full name.
217    pub fn with_full_name(mut self, full_name: impl Into<String>) -> Self {
218        self.full_name = Some(full_name.into());
219        self
220    }
221}
222
223impl From<String> for StartTestCaseParams {
224    fn from(name: String) -> Self {
225        Self {
226            name,
227            ..Default::default()
228        }
229    }
230}
231
232impl From<&str> for StartTestCaseParams {
233    fn from(name: &str) -> Self {
234        Self::from(name.to_string())
235    }
236}
237
238#[derive(Default)]
239struct LifecycleState {
240    tests: HashMap<String, TestState>,
241    scopes: HashMap<String, ScopeState>,
242}
243
244struct TestState {
245    test: TestResult,
246    step_stack: Vec<RunningStep>,
247    linked_scopes: Vec<String>,
248}
249
250struct ScopeState {
251    container: TestResultContainer,
252    running_fixture: Option<RunningFixture>,
253}
254
255struct RunningFixture {
256    kind: FixtureKind,
257    fixture: FixtureResult,
258    step_stack: Vec<RunningStep>,
259}
260
261enum FixtureKind {
262    Before,
263    After,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267enum RunningStepKind {
268    Step,
269    Stage,
270}
271
272struct RunningStep {
273    result: StepResult,
274    kind: RunningStepKind,
275}
276
277impl RunningStep {
278    fn new(name: impl Into<String>, timestamp: i64, kind: RunningStepKind) -> Self {
279        Self {
280            result: StepResult {
281                name: name.into(),
282                stage: Some(Stage::Running),
283                start: Some(timestamp),
284                ..Default::default()
285            },
286            kind,
287        }
288    }
289}
290
291impl std::ops::Deref for RunningStep {
292    type Target = StepResult;
293
294    fn deref(&self) -> &Self::Target {
295        &self.result
296    }
297}
298
299impl std::ops::DerefMut for RunningStep {
300    fn deref_mut(&mut self) -> &mut Self::Target {
301        &mut self.result
302    }
303}
304
305impl AllureLifecycle {
306    /// Starts a test case and makes it current on this thread.
307    pub fn start_test_case(&self, params: impl Into<StartTestCaseParams>) {
308        let params = params.into();
309        let name = params.name;
310        let uuid = params.uuid.unwrap_or_else(next_id);
311        let full_name = params.full_name.or_else(|| Some(name.clone()));
312        let mut labels = config::global_labels_from_environment()
313            .into_iter()
314            .map(|(name, value)| Label { name, value })
315            .collect::<Vec<_>>();
316        labels.extend(params.labels);
317
318        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
319        lock.tests.insert(
320            uuid.clone(),
321            TestState {
322                test: TestResult {
323                    uuid: uuid.clone(),
324                    name,
325                    full_name,
326                    history_id: params.history_id,
327                    test_case_id: params.test_case_id,
328                    description: params.description,
329                    description_html: params.description_html,
330                    status: params.status,
331                    status_details: params.status_details,
332                    stage: params.stage.or(Some(Stage::Running)),
333                    labels,
334                    links: params.links,
335                    parameters: params.parameters,
336                    steps: params.steps,
337                    attachments: params.attachments,
338                    title_path: params.title_path,
339                    start: params.start.or_else(|| Some(now_millis())),
340                    stop: params.stop,
341                },
342                step_stack: Vec::new(),
343                linked_scopes: Vec::new(),
344            },
345        );
346        ACTIVE_TEST_ROOT.with(|cell| cell.borrow_mut().push(uuid));
347    }
348
349    /// Returns the UUID of the current test on this thread.
350    pub fn current_test_uuid(&self) -> Option<String> {
351        ACTIVE_TEST_ROOT.with(|cell| cell.borrow().last().cloned())
352    }
353
354    /// Stops and writes the current test case.
355    pub fn stop_test_case(&self, status: Status, details: Option<StatusDetails>) {
356        let Some(test_uuid) = ACTIVE_TEST_ROOT.with(|cell| cell.borrow().last().cloned()) else {
357            return;
358        };
359
360        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
361        if let Some(mut state) = lock.tests.remove(&test_uuid) {
362            finalize_steps(
363                &mut state.step_stack,
364                &mut state.test.steps,
365                status.clone(),
366                details.clone(),
367            );
368            merge_before_scope_metadata(&lock, &mut state.test, &state.linked_scopes);
369
370            state.test.status = Some(status);
371            state.test.status_details = details;
372            state.test.stage = Some(Stage::Finished);
373            let fallback_stop = now_millis();
374            if state.test.test_case_id.is_none() {
375                state.test.test_case_id = derive_test_case_id(&state.test);
376            }
377            if state.test.history_id.is_none() {
378                state.test.history_id = derive_history_id(&state.test);
379            }
380            normalize_test_result(&mut state.test, fallback_stop);
381            let _ = self.writer.write_result(&state.test);
382        }
383
384        ACTIVE_TEST_ROOT.with(|cell| {
385            let mut roots = cell.borrow_mut();
386            if roots.last().is_some_and(|uuid| uuid == &test_uuid) {
387                roots.pop();
388            } else {
389                roots.retain(|uuid| uuid != &test_uuid);
390            }
391        });
392    }
393
394    /// Mutates the current test result.
395    pub fn update_test_case<F>(&self, update: F)
396    where
397        F: FnOnce(&mut TestResult),
398    {
399        let Some(test_uuid) = ACTIVE_TEST_ROOT.with(|cell| cell.borrow().last().cloned()) else {
400            return;
401        };
402        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
403        if let Some(state) = lock.tests.get_mut(&test_uuid) {
404            update(&mut state.test);
405        }
406    }
407
408    /// Sets the current test case identifier.
409    pub fn set_test_case_id(&self, test_case_id: impl Into<String>) {
410        let test_case_id = test_case_id.into();
411        self.update_test_case(|test| test.test_case_id = Some(test_case_id));
412    }
413
414    /// Sets the current test history identifier.
415    pub fn set_history_id(&self, history_id: impl Into<String>) {
416        let history_id = history_id.into();
417        self.update_test_case(|test| test.history_id = Some(history_id));
418    }
419
420    /// Adds a label to the current test.
421    pub fn add_label(&self, name: impl Into<String>, value: impl Into<String>) {
422        let name = name.into();
423        let value = value.into();
424        self.update_test_case(|test| {
425            if matches!(name.as_str(), "parentSuite" | "suite" | "subSuite") {
426                test.labels.retain(|label| label.name != name);
427            }
428            test.labels.push(Label {
429                name: name.clone(),
430                value: value.clone(),
431            });
432        });
433    }
434
435    /// Adds a link to the current test.
436    pub fn add_link(
437        &self,
438        url: impl Into<String>,
439        name: Option<String>,
440        link_type: Option<String>,
441    ) {
442        let url = url.into();
443        self.update_test_case(|test| {
444            test.links.push(Link {
445                name,
446                url,
447                link_type,
448            })
449        });
450    }
451
452    /// Adds a parameter to the current test.
453    pub fn add_parameter(&self, name: impl Into<String>, value: impl Into<String>) {
454        self.add_parameter_with_options(name, value, None, None);
455    }
456
457    /// Adds a parameter with identity and display options to the current test.
458    pub fn add_parameter_with_options(
459        &self,
460        name: impl Into<String>,
461        value: impl Into<String>,
462        excluded: Option<bool>,
463        mode: Option<ParameterMode>,
464    ) {
465        let name = name.into();
466        let value = value.into();
467        self.update_test_case(|test| {
468            test.parameters.push(Parameter {
469                name,
470                value,
471                excluded,
472                mode,
473            })
474        });
475    }
476
477    /// Starts a fixture/test container scope.
478    pub fn start_scope(&self, name: Option<String>) -> String {
479        let uuid = next_id();
480        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
481        lock.scopes.insert(
482            uuid.clone(),
483            ScopeState {
484                container: TestResultContainer {
485                    uuid: uuid.clone(),
486                    name,
487                    start: Some(now_millis()),
488                    ..Default::default()
489                },
490                running_fixture: None,
491            },
492        );
493        uuid
494    }
495
496    /// Links an existing scope to an existing test.
497    pub fn link_scope_to_test(&self, scope_uuid: &str, test_uuid: &str) {
498        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
499        let has_scope = lock.scopes.contains_key(scope_uuid);
500        let has_test = lock.tests.contains_key(test_uuid);
501        if !(has_scope && has_test) {
502            return;
503        }
504
505        if let Some(scope) = lock.scopes.get_mut(scope_uuid) {
506            if !scope
507                .container
508                .children
509                .iter()
510                .any(|child| child == test_uuid)
511            {
512                scope.container.children.push(test_uuid.to_string());
513            }
514        }
515        if let Some(test) = lock.tests.get_mut(test_uuid) {
516            if !test.linked_scopes.iter().any(|scope| scope == scope_uuid) {
517                test.linked_scopes.push(scope_uuid.to_string());
518            }
519        }
520    }
521
522    /// Stops a scope and finalizes any running fixture inside it.
523    pub fn stop_scope(&self, scope_uuid: &str) {
524        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
525        if let Some(scope) = lock.scopes.get_mut(scope_uuid) {
526            finish_running_fixture(scope);
527            normalize_container_times(&mut scope.container, now_millis());
528        }
529        ACTIVE_SCOPE_ROOT.with(|cell| {
530            if cell.borrow().as_deref() == Some(scope_uuid) {
531                *cell.borrow_mut() = None;
532            }
533        });
534    }
535
536    /// Writes and removes a stopped scope.
537    pub fn write_scope(&self, scope_uuid: &str) {
538        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
539        if let Some(scope) = lock.scopes.remove(scope_uuid) {
540            let _ = self.writer.write_container(&scope.container);
541        }
542    }
543
544    /// Starts a before fixture inside a scope.
545    pub fn start_before_fixture(&self, scope_uuid: &str, name: impl Into<String>) {
546        self.start_fixture(scope_uuid, name.into(), FixtureKind::Before);
547    }
548
549    /// Stops the running before fixture inside a scope.
550    pub fn stop_before_fixture(
551        &self,
552        scope_uuid: &str,
553        status: Status,
554        details: Option<StatusDetails>,
555    ) {
556        self.stop_fixture(scope_uuid, FixtureKind::Before, status, details);
557    }
558
559    /// Starts an after fixture inside a scope.
560    pub fn start_after_fixture(&self, scope_uuid: &str, name: impl Into<String>) {
561        self.start_fixture(scope_uuid, name.into(), FixtureKind::After);
562    }
563
564    /// Stops the running after fixture inside a scope.
565    pub fn stop_after_fixture(
566        &self,
567        scope_uuid: &str,
568        status: Status,
569        details: Option<StatusDetails>,
570    ) {
571        self.stop_fixture(scope_uuid, FixtureKind::After, status, details);
572    }
573
574    /// Adds an attachment to the exact current owner.
575    pub fn add_attachment(
576        &self,
577        name: impl Into<String>,
578        content_type: impl Into<String>,
579        bytes: &[u8],
580    ) {
581        let name = name.into();
582        let content_type = content_type.into();
583        let id = next_id();
584        if let Ok((source, _)) =
585            self.writer
586                .write_attachment_auto(&id, Some(&name), Some(&content_type), bytes)
587        {
588            let attachment = Attachment {
589                name,
590                source,
591                content_type,
592            };
593            let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
594            if let Some(scope_uuid) = ACTIVE_SCOPE_ROOT.with(|cell| cell.borrow().clone()) {
595                if let Some(scope) = lock.scopes.get_mut(&scope_uuid) {
596                    if let Some(fixture) = scope.running_fixture.as_mut() {
597                        if let Some(step) = fixture.step_stack.last_mut() {
598                            step.attachments.push(attachment);
599                        } else {
600                            fixture.fixture.attachments.push(attachment);
601                        }
602                        return;
603                    }
604                }
605            }
606
607            if let Some(test_uuid) = ACTIVE_TEST_ROOT.with(|cell| cell.borrow().last().cloned()) {
608                if let Some(test_state) = lock.tests.get_mut(&test_uuid) {
609                    if let Some(step) = test_state.step_stack.last_mut() {
610                        step.attachments.push(attachment);
611                    } else {
612                        test_state.test.attachments.push(attachment);
613                    }
614                }
615            }
616        }
617    }
618
619    /// Adds an HTTP exchange attachment to the exact current owner.
620    pub fn add_http_exchange(&self, exchange: HttpExchange) {
621        self.add_http_exchange_named(HTTP_EXCHANGE_ATTACHMENT_NAME, exchange);
622    }
623
624    /// Adds a named HTTP exchange attachment to the exact current owner.
625    pub fn add_http_exchange_named(&self, name: impl Into<String>, exchange: HttpExchange) {
626        if let Ok(bytes) = serde_json::to_vec(&exchange) {
627            self.add_attachment(name, HTTP_EXCHANGE_ATTACHMENT_MIME, &bytes);
628        }
629    }
630
631    /// Writes a run-level attachment.
632    pub fn add_global_attachment(
633        &self,
634        name: impl Into<String>,
635        content_type: impl Into<String>,
636        bytes: &[u8],
637    ) -> std::io::Result<()> {
638        let name = name.into();
639        let content_type = content_type.into();
640        let (source, _) = self.writer.write_attachment_auto(
641            &next_id(),
642            Some(&name),
643            Some(&content_type),
644            bytes,
645        )?;
646        self.writer
647            .write_globals_typed(&Globals {
648                attachments: vec![GlobalAttachment {
649                    name,
650                    source,
651                    content_type,
652                }],
653                errors: Vec::new(),
654            })
655            .map(|_| ())
656    }
657
658    /// Writes a run-level error.
659    pub fn add_global_error(
660        &self,
661        message: impl Into<String>,
662        trace: Option<String>,
663    ) -> std::io::Result<()> {
664        self.writer
665            .write_globals_typed(&Globals {
666                attachments: Vec::new(),
667                errors: vec![GlobalError {
668                    message: message.into(),
669                    trace,
670                }],
671            })
672            .map(|_| ())
673    }
674
675    /// Starts a step under the current owner.
676    pub fn start_step(&self, name: impl Into<String>) {
677        self.start_step_at(name, None);
678    }
679
680    /// Starts a step under the current owner at an optional timestamp.
681    pub fn start_step_at(&self, name: impl Into<String>, timestamp: Option<i64>) -> i64 {
682        let timestamp = timestamp.unwrap_or_else(now_millis);
683        let step = RunningStep::new(name, timestamp, RunningStepKind::Step);
684        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
685
686        if let Some(scope_uuid) = ACTIVE_SCOPE_ROOT.with(|cell| cell.borrow().clone()) {
687            if let Some(scope) = lock.scopes.get_mut(&scope_uuid) {
688                if let Some(fixture) = scope.running_fixture.as_mut() {
689                    fixture.step_stack.push(step);
690                    return timestamp;
691                }
692            }
693        }
694
695        if let Some(test_uuid) = ACTIVE_TEST_ROOT.with(|cell| cell.borrow().last().cloned()) {
696            if let Some(test_state) = lock.tests.get_mut(&test_uuid) {
697                test_state.step_stack.push(step);
698            }
699        }
700
701        timestamp
702    }
703
704    /// Starts a semantic stage under the current owner.
705    pub fn start_stage(&self, name: impl Into<String>) {
706        self.start_stage_at(name, None);
707    }
708
709    /// Starts a semantic stage under the current owner at an optional timestamp.
710    pub fn start_stage_at(&self, name: impl Into<String>, timestamp: Option<i64>) -> i64 {
711        let timestamp = timestamp.unwrap_or_else(now_millis);
712        let name = name.into();
713        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
714
715        if let Some(scope_uuid) = ACTIVE_SCOPE_ROOT.with(|cell| cell.borrow().clone()) {
716            if let Some(scope) = lock.scopes.get_mut(&scope_uuid) {
717                if let Some(fixture) = scope.running_fixture.as_mut() {
718                    start_stage_in_stack(
719                        &mut fixture.step_stack,
720                        &mut fixture.fixture.steps,
721                        name,
722                        timestamp,
723                    );
724                    return timestamp;
725                }
726            }
727        }
728
729        if let Some(test_uuid) = ACTIVE_TEST_ROOT.with(|cell| cell.borrow().last().cloned()) {
730            if let Some(test_state) = lock.tests.get_mut(&test_uuid) {
731                start_stage_in_stack(
732                    &mut test_state.step_stack,
733                    &mut test_state.test.steps,
734                    name,
735                    timestamp,
736                );
737            }
738        }
739
740        timestamp
741    }
742
743    /// Stops the current step.
744    pub fn stop_step(&self, status: Status, details: Option<StatusDetails>) {
745        self.stop_step_at(None, status, details);
746    }
747
748    /// Stops the current step at an optional timestamp.
749    pub fn stop_step_at(
750        &self,
751        timestamp: Option<i64>,
752        status: Status,
753        details: Option<StatusDetails>,
754    ) {
755        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
756
757        if let Some(scope_uuid) = ACTIVE_SCOPE_ROOT.with(|cell| cell.borrow().clone()) {
758            if let Some(scope) = lock.scopes.get_mut(&scope_uuid) {
759                if let Some(fixture) = scope.running_fixture.as_mut() {
760                    stop_one_step(
761                        &mut fixture.step_stack,
762                        &mut fixture.fixture.steps,
763                        timestamp,
764                        status,
765                        details,
766                    );
767                    return;
768                }
769            }
770        }
771
772        if let Some(test_uuid) = ACTIVE_TEST_ROOT.with(|cell| cell.borrow().last().cloned()) {
773            if let Some(test_state) = lock.tests.get_mut(&test_uuid) {
774                stop_one_step(
775                    &mut test_state.step_stack,
776                    &mut test_state.test.steps,
777                    timestamp,
778                    status,
779                    details,
780                );
781            }
782        }
783    }
784
785    /// Renames the current step.
786    pub fn set_current_step_display_name(&self, name: impl Into<String>) {
787        let name = name.into();
788        self.update_current_step(
789            move |step| step.name = name,
790            "attempted to rename current step, but no step is active",
791        );
792    }
793
794    /// Adds a parameter to the current step.
795    pub fn add_current_step_parameter(&self, name: impl Into<String>, value: impl Into<String>) {
796        self.add_current_step_parameter_with_options(name, value, None, None);
797    }
798
799    /// Adds a parameter with identity and display options to the current step.
800    pub fn add_current_step_parameter_with_options(
801        &self,
802        name: impl Into<String>,
803        value: impl Into<String>,
804        excluded: Option<bool>,
805        mode: Option<ParameterMode>,
806    ) {
807        let parameter = Parameter {
808            name: name.into(),
809            value: value.into(),
810            excluded,
811            mode,
812        };
813        self.update_current_step(
814            move |step| step.parameters.push(parameter),
815            "attempted to add a parameter to the current step, but no step is active",
816        );
817    }
818
819    fn start_fixture(&self, scope_uuid: &str, name: String, kind: FixtureKind) {
820        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
821        if let Some(scope) = lock.scopes.get_mut(scope_uuid) {
822            finish_running_fixture(scope);
823            scope.running_fixture = Some(RunningFixture {
824                kind,
825                fixture: FixtureResult {
826                    name,
827                    stage: Some(Stage::Running),
828                    start: Some(now_millis()),
829                    ..Default::default()
830                },
831                step_stack: Vec::new(),
832            });
833            ACTIVE_SCOPE_ROOT.with(|cell| *cell.borrow_mut() = Some(scope_uuid.to_string()));
834        }
835    }
836
837    pub(crate) fn update_current_step<F>(&self, update: F, missing_step_message: &str)
838    where
839        F: FnOnce(&mut StepResult),
840    {
841        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
842
843        if let Some(scope_uuid) = ACTIVE_SCOPE_ROOT.with(|cell| cell.borrow().clone()) {
844            if let Some(scope) = lock.scopes.get_mut(&scope_uuid) {
845                if let Some(fixture) = scope.running_fixture.as_mut() {
846                    if let Some(step) = fixture.step_stack.last_mut() {
847                        update(step);
848                        return;
849                    }
850                }
851            }
852        }
853
854        if let Some(test_uuid) = ACTIVE_TEST_ROOT.with(|cell| cell.borrow().last().cloned()) {
855            if let Some(test_state) = lock.tests.get_mut(&test_uuid) {
856                if let Some(step) = test_state.step_stack.last_mut() {
857                    update(step);
858                    return;
859                }
860            }
861        }
862
863        eprintln!("[allure-rust] {missing_step_message}");
864    }
865
866    fn stop_fixture(
867        &self,
868        scope_uuid: &str,
869        expected_kind: FixtureKind,
870        status: Status,
871        details: Option<StatusDetails>,
872    ) {
873        let mut lock = self.state.lock().expect("poisoned allure lifecycle mutex");
874        if let Some(scope) = lock.scopes.get_mut(scope_uuid) {
875            if let Some(mut fixture) = scope.running_fixture.take() {
876                if !matches!(
877                    (&fixture.kind, &expected_kind),
878                    (FixtureKind::Before, FixtureKind::Before)
879                        | (FixtureKind::After, FixtureKind::After)
880                ) {
881                    scope.running_fixture = Some(fixture);
882                    return;
883                }
884
885                finalize_steps(
886                    &mut fixture.step_stack,
887                    &mut fixture.fixture.steps,
888                    status.clone(),
889                    details.clone(),
890                );
891                fixture.fixture.status = Some(status);
892                fixture.fixture.status_details = details;
893                fixture.fixture.stage = Some(Stage::Finished);
894                normalize_fixture_result(&mut fixture.fixture, now_millis());
895                match fixture.kind {
896                    FixtureKind::Before => scope.container.befores.push(fixture.fixture),
897                    FixtureKind::After => scope.container.afters.push(fixture.fixture),
898                }
899            }
900        }
901        ACTIVE_SCOPE_ROOT.with(|cell| {
902            if cell.borrow().as_deref() == Some(scope_uuid) {
903                *cell.borrow_mut() = None;
904            }
905        });
906    }
907}
908
909fn stop_one_step(
910    stack: &mut Vec<RunningStep>,
911    root_steps: &mut Vec<StepResult>,
912    timestamp: Option<i64>,
913    status: Status,
914    details: Option<StatusDetails>,
915) {
916    close_active_stages(
917        stack,
918        root_steps,
919        timestamp,
920        status.clone(),
921        details.clone(),
922    );
923    if stack.is_empty() {
924        return;
925    }
926    stop_top_step(stack, root_steps, timestamp, status, details);
927}
928
929fn start_stage_in_stack(
930    stack: &mut Vec<RunningStep>,
931    root_steps: &mut Vec<StepResult>,
932    name: impl Into<String>,
933    timestamp: i64,
934) {
935    if matches!(
936        stack.last().map(|step| step.kind),
937        Some(RunningStepKind::Stage)
938    ) {
939        stop_top_step(stack, root_steps, Some(timestamp), Status::Passed, None);
940    }
941    stack.push(RunningStep::new(name, timestamp, RunningStepKind::Stage));
942}
943
944fn close_active_stages(
945    stack: &mut Vec<RunningStep>,
946    root_steps: &mut Vec<StepResult>,
947    timestamp: Option<i64>,
948    status: Status,
949    details: Option<StatusDetails>,
950) {
951    while matches!(
952        stack.last().map(|step| step.kind),
953        Some(RunningStepKind::Stage)
954    ) {
955        stop_top_step(
956            stack,
957            root_steps,
958            timestamp,
959            status.clone(),
960            details.clone(),
961        );
962    }
963}
964
965fn stop_top_step(
966    stack: &mut Vec<RunningStep>,
967    root_steps: &mut Vec<StepResult>,
968    timestamp: Option<i64>,
969    status: Status,
970    details: Option<StatusDetails>,
971) {
972    if let Some(mut step) = stack.pop() {
973        finish_step_result(&mut step.result, timestamp, status, details);
974        if let Some(parent) = stack.last_mut() {
975            parent.steps.push(step.result);
976        } else {
977            root_steps.push(step.result);
978        }
979    }
980}
981
982fn finish_step_result(
983    step: &mut StepResult,
984    timestamp: Option<i64>,
985    status: Status,
986    details: Option<StatusDetails>,
987) {
988    step.status.get_or_insert(status);
989    if step.status_details.is_none() {
990        step.status_details = details;
991    }
992    step.stage = Some(Stage::Finished);
993    normalize_step_result(step, timestamp.unwrap_or_else(now_millis));
994    if let Some(stop) = timestamp {
995        step.stop = Some(stop);
996        if step.start.is_none() {
997            step.start = Some(stop);
998        }
999    }
1000}
1001
1002fn finalize_steps(
1003    stack: &mut Vec<RunningStep>,
1004    root_steps: &mut Vec<StepResult>,
1005    context_status: Status,
1006    context_details: Option<StatusDetails>,
1007) {
1008    while let Some(mut step) = stack.pop() {
1009        let status = match step.kind {
1010            RunningStepKind::Step => Status::Broken,
1011            RunningStepKind::Stage => context_status.clone(),
1012        };
1013        let details = match step.kind {
1014            RunningStepKind::Step => None,
1015            RunningStepKind::Stage => context_details.clone(),
1016        };
1017        finish_step_result(&mut step.result, None, status, details);
1018        if let Some(parent) = stack.last_mut() {
1019            parent.steps.push(step.result);
1020        } else {
1021            root_steps.push(step.result);
1022        }
1023    }
1024}
1025
1026fn finish_running_fixture(scope: &mut ScopeState) {
1027    if let Some(mut fixture) = scope.running_fixture.take() {
1028        finalize_steps(
1029            &mut fixture.step_stack,
1030            &mut fixture.fixture.steps,
1031            Status::Broken,
1032            None,
1033        );
1034        fixture.fixture.status.get_or_insert(Status::Broken);
1035        fixture.fixture.stage = Some(Stage::Finished);
1036        normalize_fixture_result(&mut fixture.fixture, now_millis());
1037        match fixture.kind {
1038            FixtureKind::Before => scope.container.befores.push(fixture.fixture),
1039            FixtureKind::After => scope.container.afters.push(fixture.fixture),
1040        }
1041    }
1042}
1043
1044fn merge_before_scope_metadata(
1045    lock: &LifecycleState,
1046    test: &mut TestResult,
1047    linked_scopes: &[String],
1048) {
1049    for scope_uuid in linked_scopes {
1050        if let Some(scope) = lock.scopes.get(scope_uuid) {
1051            for link in &scope.container.links {
1052                test.links.push(link.clone());
1053            }
1054            for fixture in &scope.container.befores {
1055                for parameter in &fixture.parameters {
1056                    test.parameters.push(parameter.clone());
1057                }
1058            }
1059        }
1060    }
1061}
1062
1063#[cfg(test)]
1064#[path = "lifecycle_tests.rs"]
1065mod lifecycle_tests;