1use std::{
4 collections::HashMap,
5 env, fs,
6 path::{Path, PathBuf},
7 sync::{Mutex, OnceLock},
8};
9
10use crate::{
11 writer::{ALLURE_RESULTS_DIR_ENV, DEFAULT_RESULTS_DIR},
12 AllureFacade,
13};
14
15static GLOBAL_CONFIG: OnceLock<GlobalAllureConfig> = OnceLock::new();
16static CONFIGS: OnceLock<Mutex<HashMap<String, AllureConfig>>> = OnceLock::new();
17
18#[derive(Clone, Debug)]
20pub struct GlobalAllureConfig {
21 results_dir: PathBuf,
22 global_labels: Vec<(String, String)>,
23 host_name: Option<String>,
24 thread_name_override: Option<String>,
25 log_asserts_override: Option<bool>,
26}
27
28impl GlobalAllureConfig {
29 pub fn results_dir(&self) -> &Path {
31 &self.results_dir
32 }
33
34 pub fn global_labels(&self) -> &[(String, String)] {
36 &self.global_labels
37 }
38
39 pub fn host_name(&self) -> Option<&str> {
41 self.host_name.as_deref()
42 }
43
44 pub fn thread_name_override(&self) -> Option<&str> {
46 self.thread_name_override.as_deref()
47 }
48
49 pub fn log_asserts_override(&self) -> Option<bool> {
51 self.log_asserts_override
52 }
53
54 fn from_environment() -> Self {
55 Self {
56 results_dir: env::var_os(ALLURE_RESULTS_DIR_ENV)
57 .map(PathBuf::from)
58 .unwrap_or_else(|| PathBuf::from(DEFAULT_RESULTS_DIR)),
59 global_labels: global_labels_from_env_vars(env::vars()),
60 host_name: env_string("ALLURE_HOST_NAME").or_else(resolve_host_name),
61 thread_name_override: env_string("ALLURE_THREAD_NAME"),
62 log_asserts_override: env::var("ALLURE_LOG_ASSERTS")
63 .ok()
64 .or_else(|| env::var("allure.log_asserts").ok())
65 .and_then(|value| parse_bool(&value)),
66 }
67 }
68}
69
70pub fn global_config() -> &'static GlobalAllureConfig {
72 GLOBAL_CONFIG.get_or_init(GlobalAllureConfig::from_environment)
73}
74
75pub fn apply_common_runtime_labels(allure: &AllureFacade) {
77 allure.label("language", "rust");
78
79 if let Some(host) = global_config().host_name() {
80 allure.label("host", host);
81 }
82
83 allure.label("thread", detect_thread_name());
84}
85
86pub fn apply_config_labels(
88 allure: &AllureFacade,
89 manifest_dir: &str,
90 module_path: &str,
91 title_path: &[String],
92) {
93 let config = config_for(manifest_dir);
94 let title_path = title_path.join("/");
95
96 for (name, value) in &config.labels {
97 allure.label(name, value);
98 }
99
100 for module in &config.modules {
101 if module.matches(module_path, &title_path) {
102 for (name, value) in &module.labels {
103 allure.label(name, value);
104 }
105 }
106 }
107}
108
109pub fn log_asserts_enabled(manifest_dir: &str) -> bool {
114 global_config()
115 .log_asserts_override()
116 .unwrap_or_else(|| config_for(manifest_dir).log_asserts)
117}
118
119fn env_string(name: &str) -> Option<String> {
120 env::var(name)
121 .ok()
122 .map(|value| value.trim().to_string())
123 .filter(|value| !value.is_empty())
124}
125
126fn resolve_host_name() -> Option<String> {
127 #[cfg(unix)]
128 {
129 use std::os::raw::{c_char, c_int};
130
131 unsafe extern "C" {
132 fn gethostname(name: *mut c_char, len: usize) -> c_int;
133 }
134
135 let mut buf = [0_u8; 256];
136 let result = unsafe { gethostname(buf.as_mut_ptr().cast(), buf.len()) };
138 if result == 0 {
139 let len = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
140 let host_name = String::from_utf8_lossy(&buf[..len]).trim().to_string();
141 if !host_name.is_empty() {
142 return Some(host_name);
143 }
144 }
145 }
146
147 env::var("HOSTNAME")
148 .ok()
149 .or_else(|| env::var("COMPUTERNAME").ok())
150 .map(|value| value.trim().to_string())
151 .filter(|value| !value.is_empty())
152}
153
154fn detect_thread_name() -> String {
155 if let Some(thread_name) = global_config().thread_name_override() {
156 return thread_name.to_string();
157 }
158
159 std::thread::current()
160 .name()
161 .map(ToString::to_string)
162 .unwrap_or_else(|| format!("{:?}", std::thread::current().id()))
163}
164
165pub fn global_labels_from_environment() -> Vec<(String, String)> {
167 global_config().global_labels().to_vec()
168}
169
170fn global_labels_from_env_vars<I>(vars: I) -> Vec<(String, String)>
171where
172 I: IntoIterator<Item = (String, String)>,
173{
174 let mut labels = Vec::new();
175
176 for (key, value) in vars {
177 if let Some(name) = key.strip_prefix("ALLURE_LABEL_") {
178 if !name.is_empty() && !value.is_empty() {
179 labels.push((name.to_string(), value.clone()));
180 }
181 }
182
183 if let Some(name) = key.strip_prefix("allure.label.") {
184 if !name.is_empty() && !value.is_empty() {
185 labels.push((name.to_string(), value));
186 }
187 }
188 }
189
190 labels
191}
192
193fn config_for(manifest_dir: &str) -> AllureConfig {
194 let manifest_dir = normalize_path(manifest_dir);
195 let cache = CONFIGS.get_or_init(|| Mutex::new(HashMap::new()));
196
197 if let Some(config) = cache
198 .lock()
199 .expect("poisoned allure config cache")
200 .get(&manifest_dir)
201 .cloned()
202 {
203 return config;
204 }
205
206 let config = read_config(&manifest_dir);
207 cache
208 .lock()
209 .expect("poisoned allure config cache")
210 .entry(manifest_dir)
211 .or_insert_with(|| config.clone())
212 .clone()
213}
214
215fn read_config(manifest_dir: &str) -> AllureConfig {
216 let manifest = Path::new(manifest_dir).join("Cargo.toml");
217 fs::read_to_string(manifest)
218 .map(|source| parse_config(&source))
219 .unwrap_or_default()
220}
221
222#[derive(Clone, Debug)]
223struct AllureConfig {
224 log_asserts: bool,
225 labels: Vec<(String, String)>,
226 modules: Vec<ModuleConfig>,
227}
228
229impl Default for AllureConfig {
230 fn default() -> Self {
231 Self {
232 log_asserts: true,
233 labels: Vec::new(),
234 modules: Vec::new(),
235 }
236 }
237}
238
239#[derive(Clone, Debug, Default)]
240struct ModuleConfig {
241 path: Option<String>,
242 module: Option<String>,
243 title_path: Option<Vec<String>>,
244 labels: Vec<(String, String)>,
245}
246
247impl ModuleConfig {
248 fn matches(&self, module_path: &str, title_path: &str) -> bool {
249 let path_matches = self.path.as_ref().is_some_and(|path| {
250 if path.ends_with('/') {
251 title_path.starts_with(path)
252 } else {
253 title_path == path
254 }
255 });
256
257 let title_path_matches = self
258 .title_path
259 .as_ref()
260 .is_some_and(|expected| expected.join("/") == title_path);
261
262 let module_matches = self.module.as_ref().is_some_and(|module| {
263 module_path == module || module_path.starts_with(&format!("{module}::"))
264 });
265
266 path_matches || title_path_matches || module_matches
267 }
268}
269
270#[derive(Clone, Copy)]
271enum ConfigSection {
272 Ignore,
273 Root,
274 Labels,
275 Module(usize),
276 ModuleLabels(usize),
277}
278
279fn parse_config(source: &str) -> AllureConfig {
280 let mut config = AllureConfig::default();
281 let mut section = ConfigSection::Ignore;
282
283 for raw_line in source.lines() {
284 let line = strip_comment(raw_line);
285 let line = line.trim();
286 if line.is_empty() {
287 continue;
288 }
289
290 if let Some(header) = table_header(line) {
291 section = match header.as_str() {
292 "[package.metadata.allure]" => ConfigSection::Root,
293 "[package.metadata.allure.labels]" => ConfigSection::Labels,
294 "[[package.metadata.allure.modules]]" => {
295 config.modules.push(ModuleConfig::default());
296 ConfigSection::Module(config.modules.len() - 1)
297 }
298 "[package.metadata.allure.modules.labels]" => {
299 if config.modules.is_empty() {
300 ConfigSection::Ignore
301 } else {
302 ConfigSection::ModuleLabels(config.modules.len() - 1)
303 }
304 }
305 _ => ConfigSection::Ignore,
306 };
307 continue;
308 }
309
310 let Some((key, value)) = split_key_value(line) else {
311 continue;
312 };
313 let key = normalize_key(key);
314
315 match section {
316 ConfigSection::Root if key == "log_asserts" => {
317 if let Some(value) = parse_bool(value) {
318 config.log_asserts = value;
319 }
320 }
321 ConfigSection::Root if key == "labels" => {
322 config.labels.extend(parse_inline_labels(value));
323 }
324 ConfigSection::Labels => {
325 config.labels.extend(parse_label_values(key, value));
326 }
327 ConfigSection::Module(index) => {
328 let Some(module) = config.modules.get_mut(index) else {
329 continue;
330 };
331 match key.as_str() {
332 "path" => module.path = parse_string(value).map(|value| normalize_path(&value)),
333 "module" => module.module = parse_string(value),
334 "title_path" => module.title_path = parse_string_array(value),
335 "labels" => module.labels.extend(parse_inline_labels(value)),
336 _ => {}
337 }
338 }
339 ConfigSection::ModuleLabels(index) => {
340 if let Some(module) = config.modules.get_mut(index) {
341 module.labels.extend(parse_label_values(key, value));
342 }
343 }
344 ConfigSection::Ignore | ConfigSection::Root => {}
345 }
346 }
347
348 config
349}
350
351fn parse_bool(value: &str) -> Option<bool> {
352 let normalized = value
353 .trim()
354 .trim_matches('"')
355 .trim_matches('\'')
356 .to_ascii_lowercase();
357 match normalized.as_str() {
358 "true" | "1" | "yes" | "on" => Some(true),
359 "false" | "0" | "no" | "off" => Some(false),
360 _ => None,
361 }
362}
363
364fn table_header(line: &str) -> Option<String> {
365 let line = line.trim();
366 if line.starts_with("[[") && line.ends_with("]]") {
367 return Some(line.to_string());
368 }
369 if line.starts_with('[') && line.ends_with(']') {
370 return Some(line.to_string());
371 }
372 None
373}
374
375fn strip_comment(line: &str) -> &str {
376 let mut quote = None;
377 let mut escaped = false;
378
379 for (idx, ch) in line.char_indices() {
380 if let Some(active_quote) = quote {
381 if escaped {
382 escaped = false;
383 continue;
384 }
385 if active_quote == '"' && ch == '\\' {
386 escaped = true;
387 continue;
388 }
389 if ch == active_quote {
390 quote = None;
391 }
392 continue;
393 }
394
395 match ch {
396 '"' | '\'' => quote = Some(ch),
397 '#' => return &line[..idx],
398 _ => {}
399 }
400 }
401
402 line
403}
404
405fn split_key_value(input: &str) -> Option<(&str, &str)> {
406 let mut quote = None;
407 let mut escaped = false;
408
409 for (idx, ch) in input.char_indices() {
410 if let Some(active_quote) = quote {
411 if escaped {
412 escaped = false;
413 continue;
414 }
415 if active_quote == '"' && ch == '\\' {
416 escaped = true;
417 continue;
418 }
419 if ch == active_quote {
420 quote = None;
421 }
422 continue;
423 }
424
425 match ch {
426 '"' | '\'' => quote = Some(ch),
427 '=' => return Some((&input[..idx], &input[idx + 1..])),
428 _ => {}
429 }
430 }
431
432 None
433}
434
435fn parse_inline_labels(value: &str) -> Vec<(String, String)> {
436 let value = value.trim();
437 let Some(value) = value
438 .strip_prefix('{')
439 .and_then(|value| value.strip_suffix('}'))
440 else {
441 return Vec::new();
442 };
443
444 split_top_level(value, ',')
445 .into_iter()
446 .flat_map(|entry| {
447 let Some((key, value)) = split_key_value(entry) else {
448 return Vec::new();
449 };
450 parse_label_values(normalize_key(key), value)
451 })
452 .collect()
453}
454
455fn parse_label_values(key: String, value: &str) -> Vec<(String, String)> {
456 if let Some(value) = parse_string(value) {
457 return vec![(key, value)];
458 }
459
460 parse_string_array(value)
461 .unwrap_or_default()
462 .into_iter()
463 .map(|value| (key.clone(), value))
464 .collect()
465}
466
467fn parse_string_array(value: &str) -> Option<Vec<String>> {
468 let value = value.trim();
469 let value = value.strip_prefix('[')?.strip_suffix(']')?;
470 let values = split_top_level(value, ',')
471 .into_iter()
472 .filter_map(parse_string)
473 .collect::<Vec<_>>();
474
475 if values.is_empty() {
476 None
477 } else {
478 Some(values)
479 }
480}
481
482fn split_top_level(input: &str, delimiter: char) -> Vec<&str> {
483 let mut parts = Vec::new();
484 let mut start = 0;
485 let mut quote = None;
486 let mut escaped = false;
487 let mut depth = 0_u32;
488
489 for (idx, ch) in input.char_indices() {
490 if let Some(active_quote) = quote {
491 if escaped {
492 escaped = false;
493 continue;
494 }
495 if active_quote == '"' && ch == '\\' {
496 escaped = true;
497 continue;
498 }
499 if ch == active_quote {
500 quote = None;
501 }
502 continue;
503 }
504
505 match ch {
506 '"' | '\'' => quote = Some(ch),
507 '[' | '{' | '(' => depth = depth.saturating_add(1),
508 ']' | '}' | ')' => depth = depth.saturating_sub(1),
509 _ if ch == delimiter && depth == 0 => {
510 parts.push(input[start..idx].trim());
511 start = idx + ch.len_utf8();
512 }
513 _ => {}
514 }
515 }
516
517 parts.push(input[start..].trim());
518 parts
519}
520
521fn parse_string(value: &str) -> Option<String> {
522 let value = value.trim();
523 if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
524 return Some(unescape_basic_string(&value[1..value.len() - 1]));
525 }
526 if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
527 return Some(value[1..value.len() - 1].to_string());
528 }
529 None
530}
531
532fn unescape_basic_string(value: &str) -> String {
533 let mut result = String::new();
534 let mut chars = value.chars();
535
536 while let Some(ch) = chars.next() {
537 if ch != '\\' {
538 result.push(ch);
539 continue;
540 }
541
542 match chars.next() {
543 Some('n') => result.push('\n'),
544 Some('r') => result.push('\r'),
545 Some('t') => result.push('\t'),
546 Some('"') => result.push('"'),
547 Some('\\') => result.push('\\'),
548 Some(other) => {
549 result.push('\\');
550 result.push(other);
551 }
552 None => result.push('\\'),
553 }
554 }
555
556 result
557}
558
559fn normalize_key(key: &str) -> String {
560 let key = key.trim();
561 parse_string(key).unwrap_or_else(|| key.to_string())
562}
563
564fn normalize_path(path: &str) -> String {
565 path.replace('\\', "/")
566}
567
568pub fn title_path(file: &str, manifest_dir: &str) -> Vec<String> {
570 relative_file_path(file, manifest_dir)
571 .split('/')
572 .filter(|part| !part.is_empty())
573 .map(ToString::to_string)
574 .collect()
575}
576
577pub fn relative_file_path(file: &str, manifest_dir: &str) -> String {
579 let file = file.replace('\\', "/");
580 let manifest_dir = manifest_dir.replace('\\', "/");
581 if let Some(relative) = file
582 .strip_prefix(&manifest_dir)
583 .map(|path| path.trim_start_matches('/'))
584 {
585 return relative.to_string();
586 }
587
588 let Some(package_name) = manifest_dir.rsplit('/').next() else {
589 return file;
590 };
591 let package_segment = format!("/{package_name}/");
592 if let Some((_, relative)) = file.split_once(&package_segment) {
593 return relative.to_string();
594 }
595 let package_prefix = format!("{package_name}/");
596 if let Some(relative) = file.strip_prefix(&package_prefix) {
597 return relative.to_string();
598 }
599
600 file
601}
602
603pub fn apply_synthetic_suite_labels(allure: &AllureFacade, full_name: Option<&str>) {
605 let Some(full_name) = full_name else {
606 return;
607 };
608
609 let mut segments = full_name.split("::").collect::<Vec<_>>();
610 if segments.len() < 2 {
611 return;
612 }
613
614 segments.pop();
615 match segments.as_slice() {
616 [] => {}
617 [suite] => allure.suite(*suite),
618 [parent_suite, suite] => {
619 allure.parent_suite(*parent_suite);
620 allure.suite(*suite);
621 }
622 [parent_suite, suite, rest @ ..] => {
623 allure.parent_suite(*parent_suite);
624 allure.suite(*suite);
625 allure.sub_suite(rest.join("::"));
626 }
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use crate::test_utils::allure_test;
633
634 use super::{global_labels_from_env_vars, parse_config};
635
636 #[test]
637 fn enables_assertion_logging_by_default() {
638 allure_test(
639 module_path!(),
640 "enables_assertion_logging_by_default",
641 "Verifies assertion logging is enabled when no package metadata override is set.",
642 || {
643 let config = parse_config("");
644
645 assert!(config.log_asserts);
646 },
647 );
648 }
649
650 #[test]
651 fn package_metadata_can_disable_assertion_logging() {
652 allure_test(
653 module_path!(),
654 "package_metadata_can_disable_assertion_logging",
655 "Verifies package metadata can disable assertion step logging for the current manifest.",
656 || {
657 let config = parse_config(
658 r#"
659[package.metadata.allure]
660log_asserts = false
661"#,
662 );
663
664 assert!(!config.log_asserts);
665 },
666 );
667 }
668
669 #[test]
670 fn parses_package_metadata_allure_labels() {
671 allure_test(
672 module_path!(),
673 "parses_package_metadata_allure_labels",
674 "Verifies package metadata labels and module-specific labels are parsed for matching source paths.",
675 || {
676 let config = parse_config(
677 r#"
678[package.metadata.allure]
679log_asserts = true
680
681[package.metadata.allure.labels]
682module = "checkout"
683layer = "e2e"
684tag = ["smoke", "regression"]
685
686[[package.metadata.allure.modules]]
687path = "tests/payments.rs"
688labels = { component = "payments", owner = "qa", tag = ["payments-smoke", "payments-regression"] }
689
690[[package.metadata.allure.modules]]
691module = "payments::cards"
692[package.metadata.allure.modules.labels]
693feature = "cards"
694story = ["visa", "mastercard"]
695"#,
696 );
697
698 assert_eq!(
699 config.labels,
700 vec![
701 ("module".to_string(), "checkout".to_string()),
702 ("layer".to_string(), "e2e".to_string()),
703 ("tag".to_string(), "smoke".to_string()),
704 ("tag".to_string(), "regression".to_string()),
705 ]
706 );
707 assert!(config.log_asserts);
708 assert!(config.modules[0].matches("payments", "tests/payments.rs"));
709 assert!(config.modules[1].matches("payments::cards::visa", "src/cards.rs"));
710 assert_eq!(
711 config.modules[0].labels,
712 vec![
713 ("component".to_string(), "payments".to_string()),
714 ("owner".to_string(), "qa".to_string()),
715 ("tag".to_string(), "payments-smoke".to_string()),
716 ("tag".to_string(), "payments-regression".to_string()),
717 ]
718 );
719 assert_eq!(
720 config.modules[1].labels,
721 vec![
722 ("feature".to_string(), "cards".to_string()),
723 ("story".to_string(), "visa".to_string()),
724 ("story".to_string(), "mastercard".to_string()),
725 ]
726 );
727 },
728 );
729 }
730
731 #[test]
732 fn collects_global_labels_from_environment_variables() {
733 allure_test(
734 module_path!(),
735 "collects_global_labels_from_environment_variables",
736 "Verifies ALLURE_LABEL environment variables become runtime labels.",
737 || {
738 let labels = global_labels_from_env_vars([
739 ("ALLURE_LABEL_component".to_string(), "checkout".to_string()),
740 ("ALLURE_LABEL_".to_string(), "ignored".to_string()),
741 ("ALLURE_LABEL_empty".to_string(), String::new()),
742 ("allure.label.layer".to_string(), "e2e".to_string()),
743 ("OTHER".to_string(), "ignored".to_string()),
744 ]);
745
746 assert_eq!(
747 labels,
748 vec![
749 ("component".to_string(), "checkout".to_string()),
750 ("layer".to_string(), "e2e".to_string()),
751 ]
752 );
753 },
754 );
755 }
756}