Skip to main content

allure_core/
runtime.rs

1//! Runtime context management for tracking test execution state.
2//!
3//! This module provides thread-local storage for synchronous tests and
4//! optional tokio task-local storage for async tests.
5
6use std::backtrace::Backtrace;
7use std::cell::RefCell;
8use std::panic::{catch_unwind, AssertUnwindSafe};
9use std::sync::OnceLock;
10#[cfg(feature = "tokio")]
11use std::sync::{Arc, Mutex};
12
13#[cfg(feature = "tokio")]
14type SharedAsyncContext = Arc<Mutex<Option<TestContext>>>;
15#[cfg(feature = "tokio")]
16type GlobalAsyncContexts = Mutex<Vec<SharedAsyncContext>>;
17
18use crate::enums::{ContentType, LabelName, LinkType, Severity, Status};
19use crate::model::{Attachment, Label, Parameter, StepResult, TestResult, TestResultContainer};
20use crate::writer::{compute_history_id, generate_uuid, AllureWriter};
21
22/// Global configuration for the Allure runtime.
23static CONFIG: OnceLock<AllureConfig> = OnceLock::new();
24
25#[cfg(feature = "tokio")]
26tokio::task_local! {
27    static TOKIO_CONTEXT: RefCell<Option<SharedAsyncContext>>;
28}
29
30#[cfg(feature = "tokio")]
31fn global_async_context() -> &'static GlobalAsyncContexts {
32    static GLOBAL: OnceLock<GlobalAsyncContexts> = OnceLock::new();
33    GLOBAL.get_or_init(|| Mutex::new(Vec::new()))
34}
35
36#[cfg(feature = "tokio")]
37fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
38    mutex.lock().unwrap_or_else(|e| e.into_inner())
39}
40
41#[cfg(feature = "tokio")]
42fn register_global_context(handle: SharedAsyncContext) {
43    let mut handles = lock_unpoisoned(global_async_context());
44    handles.push(handle);
45}
46
47#[cfg(feature = "tokio")]
48fn unregister_global_context(handle: &SharedAsyncContext) {
49    let mut handles = lock_unpoisoned(global_async_context());
50    handles.retain(|candidate| !Arc::ptr_eq(candidate, handle));
51}
52
53#[cfg(feature = "tokio")]
54fn current_global_context() -> Option<SharedAsyncContext> {
55    let handles = lock_unpoisoned(global_async_context());
56    if handles.len() == 1 {
57        Some(handles[0].clone())
58    } else {
59        None
60    }
61}
62
63#[cfg(feature = "tokio")]
64struct GlobalContextRegistration {
65    handle: SharedAsyncContext,
66}
67
68#[cfg(feature = "tokio")]
69impl GlobalContextRegistration {
70    fn new(handle: SharedAsyncContext) -> Self {
71        register_global_context(handle.clone());
72        Self { handle }
73    }
74}
75
76#[cfg(feature = "tokio")]
77impl Drop for GlobalContextRegistration {
78    fn drop(&mut self) {
79        unregister_global_context(&self.handle);
80    }
81}
82
83/// Configuration for the Allure runtime.
84#[derive(Debug, Clone)]
85pub struct AllureConfig {
86    /// Directory where results are written.
87    pub results_dir: String,
88    /// Whether to clean the results directory on init.
89    pub clean_results: bool,
90}
91
92impl Default for AllureConfig {
93    fn default() -> Self {
94        Self {
95            results_dir: crate::writer::DEFAULT_RESULTS_DIR.to_string(),
96            clean_results: true,
97        }
98    }
99}
100
101/// Builder for configuring the Allure runtime.
102#[derive(Debug, Default)]
103pub struct AllureConfigBuilder {
104    config: AllureConfig,
105}
106
107impl AllureConfigBuilder {
108    /// Creates a new configuration builder.
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Sets the results directory.
114    pub fn results_dir(mut self, path: impl Into<String>) -> Self {
115        self.config.results_dir = path.into();
116        self
117    }
118
119    /// Sets whether to clean the results directory.
120    pub fn clean_results(mut self, clean: bool) -> Self {
121        self.config.clean_results = clean;
122        self
123    }
124
125    /// Initializes the Allure runtime with this configuration.
126    pub fn init(self) -> std::io::Result<()> {
127        let writer = AllureWriter::with_results_dir(&self.config.results_dir);
128        writer.init(self.config.clean_results)?;
129        CONFIG.set(self.config).ok();
130        Ok(())
131    }
132}
133
134/// Configures the Allure runtime.
135pub fn configure() -> AllureConfigBuilder {
136    AllureConfigBuilder::new()
137}
138
139/// Gets the current configuration or the default.
140pub fn get_config() -> AllureConfig {
141    CONFIG.get().cloned().unwrap_or_default()
142}
143
144/// Test context holding the current test result and step stack.
145#[derive(Debug)]
146pub struct TestContext {
147    /// The current test result being built.
148    pub result: TestResult,
149    /// Stack of active steps (for nested steps).
150    pub step_stack: Vec<StepResult>,
151    /// The writer for this context.
152    pub writer: AllureWriter,
153}
154
155impl TestContext {
156    /// Creates a new test context.
157    pub fn new(name: impl Into<String>, full_name: impl Into<String>) -> Self {
158        let config = get_config();
159        let uuid = generate_uuid();
160        let mut result = TestResult::new(uuid, name.into());
161        result.full_name = Some(full_name.into());
162
163        // Add default labels
164        result.labels.push(Label::language("rust"));
165        result.labels.push(Label::framework("allure-rs"));
166
167        // Add host and thread labels
168        if let Ok(hostname) = std::env::var("HOSTNAME") {
169            result.labels.push(Label::host(hostname));
170        } else if let Ok(hostname) = hostname::get() {
171            if let Some(name) = hostname.to_str() {
172                result.labels.push(Label::host(name));
173            }
174        }
175
176        let thread_id = format!("{:?}", std::thread::current().id());
177        result.labels.push(Label::thread(thread_id));
178
179        Self {
180            result,
181            step_stack: Vec::new(),
182            writer: AllureWriter::with_results_dir(config.results_dir),
183        }
184    }
185
186    /// Adds a label to the current test.
187    pub fn add_label(&mut self, name: impl Into<String>, value: impl Into<String>) {
188        self.result.add_label(name, value);
189    }
190
191    /// Adds a label using a reserved name.
192    pub fn add_label_name(&mut self, name: LabelName, value: impl Into<String>) {
193        self.result.add_label_name(name, value);
194    }
195
196    /// Adds a link to the current test.
197    pub fn add_link(&mut self, url: impl Into<String>, name: Option<String>, link_type: LinkType) {
198        self.result.add_link(url, name, link_type);
199    }
200
201    /// Adds a parameter to the current test or step.
202    pub fn add_parameter(&mut self, name: impl Into<String>, value: impl Into<String>) {
203        if let Some(step) = self.step_stack.last_mut() {
204            step.add_parameter(name, value);
205        } else {
206            self.result.add_parameter(name, value);
207        }
208    }
209
210    /// Adds a parameter with custom options (hidden/masked/excluded).
211    pub fn add_parameter_struct(&mut self, parameter: Parameter) {
212        if let Some(step) = self.step_stack.last_mut() {
213            step.parameters.push(parameter);
214        } else {
215            self.result.parameters.push(parameter);
216        }
217    }
218
219    /// Adds an attachment to the current test or step.
220    pub fn add_attachment(&mut self, attachment: Attachment) {
221        if let Some(step) = self.step_stack.last_mut() {
222            step.add_attachment(attachment);
223        } else {
224            self.result.add_attachment(attachment);
225        }
226    }
227
228    /// Starts a new step.
229    pub fn start_step(&mut self, name: impl Into<String>) {
230        let step = StepResult::new(name);
231        self.step_stack.push(step);
232    }
233
234    /// Finishes the current step with the given status.
235    pub fn finish_step(&mut self, status: Status, message: Option<String>, trace: Option<String>) {
236        if let Some(mut step) = self.step_stack.pop() {
237            match status {
238                Status::Passed => step.pass(),
239                Status::Failed => step.fail(message, trace),
240                Status::Broken => step.broken(message, trace),
241                _ => {
242                    step.status = status;
243                    step.stage = crate::enums::Stage::Finished;
244                    step.stop = crate::model::current_time_ms();
245                }
246            }
247
248            // Add the finished step to the parent (either another step or the test result)
249            if let Some(parent_step) = self.step_stack.last_mut() {
250                parent_step.add_step(step);
251            } else {
252                self.result.add_step(step);
253            }
254        }
255    }
256
257    /// Computes and sets the history ID based on the full name and parameters.
258    pub fn compute_history_id(&mut self) {
259        if let Some(ref full_name) = self.result.full_name {
260            let history_id = compute_history_id(full_name, &self.result.parameters);
261            self.result.history_id = Some(history_id);
262        }
263    }
264
265    /// Finishes the test with the given status and writes the result.
266    pub fn finish(&mut self, status: Status, message: Option<String>, trace: Option<String>) {
267        // Finish any remaining open steps
268        while !self.step_stack.is_empty() {
269            self.finish_step(Status::Broken, Some("Step not completed".to_string()), None);
270        }
271
272        // Compute history ID before finishing
273        self.compute_history_id();
274
275        match status {
276            Status::Passed => self.result.pass(),
277            Status::Failed => self.result.fail(message, trace),
278            Status::Broken => self.result.broken(message, trace),
279            Status::Skipped => {
280                if message.is_some() || trace.is_some() {
281                    self.result.status_details = Some(crate::model::StatusDetails {
282                        message,
283                        trace,
284                        ..Default::default()
285                    });
286                }
287                self.result.status = status;
288                self.result.finish();
289            }
290            _ => {
291                self.result.status = status;
292                self.result.finish();
293            }
294        }
295
296        // Write the result
297        if let Err(e) = self.writer.write_test_result(&self.result) {
298            eprintln!("Failed to write Allure test result: {}", e);
299        }
300
301        // Emit a container linking this test (even if no fixtures are present yet)
302        let mut container = TestResultContainer::new(generate_uuid());
303        container.children.push(self.result.uuid.clone());
304        container.start = Some(self.result.start);
305        container.stop = Some(self.result.stop);
306        if let Err(e) = self.writer.write_container(&container) {
307            eprintln!("Failed to write Allure container: {}", e);
308        }
309    }
310
311    /// Creates a text attachment.
312    pub fn attach_text(&mut self, name: impl Into<String>, content: impl AsRef<str>) {
313        match self.writer.write_text_attachment(name, content) {
314            Ok(attachment) => self.add_attachment(attachment),
315            Err(e) => eprintln!("Failed to write text attachment: {}", e),
316        }
317    }
318
319    /// Creates a JSON attachment.
320    pub fn attach_json<T: serde::Serialize>(&mut self, name: impl Into<String>, value: &T) {
321        match self.writer.write_json_attachment(name, value) {
322            Ok(attachment) => self.add_attachment(attachment),
323            Err(e) => eprintln!("Failed to write JSON attachment: {}", e),
324        }
325    }
326
327    /// Creates a binary attachment.
328    pub fn attach_binary(
329        &mut self,
330        name: impl Into<String>,
331        content: &[u8],
332        content_type: ContentType,
333    ) {
334        match self
335            .writer
336            .write_binary_attachment(name, content, content_type)
337        {
338            Ok(attachment) => self.add_attachment(attachment),
339            Err(e) => eprintln!("Failed to write binary attachment: {}", e),
340        }
341    }
342
343    /// Attaches a file from the filesystem.
344    pub fn attach_file(
345        &mut self,
346        name: impl Into<String>,
347        path: impl AsRef<std::path::Path>,
348        content_type: Option<ContentType>,
349    ) {
350        match self.writer.copy_file_attachment(name, path, content_type) {
351            Ok(attachment) => self.add_attachment(attachment),
352            Err(e) => eprintln!("Failed to copy file attachment: {}", e),
353        }
354    }
355}
356
357// Thread-local storage for synchronous tests
358thread_local! {
359    static CURRENT_CONTEXT: RefCell<Option<TestContext>> = const { RefCell::new(None) };
360}
361
362/// Sets the current test context for the thread.
363pub fn set_context(ctx: TestContext) {
364    CURRENT_CONTEXT.with(|c| {
365        *c.borrow_mut() = Some(ctx);
366    });
367}
368
369/// Takes the current test context, leaving None in its place.
370pub fn take_context() -> Option<TestContext> {
371    #[cfg(feature = "tokio")]
372    {
373        if let Ok(context) = TOKIO_CONTEXT.try_with(|c| {
374            let handle_opt = c.borrow().clone();
375            handle_opt.and_then(|handle| {
376                let mut guard = lock_unpoisoned(&handle);
377                guard.take()
378            })
379        }) {
380            if context.is_some() {
381                return context;
382            }
383        }
384    }
385
386    let thread_local = CURRENT_CONTEXT.with(|c| c.borrow_mut().take());
387    if thread_local.is_some() {
388        return thread_local;
389    }
390
391    None
392}
393
394/// Executes a function with the current test context.
395pub fn with_context<F, R>(f: F) -> Option<R>
396where
397    F: FnOnce(&mut TestContext) -> R,
398{
399    let mut f_opt = Some(f);
400
401    #[cfg(feature = "tokio")]
402    {
403        if let Ok(result) = TOKIO_CONTEXT.try_with(|c| {
404            let handle_opt = c.borrow().clone();
405            if let Some(handle) = handle_opt {
406                let mut guard = lock_unpoisoned(&handle);
407                if let Some(ctx) = guard.as_mut() {
408                    if let Some(func) = f_opt.take() {
409                        return Some(func(ctx));
410                    }
411                }
412            }
413            None
414        }) {
415            if result.is_some() {
416                return result;
417            }
418        }
419    }
420
421    let thread_local = CURRENT_CONTEXT
422        .with(|c| {
423            let mut ctx = c.borrow_mut();
424            if let Some(ctx) = ctx.as_mut() {
425                if let Some(func) = f_opt.take() {
426                    return Some(func(ctx));
427                }
428            }
429            None
430        })
431        .or_else(|| {
432            #[cfg(feature = "tokio")]
433            {
434                if let Some(handle) = current_global_context() {
435                    let mut guard = lock_unpoisoned(&handle);
436                    if let Some(ctx) = guard.as_mut() {
437                        if let Some(func) = f_opt.take() {
438                            return Some(func(ctx));
439                        }
440                    }
441                }
442            }
443            None
444        });
445
446    thread_local
447}
448
449/// Runs a test function with Allure tracking.
450pub fn run_test<F>(name: &str, full_name: &str, f: F)
451where
452    F: FnOnce() + std::panic::UnwindSafe,
453{
454    let ctx = TestContext::new(name, full_name);
455    set_context(ctx);
456
457    let result = catch_unwind(AssertUnwindSafe(f));
458
459    // Extract panic message if there was an error
460    let (is_err, panic_payload) = match &result {
461        Ok(()) => (false, None),
462        Err(panic_info) => {
463            let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
464                Some(s.to_string())
465            } else if let Some(s) = panic_info.downcast_ref::<String>() {
466                Some(s.clone())
467            } else {
468                Some("Test panicked".to_string())
469            };
470            (true, msg)
471        }
472    };
473
474    // Finish the test context
475    if let Some(mut ctx) = take_context() {
476        if is_err {
477            let trace = capture_trace();
478            ctx.finish(Status::Failed, panic_payload, trace);
479        } else {
480            ctx.finish(Status::Passed, None, None);
481        }
482    }
483
484    // Re-panic if the test failed
485    if let Err(e) = result {
486        std::panic::resume_unwind(e);
487    }
488}
489
490/// Executes a closure with a temporary test context for documentation examples.
491///
492/// This function is useful for running doc tests that use runtime functions
493/// like `step()`, `label()`, etc. without needing the full test infrastructure.
494/// No test results are written to disk.
495///
496/// # Example
497///
498/// ```
499/// use allure_core::runtime::{with_test_context, step, epic};
500///
501/// with_test_context(|| {
502///     epic("My Epic");
503///     step("Do something", || {
504///         // test code
505///     });
506/// });
507/// ```
508#[doc(hidden)]
509pub fn with_test_context<F, R>(f: F) -> R
510where
511    F: FnOnce() -> R,
512{
513    let ctx = TestContext::new("doctest", "doctest::example");
514    set_context(ctx);
515    let result = f();
516    let _ = take_context(); // cleanup without writing
517    result
518}
519
520// === Public API functions ===
521
522/// Adds a label to the current test.
523///
524/// # Example
525///
526/// ```
527/// use allure_core::runtime::{with_test_context, label};
528///
529/// with_test_context(|| {
530///     label("environment", "staging");
531///     label("browser", "chrome");
532/// });
533/// ```
534pub fn label(name: impl Into<String>, value: impl Into<String>) {
535    with_context(|ctx| ctx.add_label(name, value));
536}
537
538/// Adds an epic label to the current test.
539///
540/// Epics represent high-level business capabilities in the BDD hierarchy.
541///
542/// # Example
543///
544/// ```
545/// use allure_core::runtime::{with_test_context, epic};
546///
547/// with_test_context(|| {
548///     epic("User Management");
549/// });
550/// ```
551pub fn epic(name: impl Into<String>) {
552    with_context(|ctx| ctx.add_label_name(LabelName::Epic, name));
553}
554
555/// Adds a feature label to the current test.
556///
557/// Features represent specific functionality under an epic.
558///
559/// # Example
560///
561/// ```
562/// use allure_core::runtime::{with_test_context, feature};
563///
564/// with_test_context(|| {
565///     feature("User Registration");
566/// });
567/// ```
568pub fn feature(name: impl Into<String>) {
569    with_context(|ctx| ctx.add_label_name(LabelName::Feature, name));
570}
571
572/// Adds a story label to the current test.
573///
574/// Stories represent user stories under a feature.
575///
576/// # Example
577///
578/// ```
579/// use allure_core::runtime::{with_test_context, story};
580///
581/// with_test_context(|| {
582///     story("User can register with email");
583/// });
584/// ```
585pub fn story(name: impl Into<String>) {
586    with_context(|ctx| ctx.add_label_name(LabelName::Story, name));
587}
588
589/// Adds a suite label to the current test.
590pub fn suite(name: impl Into<String>) {
591    with_context(|ctx| ctx.add_label_name(LabelName::Suite, name));
592}
593
594/// Adds a parent suite label to the current test.
595pub fn parent_suite(name: impl Into<String>) {
596    with_context(|ctx| ctx.add_label_name(LabelName::ParentSuite, name));
597}
598
599/// Adds a sub-suite label to the current test.
600pub fn sub_suite(name: impl Into<String>) {
601    with_context(|ctx| ctx.add_label_name(LabelName::SubSuite, name));
602}
603
604/// Adds a severity label to the current test.
605///
606/// # Example
607///
608/// ```
609/// use allure_core::runtime::{with_test_context, severity};
610/// use allure_core::Severity;
611///
612/// with_test_context(|| {
613///     severity(Severity::Critical);
614/// });
615/// ```
616pub fn severity(severity: Severity) {
617    with_context(|ctx| ctx.add_label_name(LabelName::Severity, severity.as_str()));
618}
619
620/// Adds an owner label to the current test.
621///
622/// # Example
623///
624/// ```
625/// use allure_core::runtime::{with_test_context, owner};
626///
627/// with_test_context(|| {
628///     owner("platform-team");
629/// });
630/// ```
631pub fn owner(name: impl Into<String>) {
632    with_context(|ctx| ctx.add_label_name(LabelName::Owner, name));
633}
634
635/// Adds a tag label to the current test.
636///
637/// # Example
638///
639/// ```
640/// use allure_core::runtime::{with_test_context, tag};
641///
642/// with_test_context(|| {
643///     tag("smoke");
644///     tag("regression");
645/// });
646/// ```
647pub fn tag(name: impl Into<String>) {
648    with_context(|ctx| ctx.add_label_name(LabelName::Tag, name));
649}
650
651/// Adds multiple tag labels to the current test.
652///
653/// # Example
654///
655/// ```
656/// use allure_core::runtime::{with_test_context, tags};
657///
658/// with_test_context(|| {
659///     tags(&["smoke", "regression", "api"]);
660/// });
661/// ```
662pub fn tags(names: &[&str]) {
663    with_context(|ctx| {
664        for name in names {
665            ctx.add_label_name(LabelName::Tag, *name);
666        }
667    });
668}
669
670/// Adds an Allure ID label to the current test.
671pub fn allure_id(id: impl Into<String>) {
672    with_context(|ctx| ctx.add_label_name(LabelName::AllureId, id));
673}
674
675/// Sets a custom title for the current test.
676///
677/// This overrides the test name displayed in the Allure report.
678///
679/// # Example
680///
681/// ```
682/// use allure_core::runtime::{with_test_context, title};
683///
684/// with_test_context(|| {
685///     title("User can login with valid credentials");
686/// });
687/// ```
688pub fn title(name: impl Into<String>) {
689    with_context(|ctx| ctx.result.name = name.into());
690}
691
692/// Sets the test description (markdown).
693pub fn description(text: impl Into<String>) {
694    with_context(|ctx| ctx.result.description = Some(text.into()));
695}
696
697/// Sets the test description (HTML).
698pub fn description_html(html: impl Into<String>) {
699    with_context(|ctx| ctx.result.description_html = Some(html.into()));
700}
701
702/// Adds an issue link to the current test.
703pub fn issue(url: impl Into<String>, name: Option<String>) {
704    with_context(|ctx| ctx.add_link(url, name, LinkType::Issue));
705}
706
707/// Adds a TMS link to the current test.
708pub fn tms(url: impl Into<String>, name: Option<String>) {
709    with_context(|ctx| ctx.add_link(url, name, LinkType::Tms));
710}
711
712/// Adds a generic link to the current test.
713pub fn link(url: impl Into<String>, name: Option<String>) {
714    with_context(|ctx| ctx.add_link(url, name, LinkType::Default));
715}
716
717/// Adds a parameter to the current test or step.
718///
719/// Parameters are displayed in the Allure report and can be used
720/// to understand what inputs were used for a test run.
721///
722/// # Example
723///
724/// ```
725/// use allure_core::runtime::{with_test_context, parameter};
726///
727/// with_test_context(|| {
728///     parameter("username", "john_doe");
729///     parameter("count", 42);
730/// });
731/// ```
732pub fn parameter(name: impl Into<String>, value: impl ToString) {
733    with_context(|ctx| ctx.add_parameter(name, value.to_string()));
734}
735
736/// Adds a parameter hidden from display (value not shown in the report).
737pub fn parameter_hidden(name: impl Into<String>, value: impl ToString) {
738    with_context(|ctx| ctx.add_parameter_struct(Parameter::hidden(name, value.to_string())));
739}
740
741/// Adds a parameter with a masked value (e.g., passwords).
742pub fn parameter_masked(name: impl Into<String>, value: impl ToString) {
743    with_context(|ctx| ctx.add_parameter_struct(Parameter::masked(name, value.to_string())));
744}
745
746/// Adds a parameter excluded from history ID calculation.
747pub fn parameter_excluded(name: impl Into<String>, value: impl ToString) {
748    with_context(|ctx| ctx.add_parameter_struct(Parameter::excluded(name, value.to_string())));
749}
750
751/// Executes a step with the given name and body.
752///
753/// Steps are the building blocks of test reports. They provide
754/// a hierarchical view of what the test is doing and can be nested.
755///
756/// # Example
757///
758/// ```
759/// use allure_core::runtime::{with_test_context, step};
760///
761/// with_test_context(|| {
762///     step("Login to application", || {
763///         step("Enter credentials", || {
764///             // Enter username and password
765///         });
766///         step("Click submit", || {
767///             // Click the submit button
768///         });
769///     });
770/// });
771/// ```
772///
773/// Steps can also return values:
774///
775/// ```
776/// use allure_core::runtime::{with_test_context, step};
777///
778/// with_test_context(|| {
779///     let result = step("Calculate result", || {
780///         2 + 2
781///     });
782///     assert_eq!(result, 4);
783/// });
784/// ```
785pub fn step<F, R>(name: impl Into<String>, body: F) -> R
786where
787    F: FnOnce() -> R,
788{
789    let step_name = name.into();
790
791    with_context(|ctx| ctx.start_step(&step_name));
792
793    let result = catch_unwind(AssertUnwindSafe(body));
794
795    match &result {
796        Ok(_) => {
797            with_context(|ctx| ctx.finish_step(Status::Passed, None, None));
798        }
799        Err(panic_info) => {
800            let message = if let Some(s) = panic_info.downcast_ref::<&str>() {
801                Some(s.to_string())
802            } else if let Some(s) = panic_info.downcast_ref::<String>() {
803                Some(s.clone())
804            } else {
805                Some("Step panicked".to_string())
806            };
807            let trace = capture_trace();
808            with_context(|ctx| ctx.finish_step(Status::Failed, message, trace));
809        }
810    }
811
812    match result {
813        Ok(value) => value,
814        Err(e) => std::panic::resume_unwind(e),
815    }
816}
817
818/// Logs a step without a body (for simple logging).
819///
820/// This is useful for logging actions that don't have a body,
821/// such as noting an event or state.
822///
823/// # Example
824///
825/// ```
826/// use allure_core::runtime::{with_test_context, log_step};
827/// use allure_core::Status;
828///
829/// with_test_context(|| {
830///     log_step("Database connection established", Status::Passed);
831///     log_step("Cache was cleared", Status::Passed);
832/// });
833/// ```
834pub fn log_step(name: impl Into<String>, status: Status) {
835    with_context(|ctx| {
836        ctx.start_step(name);
837        ctx.finish_step(status, None, None);
838    });
839}
840
841/// Attaches text content to the current test or step.
842///
843/// # Example
844///
845/// ```
846/// use allure_core::runtime::{with_test_context, attach_text};
847///
848/// with_test_context(|| {
849///     attach_text("API Response", r#"{"status": "ok"}"#);
850///     attach_text("Log Output", "Test completed successfully");
851/// });
852/// ```
853pub fn attach_text(name: impl Into<String>, content: impl AsRef<str>) {
854    with_context(|ctx| ctx.attach_text(name, content));
855}
856
857/// Attaches JSON content to the current test or step.
858///
859/// The value is serialized to JSON using serde.
860///
861/// # Example
862///
863/// ```
864/// use allure_core::runtime::{with_test_context, attach_json};
865/// use serde::Serialize;
866///
867/// #[derive(Serialize)]
868/// struct User {
869///     name: String,
870///     email: String,
871/// }
872///
873/// with_test_context(|| {
874///     let user = User {
875///         name: "John".to_string(),
876///         email: "john@example.com".to_string(),
877///     };
878///     attach_json("User Data", &user);
879/// });
880/// ```
881pub fn attach_json<T: serde::Serialize>(name: impl Into<String>, value: &T) {
882    with_context(|ctx| ctx.attach_json(name, value));
883}
884
885/// Attaches binary content to the current test or step.
886///
887/// # Example
888///
889/// ```
890/// use allure_core::runtime::{with_test_context, attach_binary};
891/// use allure_core::ContentType;
892///
893/// with_test_context(|| {
894///     let png_data: &[u8] = &[0x89, 0x50, 0x4E, 0x47]; // PNG header
895///     attach_binary("Screenshot", png_data, ContentType::Png);
896/// });
897/// ```
898pub fn attach_binary(name: impl Into<String>, content: &[u8], content_type: ContentType) {
899    with_context(|ctx| ctx.attach_binary(name, content, content_type));
900}
901
902/// Marks the current test as flaky.
903///
904/// Flaky tests are tests that can fail intermittently due to
905/// external factors like network issues or timing problems.
906///
907/// # Example
908///
909/// ```
910/// use allure_core::runtime::{with_test_context, flaky};
911///
912/// with_test_context(|| {
913///     flaky();
914///     // Test code that sometimes fails due to network issues
915/// });
916/// ```
917pub fn flaky() {
918    with_context(|ctx| {
919        let details = ctx
920            .result
921            .status_details
922            .get_or_insert_with(Default::default);
923        details.flaky = Some(true);
924    });
925}
926
927/// Marks the current test as muted.
928///
929/// Muted tests are tests whose results will not affect the statistics
930/// in the Allure report. The test is still executed and documented,
931/// but won't impact pass/fail metrics.
932///
933/// # Example
934///
935/// ```
936/// use allure_core::runtime::{with_test_context, muted};
937///
938/// with_test_context(|| {
939///     muted();
940///     // Test code that shouldn't affect statistics
941/// });
942/// ```
943pub fn muted() {
944    with_context(|ctx| {
945        let details = ctx
946            .result
947            .status_details
948            .get_or_insert_with(Default::default);
949        details.muted = Some(true);
950    });
951}
952
953/// Marks the current test as having a known issue.
954///
955/// This adds an issue link and marks the test status as having a known issue.
956///
957/// # Example
958///
959/// ```
960/// use allure_core::runtime::{with_test_context, known_issue};
961///
962/// with_test_context(|| {
963///     known_issue("https://github.com/example/project/issues/123");
964/// });
965/// ```
966pub fn known_issue(issue_id: impl Into<String>) {
967    let id = issue_id.into();
968    with_context(|ctx| {
969        let details = ctx
970            .result
971            .status_details
972            .get_or_insert_with(Default::default);
973        details.known = Some(true);
974        // Also add as an issue link
975        ctx.add_link(&id, Some(id.clone()), LinkType::Issue);
976    });
977}
978
979/// Marks the current test as skipped and finalizes the result.
980pub fn skip(reason: impl Into<String>) {
981    let reason = reason.into();
982    if let Some(mut ctx) = take_context() {
983        ctx.finish(Status::Skipped, Some(reason), None);
984    }
985}
986
987/// Sets the display name for the current test.
988///
989/// This overrides the test name that was set when the test context was created.
990pub fn display_name(name: impl Into<String>) {
991    with_context(|ctx| ctx.result.name = name.into());
992}
993
994/// Sets the test case ID for the current test.
995///
996/// This is used to link the test to a test case in a test management system.
997pub fn test_case_id(id: impl Into<String>) {
998    with_context(|ctx| ctx.result.test_case_id = Some(id.into()));
999}
1000
1001/// Attaches a file from the filesystem to the current test or step.
1002///
1003/// The file is copied to the Allure results directory.
1004pub fn attach_file(
1005    name: impl Into<String>,
1006    path: impl AsRef<std::path::Path>,
1007    content_type: Option<ContentType>,
1008) {
1009    with_context(|ctx| ctx.attach_file(name, path, content_type));
1010}
1011
1012/// Captures a backtrace as a string when available.
1013fn capture_trace() -> Option<String> {
1014    let bt = Backtrace::force_capture();
1015    let snapshot = format!("{bt:?}");
1016    if snapshot.contains("disabled") {
1017        return None;
1018    }
1019    Some(snapshot)
1020}
1021
1022/// Executes an async block with a task-local test context (tokio only).
1023#[cfg(feature = "tokio")]
1024pub async fn with_async_context<F, R>(ctx: TestContext, fut: F) -> R
1025where
1026    F: std::future::Future<Output = R>,
1027{
1028    let handle = Arc::new(Mutex::new(Some(ctx)));
1029    let _registration = GlobalContextRegistration::new(handle.clone());
1030
1031    let cell = RefCell::new(Some(handle));
1032    TOKIO_CONTEXT.scope(cell, fut).await
1033}
1034
1035/// Executes an async block with a thread-local test context (non-tokio fallback).
1036#[cfg(not(feature = "tokio"))]
1037pub async fn with_async_context<F, R>(ctx: TestContext, fut: F) -> R
1038where
1039    F: std::future::Future<Output = R>,
1040{
1041    set_context(ctx);
1042    let result = fut.await;
1043    let _ = take_context();
1044    result
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050    use serde_json::Value;
1051    use std::path::PathBuf;
1052
1053    #[test]
1054    fn test_config_builder() {
1055        let config = AllureConfigBuilder::new()
1056            .results_dir("custom-results")
1057            .clean_results(false)
1058            .config;
1059
1060        assert_eq!(config.results_dir, "custom-results");
1061        assert!(!config.clean_results);
1062    }
1063
1064    #[test]
1065    fn test_context_creation() {
1066        let ctx = TestContext::new("My Test", "tests::my_test");
1067        assert_eq!(ctx.result.name, "My Test");
1068        assert_eq!(ctx.result.full_name, Some("tests::my_test".to_string()));
1069        assert!(ctx
1070            .result
1071            .labels
1072            .iter()
1073            .any(|l| l.name == "language" && l.value == "rust"));
1074    }
1075
1076    #[test]
1077    fn test_step_nesting() {
1078        let mut ctx = TestContext::new("Test", "test::test");
1079
1080        ctx.start_step("Step 1");
1081        ctx.start_step("Step 1.1");
1082        ctx.finish_step(Status::Passed, None, None);
1083        ctx.finish_step(Status::Passed, None, None);
1084
1085        assert_eq!(ctx.result.steps.len(), 1);
1086        assert_eq!(ctx.result.steps[0].name, "Step 1");
1087        assert_eq!(ctx.result.steps[0].steps.len(), 1);
1088        assert_eq!(ctx.result.steps[0].steps[0].name, "Step 1.1");
1089    }
1090
1091    #[test]
1092    fn test_thread_local_context() {
1093        let ctx = TestContext::new("Test", "test::test");
1094        set_context(ctx);
1095
1096        with_context(|ctx| {
1097            ctx.add_label("custom", "value");
1098        });
1099
1100        let ctx = take_context().unwrap();
1101        assert!(ctx
1102            .result
1103            .labels
1104            .iter()
1105            .any(|l| l.name == "custom" && l.value == "value"));
1106    }
1107
1108    #[test]
1109    fn test_capture_trace_runs() {
1110        // We only assert that it does not panic and returns an Option.
1111        let _maybe_trace = capture_trace();
1112    }
1113
1114    #[test]
1115    fn test_run_test_writes_results_and_container_on_panic() {
1116        let desired_dir = PathBuf::from("target/allure-runtime-tests");
1117        let _ = std::fs::remove_dir_all(&desired_dir);
1118
1119        let config_ref = CONFIG.get_or_init(|| AllureConfig {
1120            results_dir: desired_dir.to_string_lossy().to_string(),
1121            clean_results: true,
1122        });
1123        let dir = PathBuf::from(&config_ref.results_dir);
1124        let _ = std::fs::remove_dir_all(&dir);
1125        std::fs::create_dir_all(&dir).unwrap();
1126
1127        let outcome = std::panic::catch_unwind(|| {
1128            run_test("panic_test", "runtime::panic_test", || {
1129                panic!("runtime boom");
1130            });
1131        });
1132        assert!(outcome.is_err());
1133
1134        let mut result_files = Vec::new();
1135        let mut container_files = Vec::new();
1136        for entry in std::fs::read_dir(&dir).unwrap() {
1137            let path = entry.unwrap().path();
1138            if path.extension().and_then(|e| e.to_str()) == Some("json") {
1139                let name = path.file_name().unwrap().to_string_lossy().to_string();
1140                if name.contains("-result.json") {
1141                    result_files.push(path.clone());
1142                } else if name.contains("-container.json") {
1143                    container_files.push(path.clone());
1144                }
1145            }
1146        }
1147
1148        assert!(!result_files.is_empty());
1149        assert!(!container_files.is_empty());
1150
1151        let panic_result_json = result_files
1152            .iter()
1153            .find_map(|path| {
1154                let result_json: Value =
1155                    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
1156                (result_json["name"] == "panic_test").then_some(result_json)
1157            })
1158            .expect("panic_test result json should exist");
1159
1160        assert_eq!(panic_result_json["status"], "failed");
1161        assert!(panic_result_json["statusDetails"]["message"]
1162            .as_str()
1163            .unwrap()
1164            .contains("runtime boom"));
1165    }
1166
1167    #[cfg(feature = "tokio")]
1168    #[tokio::test(flavor = "current_thread")]
1169    async fn test_take_context_reads_tokio_task_local() {
1170        let ctx = TestContext::new("tokio_ctx", "module::tokio_ctx");
1171        let taken = with_async_context(ctx, async {
1172            let inner = take_context();
1173            assert!(inner.is_some());
1174            inner.unwrap().result.name
1175        })
1176        .await;
1177        assert_eq!(taken, "tokio_ctx");
1178    }
1179
1180    #[cfg(feature = "tokio")]
1181    #[tokio::test(flavor = "current_thread")]
1182    async fn test_with_context_uses_tokio_task_local() {
1183        let ctx = TestContext::new("tokio_ctx", "module::tokio_ctx");
1184        with_async_context(ctx, async {
1185            let mut seen = None;
1186            with_context(|c| {
1187                seen = Some(c.result.name.clone());
1188            });
1189            assert_eq!(seen.as_deref(), Some("tokio_ctx"));
1190        })
1191        .await;
1192    }
1193
1194    #[cfg(feature = "tokio")]
1195    #[test]
1196    fn test_global_context_avoids_ambiguous_assignment() {
1197        let launch_barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
1198        let probe_barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
1199        let settle_barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
1200
1201        let run_in_runtime =
1202            |name: &'static str,
1203             launch_barrier: std::sync::Arc<tokio::sync::Barrier>,
1204             probe_barrier: std::sync::Arc<tokio::sync::Barrier>,
1205             settle_barrier: std::sync::Arc<tokio::sync::Barrier>| {
1206                std::thread::spawn(move || {
1207                    let rt = tokio::runtime::Builder::new_current_thread()
1208                        .enable_all()
1209                        .build()
1210                        .unwrap();
1211
1212                    rt.block_on(async move {
1213                        let ctx = TestContext::new(name, format!("module::{name}"));
1214                        with_async_context(ctx, async move {
1215                            launch_barrier.wait().await;
1216                            tokio::spawn(async move {
1217                                probe_barrier.wait().await;
1218                                let mut seen = None;
1219                                with_context(|c| {
1220                                    seen = Some(c.result.name.clone());
1221                                });
1222                                settle_barrier.wait().await;
1223                                seen
1224                            })
1225                            .await
1226                            .unwrap()
1227                        })
1228                        .await
1229                    })
1230                })
1231            };
1232
1233        let t1 = run_in_runtime(
1234            "ctx-one",
1235            launch_barrier.clone(),
1236            probe_barrier.clone(),
1237            settle_barrier.clone(),
1238        );
1239        let t2 = run_in_runtime("ctx-two", launch_barrier, probe_barrier, settle_barrier);
1240
1241        let seen1 = t1.join().unwrap();
1242        let seen2 = t2.join().unwrap();
1243
1244        assert!(seen1.is_none());
1245        assert!(seen2.is_none());
1246    }
1247
1248    #[test]
1249    fn test_with_test_context_clears_after_use() {
1250        with_test_context(|| {
1251            label("temp", "value");
1252        });
1253        assert!(take_context().is_none());
1254    }
1255
1256    #[test]
1257    fn test_tags_and_metadata_helpers() {
1258        let ctx = TestContext::new("meta", "module::meta");
1259        set_context(ctx);
1260
1261        label("env", "staging");
1262        tags(&["smoke", "api"]);
1263        title("Custom Title");
1264        description("Markdown");
1265        description_html("<p>HTML</p>");
1266        test_case_id("TC-1");
1267
1268        let ctx = take_context().unwrap();
1269        assert_eq!(ctx.result.name, "Custom Title");
1270        assert_eq!(ctx.result.description.as_deref(), Some("Markdown"));
1271        assert_eq!(ctx.result.description_html.as_deref(), Some("<p>HTML</p>"));
1272        assert_eq!(ctx.result.test_case_id.as_deref(), Some("TC-1"));
1273        assert!(ctx.result.labels.iter().any(|l| l.value == "staging"));
1274        assert!(ctx.result.labels.iter().any(|l| l.value == "smoke"));
1275        assert!(ctx.result.labels.iter().any(|l| l.value == "api"));
1276    }
1277
1278    #[test]
1279    fn test_step_failure_records_message_and_rethrows() {
1280        let ctx = TestContext::new("step_fail", "module::step_fail");
1281        set_context(ctx);
1282
1283        let result = std::panic::catch_unwind(|| {
1284            step("will panic", || panic!("boom step"));
1285        });
1286        assert!(result.is_err());
1287
1288        let ctx = take_context().unwrap();
1289        assert_eq!(ctx.result.steps.len(), 1);
1290        let step = &ctx.result.steps[0];
1291        assert_eq!(step.status, Status::Failed);
1292        assert!(step
1293            .status_details
1294            .as_ref()
1295            .unwrap()
1296            .message
1297            .as_ref()
1298            .unwrap()
1299            .contains("boom step"));
1300    }
1301
1302    #[test]
1303    fn test_finish_step_skipped_branch() {
1304        let mut ctx = TestContext::new("skip_step", "module::skip_step");
1305        ctx.start_step("inner");
1306        ctx.finish_step(
1307            Status::Skipped,
1308            Some("not run".into()),
1309            Some("trace".into()),
1310        );
1311        assert_eq!(ctx.result.steps[0].status, Status::Skipped);
1312        assert_eq!(ctx.result.steps[0].stage, crate::enums::Stage::Finished);
1313    }
1314
1315    #[test]
1316    fn test_finish_step_broken_and_unknown_branches() {
1317        let mut ctx = TestContext::new("broken_step", "module::broken_step");
1318        ctx.start_step("broken");
1319        ctx.finish_step(Status::Broken, Some("oops".into()), None);
1320        assert_eq!(ctx.result.steps[0].status, Status::Broken);
1321        assert!(ctx.result.steps[0]
1322            .status_details
1323            .as_ref()
1324            .unwrap()
1325            .message
1326            .as_ref()
1327            .unwrap()
1328            .contains("oops"));
1329
1330        ctx.start_step("unknown");
1331        ctx.finish_step(Status::Unknown, None, None);
1332        assert_eq!(ctx.result.steps[1].status, Status::Unknown);
1333        assert_eq!(ctx.result.steps[1].stage, crate::enums::Stage::Finished);
1334    }
1335
1336    #[test]
1337    fn test_muted_sets_flag() {
1338        let ctx = TestContext::new("muted_test", "module::muted_test");
1339        set_context(ctx);
1340        muted();
1341        let ctx = take_context().unwrap();
1342        let details = ctx.result.status_details.unwrap();
1343        assert_eq!(details.muted, Some(true));
1344    }
1345
1346    #[test]
1347    fn test_host_env_override_used_in_context_creation() {
1348        std::env::set_var("HOSTNAME", "test-host");
1349        let ctx = TestContext::new("hosted", "module::hosted");
1350        assert!(ctx
1351            .result
1352            .labels
1353            .iter()
1354            .any(|l| l.name == "host" && l.value == "test-host"));
1355    }
1356
1357    #[test]
1358    fn test_add_parameter_struct_applies_to_steps() {
1359        let mut ctx = TestContext::new("params", "module::params");
1360        ctx.start_step("outer");
1361        ctx.add_parameter_struct(crate::model::Parameter::excluded("k", "v"));
1362        assert_eq!(ctx.step_stack[0].parameters.len(), 1);
1363        assert_eq!(ctx.step_stack[0].parameters[0].excluded, Some(true));
1364    }
1365
1366    #[test]
1367    fn test_finish_writes_and_breaks_unfinished_steps() {
1368        let temp = tempfile::tempdir().unwrap();
1369        CONFIG.get_or_init(|| AllureConfig {
1370            results_dir: temp.path().to_string_lossy().to_string(),
1371            clean_results: true,
1372        });
1373        let mut ctx = TestContext::new("unclosed", "module::unclosed");
1374        ctx.start_step("still running");
1375        ctx.finish(Status::Passed, None, None);
1376        assert_eq!(ctx.result.steps[0].status, Status::Broken);
1377        assert!(ctx.result.steps[0]
1378            .status_details
1379            .as_ref()
1380            .unwrap()
1381            .message
1382            .as_ref()
1383            .unwrap()
1384            .contains("Step not completed"));
1385    }
1386
1387    #[test]
1388    fn test_finish_handles_broken_status_with_details() {
1389        let temp = tempfile::tempdir().unwrap();
1390        CONFIG.get_or_init(|| AllureConfig {
1391            results_dir: temp.path().to_string_lossy().to_string(),
1392            clean_results: true,
1393        });
1394        let mut ctx = TestContext::new("broken_test", "module::broken_test");
1395        ctx.finish(Status::Broken, Some("fail".into()), Some("trace".into()));
1396        assert_eq!(ctx.result.status, Status::Broken);
1397        let details = ctx.result.status_details.as_ref().unwrap();
1398        assert_eq!(details.message.as_deref(), Some("fail"));
1399        assert_eq!(details.trace.as_deref(), Some("trace"));
1400    }
1401
1402    #[test]
1403    fn test_context_creation_uses_hostname_when_env_missing() {
1404        // Temporarily remove HOSTNAME to exercise hostname crate path
1405        let original = std::env::var("HOSTNAME").ok();
1406        std::env::remove_var("HOSTNAME");
1407        let ctx = TestContext::new("host", "module::host");
1408        if let Some(orig) = original {
1409            std::env::set_var("HOSTNAME", orig);
1410        }
1411        assert!(ctx.result.labels.iter().any(|l| l.name == "host"));
1412    }
1413}