//! Router code generation and macro input parsing.
//!
//! This module contains the core logic for:
//! - Parsing `vespera!` and `export_app!` macro inputs
//! - Processing input into validated configuration
//! - Generating Axum router code from collected metadata
//!
//! # Overview
//!
//! The vespera macros accept configuration parameters (directory, `OpenAPI` files, etc.)
//! which are parsed and processed into a normalized form. This module then generates
//! the `TokenStream` that creates the Axum router with all discovered routes.
//!
//! # Key Components
//!
//! - [`AutoRouterInput`] - Parsed `vespera!()` macro arguments
//! - [`ExportAppInput`] - Parsed `export_app!()` macro arguments
//! - [`process_vespera_input`] - Validate and process vespera! arguments
//! - [`generate_router_code`] - Generate the router `TokenStream`
//!
//! # Macro Parameters
//!
//! **vespera!()** accepts:
//! - `dir` - Route discovery folder (default: "routes")
//! - `openapi` - Output file path(s) for `OpenAPI` spec
//! - `title` - API title (`OpenAPI` info.title)
//! - `version` - API version (`OpenAPI` info.version)
//! - `docs_url` - Swagger UI endpoint
//! - `redoc_url` - `ReDoc` endpoint
//! - `servers` - Array of server configurations
//! - `merge` - Child vespera apps to merge
//!
//! **`export_app`!()** accepts:
//! - `dir` - Route discovery folder (default: "routes")
use proc_macro2::Span;
use quote::quote;
use syn::{
LitStr, bracketed,
parse::{Parse, ParseStream},
punctuated::Punctuated,
};
use vespera_core::{openapi::Server, route::HttpMethod};
use crate::{metadata::CollectedMetadata, method::http_method_to_token_stream};
/// Server configuration for `OpenAPI`
#[derive(Clone)]
pub struct ServerConfig {
pub url: String,
pub description: Option<String>,
}
/// Input for the `vespera!` macro
pub struct AutoRouterInput {
pub dir: Option<LitStr>,
pub openapi: Option<Vec<LitStr>>,
pub title: Option<LitStr>,
pub version: Option<LitStr>,
pub docs_url: Option<LitStr>,
pub redoc_url: Option<LitStr>,
pub servers: Option<Vec<ServerConfig>>,
/// Apps to merge (e.g., [`third::ThirdApp`, `another::AnotherApp`])
pub merge: Option<Vec<syn::Path>>,
}
impl Parse for AutoRouterInput {
#[allow(clippy::too_many_lines)]
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut dir = None;
let mut openapi = None;
let mut title = None;
let mut version = None;
let mut docs_url = None;
let mut redoc_url = None;
let mut servers = None;
let mut merge = None;
while !input.is_empty() {
let lookahead = input.lookahead1();
if lookahead.peek(syn::Ident) {
let ident: syn::Ident = input.parse()?;
let ident_str = ident.to_string();
match ident_str.as_str() {
"dir" => {
input.parse::<syn::Token![=]>()?;
dir = Some(input.parse()?);
}
"openapi" => {
openapi = Some(parse_openapi_values(input)?);
}
"docs_url" => {
input.parse::<syn::Token![=]>()?;
docs_url = Some(input.parse()?);
}
"redoc_url" => {
input.parse::<syn::Token![=]>()?;
redoc_url = Some(input.parse()?);
}
"title" => {
input.parse::<syn::Token![=]>()?;
title = Some(input.parse()?);
}
"version" => {
input.parse::<syn::Token![=]>()?;
version = Some(input.parse()?);
}
"servers" => {
servers = Some(parse_servers_values(input)?);
}
"merge" => {
merge = Some(parse_merge_values(input)?);
}
_ => {
return Err(syn::Error::new(
ident.span(),
format!(
"unknown field: `{ident_str}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, `servers`, or `merge`"
),
));
}
}
} else if lookahead.peek(syn::LitStr) {
// If just a string, treat it as dir (for backward compatibility)
dir = Some(input.parse()?);
} else {
return Err(lookahead.error());
}
if input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
} else {
break;
}
}
Ok(Self {
dir: dir.or_else(|| {
std::env::var("VESPERA_DIR")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
openapi: openapi.or_else(|| {
std::env::var("VESPERA_OPENAPI")
.map(|f| vec![LitStr::new(&f, Span::call_site())])
.ok()
}),
title: title.or_else(|| {
std::env::var("VESPERA_TITLE")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
version: version
.or_else(|| {
std::env::var("VESPERA_VERSION")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
})
.or_else(|| {
std::env::var("CARGO_PKG_VERSION")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
docs_url: docs_url.or_else(|| {
std::env::var("VESPERA_DOCS_URL")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
redoc_url: redoc_url.or_else(|| {
std::env::var("VESPERA_REDOC_URL")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
servers: servers.or_else(|| {
std::env::var("VESPERA_SERVER_URL")
.ok()
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
.map(|url| {
vec![ServerConfig {
url,
description: std::env::var("VESPERA_SERVER_DESCRIPTION").ok(),
}]
})
}),
merge,
})
}
}
/// Parse merge values: merge = [`path::to::App`, `another::App`]
fn parse_merge_values(input: ParseStream) -> syn::Result<Vec<syn::Path>> {
input.parse::<syn::Token![=]>()?;
let content;
let _ = bracketed!(content in input);
let paths: Punctuated<syn::Path, syn::Token![,]> =
content.parse_terminated(syn::Path::parse, syn::Token![,])?;
Ok(paths.into_iter().collect())
}
fn parse_openapi_values(input: ParseStream) -> syn::Result<Vec<LitStr>> {
input.parse::<syn::Token![=]>()?;
if input.peek(syn::token::Bracket) {
let content;
let _ = bracketed!(content in input);
let entries: Punctuated<LitStr, syn::Token![,]> =
content.parse_terminated(syn::parse::ParseBuffer::parse::<LitStr>, syn::Token![,])?;
Ok(entries.into_iter().collect())
} else {
let single: LitStr = input.parse()?;
Ok(vec![single])
}
}
/// Validate that a URL starts with http:// or https://
fn validate_server_url(url: &LitStr) -> syn::Result<String> {
let url_value = url.value();
if !url_value.starts_with("http://") && !url_value.starts_with("https://") {
return Err(syn::Error::new(
url.span(),
format!(
"invalid server URL: `{url_value}`. URL must start with `http://` or `https://`"
),
));
}
Ok(url_value)
}
/// Parse server values in various formats:
/// - `servers = "url"` - single URL
/// - `servers = ["url1", "url2"]` - multiple URLs (strings only)
/// - `servers = [("url", "description")]` - tuple format with descriptions
/// - `servers = [{url = "...", description = "..."}]` - struct-like format
/// - `servers = {url = "...", description = "..."}` - single server struct-like format
fn parse_servers_values(input: ParseStream) -> syn::Result<Vec<ServerConfig>> {
use syn::token::{Brace, Paren};
input.parse::<syn::Token![=]>()?;
if input.peek(syn::token::Bracket) {
// Array format: [...]
let content;
let _ = bracketed!(content in input);
let mut servers = Vec::new();
while !content.is_empty() {
if content.peek(Paren) {
// Parse tuple: ("url", "description")
let tuple_content;
syn::parenthesized!(tuple_content in content);
let url: LitStr = tuple_content.parse()?;
let url_value = validate_server_url(&url)?;
let description = if tuple_content.peek(syn::Token![,]) {
tuple_content.parse::<syn::Token![,]>()?;
Some(tuple_content.parse::<LitStr>()?.value())
} else {
None
};
servers.push(ServerConfig {
url: url_value,
description,
});
} else if content.peek(Brace) {
// Parse struct-like: {url = "...", description = "..."}
let server = parse_server_struct(&content)?;
servers.push(server);
} else {
// Parse simple string: "url"
let url: LitStr = content.parse()?;
let url_value = validate_server_url(&url)?;
servers.push(ServerConfig {
url: url_value,
description: None,
});
}
if content.peek(syn::Token![,]) {
content.parse::<syn::Token![,]>()?;
} else {
break;
}
}
Ok(servers)
} else if input.peek(syn::token::Brace) {
// Single struct-like format: servers = {url = "...", description = "..."}
let server = parse_server_struct(input)?;
Ok(vec![server])
} else {
// Single string: servers = "url"
let single: LitStr = input.parse()?;
let url_value = validate_server_url(&single)?;
Ok(vec![ServerConfig {
url: url_value,
description: None,
}])
}
}
/// Parse a single server in struct-like format: {url = "...", description = "..."}
fn parse_server_struct(input: ParseStream) -> syn::Result<ServerConfig> {
let content;
syn::braced!(content in input);
let mut url: Option<String> = None;
let mut description: Option<String> = None;
while !content.is_empty() {
let ident: syn::Ident = content.parse()?;
let ident_str = ident.to_string();
match ident_str.as_str() {
"url" => {
content.parse::<syn::Token![=]>()?;
let url_lit: LitStr = content.parse()?;
url = Some(validate_server_url(&url_lit)?);
}
"description" => {
content.parse::<syn::Token![=]>()?;
description = Some(content.parse::<LitStr>()?.value());
}
_ => {
return Err(syn::Error::new(
ident.span(),
format!("unknown field: `{ident_str}`. Expected `url` or `description`"),
));
}
}
if content.peek(syn::Token![,]) {
content.parse::<syn::Token![,]>()?;
} else {
break;
}
}
let url = url.ok_or_else(|| syn::Error::new(proc_macro2::Span::call_site(), "vespera! macro: server configuration missing required `url` field. Use format: `servers = { url = \"http://localhost:3000\" }` or `servers = { url = \"...\", description = \"...\" }`."))?;
Ok(ServerConfig { url, description })
}
/// Processed vespera input with extracted values
pub struct ProcessedVesperaInput {
pub folder_name: String,
pub openapi_file_names: Vec<String>,
pub title: Option<String>,
pub version: Option<String>,
pub docs_url: Option<String>,
pub redoc_url: Option<String>,
pub servers: Option<Vec<Server>>,
/// Apps to merge (`syn::Path` for code generation)
pub merge: Vec<syn::Path>,
}
/// Process `AutoRouterInput` into extracted values
pub fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput {
ProcessedVesperaInput {
folder_name: input
.dir
.map_or_else(|| "routes".to_string(), |f| f.value()),
openapi_file_names: input
.openapi
.unwrap_or_default()
.into_iter()
.map(|f| f.value())
.collect(),
title: input.title.map(|t| t.value()),
version: input.version.map(|v| v.value()),
docs_url: input.docs_url.map(|u| u.value()),
redoc_url: input.redoc_url.map(|u| u.value()),
servers: input.servers.map(|svrs| {
svrs.into_iter()
.map(|s| Server {
url: s.url,
description: s.description,
variables: None,
})
.collect()
}),
merge: input.merge.unwrap_or_default(),
}
}
/// Input for `export_app`! macro
pub struct ExportAppInput {
/// App name (struct name to generate)
pub name: syn::Ident,
/// Route directory
pub dir: Option<LitStr>,
}
impl Parse for ExportAppInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: syn::Ident = input.parse()?;
let mut dir = None;
// Parse optional comma and arguments
while input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
if input.is_empty() {
break;
}
let ident: syn::Ident = input.parse()?;
let ident_str = ident.to_string();
match ident_str.as_str() {
"dir" => {
input.parse::<syn::Token![=]>()?;
dir = Some(input.parse()?);
}
_ => {
return Err(syn::Error::new(
ident.span(),
format!("unknown field: `{ident_str}`. Expected `dir`"),
));
}
}
}
Ok(Self { name, dir })
}
}
/// Swagger UI HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON.
const SWAGGER_UI_HTML: &str = r##"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Swagger UI</title><link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" /></head><body style="margin: 0; padding: 0;"><div id="swagger-ui"></div><script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script><script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script><script>const openapiSpec = {};window.onload = () => {{ SwaggerUIBundle({{ spec: openapiSpec, dom_id: "#swagger-ui", presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset], layout: "StandaloneLayout" }}); }};</script></body></html>"##;
/// ReDoc HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON.
const REDOC_HTML: &str = r#"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>ReDoc</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body {{ margin: 0; padding: 0; }}</style><link rel="stylesheet" href="https://unpkg.com/redoc/bundles/redoc.standalone.css" /></head><body><div id="redoc-container"></div><script src="https://unpkg.com/redoc/bundles/redoc.standalone.js"></script><script>const openapiSpec = {};Redoc.init(openapiSpec, {{}}, document.getElementById("redoc-container"));</script></body></html>"#;
/// Generate a documentation route handler (Swagger UI or ReDoc).
///
/// When `has_merge` is true, the handler merges specs from child apps at runtime.
/// When false, it serves the spec directly from the compile-time constant.
fn generate_docs_route_tokens(
url: &str,
html_template: &str,
merge_spec_code: &[proc_macro2::TokenStream],
has_merge: bool,
) -> proc_macro2::TokenStream {
let method_path = http_method_to_token_stream(HttpMethod::Get);
if has_merge {
quote!(
.route(#url, #method_path(|| async {
static MERGED_SPEC: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let spec = MERGED_SPEC.get_or_init(|| {
let mut merged: vespera::OpenApi = vespera::serde_json::from_str(__VESPERA_SPEC).unwrap();
#(#merge_spec_code)*
vespera::serde_json::to_string(&merged).unwrap()
});
static HTML: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let html = HTML.get_or_init(|| {
format!(#html_template, spec)
});
vespera::axum::response::Html(html.as_str())
}))
)
} else {
quote!(
.route(#url, #method_path(|| async {
static HTML: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let html = HTML.get_or_init(|| {
format!(#html_template, __VESPERA_SPEC)
});
vespera::axum::response::Html(html.as_str())
}))
)
}
}
/// Generate Axum router code from collected metadata
#[allow(clippy::too_many_lines)]
pub fn generate_router_code(
metadata: &CollectedMetadata,
docs_url: Option<&str>,
redoc_url: Option<&str>,
spec_tokens: Option<proc_macro2::TokenStream>,
merge_apps: &[syn::Path],
) -> proc_macro2::TokenStream {
let mut router_nests = Vec::new();
for route in &metadata.routes {
let Ok(http_method) = HttpMethod::try_from(route.method.as_str()) else {
eprintln!(
"vespera: skipping route '{}' — unknown HTTP method '{}'",
route.path, route.method
);
continue;
};
let method_path = http_method_to_token_stream(http_method);
let path = &route.path;
let module_path = &route.module_path;
let function_name = &route.function_name;
let mut p: syn::punctuated::Punctuated<syn::PathSegment, syn::Token![::]> =
syn::punctuated::Punctuated::new();
p.push(syn::PathSegment {
ident: syn::Ident::new("crate", Span::call_site()),
arguments: syn::PathArguments::None,
});
p.extend(module_path.split("::").filter_map(|s| {
if s.is_empty() {
None
} else {
Some(syn::PathSegment {
ident: syn::Ident::new(s, Span::call_site()),
arguments: syn::PathArguments::None,
})
}
}));
let func_name = syn::Ident::new(function_name, Span::call_site());
router_nests.push(quote!(
.route(#path, #method_path(#p::#func_name))
));
}
// Check if we need to merge specs at runtime
let has_merge = !merge_apps.is_empty();
// Generate merge code once, reuse in both docs_url and redoc_url routes
let merge_spec_code: Vec<_> = merge_apps
.iter()
.map(|app_path| {
quote! {
if let Ok(other) = vespera::serde_json::from_str::<vespera::OpenApi>(#app_path::OPENAPI_SPEC) {
merged.merge(other);
}
}
})
.collect();
if let Some(docs_url) = docs_url {
router_nests.push(generate_docs_route_tokens(
docs_url,
SWAGGER_UI_HTML,
&merge_spec_code,
has_merge,
));
}
if let Some(redoc_url) = redoc_url {
router_nests.push(generate_docs_route_tokens(
redoc_url,
REDOC_HTML,
&merge_spec_code,
has_merge,
));
}
let needs_spec_const = spec_tokens.is_some() && (docs_url.is_some() || redoc_url.is_some());
if needs_spec_const {
let spec_expr = spec_tokens.unwrap();
if merge_apps.is_empty() {
quote! {
{
const __VESPERA_SPEC: &str = #spec_expr;
vespera::axum::Router::new()
#( #router_nests )*
}
}
} else {
quote! {
{
const __VESPERA_SPEC: &str = #spec_expr;
vespera::VesperaRouter::new(
vespera::axum::Router::new()
#( #router_nests )*,
vec![#( #merge_apps::router ),*]
)
}
}
}
} else if merge_apps.is_empty() {
quote! {
vespera::axum::Router::new()
#( #router_nests )*
}
} else {
// When merging apps, return VesperaRouter which defers the merge
// until with_state() is called. This is necessary because Axum requires
// merged routers to have the same state type.
quote! {
vespera::VesperaRouter::new(
vespera::axum::Router::new()
#( #router_nests )*,
vec![#( #merge_apps::router ),*]
)
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use rstest::rstest;
use tempfile::TempDir;
use super::*;
use crate::collector::collect_metadata;
fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf {
let file_path = dir.path().join(filename);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).expect("Failed to create parent directory");
}
fs::write(&file_path, content).expect("Failed to write temp file");
file_path
}
#[test]
fn test_generate_router_code_empty() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name, &[])
.unwrap()
.0,
None,
None,
None,
&[],
);
let code = result.to_string();
// Should generate empty router
// quote! generates "vespera :: axum :: Router :: new ()" format
assert!(
code.contains("Router") && code.contains("new"),
"Code should contain Router::new(), got: {code}"
);
assert!(
!code.contains("route"),
"Code should not contain route, got: {code}"
);
drop(temp_dir);
}
#[rstest]
#[case::single_get_route(
"routes",
vec![(
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
)],
"get",
"/users",
"routes::users::get_users",
)]
#[case::single_post_route(
"routes",
vec![(
"create_user.rs",
r#"
#[route(post)]
pub fn create_user() -> String {
"created".to_string()
}
"#,
)],
"post",
"/create-user",
"routes::create_user::create_user",
)]
#[case::single_put_route(
"routes",
vec![(
"update_user.rs",
r#"
#[route(put)]
pub fn update_user() -> String {
"updated".to_string()
}
"#,
)],
"put",
"/update-user",
"routes::update_user::update_user",
)]
#[case::single_delete_route(
"routes",
vec![(
"delete_user.rs",
r#"
#[route(delete)]
pub fn delete_user() -> String {
"deleted".to_string()
}
"#,
)],
"delete",
"/delete-user",
"routes::delete_user::delete_user",
)]
#[case::single_patch_route(
"routes",
vec![(
"patch_user.rs",
r#"
#[route(patch)]
pub fn patch_user() -> String {
"patched".to_string()
}
"#,
)],
"patch",
"/patch-user",
"routes::patch_user::patch_user",
)]
#[case::route_with_custom_path(
"routes",
vec![(
"users.rs",
r#"
#[route(get, path = "/api/users")]
pub fn get_users() -> String {
"users".to_string()
}
"#,
)],
"get",
"/users/api/users",
"routes::users::get_users",
)]
#[case::nested_module(
"routes",
vec![(
"api/users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
)],
"get",
"/api/users",
"routes::api::users::get_users",
)]
#[case::deeply_nested_module(
"routes",
vec![(
"api/v1/users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
)],
"get",
"/api/v1/users",
"routes::api::v1::users::get_users",
)]
fn test_generate_router_code_single_route(
#[case] folder_name: &str,
#[case] files: Vec<(&str, &str)>,
#[case] expected_method: &str,
#[case] expected_path: &str,
#[case] expected_function_path: &str,
) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
for (filename, content) in files {
create_temp_file(&temp_dir, filename, content);
}
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name, &[])
.unwrap()
.0,
None,
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(
code.contains("Router") && code.contains("new"),
"Code should contain Router::new(), got: {code}"
);
// Check route method
assert!(
code.contains(expected_method),
"Code should contain method: {expected_method}, got: {code}"
);
// Check route path
assert!(
code.contains(expected_path),
"Code should contain path: {expected_path}, got: {code}"
);
// Check function path (quote! adds spaces, so we check for parts)
let function_parts: Vec<&str> = expected_function_path.split("::").collect();
for part in &function_parts {
if !part.is_empty() {
assert!(
code.contains(part),
"Code should contain function part: {part}, got: {code}"
);
}
}
drop(temp_dir);
}
#[test]
fn test_generate_router_code_multiple_routes() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
// Create multiple route files
create_temp_file(
&temp_dir,
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
);
create_temp_file(
&temp_dir,
"create_user.rs",
r#"
#[route(post)]
pub fn create_user() -> String {
"created".to_string()
}
"#,
);
create_temp_file(
&temp_dir,
"update_user.rs",
r#"
#[route(put)]
pub fn update_user() -> String {
"updated".to_string()
}
"#,
);
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name, &[])
.unwrap()
.0,
None,
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(code.contains("Router") && code.contains("new"));
// Check all routes are present
assert!(code.contains("get_users"));
assert!(code.contains("create_user"));
assert!(code.contains("update_user"));
// Check methods
assert!(code.contains("get"));
assert!(code.contains("post"));
assert!(code.contains("put"));
// Count route calls (quote! generates ". route (" with spaces)
// Count occurrences of ". route (" pattern
let route_count = code.matches(". route (").count();
assert_eq!(
route_count, 3,
"Should have 3 route calls, got: {route_count}, code: {code}"
);
drop(temp_dir);
}
#[test]
fn test_generate_router_code_same_path_different_methods() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
// Create routes with same path but different methods
create_temp_file(
&temp_dir,
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
#[route(post)]
pub fn create_users() -> String {
"created".to_string()
}
"#,
);
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name, &[])
.unwrap()
.0,
None,
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(code.contains("Router") && code.contains("new"));
// Check both routes are present
assert!(code.contains("get_users"));
assert!(code.contains("create_users"));
// Check methods
assert!(code.contains("get"));
assert!(code.contains("post"));
// Should have 2 routes (quote! generates ". route (" with spaces)
let route_count = code.matches(". route (").count();
assert_eq!(
route_count, 2,
"Should have 2 routes, got: {route_count}, code: {code}"
);
drop(temp_dir);
}
#[test]
fn test_generate_router_code_with_mod_rs() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
// Create mod.rs file
create_temp_file(
&temp_dir,
"mod.rs",
r#"
#[route(get)]
pub fn index() -> String {
"index".to_string()
}
"#,
);
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name, &[])
.unwrap()
.0,
None,
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(code.contains("Router") && code.contains("new"));
// Check route is present
assert!(code.contains("index"));
// Path should be / (mod.rs maps to root, segments is empty)
// quote! generates "\"/\""
assert!(code.contains("\"/\""));
drop(temp_dir);
}
#[test]
fn test_generate_router_code_empty_folder_name() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "";
create_temp_file(
&temp_dir,
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
);
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name, &[])
.unwrap()
.0,
None,
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(code.contains("Router") && code.contains("new"));
// Check route is present
assert!(code.contains("get_users"));
// Module path should not have double colons
assert!(!code.contains("::users::users"));
drop(temp_dir);
}
// ========== Tests for parsing functions ==========
#[test]
fn test_parse_openapi_values_single() {
// Test that single string openapi value parses correctly via AutoRouterInput
let tokens = quote::quote!(openapi = "openapi.json");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let openapi = input.openapi.unwrap();
assert_eq!(openapi.len(), 1);
assert_eq!(openapi[0].value(), "openapi.json");
}
#[test]
fn test_parse_openapi_values_array() {
// Test that array openapi value parses correctly via AutoRouterInput
let tokens = quote::quote!(openapi = ["openapi.json", "api.json"]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let openapi = input.openapi.unwrap();
assert_eq!(openapi.len(), 2);
assert_eq!(openapi[0].value(), "openapi.json");
assert_eq!(openapi[1].value(), "api.json");
}
#[test]
fn test_validate_server_url_valid_http() {
let lit = LitStr::new("http://localhost:3000", Span::call_site());
let result = validate_server_url(&lit);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "http://localhost:3000");
}
#[test]
fn test_validate_server_url_valid_https() {
let lit = LitStr::new("https://api.example.com", Span::call_site());
let result = validate_server_url(&lit);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "https://api.example.com");
}
#[test]
fn test_validate_server_url_invalid() {
let lit = LitStr::new("ftp://example.com", Span::call_site());
let result = validate_server_url(&lit);
assert!(result.is_err());
}
#[test]
fn test_validate_server_url_no_scheme() {
let lit = LitStr::new("example.com", Span::call_site());
let result = validate_server_url(&lit);
assert!(result.is_err());
}
#[test]
fn test_auto_router_input_parse_dir_only() {
let tokens = quote::quote!(dir = "api");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert_eq!(input.dir.unwrap().value(), "api");
assert!(input.openapi.is_none());
}
#[test]
fn test_auto_router_input_parse_string_as_dir() {
let tokens = quote::quote!("routes");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert_eq!(input.dir.unwrap().value(), "routes");
}
#[test]
fn test_auto_router_input_parse_openapi_single() {
let tokens = quote::quote!(openapi = "openapi.json");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let openapi = input.openapi.unwrap();
assert_eq!(openapi.len(), 1);
assert_eq!(openapi[0].value(), "openapi.json");
}
#[test]
fn test_auto_router_input_parse_openapi_array() {
let tokens = quote::quote!(openapi = ["a.json", "b.json"]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let openapi = input.openapi.unwrap();
assert_eq!(openapi.len(), 2);
}
#[test]
fn test_auto_router_input_parse_title_version() {
let tokens = quote::quote!(title = "My API", version = "2.0.0");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert_eq!(input.title.unwrap().value(), "My API");
assert_eq!(input.version.unwrap().value(), "2.0.0");
}
#[test]
fn test_auto_router_input_parse_docs_redoc() {
let tokens = quote::quote!(docs_url = "/docs", redoc_url = "/redoc");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert_eq!(input.docs_url.unwrap().value(), "/docs");
assert_eq!(input.redoc_url.unwrap().value(), "/redoc");
}
#[test]
fn test_auto_router_input_parse_servers_single() {
let tokens = quote::quote!(servers = "http://localhost:3000");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
assert!(servers[0].description.is_none());
}
#[test]
fn test_auto_router_input_parse_servers_array_strings() {
let tokens = quote::quote!(servers = ["http://localhost:3000", "https://api.example.com"]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 2);
}
#[test]
fn test_auto_router_input_parse_servers_tuple() {
let tokens = quote::quote!(servers = [("http://localhost:3000", "Development")]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
assert_eq!(servers[0].description, Some("Development".to_string()));
}
#[test]
fn test_auto_router_input_parse_servers_struct() {
let tokens =
quote::quote!(servers = [{ url = "http://localhost:3000", description = "Dev" }]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
assert_eq!(servers[0].description, Some("Dev".to_string()));
}
#[test]
fn test_auto_router_input_parse_servers_single_struct() {
let tokens = quote::quote!(servers = { url = "https://api.example.com" });
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "https://api.example.com");
}
#[test]
fn test_auto_router_input_parse_unknown_field() {
let tokens = quote::quote!(unknown_field = "value");
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_auto_router_input_parse_all_fields() {
let tokens = quote::quote!(
dir = "api",
openapi = "openapi.json",
title = "Test API",
version = "1.0.0",
docs_url = "/docs",
redoc_url = "/redoc",
servers = "http://localhost:3000"
);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert!(input.dir.is_some());
assert!(input.openapi.is_some());
assert!(input.title.is_some());
assert!(input.version.is_some());
assert!(input.docs_url.is_some());
assert!(input.redoc_url.is_some());
assert!(input.servers.is_some());
}
#[test]
fn test_generate_router_code_with_docs() {
let metadata = CollectedMetadata::new();
let spec = r#"{"openapi":"3.1.0"}"#;
let result = generate_router_code(
&metadata,
Some("/docs"),
None,
Some(quote::quote!(#spec)),
&[],
);
let code = result.to_string();
assert!(code.contains("/docs"));
assert!(code.contains("swagger-ui"));
assert!(code.contains("__VESPERA_SPEC"));
assert!(code.contains("OnceLock"));
}
#[test]
fn test_generate_router_code_with_redoc() {
let metadata = CollectedMetadata::new();
let spec = r#"{"openapi":"3.1.0"}"#;
let result = generate_router_code(
&metadata,
None,
Some("/redoc"),
Some(quote::quote!(#spec)),
&[],
);
let code = result.to_string();
assert!(code.contains("/redoc"));
assert!(code.contains("redoc"));
assert!(code.contains("__VESPERA_SPEC"));
assert!(code.contains("OnceLock"));
}
#[test]
fn test_generate_router_code_with_both_docs() {
let metadata = CollectedMetadata::new();
let spec = r#"{"openapi":"3.1.0"}"#;
let result = generate_router_code(
&metadata,
Some("/docs"),
Some("/redoc"),
Some(quote::quote!(#spec)),
&[],
);
let code = result.to_string();
assert!(code.contains("/docs"));
assert!(code.contains("/redoc"));
assert!(code.contains("__VESPERA_SPEC"));
}
#[test]
fn test_swagger_html_template_renders_valid_quotes() {
assert!(
!SWAGGER_UI_HTML.contains(r#"\""#),
"Swagger template should not contain literal backslash-quotes: {SWAGGER_UI_HTML}"
);
assert!(
SWAGGER_UI_HTML.contains(r#"href="https://unpkg.com/swagger-ui-dist/swagger-ui.css""#)
);
assert!(
SWAGGER_UI_HTML
.contains(r#"src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js""#)
);
assert!(SWAGGER_UI_HTML.contains(r##"dom_id: "#swagger-ui""##));
}
#[test]
fn test_redoc_html_template_renders_valid_quotes() {
assert!(
!REDOC_HTML.contains(r#"\""#),
"ReDoc template should not contain literal backslash-quotes: {REDOC_HTML}"
);
assert!(
REDOC_HTML.contains(r#"href="https://unpkg.com/redoc/bundles/redoc.standalone.css""#)
);
assert!(
REDOC_HTML.contains(r#"src="https://unpkg.com/redoc/bundles/redoc.standalone.js""#)
);
assert!(REDOC_HTML.contains(r#"document.getElementById("redoc-container")"#));
}
#[test]
fn test_parse_server_struct_url_only() {
// Test server struct parsing via AutoRouterInput
let tokens = quote::quote!(servers = { url = "http://localhost:3000" });
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
assert!(servers[0].description.is_none());
}
#[test]
fn test_parse_server_struct_with_description() {
let tokens =
quote::quote!(servers = { url = "http://localhost:3000", description = "Local" });
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers[0].description, Some("Local".to_string()));
}
#[test]
fn test_parse_server_struct_unknown_field() {
let tokens = quote::quote!(servers = { url = "http://localhost:3000", unknown = "test" });
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_parse_server_struct_missing_url() {
let tokens = quote::quote!(servers = { description = "test" });
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_parse_servers_tuple_url_only() {
let tokens = quote::quote!(servers = [("http://localhost:3000")]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert!(servers[0].description.is_none());
}
#[test]
fn test_parse_servers_invalid_url() {
let tokens = quote::quote!(servers = "invalid-url");
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_generate_router_code_unknown_http_method() {
// Test lines 337-340: route with unknown HTTP method is skipped in router codegen
let mut metadata = CollectedMetadata {
routes: Vec::new(),
structs: Vec::new(),
};
metadata.routes.push(crate::metadata::RouteMetadata {
method: "INVALID".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "routes::users".to_string(),
file_path: "dummy.rs".to_string(),
signature: "fn get_users() -> String".to_string(),
error_status: None,
tags: None,
description: None,
});
let result = generate_router_code(&metadata, None, None, None, &[]);
let code = result.to_string();
// Router should be generated but without any route calls
assert!(
code.contains("Router") && code.contains("new"),
"Code should contain Router::new(), got: {code}"
);
assert!(
!code.contains(". route ("),
"Route with unknown HTTP method should be skipped, got: {code}"
);
}
#[test]
fn test_generate_router_code_unknown_method_skipped_valid_kept() {
// Test that unknown methods are skipped while valid routes are still generated
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
create_temp_file(
&temp_dir,
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
);
let (mut metadata, _file_asts) =
collect_metadata(temp_dir.path(), folder_name, &[]).unwrap();
// Inject an additional route with invalid method
metadata.routes.push(crate::metadata::RouteMetadata {
method: "CONNECT".to_string(),
path: "/invalid".to_string(),
function_name: "connect_handler".to_string(),
module_path: "routes::invalid".to_string(),
file_path: "dummy.rs".to_string(),
signature: "fn connect_handler() -> String".to_string(),
error_status: None,
tags: None,
description: None,
});
let result = generate_router_code(&metadata, None, None, None, &[]);
let code = result.to_string();
// Valid route should be present
assert!(
code.contains("get_users"),
"Valid route should be present, got: {code}"
);
// Invalid route should be skipped
assert!(
!code.contains("connect_handler"),
"Invalid method route should be skipped, got: {code}"
);
drop(temp_dir);
}
#[test]
fn test_auto_router_input_parse_invalid_token() {
// Test line 149: neither ident nor string literal triggers lookahead error
let tokens = quote::quote!(123);
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_auto_router_input_empty() {
// Test empty input - should use defaults/env vars
let tokens = quote::quote!();
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_ok());
}
#[test]
fn test_auto_router_input_multiple_commas() {
// Test input with trailing comma
let tokens = quote::quote!(dir = "api",);
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_ok());
}
#[test]
fn test_auto_router_input_no_comma() {
// Test input without comma between fields (should stop at second field)
let tokens = quote::quote!(dir = "api" title = "Test");
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
// This should fail or only parse first field
assert!(result.is_err());
}
// ========== Tests for process_vespera_input ==========
#[test]
fn test_process_vespera_input_defaults() {
let tokens = quote::quote!();
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let processed = process_vespera_input(input);
assert_eq!(processed.folder_name, "routes");
assert!(processed.openapi_file_names.is_empty());
assert!(processed.title.is_none());
assert!(processed.docs_url.is_none());
}
#[test]
fn test_process_vespera_input_all_fields() {
let tokens = quote::quote!(
dir = "api",
openapi = ["openapi.json", "api.json"],
title = "My API",
version = "1.0.0",
docs_url = "/docs",
redoc_url = "/redoc",
servers = "http://localhost:3000"
);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let processed = process_vespera_input(input);
assert_eq!(processed.folder_name, "api");
assert_eq!(
processed.openapi_file_names,
vec!["openapi.json", "api.json"]
);
assert_eq!(processed.title, Some("My API".to_string()));
assert_eq!(processed.version, Some("1.0.0".to_string()));
assert_eq!(processed.docs_url, Some("/docs".to_string()));
assert_eq!(processed.redoc_url, Some("/redoc".to_string()));
let servers = processed.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
}
#[test]
fn test_process_vespera_input_servers_with_description() {
let tokens = quote::quote!(
servers = [{ url = "https://api.example.com", description = "Production" }]
);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let processed = process_vespera_input(input);
let servers = processed.servers.unwrap();
assert_eq!(servers[0].url, "https://api.example.com");
assert_eq!(servers[0].description, Some("Production".to_string()));
}
// ========== Tests for parse_merge_values ==========
#[test]
fn test_parse_merge_values_single() {
let tokens = quote::quote!(merge = [some::path::App]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let merge = input.merge.unwrap();
assert_eq!(merge.len(), 1);
// Check the path segments
let path = &merge[0];
let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect();
assert_eq!(segments, vec!["some", "path", "App"]);
}
#[test]
fn test_parse_merge_values_multiple() {
let tokens = quote::quote!(merge = [first::App, second::Other]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let merge = input.merge.unwrap();
assert_eq!(merge.len(), 2);
}
#[test]
fn test_parse_merge_values_empty() {
let tokens = quote::quote!(merge = []);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let merge = input.merge.unwrap();
assert!(merge.is_empty());
}
#[test]
fn test_parse_merge_values_with_trailing_comma() {
let tokens = quote::quote!(merge = [app::MyApp,]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let merge = input.merge.unwrap();
assert_eq!(merge.len(), 1);
}
// ========== Tests for generate_router_code with merge ==========
#[test]
fn test_generate_router_code_with_merge_apps() {
let metadata = CollectedMetadata::new();
let merge_apps: Vec<syn::Path> = vec![syn::parse_quote!(third::ThirdApp)];
let result = generate_router_code(&metadata, None, None, None, &merge_apps);
let code = result.to_string();
// Should use VesperaRouter instead of plain Router
assert!(
code.contains("VesperaRouter"),
"Should use VesperaRouter for merge, got: {code}"
);
assert!(
code.contains("third :: ThirdApp") || code.contains("third::ThirdApp"),
"Should reference merged app, got: {code}"
);
}
#[test]
fn test_generate_router_code_with_docs_and_merge() {
let metadata = CollectedMetadata::new();
let spec = r#"{"openapi":"3.1.0"}"#;
let merge_apps: Vec<syn::Path> = vec![syn::parse_quote!(app::MyApp)];
let result = generate_router_code(
&metadata,
Some("/docs"),
None,
Some(quote::quote!(#spec)),
&merge_apps,
);
let code = result.to_string();
// Should have merge code for docs
assert!(
code.contains("OnceLock"),
"Should use OnceLock for merged docs, got: {code}"
);
assert!(
code.contains("MERGED_SPEC"),
"Should have MERGED_SPEC, got: {code}"
);
// quote! generates "merged . merge" with spaces
assert!(
code.contains("merged . merge") || code.contains("merged.merge"),
"Should call merge on spec, got: {code}"
);
}
#[test]
fn test_generate_router_code_with_redoc_and_merge() {
let metadata = CollectedMetadata::new();
let spec = r#"{"openapi":"3.1.0"}"#;
let merge_apps: Vec<syn::Path> = vec![syn::parse_quote!(other::OtherApp)];
let result = generate_router_code(
&metadata,
None,
Some("/redoc"),
Some(quote::quote!(#spec)),
&merge_apps,
);
let code = result.to_string();
// Should have merge code for redoc
assert!(
code.contains("OnceLock"),
"Should use OnceLock for merged redoc"
);
assert!(code.contains("redoc"), "Should contain redoc");
}
#[test]
fn test_generate_router_code_with_both_docs_and_merge() {
let metadata = CollectedMetadata::new();
let spec = r#"{"openapi":"3.1.0"}"#;
let merge_apps: Vec<syn::Path> = vec![syn::parse_quote!(merged::App)];
let result = generate_router_code(
&metadata,
Some("/docs"),
Some("/redoc"),
Some(quote::quote!(#spec)),
&merge_apps,
);
let code = result.to_string();
// Both docs should have merge code
// Count MERGED_SPEC occurrences - should appear in docs and redoc handlers
let merged_spec_count = code.matches("MERGED_SPEC").count();
assert!(
merged_spec_count >= 2,
"Should have at least 2 MERGED_SPEC for docs and redoc, got: {merged_spec_count}"
);
// __VESPERA_SPEC should appear exactly once (the const declaration)
let vespera_spec_count = code.matches("__VESPERA_SPEC").count();
assert!(
vespera_spec_count >= 1,
"Should have __VESPERA_SPEC const, got: {vespera_spec_count}"
);
// Both docs_url and redoc_url should be present
assert!(
code.contains("/docs") && code.contains("/redoc"),
"Should contain both /docs and /redoc"
);
}
#[test]
fn test_generate_router_code_with_multiple_merge_apps() {
let metadata = CollectedMetadata::new();
let merge_apps: Vec<syn::Path> = vec![
syn::parse_quote!(first::App),
syn::parse_quote!(second::App),
];
let result = generate_router_code(&metadata, None, None, None, &merge_apps);
let code = result.to_string();
// Should reference both apps
assert!(
code.contains("first") && code.contains("second"),
"Should reference both merge apps, got: {code}"
);
}
// ========== Tests for ExportAppInput parsing ==========
#[test]
fn test_export_app_input_name_only() {
let tokens = quote::quote!(MyApp);
let input: ExportAppInput = syn::parse2(tokens).unwrap();
assert_eq!(input.name.to_string(), "MyApp");
assert!(input.dir.is_none());
}
#[test]
fn test_export_app_input_with_dir() {
let tokens = quote::quote!(MyApp, dir = "api");
let input: ExportAppInput = syn::parse2(tokens).unwrap();
assert_eq!(input.name.to_string(), "MyApp");
assert_eq!(input.dir.unwrap().value(), "api");
}
#[test]
fn test_export_app_input_with_trailing_comma() {
let tokens = quote::quote!(MyApp,);
let input: ExportAppInput = syn::parse2(tokens).unwrap();
assert_eq!(input.name.to_string(), "MyApp");
assert!(input.dir.is_none());
}
#[test]
fn test_export_app_input_unknown_field() {
let tokens = quote::quote!(MyApp, unknown = "value");
let result: syn::Result<ExportAppInput> = syn::parse2(tokens);
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.to_compile_error().to_string().contains("unknown field"));
}
#[test]
fn test_export_app_input_multiple_commas() {
let tokens = quote::quote!(MyApp, dir = "api",);
let input: ExportAppInput = syn::parse2(tokens).unwrap();
assert_eq!(input.name.to_string(), "MyApp");
assert_eq!(input.dir.unwrap().value(), "api");
}
// ========== Tests for env var fallbacks (lines 181-183) ==========
// Note: These tests use env vars which are global state.
// The tests are designed to be resilient to parallel test execution.
#[test]
fn test_auto_router_input_server_env_var_fallback() {
// Test lines 181-183: VESPERA_SERVER_URL env var fallback
// This test verifies the code path but may be affected by parallel tests
// Using a unique test URL to reduce collision chances
let test_url = "https://vespera-test-unique-12345.example.com";
let test_desc = "Vespera Test Server 12345";
// Save current state
let old_server_url = std::env::var("VESPERA_SERVER_URL").ok();
let old_server_desc = std::env::var("VESPERA_SERVER_DESCRIPTION").ok();
// SAFETY: Single-threaded test context
unsafe {
std::env::set_var("VESPERA_SERVER_URL", test_url);
std::env::set_var("VESPERA_SERVER_DESCRIPTION", test_desc);
}
// Parse empty input - should pick up env vars
let tokens = quote::quote!();
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
// Restore env vars immediately after parsing
unsafe {
if let Some(url) = old_server_url {
std::env::set_var("VESPERA_SERVER_URL", url);
} else {
std::env::remove_var("VESPERA_SERVER_URL");
}
if let Some(desc) = old_server_desc {
std::env::set_var("VESPERA_SERVER_DESCRIPTION", desc);
} else {
std::env::remove_var("VESPERA_SERVER_DESCRIPTION");
}
}
// Check if servers was set - may not be if another test interfered
if let Some(servers) = input.servers {
// If we got servers, verify they match our test values
if servers.len() == 1 && servers[0].url == test_url {
assert_eq!(servers[0].description, Some(test_desc.to_string()));
}
// Otherwise another test's values were picked up, which is fine
}
// If servers is None, another test may have cleared the env var - acceptable
}
#[test]
fn test_auto_router_input_server_env_var_invalid_url_filtered() {
// Test that invalid URLs (not http/https) are filtered out by the .filter() call
// This exercises the filter branch, not lines 181-183 directly
let old_server_url = std::env::var("VESPERA_SERVER_URL").ok();
// SAFETY: Single-threaded test context
unsafe {
std::env::set_var("VESPERA_SERVER_URL", "ftp://invalid-url-test.com");
}
let tokens = quote::quote!();
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
// Restore env var
unsafe {
if let Some(url) = old_server_url {
std::env::set_var("VESPERA_SERVER_URL", url);
} else {
std::env::remove_var("VESPERA_SERVER_URL");
}
}
// If servers is Some, it means another test set a valid URL - acceptable
// If servers is None, our invalid URL was correctly filtered
if let Some(servers) = &input.servers {
// Another test set a valid URL, check it's not our invalid one
assert!(
servers.is_empty() || servers[0].url != "ftp://invalid-url-test.com",
"Invalid ftp:// URL should have been filtered"
);
}
}
}