use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
pub static SCANNER_VERB_MAP: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
let mut m = HashMap::new();
m.insert("GET", "list");
m.insert("GET_ID", "get");
m.insert("POST", "create");
m.insert("PUT", "update");
m.insert("PATCH", "patch");
m.insert("DELETE", "delete");
m.insert("HEAD", "head");
m.insert("OPTIONS", "options");
m
});
static PATH_PARAM_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{[^}]+\}|:[a-zA-Z_]\w*").expect("valid regex"));
static PATH_PARAM_FULL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:\{[^}]+\}|:[a-zA-Z_]\w*)$").expect("valid regex"));
static PATH_PARAM_NAMED_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\{(?P<brace>[^}]+)\}|:(?P<colon>[a-zA-Z_]\w*)").expect("valid regex")
});
pub fn has_path_params(path: &str) -> bool {
PATH_PARAM_RE.is_match(path)
}
pub fn resolve_http_verb(method: &str, path_has_params: bool) -> String {
let method_upper = method.to_uppercase();
if method_upper == "GET" {
let key = if path_has_params { "GET_ID" } else { "GET" };
return SCANNER_VERB_MAP.get(key).copied().unwrap_or("").to_string();
}
SCANNER_VERB_MAP
.get(method_upper.as_str())
.copied()
.map(|s| s.to_string())
.unwrap_or_else(|| method.to_lowercase())
}
pub fn extract_path_param_names(path: &str) -> HashSet<String> {
let mut names: HashSet<String> = HashSet::new();
for caps in PATH_PARAM_NAMED_RE.captures_iter(path) {
if let Some(m) = caps.name("brace").or_else(|| caps.name("colon")) {
names.insert(m.as_str().to_string());
}
}
names
}
pub fn substitute_path_params<V: AsRef<str>>(path: &str, values: &HashMap<&str, V>) -> String {
let mut result = String::with_capacity(path.len());
let mut last = 0usize;
for caps in PATH_PARAM_NAMED_RE.captures_iter(path) {
let whole = caps.get(0).expect("full match present");
result.push_str(&path[last..whole.start()]);
let name = caps
.name("brace")
.or_else(|| caps.name("colon"))
.map(|m| m.as_str());
match name.and_then(|n| values.get(n)) {
Some(v) => result.push_str(v.as_ref()),
None => result.push_str(whole.as_str()),
}
last = whole.end();
}
result.push_str(&path[last..]);
result
}
pub fn generate_suggested_alias(path: &str, method: &str) -> String {
let trimmed = path.trim_matches('/');
let raw_segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
let segments: Vec<&str> = raw_segments
.iter()
.copied()
.filter(|s| !PATH_PARAM_FULL_RE.is_match(s))
.collect();
let is_single_resource = raw_segments
.last()
.map(|s| PATH_PARAM_FULL_RE.is_match(s))
.unwrap_or(false);
let verb = resolve_http_verb(method, is_single_resource);
let mut parts: Vec<String> = segments.iter().map(|s| s.to_string()).collect();
parts.push(verb);
parts.join(".")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_has_path_params_empty_string() {
assert!(!has_path_params(""));
}
#[test]
fn test_has_path_params_root_path() {
assert!(!has_path_params("/"));
}
#[test]
fn test_has_path_params_static_path() {
assert!(!has_path_params("/tasks"));
}
#[test]
fn test_has_path_params_brace_style() {
assert!(has_path_params("/tasks/{id}"));
}
#[test]
fn test_has_path_params_colon_style() {
assert!(has_path_params("/tasks/:id"));
}
#[test]
fn test_has_path_params_mixed_styles() {
assert!(has_path_params("/{id}/:name"));
}
#[test]
fn test_has_path_params_multi_segment_static() {
assert!(!has_path_params("/a/b/c"));
}
#[test]
fn test_has_path_params_empty_brace() {
assert!(!has_path_params("/tasks/{}"));
}
#[test]
fn test_resolve_http_verb_get_collection() {
assert_eq!(resolve_http_verb("GET", false), "list");
}
#[test]
fn test_resolve_http_verb_get_single() {
assert_eq!(resolve_http_verb("GET", true), "get");
}
#[test]
fn test_resolve_http_verb_get_case_insensitive() {
assert_eq!(resolve_http_verb("get", false), "list");
}
#[test]
fn test_resolve_http_verb_post_no_params() {
assert_eq!(resolve_http_verb("POST", false), "create");
}
#[test]
fn test_resolve_http_verb_post_with_params() {
assert_eq!(resolve_http_verb("POST", true), "create");
}
#[test]
fn test_resolve_http_verb_put() {
assert_eq!(resolve_http_verb("PUT", true), "update");
}
#[test]
fn test_resolve_http_verb_patch() {
assert_eq!(resolve_http_verb("PATCH", true), "patch");
}
#[test]
fn test_resolve_http_verb_delete() {
assert_eq!(resolve_http_verb("DELETE", true), "delete");
}
#[test]
fn test_resolve_http_verb_head() {
assert_eq!(resolve_http_verb("HEAD", false), "head");
}
#[test]
fn test_resolve_http_verb_options() {
assert_eq!(resolve_http_verb("OPTIONS", false), "options");
}
#[test]
fn test_resolve_http_verb_unknown_method() {
assert_eq!(resolve_http_verb("PURGE", false), "purge");
}
#[test]
fn test_resolve_http_verb_empty_method() {
assert_eq!(resolve_http_verb("", false), "");
}
#[test]
fn test_generate_alias_post_collection() {
assert_eq!(
generate_suggested_alias("/tasks/user_data", "POST"),
"tasks.user_data.create"
);
}
#[test]
fn test_generate_alias_get_collection() {
assert_eq!(
generate_suggested_alias("/tasks/user_data", "GET"),
"tasks.user_data.list"
);
}
#[test]
fn test_generate_alias_get_single() {
assert_eq!(
generate_suggested_alias("/tasks/user_data/{id}", "GET"),
"tasks.user_data.get"
);
}
#[test]
fn test_generate_alias_put_single() {
assert_eq!(
generate_suggested_alias("/tasks/user_data/{id}", "PUT"),
"tasks.user_data.update"
);
}
#[test]
fn test_generate_alias_patch_single() {
assert_eq!(
generate_suggested_alias("/tasks/user_data/{id}", "PATCH"),
"tasks.user_data.patch"
);
}
#[test]
fn test_generate_alias_delete_single() {
assert_eq!(
generate_suggested_alias("/tasks/user_data/{id}", "DELETE"),
"tasks.user_data.delete"
);
}
#[test]
fn test_generate_alias_single_segment() {
assert_eq!(generate_suggested_alias("/health", "GET"), "health.list");
}
#[test]
fn test_generate_alias_root_path() {
assert_eq!(generate_suggested_alias("/", "GET"), "list");
}
#[test]
fn test_generate_alias_empty_path() {
assert_eq!(generate_suggested_alias("", "GET"), "list");
}
#[test]
fn test_generate_alias_colon_param() {
assert_eq!(
generate_suggested_alias("/users/:user_id", "GET"),
"users.get"
);
}
#[test]
fn test_generate_alias_version_prefix() {
assert_eq!(
generate_suggested_alias("/api/v2/users", "GET"),
"api.v2.users.list"
);
}
#[test]
fn test_generate_alias_nested_params_collection() {
assert_eq!(
generate_suggested_alias("/orgs/{org_id}/teams/{team_id}/members", "GET"),
"orgs.teams.members.list"
);
}
#[test]
fn test_generate_alias_double_slashes() {
assert_eq!(
generate_suggested_alias("//tasks//user_data//", "POST"),
"tasks.user_data.create"
);
}
#[test]
fn test_generate_alias_param_only_path() {
assert_eq!(generate_suggested_alias("/{id}", "GET"), "get");
}
#[test]
fn test_extract_names_static_path_empty() {
assert!(extract_path_param_names("/tasks").is_empty());
}
#[test]
fn test_extract_names_brace_single() {
let names = extract_path_param_names("/users/{id}");
assert_eq!(names.len(), 1);
assert!(names.contains("id"));
}
#[test]
fn test_extract_names_colon_single() {
let names = extract_path_param_names("/users/:id");
assert_eq!(names.len(), 1);
assert!(names.contains("id"));
}
#[test]
fn test_extract_names_mixed_multiple() {
let names = extract_path_param_names("/orgs/{org_id}/members/:member_id");
assert_eq!(names.len(), 2);
assert!(names.contains("org_id"));
assert!(names.contains("member_id"));
}
#[test]
fn test_extract_names_deduplicates() {
let names = extract_path_param_names("/a/{id}/b/{id}");
assert_eq!(names.len(), 1);
assert!(names.contains("id"));
}
#[test]
fn test_substitute_brace_value() {
let mut values: HashMap<&str, String> = HashMap::new();
values.insert("id", "42".to_string());
assert_eq!(substitute_path_params("/users/{id}", &values), "/users/42");
}
#[test]
fn test_substitute_colon_value() {
let mut values: HashMap<&str, String> = HashMap::new();
values.insert("id", "abc".to_string());
assert_eq!(substitute_path_params("/users/:id", &values), "/users/abc");
}
#[test]
fn test_substitute_leaves_unknown_placeholder() {
let mut values: HashMap<&str, String> = HashMap::new();
values.insert("id", "1".to_string());
assert_eq!(
substitute_path_params("/users/{id}/{role}", &values),
"/users/1/{role}"
);
}
#[test]
fn test_substitute_ignores_extra_keys() {
let mut values: HashMap<&str, String> = HashMap::new();
values.insert("id", "1".to_string());
values.insert("extra", "x".to_string());
assert_eq!(substitute_path_params("/users/{id}", &values), "/users/1");
}
#[test]
fn test_substitute_no_placeholders() {
let values: HashMap<&str, String> = HashMap::new();
assert_eq!(substitute_path_params("/tasks", &values), "/tasks");
}
#[test]
fn test_substitute_multiple_mixed_styles() {
let mut values: HashMap<&str, String> = HashMap::new();
values.insert("org_id", "7".to_string());
values.insert("m", "me".to_string());
assert_eq!(
substitute_path_params("/orgs/{org_id}/members/:m", &values),
"/orgs/7/members/me"
);
}
#[test]
fn test_scanner_verb_map_contains_standard_methods() {
for k in &[
"GET", "GET_ID", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
] {
assert!(SCANNER_VERB_MAP.contains_key(k), "missing key: {}", k);
}
}
#[test]
fn test_scanner_verb_map_values_lowercase() {
for v in SCANNER_VERB_MAP.values() {
assert_eq!(*v, &*v.to_lowercase());
}
}
#[test]
fn test_conformance_fixture() {
let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("scanner_verb_map.json");
let content = std::fs::read_to_string(&fixture_path)
.unwrap_or_else(|e| panic!("failed to read fixture at {:?}: {}", fixture_path, e));
let cases: serde_json::Value =
serde_json::from_str(&content).expect("fixture must be valid JSON");
let array = cases.as_array().expect("fixture must be a JSON array");
assert!(!array.is_empty(), "fixture must contain at least one case");
for case in array {
let path = case["path"].as_str().unwrap();
let method = case["method"].as_str().unwrap();
let expected = case["expected_alias"].as_str().unwrap();
let result = generate_suggested_alias(path, method);
assert_eq!(
result, expected,
"fixture mismatch for {} {}: got {}, expected {}",
method, path, result, expected
);
}
}
}