use std::time::Duration;
use proc_macro2::TokenStream;
use quote::quote;
pub fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect()
}
fn parse_duration(s: &str) -> Option<Duration> {
let s = s.trim();
if let Some(num) = s.strip_suffix("ms") {
num.parse::<u64>().ok().map(Duration::from_millis)
} else if let Some(num) = s.strip_suffix('s') {
num.parse::<u64>().ok().map(Duration::from_secs)
} else if let Some(num) = s.strip_suffix('m') {
num.parse::<u64>().ok().map(|m| Duration::from_secs(m * 60))
} else if let Some(num) = s.strip_suffix('h') {
num.parse::<u64>()
.ok()
.map(|h| Duration::from_secs(h * 3600))
} else if let Some(num) = s.strip_suffix('d') {
num.parse::<u64>()
.ok()
.map(|d| Duration::from_secs(d * 86400))
} else {
None
}
}
pub fn parse_duration_secs(s: &str) -> Option<u64> {
parse_duration(s).map(|d| d.as_secs())
}
pub fn parse_duration_tokens(s: &str, default_secs: u64) -> TokenStream {
let s = s.trim();
let invalid = || {
let msg = format!(
"invalid duration \"{}\": use a suffix like \"30s\", \"5m\", or \"1h\"",
s
);
quote! { compile_error!(#msg) }
};
if let Some(num) = s.strip_suffix("ms") {
match num.parse::<u64>() {
Ok(n) => quote! { std::time::Duration::from_millis(#n) },
Err(_) => invalid(),
}
} else if let Some(num) = s.strip_suffix('s') {
match num.parse::<u64>() {
Ok(n) => quote! { std::time::Duration::from_secs(#n) },
Err(_) => invalid(),
}
} else if let Some(num) = s.strip_suffix('m') {
match num.parse::<u64>() {
Ok(n) => {
let secs = n * 60;
quote! { std::time::Duration::from_secs(#secs) }
}
Err(_) => invalid(),
}
} else if let Some(num) = s.strip_suffix('h') {
match num.parse::<u64>() {
Ok(n) => {
let secs = n * 3600;
quote! { std::time::Duration::from_secs(#secs) }
}
Err(_) => invalid(),
}
} else if let Some(num) = s.strip_suffix('d') {
match num.parse::<u64>() {
Ok(n) => {
let secs = n * 86400;
quote! { std::time::Duration::from_secs(#secs) }
}
Err(_) => invalid(),
}
} else {
let _ = default_secs;
invalid()
}
}
pub fn parse_size_bytes(s: &str) -> Option<usize> {
let s = s.trim().to_lowercase();
if let Some(num) = s.strip_suffix("gb") {
num.trim()
.parse::<usize>()
.ok()
.map(|n| n * 1024 * 1024 * 1024)
} else if let Some(num) = s.strip_suffix("mb") {
num.trim().parse::<usize>().ok().map(|n| n * 1024 * 1024)
} else if let Some(num) = s.strip_suffix("kb") {
num.trim().parse::<usize>().ok().map(|n| n * 1024)
} else if let Some(num) = s.strip_suffix('b') {
num.trim().parse::<usize>().ok()
} else {
s.parse::<usize>().ok()
}
}
pub fn is_primitive_arg_type(ty: &syn::Type) -> bool {
use syn::Type;
if let Type::Reference(r) = ty {
return is_primitive_arg_type(&r.elem);
}
let Type::Path(type_path) = ty else {
return false;
};
let Some(segment) = type_path.path.segments.last() else {
return false;
};
let name = segment.ident.to_string();
matches!(
name.as_str(),
"i8" | "i16"
| "i32"
| "i64"
| "i128"
| "isize"
| "u8"
| "u16"
| "u32"
| "u64"
| "u128"
| "usize"
| "f32"
| "f64"
| "bool"
| "char"
| "String"
| "str"
| "Vec"
| "Option"
| "HashMap"
| "BTreeMap"
| "HashSet"
| "BTreeSet"
| "Uuid"
)
}
pub fn every_to_cron(s: &str) -> Result<String, String> {
let s = s.trim();
if s.ends_with("ms") {
return Err("cron minimum granularity is 1 minute; use \"1m\" or higher".to_string());
}
if let Some(body) = s.strip_suffix('s') {
if body.chars().last().is_some_and(|c| c.is_ascii_digit()) {
return Err("cron minimum granularity is 1 minute; use \"1m\" or higher".to_string());
}
}
if let Some(num_str) = s.strip_suffix('m') {
let n: u64 = num_str.parse().map_err(|_| {
format!("invalid duration \"{s}\": expected a positive integer before 'm'")
})?;
if n == 0 {
return Err(format!("invalid duration \"{s}\": value must be >= 1"));
}
if n == 1 {
return Ok("* * * * *".to_string());
}
if 60 % n == 0 {
return Ok(format!("*/{n} * * * *"));
}
return Err(format!(
"every = \"{s}\": {n} must evenly divide 60 for a valid cron step (use 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, or 30)"
));
}
if let Some(num_str) = s.strip_suffix('h') {
let n: u64 = num_str.parse().map_err(|_| {
format!("invalid duration \"{s}\": expected a positive integer before 'h'")
})?;
if n == 0 {
return Err(format!("invalid duration \"{s}\": value must be >= 1"));
}
if n == 1 {
return Ok("0 * * * *".to_string());
}
if 24 % n == 0 {
return Ok(format!("0 */{n} * * *"));
}
return Err(format!(
"every = \"{s}\": {n} must evenly divide 24 for a valid cron step (use 1, 2, 3, 4, 6, 8, or 12)"
));
}
Err(format!(
"invalid duration \"{s}\": use a suffix like \"5m\" or \"1h\""
))
}
pub fn daily_at_to_cron(s: &str) -> Result<String, String> {
let s = s.trim();
let (hour_str, minute_str) = s.split_once(':').ok_or_else(|| {
format!("invalid daily_at \"{s}\": expected \"HH:MM\" format (e.g. \"03:00\")")
})?;
let hour: u32 = hour_str
.parse()
.map_err(|_| format!("invalid daily_at \"{s}\": hour must be an integer"))?;
let minute: u32 = minute_str
.parse()
.map_err(|_| format!("invalid daily_at \"{s}\": minute must be an integer"))?;
if hour > 23 {
return Err(format!(
"invalid daily_at \"{s}\": hour {hour} is out of range (0–23)"
));
}
if minute > 59 {
return Err(format!(
"invalid daily_at \"{s}\": minute {minute} is out of range (0–59)"
));
}
Ok(format!("{minute} {hour} * * *"))
}
pub fn unsupported_wire_type(name: &str) -> Option<&'static str> {
match name {
"usize" | "isize" => Some(
"platform-dependent size type is not portable across the wire; use i32 or i64 instead",
),
"u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i128" => Some(
"unsigned and narrow integer types are not supported in handler signatures; use i32 or i64 for portability",
),
_ => None,
}
}
pub fn check_arg_wire_type(ty: &syn::Type) -> Option<(String, proc_macro2::Span)> {
let ident = leaf_type_ident(ty)?;
let name = ident.to_string();
unsupported_wire_type(&name).map(|reason| (reason.to_string(), ident.span()))
}
fn leaf_type_ident(ty: &syn::Type) -> Option<&syn::Ident> {
match ty {
syn::Type::Reference(r) => leaf_type_ident(&r.elem),
syn::Type::Path(p) => {
let seg = p.path.segments.last()?;
let name = seg.ident.to_string();
if matches!(name.as_str(), "Option" | "Vec")
&& let syn::PathArguments::AngleBracketed(args) = &seg.arguments
&& let Some(syn::GenericArgument::Type(inner)) = args.args.first()
{
return leaf_type_ident(inner);
}
Some(&seg.ident)
}
_ => None,
}
}
pub(crate) fn to_snake_case(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut result = String::new();
for (i, &c) in chars.iter().enumerate() {
if c.is_uppercase() {
let prev_lower = i > 0 && chars.get(i - 1).is_some_and(|p| p.is_lowercase());
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
if i > 0 && (prev_lower || next_lower) {
result.push('_');
}
result.extend(c.to_lowercase());
} else {
result.push(c);
}
}
result
}
pub(crate) fn pluralize(s: &str) -> String {
if s.ends_with("ss")
|| s.ends_with("sh")
|| s.ends_with("ch")
|| s.ends_with('x')
|| s.ends_with("zz")
{
format!("{}es", s)
} else if s.ends_with('z') {
format!("{}zes", s)
} else if s.ends_with('s') {
format!("{}es", s)
} else if let Some(stem) = s.strip_suffix('y') {
if !s.ends_with("ay") && !s.ends_with("ey") && !s.ends_with("oy") && !s.ends_with("uy") {
format!("{}ies", stem)
} else {
format!("{}s", s)
}
} else {
format!("{}s", s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("get_user"), "GetUser");
assert_eq!(to_pascal_case("list_all_projects"), "ListAllProjects");
assert_eq!(to_pascal_case("simple"), "Simple");
assert_eq!(to_pascal_case("send_welcome_email"), "SendWelcomeEmail");
}
#[test]
fn test_parse_duration_secs() {
assert_eq!(parse_duration_secs("30s"), Some(30));
assert_eq!(parse_duration_secs("5m"), Some(300));
assert_eq!(parse_duration_secs("1h"), Some(3600));
assert_eq!(parse_duration_secs("2d"), Some(172800));
assert_eq!(parse_duration_secs("60"), None);
assert_eq!(parse_duration_secs("1000ms"), Some(1));
assert_eq!(parse_duration_secs("invalid"), None);
}
#[test]
fn test_parse_duration_tokens() {
let ts = parse_duration_tokens("30s", 30);
assert!(!ts.is_empty());
let ts = parse_duration_tokens("5m", 300);
assert!(!ts.is_empty());
let ts = parse_duration_tokens("1h", 3600);
assert!(!ts.is_empty());
let ts = parse_duration_tokens("30", 30);
let output = ts.to_string();
assert!(
output.contains("compile_error"),
"bare integer should emit compile_error, got: {output}"
);
}
#[test]
fn test_parse_size_bytes() {
assert_eq!(parse_size_bytes("100mb"), Some(100 * 1024 * 1024));
assert_eq!(parse_size_bytes("1gb"), Some(1024 * 1024 * 1024));
assert_eq!(parse_size_bytes("512kb"), Some(512 * 1024));
assert_eq!(parse_size_bytes("1024b"), Some(1024));
assert_eq!(parse_size_bytes("200MB"), Some(200 * 1024 * 1024));
assert_eq!(parse_size_bytes("1048576"), Some(1048576));
assert_eq!(parse_size_bytes("invalid"), None);
}
#[test]
fn pascal_case_handles_empty_and_edge_segments() {
assert_eq!(to_pascal_case(""), "");
assert_eq!(to_pascal_case("a"), "A");
assert_eq!(to_pascal_case("Already"), "Already");
assert_eq!(to_pascal_case("_foo"), "Foo");
assert_eq!(to_pascal_case("foo_"), "Foo");
assert_eq!(to_pascal_case("foo__bar"), "FooBar");
}
#[test]
fn parse_duration_secs_accepts_ms_and_day_suffixes() {
assert_eq!(parse_duration_secs("500ms"), Some(0));
assert_eq!(parse_duration_secs("1d"), Some(86400));
}
#[test]
fn parse_duration_tokens_covers_all_unit_branches() {
for input in ["100ms", "30s", "5m", "1h", "1d"] {
let ts = parse_duration_tokens(input, 0);
let out = ts.to_string();
assert!(!out.is_empty(), "empty token stream for {input}");
assert!(
!out.contains("compile_error"),
"expected valid duration for {input}, got: {out}"
);
}
}
#[test]
fn parse_duration_tokens_emits_compile_error_for_invalid_numeric() {
for input in ["xms", "abcs", "?m", " h", " d"] {
let out = parse_duration_tokens(input, 0).to_string();
assert!(
out.contains("compile_error"),
"{input} should emit compile_error, got: {out}"
);
}
}
#[test]
fn parse_size_bytes_is_case_insensitive_across_units() {
assert_eq!(parse_size_bytes("1KB"), Some(1024));
assert_eq!(parse_size_bytes("1Kb"), Some(1024));
assert_eq!(parse_size_bytes("1Gb"), Some(1024 * 1024 * 1024));
assert_eq!(parse_size_bytes("4B"), Some(4));
}
#[test]
fn parse_size_bytes_tolerates_whitespace_and_zero() {
assert_eq!(parse_size_bytes(" 16 kb "), Some(16 * 1024));
assert_eq!(parse_size_bytes("0gb"), Some(0));
assert_eq!(parse_size_bytes("10xy"), None);
}
fn parse_ty(src: &str) -> syn::Type {
syn::parse_str(src).expect("type parses")
}
#[test]
fn primitive_arg_type_recognizes_every_scalar() {
let scalars = [
"i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize",
"f32", "f64", "bool", "char", "String", "Uuid",
];
for s in scalars {
assert!(
is_primitive_arg_type(&parse_ty(s)),
"{s} should be treated as primitive"
);
}
}
#[test]
fn primitive_arg_type_unwraps_references() {
assert!(is_primitive_arg_type(&parse_ty("&str")));
assert!(is_primitive_arg_type(&parse_ty("&String")));
assert!(is_primitive_arg_type(&parse_ty("&i32")));
assert!(is_primitive_arg_type(&parse_ty("&&u64")));
}
#[test]
fn primitive_arg_type_matches_on_leaf_segment() {
assert!(is_primitive_arg_type(&parse_ty("std::string::String")));
assert!(is_primitive_arg_type(&parse_ty("std::vec::Vec<u8>")));
assert!(is_primitive_arg_type(&parse_ty(
"std::collections::HashMap<String, i32>"
)));
assert!(is_primitive_arg_type(&parse_ty("uuid::Uuid")));
}
#[test]
fn primitive_arg_type_treats_collection_wrappers_as_primitive() {
assert!(is_primitive_arg_type(&parse_ty("Vec<u8>")));
assert!(is_primitive_arg_type(&parse_ty("Option<String>")));
assert!(is_primitive_arg_type(&parse_ty("HashMap<String, i32>")));
assert!(is_primitive_arg_type(&parse_ty("BTreeMap<u64, bool>")));
assert!(is_primitive_arg_type(&parse_ty("HashSet<u32>")));
assert!(is_primitive_arg_type(&parse_ty("BTreeSet<i64>")));
}
#[test]
fn primitive_arg_type_rejects_custom_structs_and_tuples() {
assert!(!is_primitive_arg_type(&parse_ty("MyArgs")));
assert!(!is_primitive_arg_type(&parse_ty("crate::types::Input")));
assert!(!is_primitive_arg_type(&parse_ty("(u32, String)")));
assert!(!is_primitive_arg_type(&parse_ty("()")));
assert!(!is_primitive_arg_type(&parse_ty("[u8; 4]")));
assert!(!is_primitive_arg_type(&parse_ty("[u8]")));
}
#[test]
fn snake_case_converts_pascal_case() {
assert_eq!(to_snake_case("UserProfile"), "user_profile");
assert_eq!(to_snake_case("HTTPRequest"), "http_request");
assert_eq!(to_snake_case("simple"), "simple");
assert_eq!(to_snake_case("A"), "a");
}
#[test]
fn unsupported_wire_type_rejects_platform_dependent_and_unsigned() {
assert!(unsupported_wire_type("usize").is_some());
assert!(unsupported_wire_type("isize").is_some());
assert!(unsupported_wire_type("u8").is_some());
assert!(unsupported_wire_type("u16").is_some());
assert!(unsupported_wire_type("u32").is_some());
assert!(unsupported_wire_type("u64").is_some());
assert!(unsupported_wire_type("u128").is_some());
assert!(unsupported_wire_type("i8").is_some());
assert!(unsupported_wire_type("i16").is_some());
assert!(unsupported_wire_type("i128").is_some());
}
#[test]
fn unsupported_wire_type_accepts_portable_types() {
assert!(unsupported_wire_type("i32").is_none());
assert!(unsupported_wire_type("i64").is_none());
assert!(unsupported_wire_type("f32").is_none());
assert!(unsupported_wire_type("f64").is_none());
assert!(unsupported_wire_type("bool").is_none());
assert!(unsupported_wire_type("String").is_none());
assert!(unsupported_wire_type("Uuid").is_none());
assert!(unsupported_wire_type("MyStruct").is_none());
}
#[test]
fn check_arg_wire_type_rejects_bare_unsigned() {
assert!(check_arg_wire_type(&parse_ty("u32")).is_some());
assert!(check_arg_wire_type(&parse_ty("usize")).is_some());
assert!(check_arg_wire_type(&parse_ty("i128")).is_some());
}
#[test]
fn check_arg_wire_type_recurses_through_option_and_vec() {
assert!(check_arg_wire_type(&parse_ty("Option<u32>")).is_some());
assert!(check_arg_wire_type(&parse_ty("Vec<usize>")).is_some());
assert!(check_arg_wire_type(&parse_ty("Option<Vec<i128>>")).is_some());
}
#[test]
fn check_arg_wire_type_accepts_portable_types() {
assert!(check_arg_wire_type(&parse_ty("i32")).is_none());
assert!(check_arg_wire_type(&parse_ty("i64")).is_none());
assert!(check_arg_wire_type(&parse_ty("f64")).is_none());
assert!(check_arg_wire_type(&parse_ty("String")).is_none());
assert!(check_arg_wire_type(&parse_ty("Option<i32>")).is_none());
assert!(check_arg_wire_type(&parse_ty("Vec<String>")).is_none());
assert!(check_arg_wire_type(&parse_ty("MyStruct")).is_none());
}
#[test]
fn check_arg_wire_type_skips_non_wrapper_generics() {
assert!(check_arg_wire_type(&parse_ty("HashMap<String, u32>")).is_none());
}
#[test]
fn every_to_cron_converts_minutes() {
assert_eq!(every_to_cron("1m").unwrap(), "* * * * *");
assert_eq!(every_to_cron("5m").unwrap(), "*/5 * * * *");
assert_eq!(every_to_cron("15m").unwrap(), "*/15 * * * *");
assert_eq!(every_to_cron("30m").unwrap(), "*/30 * * * *");
assert_eq!(every_to_cron("60m").unwrap(), "*/60 * * * *");
}
#[test]
fn every_to_cron_converts_hours() {
assert_eq!(every_to_cron("1h").unwrap(), "0 * * * *");
assert_eq!(every_to_cron("2h").unwrap(), "0 */2 * * *");
assert_eq!(every_to_cron("6h").unwrap(), "0 */6 * * *");
assert_eq!(every_to_cron("12h").unwrap(), "0 */12 * * *");
}
#[test]
fn every_to_cron_rejects_sub_minute() {
let err = every_to_cron("30s").unwrap_err();
assert!(
err.contains("minimum granularity"),
"unexpected error: {err}"
);
let err = every_to_cron("500ms").unwrap_err();
assert!(err.contains("minimum granularity"), "unexpected: {err}");
}
#[test]
fn every_to_cron_rejects_non_divisors() {
let err = every_to_cron("7m").unwrap_err();
assert!(err.contains("evenly divide"), "unexpected: {err}");
let err = every_to_cron("5h").unwrap_err();
assert!(err.contains("evenly divide"), "unexpected: {err}");
}
#[test]
fn every_to_cron_rejects_zero_and_invalid() {
assert!(every_to_cron("0m").is_err());
assert!(every_to_cron("0h").is_err());
assert!(every_to_cron("xm").is_err());
assert!(every_to_cron("abc").is_err());
assert!(every_to_cron("5").is_err());
}
#[test]
fn daily_at_to_cron_converts_time() {
assert_eq!(daily_at_to_cron("03:00").unwrap(), "0 3 * * *");
assert_eq!(daily_at_to_cron("00:00").unwrap(), "0 0 * * *");
assert_eq!(daily_at_to_cron("23:59").unwrap(), "59 23 * * *");
assert_eq!(daily_at_to_cron("12:30").unwrap(), "30 12 * * *");
}
#[test]
fn daily_at_to_cron_rejects_bad_input() {
assert!(daily_at_to_cron("0300").is_err());
let err = daily_at_to_cron("24:00").unwrap_err();
assert!(err.contains("out of range"), "unexpected: {err}");
let err = daily_at_to_cron("12:60").unwrap_err();
assert!(err.contains("out of range"), "unexpected: {err}");
assert!(daily_at_to_cron("ab:cd").is_err());
assert!(daily_at_to_cron(" 09:00 ").is_ok());
}
#[test]
fn pluralize_handles_sibilants_and_z_doubling() {
assert_eq!(pluralize("user"), "users");
assert_eq!(pluralize("address"), "addresses");
assert_eq!(pluralize("box"), "boxes");
assert_eq!(pluralize("quiz"), "quizzes");
assert_eq!(pluralize("buzz"), "buzzes");
assert_eq!(pluralize("category"), "categories");
assert_eq!(pluralize("key"), "keys");
}
}