Skip to main content

edtest/
lib.rs

1#![doc = include_str!("../README.md")]
2
3/// Generate a test function using `rstest`. If used on a `async` function
4/// the test will use the `tokio` runtime.
5/// See the [rstest documentation](https://docs.rs/rstest/latest/rstest).
6pub use edtest_macros::rstest;
7
8/// Creation of test-fixtures. see the
9/// [fixture documentation](https://docs.rs/rstest/latest/rstest/attr.fixture.html).
10pub use rstest::fixture;
11
12pub use serial_test::serial;
13
14pub use static_assertions::*;
15
16/// Helper macro to set an `insta` snapshot suffix for the current scope.
17///
18/// Example:
19///
20/// set_snapshot_suffix!("{}", input);
21///
22/// Expands to code that clones the current `insta::Settings`, sets the
23/// snapshot suffix and binds the settings to the current scope so snapshots
24/// get the provided suffix for the duration of the scope.
25#[macro_export]
26macro_rules! set_snapshot_suffix {
27    ($($expr:expr),*) => {
28        let mut settings = insta::Settings::clone_current();
29        let raw_suffix = format!($($expr,)*);
30        let cleaned = $crate::internal::clean_snapshot_suffix(&raw_suffix);
31        settings.set_snapshot_suffix(cleaned);
32        let _guard = settings.bind_to_scope();
33    }
34}
35
36#[doc(hidden)]
37pub mod internal {
38    use core::hint::black_box;
39    use core::sync::atomic::{AtomicUsize, Ordering};
40
41    static COUNTER: AtomicUsize = AtomicUsize::new(0);
42
43    /// Clean a snapshot suffix so it's safe to be used in file names across
44    /// Windows, macOS and Linux.
45    ///
46    /// Strategy:
47    /// - Keep ASCII letters and digits.
48    /// - Keep '-', '_', '.', '@', '+' as-is (commonly safe on all platforms).
49    /// - Convert whitespace and path separators ('/', '\\') to '-'.
50    /// - Replace all other characters with '_'.
51    /// - Trim trailing dots/spaces (problematic on Windows).
52    /// - Avoid reserved Windows device names by prefixing with '_'.
53    /// - If the result is empty, return "snapshot".
54    #[doc(hidden)]
55    pub fn clean_snapshot_suffix(input: &str) -> String {
56        let mut out = String::with_capacity(input.len());
57        for ch in input.chars() {
58            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '@' | '+') {
59                out.push(ch);
60            } else if ch.is_whitespace() || ch == '/' || ch == '\\' {
61                out.push('-');
62            } else {
63                out.push('_');
64            }
65        }
66
67        // Windows doesn't like trailing dots or spaces in file names
68        while out.ends_with(['.', ' ']) {
69            out.pop();
70        }
71
72        // Avoid reserved device names on Windows
73        // https://learn.microsoft.com/windows/win32/fileio/naming-a-file
74        fn is_windows_reserved(name: &str) -> bool {
75            const RESERVED: &[&str] = &[
76                "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
77                "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
78                "LPT9",
79            ];
80            RESERVED.iter().any(|&r| r.eq_ignore_ascii_case(name))
81        }
82
83        if out.is_empty() || !out.chars().any(|c| c.is_ascii_alphanumeric()) {
84            return "snapshot".to_string();
85        }
86
87        if is_windows_reserved(&out) {
88            return format!("_{}", out);
89        }
90
91        out
92    }
93
94    // Tiny side effects to ensure coverage sees executed regions and prevent full optimization.
95    pub fn on_test_enter(name: &str) {
96        black_box(name.len());
97        COUNTER.fetch_add(1, Ordering::Relaxed);
98    }
99
100    pub fn on_test_exit() {
101        COUNTER.fetch_add(1, Ordering::Relaxed);
102    }
103}
104
105/// A guard that calls `internal::on_test_exit()` when dropped.
106/// Used by the macro expansion to ensure an exit hook runs even if the test panics.
107#[doc(hidden)]
108pub struct TestGuard;
109
110#[allow(clippy::new_without_default)]
111impl TestGuard {
112    #[inline(always)]
113    pub fn new() -> Self {
114        Self
115    }
116}
117
118impl Drop for TestGuard {
119    fn drop(&mut self) {
120        internal::on_test_exit();
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    #[test]
127    fn internal_hooks_execute() {
128        crate::internal::on_test_enter("sample");
129        let _g = crate::TestGuard::new();
130        crate::internal::on_test_exit();
131    }
132
133    #[test]
134    fn cleans_suffix_general() {
135        use crate::internal::clean_snapshot_suffix as clean;
136        // Mix of problem characters including separators and invalid Windows chars
137        let s = "a/b\\c:d*e?f|g<h>i\"j k.";
138        assert_eq!(clean(s), "a-b-c_d_e_f_g_h_i_j-k");
139    }
140
141    #[test]
142    fn cleans_suffix_reserved_and_empty() {
143        use crate::internal::clean_snapshot_suffix as clean;
144        assert_eq!(clean("CON"), "_CON");
145        assert_eq!(clean("con"), "_con");
146        assert_eq!(clean("   \t\n"), "snapshot");
147    }
148}