#[cfg(feature = "device-matching")]
use crate::error::LimiteronError;
use log::{debug, info};
use oxcache::Cache;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use woothee::parser::Parser;
const MAX_USER_AGENT_LENGTH: usize = 2048;
fn sanitize_user_agent(user_agent: &str) -> String {
user_agent
.chars()
.filter(|&c| c.is_ascii_graphic() || c == ' ')
.collect()
}
#[cfg(feature = "device-matching")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeviceType {
Mobile,
Desktop,
Tablet,
API,
Unknown,
}
impl DeviceType {
pub fn parse(s: &str) -> Self {
match s.to_lowercase().as_str() {
"mobile" | "smartphone" => DeviceType::Mobile,
"desktop" | "pc" => DeviceType::Desktop,
"tablet" | "ipad" => DeviceType::Tablet,
"api" | "bot" | "crawler" => DeviceType::API,
_ => DeviceType::Unknown,
}
}
pub fn as_str(&self) -> &'static str {
match self {
DeviceType::Mobile => "mobile",
DeviceType::Desktop => "desktop",
DeviceType::Tablet => "tablet",
DeviceType::API => "api",
DeviceType::Unknown => "unknown",
}
}
pub fn is_mobile(&self) -> bool {
matches!(self, DeviceType::Mobile | DeviceType::Tablet)
}
pub fn is_desktop(&self) -> bool {
matches!(self, DeviceType::Desktop)
}
pub fn is_api(&self) -> bool {
matches!(self, DeviceType::API)
}
}
impl std::fmt::Display for DeviceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[cfg(feature = "device-matching")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceInfo {
pub device_type: DeviceType,
pub browser: Option<String>,
pub browser_version: Option<String>,
pub os: Option<String>,
pub os_version: Option<String>,
pub user_agent: Option<String>,
}
impl DeviceInfo {
pub fn empty() -> Self {
Self {
device_type: DeviceType::Unknown,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
}
}
pub fn is_empty(&self) -> bool {
self.device_type == DeviceType::Unknown && self.browser.is_none() && self.os.is_none()
}
pub fn description(&self) -> String {
match (&self.device_type, &self.browser, &self.os) {
(DeviceType::API, _, _) => "API Client".to_string(),
(_dt, Some(browser), Some(os)) => {
format!(
"{} {} on {} {}",
browser,
self.browser_version.as_deref().unwrap_or(""),
os,
self.os_version.as_deref().unwrap_or("")
)
}
(dt, Some(browser), None) => {
format!(
"{} {} on {}",
browser,
self.browser_version.as_deref().unwrap_or(""),
dt
)
}
(dt, None, Some(os)) => {
format!("{} on {}", os, dt)
}
(dt, None, None) => dt.to_string(),
}
}
fn from_woothee(result: &woothee::parser::WootheeResult) -> Self {
let device_type = Self::map_woothee_device_type(result.category);
let browser = if device_type != DeviceType::API {
Some(result.name.to_string())
} else {
None
};
let browser_version = if device_type != DeviceType::API {
Some(result.version.to_string())
} else {
None
};
let os = if device_type != DeviceType::API {
Some(result.os.to_string())
} else {
None
};
let os_version = if device_type != DeviceType::API {
Some(result.os_version.to_string())
} else {
None
};
Self {
device_type,
browser,
browser_version,
os,
os_version,
user_agent: None,
}
}
fn map_woothee_device_type(category: &str) -> DeviceType {
match category.to_lowercase().as_str() {
"pc" => DeviceType::Desktop,
"smartphone" => DeviceType::Mobile,
"mobilephone" => DeviceType::Mobile,
"tablet" => DeviceType::Tablet,
"appliance" => DeviceType::API,
"crawler" => DeviceType::API,
"misc" => DeviceType::API,
_ => DeviceType::Unknown,
}
}
}
impl Default for DeviceInfo {
fn default() -> Self {
Self::empty()
}
}
#[cfg(feature = "device-matching")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceCondition {
pub device_types: Vec<DeviceType>,
pub browsers: Vec<String>,
pub os: Vec<String>,
}
impl DeviceCondition {
pub fn empty() -> Self {
Self {
device_types: vec![],
browsers: vec![],
os: vec![],
}
}
pub fn device_types(device_types: Vec<DeviceType>) -> Self {
Self {
device_types,
browsers: vec![],
os: vec![],
}
}
pub fn browsers(browsers: Vec<String>) -> Self {
Self {
device_types: vec![],
browsers,
os: vec![],
}
}
pub fn os(os: Vec<String>) -> Self {
Self {
device_types: vec![],
browsers: vec![],
os,
}
}
pub fn is_empty(&self) -> bool {
self.device_types.is_empty() && self.browsers.is_empty() && self.os.is_empty()
}
pub fn matches(&self, info: &DeviceInfo) -> bool {
if self.is_empty() {
return true;
}
if !self.device_types.is_empty() {
if self.device_types.contains(&info.device_type) {
return true;
}
return false;
}
if !self.browsers.is_empty() {
if let Some(browser) = &info.browser {
if self.browsers.iter().any(|b| {
browser.to_lowercase().contains(&b.to_lowercase())
|| b.to_lowercase().contains(&browser.to_lowercase())
}) {
return true;
}
}
return false;
}
if !self.os.is_empty() {
if let Some(os) = &info.os {
if self.os.iter().any(|o| {
os.to_lowercase().contains(&o.to_lowercase())
|| o.to_lowercase().contains(&os.to_lowercase())
}) {
return true;
}
}
return false;
}
false
}
}
impl Default for DeviceCondition {
fn default() -> Self {
Self::empty()
}
}
#[cfg(feature = "device-matching")]
pub struct DeviceMatcher {
parser: Arc<Parser>,
cache: Arc<Cache<String, DeviceInfo>>,
cache_size_limit: usize,
cache_hits: AtomicU64,
cache_misses: AtomicU64,
custom_rules: Vec<DeviceCustomRule>,
}
#[derive(Debug, Clone)]
struct DeviceCustomRule {
name: String,
pattern: String,
device_type: DeviceType,
browser: Option<String>,
os: Option<String>,
}
impl DeviceMatcher {
pub async fn new() -> Result<Self, LimiteronError> {
info!(target: "device", "创建DeviceMatcher");
let parser = Parser::new();
let cache = Cache::builder()
.build()
.await
.map_err(|e| LimiteronError::ConfigError(e.to_string()))?;
let matcher = Self {
parser: Arc::new(parser),
cache: Arc::new(cache),
cache_size_limit: 10_000,
cache_hits: AtomicU64::new(0),
cache_misses: AtomicU64::new(0),
custom_rules: Self::default_custom_rules(),
};
info!(target: "device", "DeviceMatcher创建成功");
Ok(matcher)
}
pub fn builder() -> DeviceMatcherBuilder {
DeviceMatcherBuilder::new()
}
pub fn with_dependencies(
parser: Arc<Parser>,
cache: Arc<Cache<String, DeviceInfo>>,
cache_size_limit: usize,
) -> Self {
let mut matcher = Self {
parser,
cache,
cache_size_limit,
cache_hits: AtomicU64::new(0),
cache_misses: AtomicU64::new(0),
custom_rules: Self::default_custom_rules(),
};
matcher.cache_size_limit = cache_size_limit;
matcher
}
pub async fn with_cache_limit(cache_size_limit: usize) -> Result<Self, LimiteronError> {
let mut matcher = Self::new().await?;
matcher.cache_size_limit = cache_size_limit;
Ok(matcher)
}
pub async fn parse(&self, user_agent: &str) -> Result<DeviceInfo, LimiteronError> {
let sanitized = sanitize_user_agent(user_agent);
let user_agent = sanitized.trim();
if user_agent.is_empty() {
return Ok(DeviceInfo::empty());
}
if user_agent.len() > MAX_USER_AGENT_LENGTH {
return Err(LimiteronError::ConfigError(format!(
"User-Agent 长度超过限制(最大 {} 字符)",
MAX_USER_AGENT_LENGTH
)));
}
let cache_key = user_agent.to_string();
if let Ok(Some(cached)) = self.cache.get(&cache_key).await {
log::debug!(target: "device", "缓存命中: {}", user_agent);
self.cache_hits.fetch_add(1, Ordering::Relaxed);
return Ok(cached);
}
self.cache_misses.fetch_add(1, Ordering::Relaxed);
log::debug!(target: "device", "解析User-Agent: {}", user_agent);
for rule in &self.custom_rules {
if let Ok(re) = regex::Regex::new(&rule.pattern) {
if re.is_match(user_agent) {
let info = DeviceInfo {
device_type: rule.device_type,
browser: rule.browser.clone(),
browser_version: None,
os: rule.os.clone(),
os_version: None,
user_agent: Some(user_agent.to_string()),
};
self.update_cache(user_agent, &info).await;
log::debug!(target: "device", "自定义规则匹配: {}", rule.name);
return Ok(info);
}
}
}
let result = self.parser.parse(user_agent);
let mut info = if let Some(res) = result {
DeviceInfo::from_woothee(&res)
} else {
DeviceInfo::empty()
};
info.user_agent = Some(user_agent.to_string());
self.update_cache(user_agent, &info).await;
log::debug!(
target: "device",
"User-Agent解析成功: {} -> {}",
user_agent,
info.description()
);
Ok(info)
}
pub async fn batch_parse(
&self,
user_agents: &[String],
) -> Vec<Result<DeviceInfo, LimiteronError>> {
let mut results = Vec::with_capacity(user_agents.len());
for ua in user_agents {
results.push(self.parse(ua).await);
}
results
}
pub async fn matches_user_agent(
&self,
user_agent: &str,
condition: &DeviceCondition,
) -> Result<bool, LimiteronError> {
let info = self.parse(user_agent).await?;
Ok(condition.matches(&info))
}
pub fn matches(&self, info: &DeviceInfo, condition: &DeviceCondition) -> bool {
condition.matches(info)
}
pub fn add_custom_rule(
&mut self,
name: &str,
pattern: &str,
device_type: DeviceType,
browser: Option<String>,
os: Option<String>,
) {
let rule = DeviceCustomRule {
name: name.to_string(),
pattern: pattern.to_string(),
device_type,
browser,
os,
};
if regex::Regex::new(&rule.pattern).is_err() {
log::warn!(target: "device", "无效的正则表达式: {}", pattern);
return;
}
self.custom_rules.push(rule);
log::info!(target: "device", "添加自定义规则: {}", name);
}
pub fn remove_custom_rule(&mut self, name: &str) -> bool {
let original_len = self.custom_rules.len();
self.custom_rules.retain(|r| r.name != name);
let removed = self.custom_rules.len() < original_len;
if removed {
log::info!(target: "device", "移除自定义规则: {}", name);
}
removed
}
pub async fn clear_cache(&self) {
let size = self.cache.len().await.unwrap_or(0);
let _ = self.cache.clear().await;
log::info!(target: "device", "缓存已清空,移除 {} 条记录", size);
}
pub async fn cache_stats(&self) -> DeviceCacheStats {
let hits = self.cache_hits.load(Ordering::Relaxed);
let misses = self.cache_misses.load(Ordering::Relaxed);
let total = hits.saturating_add(misses);
let hit_rate = if total > 0 {
(hits as f64 / total as f64) * 100.0
} else {
0.0
};
DeviceCacheStats {
size: self.cache.len().await.unwrap_or(0) as usize,
limit: self.cache_size_limit,
hit_rate,
hits,
misses,
}
}
async fn update_cache(&self, user_agent: &str, info: &DeviceInfo) {
let cache_len = self.cache.len().await.unwrap_or(0);
if cache_len >= self.cache_size_limit as u64 {
let _maybe_first = (0..(self.cache_size_limit / 10)).next();
debug!(target: "device", "缓存接近限制 ({}/{})", cache_len, self.cache_size_limit);
}
let _ = self.cache.set(&user_agent.to_string(), info).await;
}
fn default_custom_rules() -> Vec<DeviceCustomRule> {
vec![
DeviceCustomRule {
name: "curl".to_string(),
pattern: r"^curl/".to_string(),
device_type: DeviceType::API,
browser: Some("curl".to_string()),
os: None,
},
DeviceCustomRule {
name: "wget".to_string(),
pattern: r"^Wget/".to_string(),
device_type: DeviceType::API,
browser: Some("wget".to_string()),
os: None,
},
DeviceCustomRule {
name: "googlebot".to_string(),
pattern: r"Googlebot".to_string(),
device_type: DeviceType::API,
browser: Some("Googlebot".to_string()),
os: None,
},
DeviceCustomRule {
name: "bingbot".to_string(),
pattern: r"Bingbot".to_string(),
device_type: DeviceType::API,
browser: Some("Bingbot".to_string()),
os: None,
},
]
}
}
#[derive(Debug, Clone)]
pub struct DeviceMatcherBuilder {
cache_size_limit: usize,
custom_rules: Vec<DeviceCustomRule>,
}
impl DeviceMatcherBuilder {
pub fn new() -> Self {
Self {
cache_size_limit: 10_000,
custom_rules: Vec::new(),
}
}
pub fn cache_size_limit(mut self, cache_size_limit: usize) -> Self {
self.cache_size_limit = cache_size_limit;
self
}
pub fn add_custom_rule(
mut self,
name: &str,
pattern: &str,
device_type: DeviceType,
browser: Option<String>,
os: Option<String>,
) -> Self {
let rule = DeviceCustomRule {
name: name.to_string(),
pattern: pattern.to_string(),
device_type,
browser,
os,
};
self.custom_rules.push(rule);
self
}
pub async fn build(self) -> Result<DeviceMatcher, LimiteronError> {
let mut matcher = DeviceMatcher::new().await?;
matcher.cache_size_limit = self.cache_size_limit;
matcher.custom_rules = self.custom_rules;
Ok(matcher)
}
}
impl Default for DeviceMatcherBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCacheStats {
pub size: usize,
pub limit: usize,
pub hit_rate: f64,
pub hits: u64,
pub misses: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_device_type_from_str() {
assert_eq!(DeviceType::parse("mobile"), DeviceType::Mobile);
assert_eq!(DeviceType::parse("desktop"), DeviceType::Desktop);
assert_eq!(DeviceType::parse("tablet"), DeviceType::Tablet);
assert_eq!(DeviceType::parse("api"), DeviceType::API);
assert_eq!(DeviceType::parse("unknown"), DeviceType::Unknown);
assert_eq!(DeviceType::parse("invalid"), DeviceType::Unknown);
}
#[test]
fn test_device_type_as_str() {
assert_eq!(DeviceType::Mobile.as_str(), "mobile");
assert_eq!(DeviceType::Desktop.as_str(), "desktop");
assert_eq!(DeviceType::Tablet.as_str(), "tablet");
assert_eq!(DeviceType::API.as_str(), "api");
assert_eq!(DeviceType::Unknown.as_str(), "unknown");
}
#[test]
fn test_device_type_is_mobile() {
assert!(DeviceType::Mobile.is_mobile());
assert!(DeviceType::Tablet.is_mobile());
assert!(!DeviceType::Desktop.is_mobile());
assert!(!DeviceType::API.is_mobile());
}
#[test]
fn test_device_info_empty() {
let info = DeviceInfo::empty();
assert!(info.is_empty());
assert_eq!(info.description(), "unknown");
}
#[test]
fn test_device_info_description() {
let info1 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: Some("Safari".to_string()),
browser_version: Some("14.0".to_string()),
os: Some("iOS".to_string()),
os_version: Some("14.0".to_string()),
user_agent: None,
};
assert!(info1.description().contains("Safari"));
assert!(info1.description().contains("iOS"));
let info2 = DeviceInfo {
device_type: DeviceType::API,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert_eq!(info2.description(), "API Client");
}
#[test]
fn test_device_condition_empty() {
let condition = DeviceCondition::empty();
assert!(condition.is_empty());
let info = DeviceInfo::empty();
assert!(condition.matches(&info));
}
#[test]
fn test_device_condition_device_types() {
let condition = DeviceCondition::device_types(vec![DeviceType::Mobile]);
let info1 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(condition.matches(&info1));
let info2 = DeviceInfo {
device_type: DeviceType::Desktop,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_device_condition_browsers() {
let condition = DeviceCondition::browsers(vec!["Safari".to_string(), "Chrome".to_string()]);
let info1 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: Some("Safari".to_string()),
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(condition.matches(&info1));
let info2 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: Some("Firefox".to_string()),
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_device_condition_os() {
let condition = DeviceCondition::os(vec!["iOS".to_string(), "Android".to_string()]);
let info1 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: None,
browser_version: None,
os: Some("iOS".to_string()),
os_version: None,
user_agent: None,
};
assert!(condition.matches(&info1));
let info2 = DeviceInfo {
device_type: DeviceType::Desktop,
browser: None,
browser_version: None,
os: Some("Windows".to_string()),
os_version: None,
user_agent: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_device_condition_default() {
let condition = DeviceCondition::default();
assert!(condition.is_empty());
}
#[test]
fn test_device_info_serialization() {
let info = DeviceInfo {
device_type: DeviceType::Mobile,
browser: Some("Safari".to_string()),
browser_version: Some("14.0".to_string()),
os: Some("iOS".to_string()),
os_version: Some("14.0".to_string()),
user_agent: Some("Test".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
let deserialized: DeviceInfo = serde_json::from_str(&json).unwrap();
assert_eq!(info, deserialized);
}
#[test]
fn test_device_condition_serialization() {
let condition = DeviceCondition {
device_types: vec![DeviceType::Mobile, DeviceType::Tablet],
browsers: vec!["Safari".to_string()],
os: vec!["iOS".to_string()],
};
let json = serde_json::to_string(&condition).unwrap();
let deserialized: DeviceCondition = serde_json::from_str(&json).unwrap();
assert_eq!(condition, deserialized);
}
#[tokio::test]
async fn test_device_matcher_parse_empty() {
let matcher = DeviceMatcher::new().await.unwrap();
let info = matcher.parse("").await.unwrap();
assert!(info.is_empty());
let info = matcher.parse(" ").await.unwrap();
assert!(info.is_empty());
}
#[tokio::test]
async fn test_device_matcher_parse_iphone() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) \
AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 \
Safari/604.1";
let info = matcher.parse(user_agent).await.unwrap();
assert_eq!(info.device_type, DeviceType::Mobile);
assert!(info.browser.as_ref().unwrap().contains("Safari"));
assert!(info.os.is_some());
}
#[tokio::test]
async fn test_device_matcher_parse_desktop() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, \
like Gecko) Chrome/91.0.4472.124 Safari/537.36";
let info = matcher.parse(user_agent).await.unwrap();
assert_eq!(info.device_type, DeviceType::Desktop);
assert!(info.browser.as_ref().unwrap().contains("Chrome"));
assert!(info.os.as_ref().unwrap().contains("Windows"));
}
#[tokio::test]
async fn test_device_matcher_parse_curl() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agent = "curl/7.68.0";
let info = matcher.parse(user_agent).await.unwrap();
assert_eq!(info.device_type, DeviceType::API);
assert_eq!(info.browser, Some("curl".to_string()));
}
#[tokio::test]
async fn test_device_matcher_cache() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)";
let info1 = matcher.parse(user_agent).await.unwrap();
let stats1 = matcher.cache_stats().await;
assert_eq!(stats1.misses, 1);
assert_eq!(stats1.hits, 0);
let info2 = matcher.parse(user_agent).await.unwrap();
assert_eq!(info1, info2);
let stats2 = matcher.cache_stats().await;
assert_eq!(stats2.hits, 1);
assert_eq!(stats2.misses, 1);
matcher.clear_cache().await;
let stats3 = matcher.cache_stats().await;
assert_eq!(stats3.hits, 1);
assert_eq!(stats3.misses, 1);
}
#[tokio::test]
async fn test_device_matcher_custom_rule() {
let mut matcher = DeviceMatcher::new().await.unwrap();
matcher.add_custom_rule(
"TestApp",
r"TestApp/\d+\.\d+",
DeviceType::Mobile,
Some("TestApp".to_string()),
Some("Android".to_string()),
);
let user_agent = "TestApp/1.0.0";
let info = matcher.parse(user_agent).await.unwrap();
assert_eq!(info.device_type, DeviceType::Mobile);
assert_eq!(info.browser, Some("TestApp".to_string()));
}
#[tokio::test]
async fn test_device_matcher_remove_custom_rule() {
let mut matcher = DeviceMatcher::new().await.unwrap();
matcher.add_custom_rule(
"TestApp",
r"TestApp/\d+\.\d+",
DeviceType::Mobile,
Some("TestApp".to_string()),
Some("Android".to_string()),
);
assert!(matcher.remove_custom_rule("TestApp"));
assert!(!matcher.remove_custom_rule("NonExistent"));
}
#[tokio::test]
async fn test_device_matcher_batch_parse() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agents = vec![
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)".to_string(),
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string(),
"curl/7.68.0".to_string(),
];
let results = matcher.batch_parse(&user_agents).await;
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.is_ok()));
assert_eq!(results[0].as_ref().unwrap().device_type, DeviceType::Mobile);
assert_eq!(
results[1].as_ref().unwrap().device_type,
DeviceType::Desktop
);
assert_eq!(results[2].as_ref().unwrap().device_type, DeviceType::API);
}
#[tokio::test]
async fn test_device_matcher_matches_user_agent() {
let matcher = DeviceMatcher::new().await.unwrap();
let condition = DeviceCondition::device_types(vec![DeviceType::Mobile]);
let user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)";
let matched = matcher
.matches_user_agent(user_agent, &condition)
.await
.unwrap();
assert!(matched);
let user_agent2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
let matched2 = matcher
.matches_user_agent(user_agent2, &condition)
.await
.unwrap();
assert!(!matched2);
}
#[test]
fn test_device_type_parse_aliases() {
assert_eq!(DeviceType::parse("smartphone"), DeviceType::Mobile);
assert_eq!(DeviceType::parse("pc"), DeviceType::Desktop);
assert_eq!(DeviceType::parse("ipad"), DeviceType::Tablet);
assert_eq!(DeviceType::parse("bot"), DeviceType::API);
assert_eq!(DeviceType::parse("crawler"), DeviceType::API);
}
#[test]
fn test_device_type_helpers() {
assert!(DeviceType::Desktop.is_desktop());
assert!(!DeviceType::Mobile.is_desktop());
assert!(!DeviceType::Tablet.is_desktop());
assert!(DeviceType::API.is_api());
assert!(!DeviceType::Mobile.is_api());
assert!(!DeviceType::Desktop.is_api());
}
#[test]
fn test_device_type_display() {
assert_eq!(format!("{}", DeviceType::Mobile), "mobile");
assert_eq!(format!("{}", DeviceType::Desktop), "desktop");
assert_eq!(format!("{}", DeviceType::Tablet), "tablet");
assert_eq!(format!("{}", DeviceType::API), "api");
assert_eq!(format!("{}", DeviceType::Unknown), "unknown");
}
#[test]
fn test_device_info_description_browser_only() {
let info = DeviceInfo {
device_type: DeviceType::Desktop,
browser: Some("Chrome".to_string()),
browser_version: Some("91".to_string()),
os: None,
os_version: None,
user_agent: None,
};
assert_eq!(info.description(), "Chrome 91 on desktop");
}
#[test]
fn test_device_info_description_os_only() {
let info = DeviceInfo {
device_type: DeviceType::Desktop,
browser: None,
browser_version: None,
os: Some("Windows".to_string()),
os_version: Some("10".to_string()),
user_agent: None,
};
assert_eq!(info.description(), "Windows on desktop");
}
#[test]
fn test_device_info_description_device_only() {
let info = DeviceInfo {
device_type: DeviceType::Desktop,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert_eq!(info.description(), "desktop");
}
#[test]
fn test_device_condition_browser_none() {
let condition = DeviceCondition::browsers(vec!["Safari".to_string()]);
let info = DeviceInfo {
device_type: DeviceType::Desktop,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(!condition.matches(&info));
}
#[test]
fn test_device_condition_os_none() {
let condition = DeviceCondition::os(vec!["iOS".to_string()]);
let info = DeviceInfo {
device_type: DeviceType::Desktop,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(!condition.matches(&info));
}
#[tokio::test]
async fn test_device_matcher_builder() {
let matcher = DeviceMatcher::builder()
.cache_size_limit(500)
.add_custom_rule(
"CustomBot",
r"CustomBot/\d+",
DeviceType::API,
Some("CustomBot".to_string()),
None,
)
.build()
.await
.unwrap();
assert_eq!(matcher.cache_stats().await.limit, 500);
let info = matcher.parse("CustomBot/2.0").await.unwrap();
assert_eq!(info.device_type, DeviceType::API);
}
#[tokio::test]
async fn test_device_matcher_with_cache_limit() {
let matcher = DeviceMatcher::with_cache_limit(500).await.unwrap();
assert_eq!(matcher.cache_stats().await.limit, 500);
}
#[tokio::test]
async fn test_device_matcher_parse_long_ua() {
let matcher = DeviceMatcher::new().await.unwrap();
let long_ua = "X".repeat(3000);
let result = matcher.parse(&long_ua).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_device_matcher_parse_unrecognized() {
let matcher = DeviceMatcher::new().await.unwrap();
let info = matcher
.parse("zzz1nvalid-th1ng-th4t-w00thee-c4nt-p4rs3")
.await
.unwrap();
assert_eq!(info.device_type, DeviceType::Unknown);
assert!(info.os.is_none());
}
#[tokio::test]
async fn test_device_matcher_matches_direct() {
let matcher = DeviceMatcher::new().await.unwrap();
let info = DeviceInfo {
device_type: DeviceType::Tablet,
browser: Some("Safari".to_string()),
browser_version: None,
os: Some("iOS".to_string()),
os_version: None,
user_agent: None,
};
let condition = DeviceCondition::device_types(vec![DeviceType::Tablet]);
assert!(matcher.matches(&info, &condition));
let no_match = DeviceCondition::device_types(vec![DeviceType::Desktop]);
assert!(!matcher.matches(&info, &no_match));
}
#[tokio::test]
async fn test_device_matcher_invalid_custom_rule() {
let mut matcher = DeviceMatcher::new().await.unwrap();
let before = matcher.remove_custom_rule("non-existent");
assert!(!before);
matcher.add_custom_rule(
"BadRegex",
r"[invalid(unclosed",
DeviceType::Mobile,
None,
None,
);
let info = matcher.parse("anything").await.unwrap();
assert_eq!(info.device_type, DeviceType::Unknown);
}
#[tokio::test]
async fn test_device_matcher_parse_cache_full() {
let matcher = DeviceMatcher::with_cache_limit(2).await.unwrap();
let _ = matcher
.parse("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.await
.unwrap();
let _ = matcher.parse("curl/7.68.0").await.unwrap();
let _ = matcher.parse("Wget/1.21").await.unwrap();
let stats = matcher.cache_stats().await;
assert!(stats.hits + stats.misses >= 3);
}
#[tokio::test]
async fn test_device_matcher_clear_cache_with_data() {
let matcher = DeviceMatcher::new().await.unwrap();
let _ = matcher
.parse("Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15")
.await
.unwrap();
let _ = matcher
.parse("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.await
.unwrap();
let stats_before = matcher.cache_stats().await;
assert!(stats_before.hits == 0);
matcher.clear_cache().await;
let stats_after = matcher.cache_stats().await;
assert_eq!(stats_after.size, 0);
}
#[tokio::test]
async fn test_device_matcher_parse_tablet() {
let matcher = DeviceMatcher::new().await.unwrap();
let info = matcher.parse("Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1").await.unwrap();
assert_eq!(info.device_type, DeviceType::Mobile);
}
#[tokio::test]
async fn test_device_matcher_parse_crawler() {
let matcher = DeviceMatcher::new().await.unwrap();
let info = matcher
.parse("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
.await
.unwrap();
assert_eq!(info.device_type, DeviceType::API);
}
#[tokio::test]
async fn test_device_matcher_parse_wget() {
let matcher = DeviceMatcher::new().await.unwrap();
let info = matcher.parse("Wget/1.21.1").await.unwrap();
assert_eq!(info.device_type, DeviceType::API);
assert_eq!(info.browser, Some("wget".to_string()));
}
#[tokio::test]
async fn test_device_matcher_parse_bingbot() {
let matcher = DeviceMatcher::new().await.unwrap();
let info = matcher
.parse("Mozilla/5.0 (compatible; Bingbot/2.0; +http://www.bing.com/bingbot.htm)")
.await
.unwrap();
assert_eq!(info.device_type, DeviceType::API);
assert_eq!(info.browser, Some("Bingbot".to_string()));
}
#[test]
fn test_sanitize_user_agent_filters_non_ascii() {
let result = sanitize_user_agent("Mozilla/5.0 \u{00e9}\u{00e0}iPhone");
assert!(!result.contains('\u{00e9}'));
assert!(!result.contains('\u{00e0}'));
assert!(result.contains("Mozilla/5.0 iPhone"));
}
#[tokio::test]
async fn test_device_matcher_with_dependencies() {
use std::sync::Arc;
let parser = Arc::new(Parser::new());
let cache = Arc::new(Cache::builder().build().await.unwrap());
let matcher = DeviceMatcher::with_dependencies(parser, cache, 1000);
assert_eq!(matcher.cache_stats().await.limit, 1000);
let info = matcher.parse("curl/7.68.0").await.unwrap();
assert_eq!(info.device_type, DeviceType::API);
}
#[test]
fn test_map_woothee_device_type_all_variants() {
assert_eq!(
DeviceInfo::map_woothee_device_type("pc"),
DeviceType::Desktop
);
assert_eq!(
DeviceInfo::map_woothee_device_type("smartphone"),
DeviceType::Mobile
);
assert_eq!(
DeviceInfo::map_woothee_device_type("mobilephone"),
DeviceType::Mobile
);
assert_eq!(
DeviceInfo::map_woothee_device_type("tablet"),
DeviceType::Tablet
);
assert_eq!(
DeviceInfo::map_woothee_device_type("appliance"),
DeviceType::API
);
assert_eq!(
DeviceInfo::map_woothee_device_type("crawler"),
DeviceType::API
);
assert_eq!(DeviceInfo::map_woothee_device_type("misc"), DeviceType::API);
assert_eq!(
DeviceInfo::map_woothee_device_type("unknown_category"),
DeviceType::Unknown
);
}
#[test]
fn test_device_info_from_woothee_api_category() {
use woothee::parser::WootheeResult;
let result = WootheeResult {
name: "Googlebot",
category: "crawler",
os: "-",
os_version: "-".into(),
version: "2.1",
browser_type: "-",
vendor: "-",
};
let info = DeviceInfo::from_woothee(&result);
assert_eq!(info.device_type, DeviceType::API);
assert!(info.browser.is_none());
assert!(info.os.is_none());
}
#[test]
fn test_device_info_from_woothee_non_api() {
use woothee::parser::WootheeResult;
let result = WootheeResult {
name: "Chrome",
category: "pc",
os: "Windows",
os_version: "10".into(),
version: "91.0",
browser_type: "-",
vendor: "-",
};
let info = DeviceInfo::from_woothee(&result);
assert_eq!(info.device_type, DeviceType::Desktop);
assert_eq!(info.browser, Some("Chrome".to_string()));
assert_eq!(info.os, Some("Windows".to_string()));
}
#[test]
fn test_device_info_default() {
let info = DeviceInfo::default();
assert!(info.is_empty());
}
#[cfg(feature = "device-matching")]
#[test]
fn test_device_matcher_builder_default() {
let builder = DeviceMatcherBuilder::default();
assert_eq!(builder.cache_size_limit, 10_000);
}
#[cfg(feature = "device-matching")]
#[tokio::test]
async fn test_device_matcher_cache_near_limit() {
let matcher = DeviceMatcher::with_cache_limit(5).await.unwrap();
let user_agents = vec![
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) Safari/605",
"Mozilla/5.0 (X11; Linux x86_64; rv:89.0) Firefox/89.0",
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0) Safari/605",
"Mozilla/5.0 (Linux; Android 11) Chrome/91.0",
"curl/7.68.0",
];
for ua in &user_agents {
let _ = matcher.parse(ua).await;
}
let stats = matcher.cache_stats().await;
assert!(stats.limit == 5);
}
}