Vastly improve testing

- simplify some examples
- add macrotest for expanding/snapshotting macro expansion & add tests
  - add workaround to enable referencing relative dirs from macrotest files
  - ensure workaround doesn't get built in release
- add trybuild for testing error messages & add tests
- fix indeterministic macro output
  - sort routes after collecting
  - use BTreeMap instead of HashMap to preserve insertion order
- fix compile_error syntax error
This commit is contained in:
Tristan D. 2025-04-15 13:18:14 +02:00
parent f0ce2b2737
commit daf793eb06
Signed by: tristan
SSH key fingerprint: SHA256:9oFM1J63hYWJjCnLG6C0fxBS15rwNcWwdQNMOHYKJ/4
20 changed files with 357 additions and 25 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
.direnv/
target/
wip/

91
Cargo.lock generated
View file

@ -17,6 +17,15 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.98"
@ -93,8 +102,10 @@ dependencies = [
"macrotest",
"proc-macro2",
"quote",
"regex",
"syn",
"tokio",
"trybuild",
]
[[package]]
@ -481,6 +492,35 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rustc-demangle"
version = "0.1.24"
@ -610,6 +650,21 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "target-triple"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790"
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "tokio"
version = "1.44.2"
@ -639,6 +694,18 @@ dependencies = [
"syn",
]
[[package]]
name = "toml"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.8"
@ -709,6 +776,21 @@ dependencies = [
"once_cell",
]
[[package]]
name = "trybuild"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ae08be68c056db96f0e6c6dd820727cca756ced9e1f4cc7fdd20e2a55e23898"
dependencies = [
"glob",
"serde",
"serde_derive",
"serde_json",
"target-triple",
"termcolor",
"toml",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
@ -721,6 +803,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-sys"
version = "0.52.0"

View file

@ -21,11 +21,14 @@ quote = "1.0"
proc-macro2 = "1.0"
glob = "0.3"
macrotest = "1.1.0"
regex = "1.11.1"
[dev-dependencies]
anyhow = "1.0.98"
axum = "0.8.3"
tokio = { version = "1.44.2", features = ["full"] }
trybuild = "1.0.104"
[lints.clippy]
pedantic = "warn"
pedantic = { level = "warn", priority = -1 }
unused-async = { level = "allow", priority = 0 } # required for examples without unecessary noise

View file

@ -5,4 +5,3 @@ async fn main() -> anyhow::Result<()> {
server::server().await?;
Ok(())
}

View file

@ -6,7 +6,7 @@ struct AppState {
_foo: String,
}
// Imports route.rs files & generates an init fn
// Imports route.rs files & generates an ::into_router() fn
#[folder_router("examples/advanced/api", AppState)]
struct MyFolderRouter();

View file

@ -1,21 +1,17 @@
use axum::Router;
use axum_folder_router::folder_router;
#[derive(Clone, Debug)]
struct AppState {
_foo: String,
}
#[derive(Clone)]
struct AppState;
// Imports route.rs files & generates an init fn
// Imports route.rs files & generates an ::into_router() fn
#[folder_router("./examples/simple/api", AppState)]
struct MyFolderRouter();
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create app state
let app_state = AppState {
_foo: String::new(),
};
let app_state = AppState;
// Use the init fn generated above
let folder_router: Router<AppState> = MyFolderRouter::into_router();

View file

@ -123,7 +123,7 @@
//! - **Expects seperate directory**: To make rust-analyzer & co work correctly the macro imports all route.rs files inside the given directory tree.
//! It is highly recommended to keep the route directory seperate from the rest of your module-tree.
use std::{
collections::HashMap,
collections::BTreeMap,
fmt::Write,
fs,
path::{Path, PathBuf},
@ -131,6 +131,7 @@ use std::{
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use regex::Regex;
use syn::{
Ident,
Item,
@ -143,6 +144,7 @@ use syn::{
parse_macro_input,
};
#[derive(Debug)]
struct FolderRouterArgs {
path: String,
state_type: Ident,
@ -166,7 +168,7 @@ impl Parse for FolderRouterArgs {
struct ModuleDir {
name: String,
has_route: bool,
children: HashMap<String, ModuleDir>,
children: BTreeMap<String, ModuleDir>,
}
impl ModuleDir {
@ -174,7 +176,7 @@ impl ModuleDir {
ModuleDir {
name: name.to_string(),
has_route: false,
children: HashMap::new(),
children: BTreeMap::new(),
}
}
}
@ -192,6 +194,7 @@ impl ModuleDir {
/// This will scan all `route.rs` files in the `./src/api` directory and its
/// subdirectories, automatically mapping their path structure to URL routes
/// with the specified state type.
#[allow(clippy::missing_panics_doc)]
#[proc_macro_attribute]
pub fn folder_router(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr as FolderRouterArgs);
@ -201,19 +204,42 @@ pub fn folder_router(attr: TokenStream, item: TokenStream) -> TokenStream {
let base_path = args.path;
let state_type = args.state_type;
// Get the project root directory
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or("./".to_string());
let manifest_dir = {
// Get the project root directory
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or("./".to_string());
// Create regex to match macrotest pattern with exactly 42 alphanumeric chars
// This is the only way to enable us to reference the example route folders in
// our macrotest::expand tests
let re = Regex::new(r"^(.+)/target/tests/axum-folder-router/[A-Za-z0-9]{42}$").unwrap();
// If the pattern matches, extract the real project root
// Being extra caucious to warn any users about this unexpected Workaround
if let Some(captures) = re.captures(&dir) {
#[cfg(not(debug_assertions))]
return TokenStream::from(quote! {
compile_error!("axum-folder-router: MACROTEST_WORKAROUND compiled in non-debug env, something is likely wrong!");
});
captures.get(1).unwrap().as_str().to_string()
} else {
dir
}
};
let base_dir = Path::new(&manifest_dir).join(&base_path);
// Collect route files
let mut routes = Vec::new();
collect_route_files(&base_dir, &base_dir, &mut routes);
// ensures deterministic macro output
routes.sort();
if routes.is_empty() {
return TokenStream::from(quote! {
compile_error!(concat!("No route.rs files found in the specified directory: ",
compile_error!(concat!("No route.rs files found in the specified directory: '",
#base_path,
". Make sure the path is correct and contains route.rs files."
"'. Make sure the path is correct and contains route.rs files."
));
});
}
@ -258,8 +284,8 @@ pub fn folder_router(attr: TokenStream, item: TokenStream) -> TokenStream {
if method_registrations.is_empty() {
return TokenStream::from(quote! {
compile_error!(concat!("No routes defined in '",
#base_path
"', make sure to define at least one `pub async fn` named after an method. (E.g. get, post, put, delete)"
#base_path,
"', make sure to define at least one `pub async fn` named after an method. (e.g. get, post, put, delete)"
));
});
}

4
tests/expand.rs Normal file
View file

@ -0,0 +1,4 @@
#[test]
pub fn expand_snapshot_pass() {
macrotest::expand("tests/expand/*.rs");
}

View file

@ -0,0 +1,131 @@
use axum_folder_router::folder_router;
struct AppState {
_foo: String,
}
#[automatically_derived]
impl ::core::clone::Clone for AppState {
#[inline]
fn clone(&self) -> AppState {
AppState {
_foo: ::core::clone::Clone::clone(&self._foo),
}
}
}
#[path = "/home/tristand/code/axum-folder-router/examples/advanced/api"]
mod __folder_router__myfolderrouter__examples_advanced_api {
#[path = "route.rs"]
pub mod route {
use axum::response::{Html, IntoResponse};
pub async fn get() -> impl IntoResponse {
Html("<h1>Hello World!</h1>").into_response()
}
pub async fn post() -> impl IntoResponse {
"Posted successfully".into_response()
}
}
#[path = "files"]
pub mod files {
#[path = "route.rs"]
pub mod route {
use axum::response::{Html, IntoResponse};
pub async fn get() -> impl IntoResponse {
Html("<h1>Hello World!</h1>").into_response()
}
pub async fn post() -> impl IntoResponse {
"Posted successfully".into_response()
}
}
#[path = "[...path]"]
pub mod ___path {
#[path = "route.rs"]
pub mod route {
use axum::{extract::Path, response::IntoResponse};
pub async fn get(Path(path): Path<String>) -> impl IntoResponse {
::alloc::__export::must_use({
let res = ::alloc::fmt::format(
format_args!("Requested file path: {0}", path),
);
res
})
}
}
}
}
#[path = "users"]
pub mod users {
#[path = "route.rs"]
pub mod route {
use axum::response::{Html, IntoResponse};
pub async fn get() -> impl IntoResponse {
Html("<h1>Hello World!</h1>").into_response()
}
pub async fn post() -> impl IntoResponse {
"Posted successfully".into_response()
}
}
#[path = "[id]"]
pub mod __id {
#[path = "route.rs"]
pub mod route {
use axum::{extract::Path, response::IntoResponse};
pub async fn get(Path(id): Path<String>) -> impl IntoResponse {
::alloc::__export::must_use({
let res = ::alloc::fmt::format(format_args!("User ID: {0}", id));
res
})
}
}
}
}
}
struct MyFolderRouter();
impl MyFolderRouter {
pub fn into_router() -> axum::Router<AppState> {
let mut router = axum::Router::new();
router = router
.route(
"/files/{*path}",
axum::routing::get(
__folder_router__myfolderrouter__examples_advanced_api::files::___path::route::get,
),
);
router = router
.route(
"/files",
axum::routing::get(
__folder_router__myfolderrouter__examples_advanced_api::files::route::get,
)
.post(
__folder_router__myfolderrouter__examples_advanced_api::files::route::post,
),
);
router = router
.route(
"/",
axum::routing::get(
__folder_router__myfolderrouter__examples_advanced_api::route::get,
)
.post(
__folder_router__myfolderrouter__examples_advanced_api::route::post,
),
);
router = router
.route(
"/users/{:id}",
axum::routing::get(
__folder_router__myfolderrouter__examples_advanced_api::users::__id::route::get,
),
);
router = router
.route(
"/users",
axum::routing::get(
__folder_router__myfolderrouter__examples_advanced_api::users::route::get,
)
.post(
__folder_router__myfolderrouter__examples_advanced_api::users::route::post,
),
);
router
}
}

9
tests/expand/advanced.rs Normal file
View file

@ -0,0 +1,9 @@
use axum_folder_router::folder_router;
#[derive(Clone)]
struct AppState {
_foo: String,
}
#[folder_router("examples/advanced/api", AppState)]
struct MyFolderRouter();

View file

@ -1 +0,0 @@
../../examples

View file

@ -0,0 +1,33 @@
use axum_folder_router::folder_router;
struct AppState;
#[automatically_derived]
impl ::core::clone::Clone for AppState {
#[inline]
fn clone(&self) -> AppState {
AppState
}
}
#[path = "/home/tristand/code/axum-folder-router/examples/simple/api"]
mod __folder_router__myfolderrouter__examples_simple_api {
#[path = "route.rs"]
pub mod route {
use axum::response::{Html, IntoResponse};
pub async fn get() -> impl IntoResponse {
Html("<h1>Hello World!</h1>").into_response()
}
}
}
struct MyFolderRouter();
impl MyFolderRouter {
pub fn into_router() -> axum::Router<AppState> {
let mut router = axum::Router::new();
router = router
.route(
"/",
axum::routing::get(
__folder_router__myfolderrouter__examples_simple_api::route::get,
),
);
router
}
}

7
tests/expand/simple.rs Normal file
View file

@ -0,0 +1,7 @@
use axum_folder_router::folder_router;
#[derive(Clone)]
struct AppState;
#[folder_router("examples/simple/api", AppState)]
struct MyFolderRouter();

5
tests/failures.rs Normal file
View file

@ -0,0 +1,5 @@
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/failures/*.rs");
}

View file

@ -0,0 +1,9 @@
use axum_folder_router::folder_router;
#[derive(Clone)]
struct AppState;
#[folder_router("some/non/existing/directory", AppState)]
struct MyFolderRouter();
fn main() {}

View file

@ -0,0 +1,7 @@
error: No route.rs files found in the specified directory: 'some/non/existing/directory'. Make sure the path is correct and contains route.rs files.
--> tests/failures/no_files.rs:6:1
|
6 | #[folder_router("some/non/existing/directory", AppState)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `folder_router` (in Nightly builds, run with -Z macro-backtrace for more info)

View file

@ -0,0 +1,9 @@
use axum_folder_router::folder_router;
#[derive(Clone)]
struct AppState;
#[folder_router("../../../../tests/failures/no_routes", AppState)]
struct MyFolderRouter();
fn main() {}

View file

@ -0,0 +1,7 @@
error: No routes defined in '../../../../tests/failures/no_routes', make sure to define at least one `pub async fn` named after an method. (e.g. get, post, put, delete)
--> tests/failures/no_routes.rs:6:1
|
6 | #[folder_router("../../../../tests/failures/no_routes", AppState)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `folder_router` (in Nightly builds, run with -Z macro-backtrace for more info)

View file

View file

@ -1,4 +0,0 @@
#[test]
pub fn expand_examples_pass() {
macrotest::expand("test/expand/**/*.rs");
}