1use std::cell::RefCell;
7use std::panic::{catch_unwind, AssertUnwindSafe};
8use std::sync::OnceLock;
9
10use crate::enums::{ContentType, LabelName, LinkType, Severity, Status};
11use crate::model::{Attachment, Label, StepResult, TestResult};
12use crate::writer::{compute_history_id, generate_uuid, AllureWriter};
13
14static CONFIG: OnceLock<AllureConfig> = OnceLock::new();
16
17#[derive(Debug, Clone)]
19pub struct AllureConfig {
20 pub results_dir: String,
22 pub clean_results: bool,
24}
25
26impl Default for AllureConfig {
27 fn default() -> Self {
28 Self {
29 results_dir: crate::writer::DEFAULT_RESULTS_DIR.to_string(),
30 clean_results: true,
31 }
32 }
33}
34
35#[derive(Debug, Default)]
37pub struct AllureConfigBuilder {
38 config: AllureConfig,
39}
40
41impl AllureConfigBuilder {
42 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn results_dir(mut self, path: impl Into<String>) -> Self {
49 self.config.results_dir = path.into();
50 self
51 }
52
53 pub fn clean_results(mut self, clean: bool) -> Self {
55 self.config.clean_results = clean;
56 self
57 }
58
59 pub fn init(self) -> std::io::Result<()> {
61 let writer = AllureWriter::with_results_dir(&self.config.results_dir);
62 writer.init(self.config.clean_results)?;
63 CONFIG.set(self.config).ok();
64 Ok(())
65 }
66}
67
68pub fn configure() -> AllureConfigBuilder {
70 AllureConfigBuilder::new()
71}
72
73pub fn get_config() -> AllureConfig {
75 CONFIG.get().cloned().unwrap_or_default()
76}
77
78#[derive(Debug)]
80pub struct TestContext {
81 pub result: TestResult,
83 pub step_stack: Vec<StepResult>,
85 pub writer: AllureWriter,
87}
88
89impl TestContext {
90 pub fn new(name: impl Into<String>, full_name: impl Into<String>) -> Self {
92 let config = get_config();
93 let uuid = generate_uuid();
94 let mut result = TestResult::new(uuid, name.into());
95 result.full_name = Some(full_name.into());
96
97 result.labels.push(Label::language("rust"));
99 result.labels.push(Label::framework("allure-rs"));
100
101 if let Ok(hostname) = std::env::var("HOSTNAME") {
103 result.labels.push(Label::host(hostname));
104 } else if let Ok(hostname) = hostname::get() {
105 if let Some(name) = hostname.to_str() {
106 result.labels.push(Label::host(name));
107 }
108 }
109
110 let thread_id = format!("{:?}", std::thread::current().id());
111 result.labels.push(Label::thread(thread_id));
112
113 Self {
114 result,
115 step_stack: Vec::new(),
116 writer: AllureWriter::with_results_dir(config.results_dir),
117 }
118 }
119
120 pub fn add_label(&mut self, name: impl Into<String>, value: impl Into<String>) {
122 self.result.add_label(name, value);
123 }
124
125 pub fn add_label_name(&mut self, name: LabelName, value: impl Into<String>) {
127 self.result.add_label_name(name, value);
128 }
129
130 pub fn add_link(&mut self, url: impl Into<String>, name: Option<String>, link_type: LinkType) {
132 self.result.add_link(url, name, link_type);
133 }
134
135 pub fn add_parameter(&mut self, name: impl Into<String>, value: impl Into<String>) {
137 if let Some(step) = self.step_stack.last_mut() {
138 step.add_parameter(name, value);
139 } else {
140 self.result.add_parameter(name, value);
141 }
142 }
143
144 pub fn add_attachment(&mut self, attachment: Attachment) {
146 if let Some(step) = self.step_stack.last_mut() {
147 step.add_attachment(attachment);
148 } else {
149 self.result.add_attachment(attachment);
150 }
151 }
152
153 pub fn start_step(&mut self, name: impl Into<String>) {
155 let step = StepResult::new(name);
156 self.step_stack.push(step);
157 }
158
159 pub fn finish_step(&mut self, status: Status, message: Option<String>, trace: Option<String>) {
161 if let Some(mut step) = self.step_stack.pop() {
162 match status {
163 Status::Passed => step.pass(),
164 Status::Failed => step.fail(message, trace),
165 Status::Broken => step.broken(message, trace),
166 _ => {
167 step.status = status;
168 step.stage = crate::enums::Stage::Finished;
169 step.stop = crate::model::current_time_ms();
170 }
171 }
172
173 if let Some(parent_step) = self.step_stack.last_mut() {
175 parent_step.add_step(step);
176 } else {
177 self.result.add_step(step);
178 }
179 }
180 }
181
182 pub fn compute_history_id(&mut self) {
184 if let Some(ref full_name) = self.result.full_name {
185 let history_id = compute_history_id(full_name, &self.result.parameters);
186 self.result.history_id = Some(history_id);
187 }
188 }
189
190 pub fn finish(&mut self, status: Status, message: Option<String>, trace: Option<String>) {
192 while !self.step_stack.is_empty() {
194 self.finish_step(Status::Broken, Some("Step not completed".to_string()), None);
195 }
196
197 self.compute_history_id();
199
200 match status {
201 Status::Passed => self.result.pass(),
202 Status::Failed => self.result.fail(message, trace),
203 Status::Broken => self.result.broken(message, trace),
204 _ => {
205 self.result.status = status;
206 self.result.finish();
207 }
208 }
209
210 if let Err(e) = self.writer.write_test_result(&self.result) {
212 eprintln!("Failed to write Allure test result: {}", e);
213 }
214 }
215
216 pub fn attach_text(&mut self, name: impl Into<String>, content: impl AsRef<str>) {
218 match self.writer.write_text_attachment(name, content) {
219 Ok(attachment) => self.add_attachment(attachment),
220 Err(e) => eprintln!("Failed to write text attachment: {}", e),
221 }
222 }
223
224 pub fn attach_json<T: serde::Serialize>(&mut self, name: impl Into<String>, value: &T) {
226 match self.writer.write_json_attachment(name, value) {
227 Ok(attachment) => self.add_attachment(attachment),
228 Err(e) => eprintln!("Failed to write JSON attachment: {}", e),
229 }
230 }
231
232 pub fn attach_binary(
234 &mut self,
235 name: impl Into<String>,
236 content: &[u8],
237 content_type: ContentType,
238 ) {
239 match self
240 .writer
241 .write_binary_attachment(name, content, content_type)
242 {
243 Ok(attachment) => self.add_attachment(attachment),
244 Err(e) => eprintln!("Failed to write binary attachment: {}", e),
245 }
246 }
247
248 pub fn attach_file(
250 &mut self,
251 name: impl Into<String>,
252 path: impl AsRef<std::path::Path>,
253 content_type: Option<ContentType>,
254 ) {
255 match self.writer.copy_file_attachment(name, path, content_type) {
256 Ok(attachment) => self.add_attachment(attachment),
257 Err(e) => eprintln!("Failed to copy file attachment: {}", e),
258 }
259 }
260}
261
262thread_local! {
264 static CURRENT_CONTEXT: RefCell<Option<TestContext>> = const { RefCell::new(None) };
265}
266
267pub fn set_context(ctx: TestContext) {
269 CURRENT_CONTEXT.with(|c| {
270 *c.borrow_mut() = Some(ctx);
271 });
272}
273
274pub fn take_context() -> Option<TestContext> {
276 CURRENT_CONTEXT.with(|c| c.borrow_mut().take())
277}
278
279pub fn with_context<F, R>(f: F) -> Option<R>
281where
282 F: FnOnce(&mut TestContext) -> R,
283{
284 CURRENT_CONTEXT.with(|c| {
285 let mut ctx = c.borrow_mut();
286 ctx.as_mut().map(f)
287 })
288}
289
290pub fn run_test<F>(name: &str, full_name: &str, f: F)
292where
293 F: FnOnce() + std::panic::UnwindSafe,
294{
295 let ctx = TestContext::new(name, full_name);
296 set_context(ctx);
297
298 let result = catch_unwind(AssertUnwindSafe(f));
299
300 let (is_err, panic_payload) = match &result {
302 Ok(()) => (false, None),
303 Err(panic_info) => {
304 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
305 Some(s.to_string())
306 } else if let Some(s) = panic_info.downcast_ref::<String>() {
307 Some(s.clone())
308 } else {
309 Some("Test panicked".to_string())
310 };
311 (true, msg)
312 }
313 };
314
315 if let Some(mut ctx) = take_context() {
317 if is_err {
318 ctx.finish(Status::Failed, panic_payload, None);
319 } else {
320 ctx.finish(Status::Passed, None, None);
321 }
322 }
323
324 if let Err(e) = result {
326 std::panic::resume_unwind(e);
327 }
328}
329
330#[doc(hidden)]
349pub fn with_test_context<F, R>(f: F) -> R
350where
351 F: FnOnce() -> R,
352{
353 let ctx = TestContext::new("doctest", "doctest::example");
354 set_context(ctx);
355 let result = f();
356 let _ = take_context(); result
358}
359
360pub fn label(name: impl Into<String>, value: impl Into<String>) {
375 with_context(|ctx| ctx.add_label(name, value));
376}
377
378pub fn epic(name: impl Into<String>) {
392 with_context(|ctx| ctx.add_label_name(LabelName::Epic, name));
393}
394
395pub fn feature(name: impl Into<String>) {
409 with_context(|ctx| ctx.add_label_name(LabelName::Feature, name));
410}
411
412pub fn story(name: impl Into<String>) {
426 with_context(|ctx| ctx.add_label_name(LabelName::Story, name));
427}
428
429pub fn suite(name: impl Into<String>) {
431 with_context(|ctx| ctx.add_label_name(LabelName::Suite, name));
432}
433
434pub fn parent_suite(name: impl Into<String>) {
436 with_context(|ctx| ctx.add_label_name(LabelName::ParentSuite, name));
437}
438
439pub fn sub_suite(name: impl Into<String>) {
441 with_context(|ctx| ctx.add_label_name(LabelName::SubSuite, name));
442}
443
444pub fn severity(severity: Severity) {
457 with_context(|ctx| ctx.add_label_name(LabelName::Severity, severity.as_str()));
458}
459
460pub fn owner(name: impl Into<String>) {
472 with_context(|ctx| ctx.add_label_name(LabelName::Owner, name));
473}
474
475pub fn tag(name: impl Into<String>) {
488 with_context(|ctx| ctx.add_label_name(LabelName::Tag, name));
489}
490
491pub fn tags(names: &[&str]) {
503 with_context(|ctx| {
504 for name in names {
505 ctx.add_label_name(LabelName::Tag, *name);
506 }
507 });
508}
509
510pub fn allure_id(id: impl Into<String>) {
512 with_context(|ctx| ctx.add_label_name(LabelName::AllureId, id));
513}
514
515pub fn title(name: impl Into<String>) {
529 with_context(|ctx| ctx.result.name = name.into());
530}
531
532pub fn description(text: impl Into<String>) {
534 with_context(|ctx| ctx.result.description = Some(text.into()));
535}
536
537pub fn description_html(html: impl Into<String>) {
539 with_context(|ctx| ctx.result.description_html = Some(html.into()));
540}
541
542pub fn issue(url: impl Into<String>, name: Option<String>) {
544 with_context(|ctx| ctx.add_link(url, name, LinkType::Issue));
545}
546
547pub fn tms(url: impl Into<String>, name: Option<String>) {
549 with_context(|ctx| ctx.add_link(url, name, LinkType::Tms));
550}
551
552pub fn link(url: impl Into<String>, name: Option<String>) {
554 with_context(|ctx| ctx.add_link(url, name, LinkType::Default));
555}
556
557pub fn parameter(name: impl Into<String>, value: impl ToString) {
573 with_context(|ctx| ctx.add_parameter(name, value.to_string()));
574}
575
576pub fn step<F, R>(name: impl Into<String>, body: F) -> R
611where
612 F: FnOnce() -> R,
613{
614 let step_name = name.into();
615
616 with_context(|ctx| ctx.start_step(&step_name));
617
618 let result = catch_unwind(AssertUnwindSafe(body));
619
620 match &result {
621 Ok(_) => {
622 with_context(|ctx| ctx.finish_step(Status::Passed, None, None));
623 }
624 Err(panic_info) => {
625 let message = if let Some(s) = panic_info.downcast_ref::<&str>() {
626 Some(s.to_string())
627 } else if let Some(s) = panic_info.downcast_ref::<String>() {
628 Some(s.clone())
629 } else {
630 Some("Step panicked".to_string())
631 };
632 with_context(|ctx| ctx.finish_step(Status::Failed, message, None));
633 }
634 }
635
636 match result {
637 Ok(value) => value,
638 Err(e) => std::panic::resume_unwind(e),
639 }
640}
641
642pub fn log_step(name: impl Into<String>, status: Status) {
659 with_context(|ctx| {
660 ctx.start_step(name);
661 ctx.finish_step(status, None, None);
662 });
663}
664
665pub fn attach_text(name: impl Into<String>, content: impl AsRef<str>) {
678 with_context(|ctx| ctx.attach_text(name, content));
679}
680
681pub fn attach_json<T: serde::Serialize>(name: impl Into<String>, value: &T) {
706 with_context(|ctx| ctx.attach_json(name, value));
707}
708
709pub fn attach_binary(name: impl Into<String>, content: &[u8], content_type: ContentType) {
723 with_context(|ctx| ctx.attach_binary(name, content, content_type));
724}
725
726pub fn flaky() {
742 with_context(|ctx| {
743 let details = ctx
744 .result
745 .status_details
746 .get_or_insert_with(Default::default);
747 details.flaky = Some(true);
748 });
749}
750
751pub fn muted() {
768 with_context(|ctx| {
769 let details = ctx
770 .result
771 .status_details
772 .get_or_insert_with(Default::default);
773 details.muted = Some(true);
774 });
775}
776
777pub fn known_issue(issue_id: impl Into<String>) {
791 let id = issue_id.into();
792 with_context(|ctx| {
793 let details = ctx
794 .result
795 .status_details
796 .get_or_insert_with(Default::default);
797 details.known = Some(true);
798 ctx.add_link(&id, Some(id.clone()), LinkType::Issue);
800 });
801}
802
803pub fn display_name(name: impl Into<String>) {
807 with_context(|ctx| ctx.result.name = name.into());
808}
809
810pub fn test_case_id(id: impl Into<String>) {
814 with_context(|ctx| ctx.result.test_case_id = Some(id.into()));
815}
816
817pub fn attach_file(
821 name: impl Into<String>,
822 path: impl AsRef<std::path::Path>,
823 content_type: Option<ContentType>,
824) {
825 with_context(|ctx| ctx.attach_file(name, path, content_type));
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831
832 #[test]
833 fn test_config_builder() {
834 let config = AllureConfigBuilder::new()
835 .results_dir("custom-results")
836 .clean_results(false)
837 .config;
838
839 assert_eq!(config.results_dir, "custom-results");
840 assert!(!config.clean_results);
841 }
842
843 #[test]
844 fn test_context_creation() {
845 let ctx = TestContext::new("My Test", "tests::my_test");
846 assert_eq!(ctx.result.name, "My Test");
847 assert_eq!(ctx.result.full_name, Some("tests::my_test".to_string()));
848 assert!(ctx
849 .result
850 .labels
851 .iter()
852 .any(|l| l.name == "language" && l.value == "rust"));
853 }
854
855 #[test]
856 fn test_step_nesting() {
857 let mut ctx = TestContext::new("Test", "test::test");
858
859 ctx.start_step("Step 1");
860 ctx.start_step("Step 1.1");
861 ctx.finish_step(Status::Passed, None, None);
862 ctx.finish_step(Status::Passed, None, None);
863
864 assert_eq!(ctx.result.steps.len(), 1);
865 assert_eq!(ctx.result.steps[0].name, "Step 1");
866 assert_eq!(ctx.result.steps[0].steps.len(), 1);
867 assert_eq!(ctx.result.steps[0].steps[0].name, "Step 1.1");
868 }
869
870 #[test]
871 fn test_thread_local_context() {
872 let ctx = TestContext::new("Test", "test::test");
873 set_context(ctx);
874
875 with_context(|ctx| {
876 ctx.add_label("custom", "value");
877 });
878
879 let ctx = take_context().unwrap();
880 assert!(ctx
881 .result
882 .labels
883 .iter()
884 .any(|l| l.name == "custom" && l.value == "value"));
885 }
886}