Skip to main content

regression_test_macros/
lib.rs

1//! # regression-test-macros
2//!
3//! This crate provides procedural macros to support regression testing in Rust projects.
4//! The main macro, `regtest`, is an attribute macro designed to be applied to test functions.
5//! It automatically generates boilerplate code for managing regression test data files,
6//! ensuring that each test has a dedicated file for storing and comparing results.
7//!
8//! ## Features
9//! - Automatically determines and creates the appropriate file path for regression data
10//!   based on the test's source location (unit or integration test).
11//! - Handles compatibility with tools like rust-analyzer.
12//!
13//! ## Usage
14//!
15//! ```rust
16//! use regression_test_macros::regtest;
17//! use regression_test::RegTest;
18//!
19//! #[regtest]
20//! #[test]
21//! fn my_regression_test(mut rt: RegTest) {
22//!     // Test logic here
23//!     rt.regtest("some output");
24//!     rt.regtest_dbg(vec![1, 2, 3]);
25//! }
26//! ```
27//!
28//! The macro will ensure that a regression data file is created and passed to the test
29//! via the `RegTest` argument.
30use proc_macro::TokenStream;
31use quote::quote;
32use syn::{ItemFn, parse_macro_input};
33
34/// Attribute macro for regression tests.
35///
36/// This macro should be applied to test functions whose first argument is of type `RegTest`.
37/// It generates a `#[test]` function that automatically manages the file path for regression
38/// test data, creating the necessary directories and passing a `RegTest` instance to the test.
39///
40/// # Requirements
41/// - The first argument of the function must be of type `RegTest`.
42///
43/// # Example
44/// ```rust
45/// use regression_test::RegTest;
46/// use regression_test_macros::regtest;
47///
48/// #[regtest]
49/// #[test]
50/// fn my_test(mut rt: RegTest) {
51///     // Test logic
52///     rt.regtest("some output");
53///     rt.regtest_dbg(vec![1, 2, 3]);
54/// }
55/// ```
56///
57/// The macro will inject code to determine the appropriate file path for the regression data,
58/// create the file if necessary, and pass a `RegTest` instance to the test function.
59#[proc_macro_attribute]
60pub fn regtest(_attr: TokenStream, item: TokenStream) -> TokenStream {
61    let input_fn = parse_macro_input!(item as ItemFn);
62    let fn_name = &input_fn.sig.ident;
63    let fn_attrs = &input_fn.attrs;
64    let fn_vis = &input_fn.vis;
65    let fn_block = &input_fn.block;
66    let fn_inputs = &input_fn.sig.inputs;
67    let fn_async = &input_fn.sig.asyncness;
68
69    // Check if there is at least one argument
70    let first_arg = match fn_inputs.iter().next() {
71        Some(arg) => arg,
72        None => {
73            return syn::Error::new_spanned(
74                &input_fn.sig,
75                "Expected at least one argument of type 'RegTest', but found none.",
76            )
77            .to_compile_error()
78            .into();
79        }
80    };
81
82    // Check if the first argument is a typed argument and of type RegTest (by last segment)
83    let arg_pat = if let syn::FnArg::Typed(pat_type) = first_arg {
84        if let syn::Type::Path(type_path) = &*pat_type.ty {
85            if let Some(last_segment) = type_path.path.segments.last() {
86                if last_segment.ident == "RegTest" {
87                    &pat_type.pat
88                } else {
89                    return syn::Error::new_spanned(
90                        &pat_type.ty,
91                        format!(
92                            "Expected the first argument to be of type RegTest, but found type '{}'.",
93                            last_segment.ident
94                        )
95                    ).to_compile_error().into();
96                }
97            } else {
98                return syn::Error::new_spanned(
99                    &pat_type.ty,
100                    "Expected the first argument to be of type RegTest, but found an empty type path."
101                ).to_compile_error().into();
102            }
103        } else {
104            return syn::Error::new_spanned(
105                &pat_type.ty,
106                format!(
107                    "Expected the first argument to be of type RegTest, but found a different type: {}.",
108                    quote!(#pat_type.ty).to_string()
109                )
110            ).to_compile_error().into();
111        }
112    } else {
113        return syn::Error::new_spanned(
114            first_arg,
115            format!(
116                "Expected the first argument to be a typed argument (e.g., arg: RegTest), but found: `{}`.",
117                quote!(#first_arg).to_string()
118            )
119        ).to_compile_error().into();
120    };
121
122    // Try to get the local file path, but handle rust-analyzer bug where local_file() returns None
123    let file_path_opt = proc_macro::Span::call_site().local_file();
124
125    let regtest_path_quote = if let Some(full_file_path_buf) = file_path_opt {
126        let full_file_path_buf = full_file_path_buf
127            .canonicalize()
128            .expect("Failed to canonicalize the file path");
129
130        let full_file_path = full_file_path_buf
131            .to_str()
132            .expect("Failed to convert the file path to a string")
133            .to_string();
134
135        // Path computation quote
136        quote! {
137            // Determine the file path for the regression test data
138            let __regtest_file_path = {
139                use std::path::{Path, PathBuf};
140
141                let file = #full_file_path;
142                let test_name = stringify!(#fn_name);
143                let path = Path::new(file);
144
145                // Helper to get the relative path after "src" or "tests"
146                fn relative_mod_path(path: &std::path::Path) -> std::path::PathBuf {
147                    let mut components = path.components().peekable();
148                    let mut found = false;
149                    let mut rel = PathBuf::new();
150                    while let Some(comp) = components.next() {
151                        if found {
152                            rel.push(comp.as_os_str());
153                        }
154                        if comp.as_os_str() == "src" || comp.as_os_str() == "tests" {
155                            found = true;
156                        }
157                    }
158                    rel
159                }
160
161                let mut base = {
162                    // Check if this is an integration test (in "tests" folder)
163                    if path.components().any(|c| c.as_os_str() == "tests") {
164                        // Place the file next to the test file, preserving subfolders after "tests"
165                        let ancestor = path.ancestors().find(|a| a.ends_with("tests")).unwrap_or_else(|| Path::new(""));
166                        let rel = relative_mod_path(path);
167                        let mut p = ancestor.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
168                        p.push("regtest_data");
169                        p.push("tests");
170                        if let Some(parent) = rel.parent() {
171                            p.push(parent);
172                        }
173                        p
174                    } else {
175                        // Place the file in "unit_tests" at the same level as "src"
176                        let ancestor = path.ancestors().find(|a| a.ends_with("src")).unwrap_or_else(|| Path::new(""));
177                        let rel = relative_mod_path(path);
178                        let mut p = ancestor.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
179                        p.push("regtest_data");
180                        p.push("src");
181                        if let Some(parent) = rel.parent() {
182                            p.push(parent);
183                        }
184                        p
185                    }
186                };
187
188                // Add the file stem as a directory
189                if let Some(file_stem) = path.file_stem() {
190                    base.push(file_stem);
191                }
192
193                // Create the directory if it doesn't exist
194                std::fs::create_dir_all(&base).ok();
195
196                // Add the test name as the file
197                base.push(format!("{}.json", test_name));
198                base
199            };
200        }
201    } else {
202        // rust-analyzer fallback
203        quote! {
204            let __regtest_file_path = "./rust-analyzer-dummy.json".to_string();
205        }
206    };
207
208    let fn_quote = quote! {
209        #(#fn_attrs)*
210        #fn_vis #fn_async fn #fn_name() {
211            #regtest_path_quote
212            let #arg_pat = RegTest::new(__regtest_file_path).expect("Failed to create or open regression test file");
213            #fn_block
214        }
215    };
216
217    TokenStream::from(fn_quote)
218}