Skip to main content

allure_rust_commons/
config.rs

1//! Runtime and Cargo metadata configuration helpers.
2
3use 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/// Process-wide Allure configuration loaded from environment variables.
19#[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    /// Returns the configured results directory.
30    pub fn results_dir(&self) -> &Path {
31        &self.results_dir
32    }
33
34    /// Returns labels configured through environment variables.
35    pub fn global_labels(&self) -> &[(String, String)] {
36        &self.global_labels
37    }
38
39    /// Returns the configured or detected host name.
40    pub fn host_name(&self) -> Option<&str> {
41        self.host_name.as_deref()
42    }
43
44    /// Returns the configured thread name override.
45    pub fn thread_name_override(&self) -> Option<&str> {
46        self.thread_name_override.as_deref()
47    }
48
49    /// Returns the environment override for assertion logging.
50    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
70/// Returns the lazily initialized process-wide Allure configuration.
71pub fn global_config() -> &'static GlobalAllureConfig {
72    GLOBAL_CONFIG.get_or_init(GlobalAllureConfig::from_environment)
73}
74
75/// Adds common runtime labels to an active test.
76pub 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
86/// Applies labels configured in Cargo package metadata for a test source location.
87pub 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
109/// Returns whether assertion logging is enabled for a Cargo package.
110///
111/// Assertion logging is enabled by default. Set `ALLURE_LOG_ASSERTS=false` or
112/// `[package.metadata.allure] log_asserts = false` to disable it.
113pub 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        // SAFETY: `buf` is a valid writable buffer and its length is correctly provided.
137        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
165/// Returns labels configured through environment variables.
166pub 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
568/// Builds a title path from a source file and Cargo manifest directory.
569pub 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
577/// Returns a source file path relative to a Cargo manifest directory when possible.
578pub 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
603/// Applies synthetic suite labels derived from a Rust full test name.
604pub 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}