use regex::Regex;
use std::collections::HashSet;
fn is_valid_param_ident(name: &str) -> bool {
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit())
}
pub fn path_to_regex(template: &str) -> Result<Regex, crate::error::Error> {
let pattern = path_to_regex_pattern(template)?;
Regex::new(&pattern)
.map_err(|e| crate::error::Error::Schema(format!("invalid path regex: {}", e)))
}
pub fn match_path<'a>(path: &str, templates: &'a [String]) -> Option<&'a str> {
for template in templates {
if let Ok(re) = path_to_regex(template) {
if re.is_match(path) {
return Some(template);
}
}
}
None
}
pub struct CompiledTemplates {
entries: Vec<(String, Regex)>,
}
impl CompiledTemplates {
pub fn new(templates: &[String]) -> std::result::Result<Self, crate::error::Error> {
let mut entries = Vec::with_capacity(templates.len());
for t in templates {
let re = regex::RegexBuilder::new(&path_to_regex_pattern(t)?)
.size_limit(1 << 20)
.build()
.map_err(|e| {
crate::error::Error::Schema(format!("invalid path regex for {t:?}: {e}"))
})?;
entries.push((t.clone(), re));
}
Ok(Self { entries })
}
pub fn match_path<'a>(&'a self, path: &str) -> Option<&'a str> {
for (template, re) in &self.entries {
if re.is_match(path) {
return Some(template);
}
}
None
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
fn path_to_regex_pattern(template: &str) -> std::result::Result<String, crate::error::Error> {
let mut pattern = String::from("^");
let mut remaining = template;
while let Some(open) = remaining.find('{') {
pattern.push_str(®ex::escape(&remaining[..open]));
remaining = &remaining[open + 1..];
if let Some(close) = remaining.find('}') {
let param_name = &remaining[..close];
if !is_valid_param_ident(param_name) {
return Err(crate::error::Error::InvalidParamIdent {
name: param_name.to_string(),
});
}
pattern.push_str(&format!("(?P<{}>[^/]+)", param_name));
remaining = &remaining[close + 1..];
} else {
pattern.push_str(®ex::escape("{"));
}
}
pattern.push_str(®ex::escape(remaining));
pattern.push('$');
Ok(pattern)
}
use crate::type_hints::{is_numeric_string, is_uuid};
pub fn is_param_segment(segment: &str, custom_regex: Option<&Regex>) -> bool {
if segment.is_empty() {
return false;
}
if is_numeric_string(segment) || is_uuid(segment) {
return true;
}
if let Some(re) = custom_regex {
return re.is_match(segment);
}
false
}
pub fn suggest_param_templates(paths: &[String], custom_regex: Option<&Regex>) -> Vec<String> {
let mut templates: HashSet<String> = HashSet::new();
for path in paths {
let segments: Vec<&str> = path.split('/').collect();
let mut param_count = 0u32;
let mut template_segments: Vec<String> = Vec::new();
for segment in &segments {
if segment.is_empty() {
template_segments.push(String::new());
continue;
}
if is_param_segment(segment, custom_regex) {
param_count += 1;
template_segments.push(format!("{{__P{}}}", param_count));
} else {
template_segments.push((*segment).to_string());
}
}
let mut template = template_segments.join("/");
if param_count == 1 {
template = template.replace("{__P1}", "{id}");
} else {
for i in 1..=param_count {
template = template.replace(&format!("{{__P{}}}", i), &format!("{{id{}}}", i));
}
}
templates.insert(template);
}
let mut result: Vec<String> = templates.into_iter().collect();
result.sort();
result
}
#[cfg(test)]
#[allow(clippy::indexing_slicing)]
mod tests {
use super::*;
#[test]
fn simple_path_matches_itself() {
let re = path_to_regex("/api/v1/users").unwrap();
assert!(re.is_match("/api/v1/users"));
}
#[test]
fn simple_path_rejects_different_path() {
let re = path_to_regex("/api/v1/users").unwrap();
assert!(!re.is_match("/api/v1/posts"));
}
#[test]
fn template_with_param_matches_numeric() {
let re = path_to_regex("/api/v1/users/{id}").unwrap();
assert!(re.is_match("/api/v1/users/123"));
}
#[test]
fn template_with_param_matches_uuid() {
let re = path_to_regex("/api/v1/users/{id}").unwrap();
assert!(re.is_match("/api/v1/users/550e8400-e29b-41d4-a716-446655440000"));
}
#[test]
fn template_does_not_match_extra_segments() {
let re = path_to_regex("/api/v1/users/{id}").unwrap();
assert!(!re.is_match("/api/v1/users/123/posts"));
}
#[test]
fn multiple_params() {
let re = path_to_regex("/api/v1/users/{user_id}/posts/{post_id}").unwrap();
assert!(re.is_match("/api/v1/users/1/posts/42"));
}
#[test]
fn special_chars_dot_in_path() {
let re = path_to_regex("/api/v1/files/{id}.json").unwrap();
assert!(re.is_match("/api/v1/files/123.json"));
assert!(!re.is_match("/api/v1/files/123xjson"));
}
#[test]
fn special_chars_plus_in_path() {
let re = path_to_regex("/api/v1/search+results").unwrap();
assert!(re.is_match("/api/v1/search+results"));
assert!(!re.is_match("/api/v1/searchhresults"));
}
#[test]
fn special_chars_question_mark() {
let re = path_to_regex("/api/v1/maybe?").unwrap();
assert!(re.is_match("/api/v1/maybe?"));
assert!(!re.is_match("/api/v1/mayb"));
}
#[test]
fn first_match_wins_param_before_literal() {
let templates = vec![
"/api/v1/users/{id}".to_string(),
"/api/v1/users/me".to_string(),
];
assert_eq!(
match_path("/api/v1/users/me", &templates),
Some("/api/v1/users/{id}")
);
}
#[test]
fn first_match_wins_literal_before_param() {
let templates = vec![
"/api/v1/users/me".to_string(),
"/api/v1/users/{id}".to_string(),
];
assert_eq!(
match_path("/api/v1/users/me", &templates),
Some("/api/v1/users/me")
);
}
#[test]
fn match_path_returns_none_when_no_match() {
let templates = vec!["/api/v1/users/{id}".to_string()];
assert_eq!(match_path("/api/v2/posts", &templates), None);
}
#[test]
fn match_path_empty_templates() {
assert_eq!(match_path("/anything", &[]), None);
}
#[test]
fn numeric_is_param() {
assert!(is_param_segment("123", None));
assert!(is_param_segment("0", None));
assert!(is_param_segment("-42", None));
}
#[test]
fn alpha_is_not_param() {
assert!(!is_param_segment("abc", None));
assert!(!is_param_segment("v1", None));
assert!(!is_param_segment("users", None));
}
#[test]
fn uuid_is_param() {
assert!(is_param_segment(
"550e8400-e29b-41d4-a716-446655440000",
None
));
}
#[test]
fn empty_is_not_param() {
assert!(!is_param_segment("", None));
}
#[test]
fn custom_regex_extends_matching() {
let re = Regex::new(r"^[a-f0-9]{8}$").unwrap();
assert!(is_param_segment("abcd1234", Some(&re)));
assert!(!is_param_segment("xyz", Some(&re)));
assert!(is_param_segment("123", Some(&re)));
}
#[test]
fn suggest_replaces_numeric_segments() {
let paths = vec!["/users/1".to_string(), "/users/2".to_string()];
let templates = suggest_param_templates(&paths, None);
assert_eq!(templates, vec!["/users/{id}"]);
}
#[test]
fn suggest_replaces_uuid_segments() {
let paths = vec![
"/users/550e8400-e29b-41d4-a716-446655440000".to_string(),
"/users/660e8400-e29b-41d4-a716-446655440001".to_string(),
];
let templates = suggest_param_templates(&paths, None);
assert_eq!(templates, vec!["/users/{id}"]);
}
#[test]
fn suggest_multiple_params() {
let paths = vec![
"/users/1/posts/10".to_string(),
"/users/2/posts/20".to_string(),
];
let templates = suggest_param_templates(&paths, None);
assert_eq!(templates, vec!["/users/{id1}/posts/{id2}"]);
}
#[test]
fn suggest_preserves_non_param_paths() {
let paths = vec!["/health".to_string(), "/api/status".to_string()];
let templates = suggest_param_templates(&paths, None);
assert!(templates.contains(&"/health".to_string()));
assert!(templates.contains(&"/api/status".to_string()));
}
#[test]
fn suggest_deduplicates() {
let paths = vec![
"/users/1".to_string(),
"/users/2".to_string(),
"/users/3".to_string(),
];
let templates = suggest_param_templates(&paths, None);
assert_eq!(templates.len(), 1);
assert_eq!(templates[0], "/users/{id}");
}
#[test]
fn suggest_mixed_param_and_literal() {
let paths = vec!["/users/1".to_string(), "/health".to_string()];
let templates = suggest_param_templates(&paths, None);
assert_eq!(templates.len(), 2);
assert!(templates.contains(&"/health".to_string()));
assert!(templates.contains(&"/users/{id}".to_string()));
}
#[test]
fn invalid_param_ident_rejected() {
let err = path_to_regex("/users/{foo bar}");
assert!(
matches!(err, Err(crate::error::Error::InvalidParamIdent { .. })),
"expected InvalidParamIdent, got {err:?}"
);
}
#[test]
fn digit_leading_param_rejected() {
let err = path_to_regex("/users/{1abc}");
assert!(matches!(
err,
Err(crate::error::Error::InvalidParamIdent { .. })
));
}
#[test]
fn root_path() {
let re = path_to_regex("/").unwrap();
assert!(re.is_match("/"));
assert!(!re.is_match("/anything"));
}
#[test]
fn param_does_not_match_slash() {
let re = path_to_regex("/users/{id}").unwrap();
assert!(!re.is_match("/users/1/2"));
}
#[test]
fn compiled_templates_matches_free_function() {
let templates = vec![
"/api/v1/users/{id}".to_string(),
"/api/v1/users/me".to_string(),
"/api/v1/posts/{post_id}/comments/{comment_id}".to_string(),
"/health".to_string(),
];
let compiled = CompiledTemplates::new(&templates).unwrap();
let test_paths = [
"/api/v1/users/123",
"/api/v1/users/me",
"/api/v1/posts/42/comments/7",
"/health",
"/nonexistent",
"/api/v2/other",
];
for path in &test_paths {
let free_result = match_path(path, &templates);
let compiled_result = compiled.match_path(path);
assert_eq!(
free_result, compiled_result,
"mismatch for path {path:?}: free={free_result:?}, compiled={compiled_result:?}"
);
}
}
#[test]
fn regex_special_chars_in_literal_parts() {
let re = path_to_regex("/api/(v1)/data").unwrap();
assert!(re.is_match("/api/(v1)/data"));
assert!(!re.is_match("/api/v1/data"));
}
}