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}