use http::HeaderMap;
#[derive(Debug, Clone, PartialEq)]
pub enum Matcher {
Host(String),
Path(String),
PathPrefix(String),
Method(String),
Headers(String, String),
}
impl Matcher {
fn matches(&self, host: Option<&str>, path: &str, method: &str, headers: &HeaderMap) -> bool {
match self {
Matcher::Host(expected) => host
.map(|h| h.eq_ignore_ascii_case(expected))
.unwrap_or(false),
Matcher::Path(expected) => path == expected,
Matcher::PathPrefix(prefix) => path.starts_with(prefix.as_str()),
Matcher::Method(expected) => method.eq_ignore_ascii_case(expected),
Matcher::Headers(key, value) => headers
.get(key.as_str())
.and_then(|v| v.to_str().ok())
.map(|v| v == value.as_str())
.unwrap_or(false),
}
}
}
#[derive(Debug, Clone)]
pub struct Rule {
matchers: Vec<Matcher>,
}
impl Rule {
pub fn parse(input: &str) -> Result<Self, String> {
let parts: Vec<&str> = input.split("&&").map(|s| s.trim()).collect();
let mut matchers = Vec::new();
for part in parts {
let matcher = Self::parse_matcher(part)?;
matchers.push(matcher);
}
if matchers.is_empty() {
return Err("Rule must contain at least one matcher".to_string());
}
Ok(Self { matchers })
}
fn parse_matcher(input: &str) -> Result<Matcher, String> {
let input = input.trim();
let paren_start = input
.find('(')
.ok_or_else(|| format!("Invalid matcher syntax, expected '(': {}", input))?;
let paren_end = input
.rfind(')')
.ok_or_else(|| format!("Invalid matcher syntax, expected ')': {}", input))?;
let name = &input[..paren_start];
let args_str = &input[paren_start + 1..paren_end];
let args = Self::parse_args(args_str)?;
match name {
"Host" => {
if args.len() != 1 {
return Err(format!("Host() expects 1 argument, got {}", args.len()));
}
Ok(Matcher::Host(args[0].clone()))
}
"Path" => {
if args.len() != 1 {
return Err(format!("Path() expects 1 argument, got {}", args.len()));
}
Ok(Matcher::Path(args[0].clone()))
}
"PathPrefix" => {
if args.len() != 1 {
return Err(format!(
"PathPrefix() expects 1 argument, got {}",
args.len()
));
}
Ok(Matcher::PathPrefix(args[0].clone()))
}
"Method" => {
if args.len() != 1 {
return Err(format!("Method() expects 1 argument, got {}", args.len()));
}
Ok(Matcher::Method(args[0].clone()))
}
"Headers" => {
if args.len() != 2 {
return Err(format!("Headers() expects 2 arguments, got {}", args.len()));
}
Ok(Matcher::Headers(args[0].clone(), args[1].clone()))
}
_ => Err(format!("Unknown matcher: {}", name)),
}
}
fn parse_args(input: &str) -> Result<Vec<String>, String> {
let mut args = Vec::new();
let mut chars = input.chars().peekable();
loop {
while chars
.peek()
.map(|c| *c == ' ' || *c == ',')
.unwrap_or(false)
{
chars.next();
}
if chars.peek().is_none() {
break;
}
match chars.next() {
Some('`') => {}
Some(c) => return Err(format!("Expected backtick, got '{}'", c)),
None => break,
}
let mut arg = String::new();
loop {
match chars.next() {
Some('`') => break,
Some(c) => arg.push(c),
None => return Err("Unterminated backtick argument".to_string()),
}
}
args.push(arg);
}
Ok(args)
}
pub fn matches(
&self,
host: Option<&str>,
path: &str,
method: &str,
headers: &HeaderMap,
) -> bool {
self.matchers
.iter()
.all(|m| m.matches(host, path, method, headers))
}
#[allow(dead_code)]
pub fn matcher_count(&self) -> usize {
self.matchers.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_host() {
let rule = Rule::parse("Host(`example.com`)").unwrap();
assert_eq!(rule.matcher_count(), 1);
}
#[test]
fn test_parse_path() {
let rule = Rule::parse("Path(`/health`)").unwrap();
assert_eq!(rule.matcher_count(), 1);
}
#[test]
fn test_parse_path_prefix() {
let rule = Rule::parse("PathPrefix(`/api`)").unwrap();
assert_eq!(rule.matcher_count(), 1);
}
#[test]
fn test_parse_method() {
let rule = Rule::parse("Method(`POST`)").unwrap();
assert_eq!(rule.matcher_count(), 1);
}
#[test]
fn test_parse_headers() {
let rule = Rule::parse("Headers(`X-Custom`, `value`)").unwrap();
assert_eq!(rule.matcher_count(), 1);
}
#[test]
fn test_parse_combined_rule() {
let rule = Rule::parse("Host(`api.example.com`) && PathPrefix(`/v1`)").unwrap();
assert_eq!(rule.matcher_count(), 2);
}
#[test]
fn test_parse_triple_rule() {
let rule = Rule::parse("Host(`api.com`) && PathPrefix(`/v1`) && Method(`GET`)").unwrap();
assert_eq!(rule.matcher_count(), 3);
}
#[test]
fn test_parse_invalid_matcher() {
let result = Rule::parse("Unknown(`test`)");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown matcher"));
}
#[test]
fn test_parse_missing_backtick() {
let result = Rule::parse("Host(example.com)");
assert!(result.is_err());
}
#[test]
fn test_parse_wrong_arg_count_host() {
let result = Rule::parse("Host(`a`, `b`)");
assert!(result.is_err());
assert!(result.unwrap_err().contains("expects 1 argument"));
}
#[test]
fn test_parse_wrong_arg_count_headers() {
let result = Rule::parse("Headers(`key`)");
assert!(result.is_err());
assert!(result.unwrap_err().contains("expects 2 arguments"));
}
#[test]
fn test_match_host() {
let rule = Rule::parse("Host(`example.com`)").unwrap();
let headers = http::HeaderMap::new();
assert!(rule.matches(Some("example.com"), "/", "GET", &headers));
assert!(rule.matches(Some("EXAMPLE.COM"), "/", "GET", &headers)); assert!(!rule.matches(Some("other.com"), "/", "GET", &headers));
assert!(!rule.matches(None, "/", "GET", &headers));
}
#[test]
fn test_match_path() {
let rule = Rule::parse("Path(`/health`)").unwrap();
let headers = http::HeaderMap::new();
assert!(rule.matches(None, "/health", "GET", &headers));
assert!(!rule.matches(None, "/health/check", "GET", &headers));
assert!(!rule.matches(None, "/", "GET", &headers));
}
#[test]
fn test_match_path_prefix() {
let rule = Rule::parse("PathPrefix(`/api`)").unwrap();
let headers = http::HeaderMap::new();
assert!(rule.matches(None, "/api", "GET", &headers));
assert!(rule.matches(None, "/api/users", "GET", &headers));
assert!(rule.matches(None, "/api/users/123", "GET", &headers));
assert!(!rule.matches(None, "/other", "GET", &headers));
}
#[test]
fn test_match_method() {
let rule = Rule::parse("Method(`POST`)").unwrap();
let headers = http::HeaderMap::new();
assert!(rule.matches(None, "/", "POST", &headers));
assert!(rule.matches(None, "/", "post", &headers)); assert!(!rule.matches(None, "/", "GET", &headers));
}
#[test]
fn test_match_headers() {
let rule = Rule::parse("Headers(`X-Custom`, `value`)").unwrap();
let mut headers = http::HeaderMap::new();
headers.insert(
"x-custom".parse::<http::header::HeaderName>().unwrap(),
"value".parse::<http::HeaderValue>().unwrap(),
);
assert!(rule.matches(None, "/", "GET", &headers));
headers.insert(
"x-custom".parse::<http::header::HeaderName>().unwrap(),
"other".parse::<http::HeaderValue>().unwrap(),
);
assert!(!rule.matches(None, "/", "GET", &headers));
let empty = http::HeaderMap::new();
assert!(!rule.matches(None, "/", "GET", &empty));
}
#[test]
fn test_match_combined_and() {
let rule = Rule::parse("Host(`api.com`) && PathPrefix(`/v1`)").unwrap();
let headers = http::HeaderMap::new();
assert!(rule.matches(Some("api.com"), "/v1/users", "GET", &headers));
assert!(!rule.matches(Some("api.com"), "/v2/users", "GET", &headers));
assert!(!rule.matches(Some("other.com"), "/v1/users", "GET", &headers));
assert!(!rule.matches(Some("other.com"), "/v2/users", "GET", &headers));
}
#[test]
fn test_match_triple_and() {
let rule = Rule::parse("Host(`api.com`) && PathPrefix(`/v1`) && Method(`GET`)").unwrap();
let headers = http::HeaderMap::new();
assert!(rule.matches(Some("api.com"), "/v1/users", "GET", &headers));
assert!(!rule.matches(Some("api.com"), "/v1/users", "POST", &headers));
}
#[test]
fn test_parse_empty_rule() {
let result = Rule::parse("");
assert!(result.is_err());
}
#[test]
fn test_parse_missing_closing_paren() {
let result = Rule::parse("Host(`unterminated");
assert!(result.is_err());
assert!(result.unwrap_err().contains("expected ')'"));
}
#[test]
fn test_parse_args_whitespace_and_commas() {
let rule = Rule::parse("Host(`example.com`)").unwrap();
assert_eq!(rule.matcher_count(), 1);
}
#[test]
fn test_parse_args_trailing_content_after_backtick() {
let result = Rule::parse("Host(`test`extra`)");
assert!(result.is_err());
}
}