Skip to main content

oxihuman_core/
user_agent_parser.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! User-Agent string parser stub — extracts browser, OS, and version info.
6
7/// Browser family detected from a User-Agent string.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum BrowserFamily {
10    Chrome,
11    Firefox,
12    Safari,
13    Edge,
14    Opera,
15    InternetExplorer,
16    Bot,
17    Unknown,
18}
19
20/// Operating system detected from a User-Agent string.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum OsFamily {
23    Windows,
24    MacOs,
25    Linux,
26    Android,
27    Ios,
28    Unknown,
29}
30
31/// Parsed result of a User-Agent string.
32#[derive(Clone, Debug)]
33pub struct UserAgent {
34    pub raw: String,
35    pub browser: BrowserFamily,
36    pub os: OsFamily,
37    pub version: Option<String>,
38    pub is_mobile: bool,
39    pub is_bot: bool,
40}
41
42/// Parses a User-Agent string into structured information.
43pub fn parse_user_agent(ua: &str) -> UserAgent {
44    let lower = ua.to_lowercase();
45    let is_bot = is_bot_agent(&lower);
46    let browser = detect_browser(&lower);
47    let os = detect_os(&lower);
48    let is_mobile =
49        lower.contains("mobile") || lower.contains("android") || lower.contains("iphone");
50    let version = extract_version(ua);
51
52    UserAgent {
53        raw: ua.into(),
54        browser,
55        os,
56        version,
57        is_mobile,
58        is_bot,
59    }
60}
61
62fn is_bot_agent(lower: &str) -> bool {
63    lower.contains("bot")
64        || lower.contains("crawler")
65        || lower.contains("spider")
66        || lower.contains("slurp")
67        || lower.contains("googlebot")
68}
69
70fn detect_browser(lower: &str) -> BrowserFamily {
71    if lower.contains("edg/") || lower.contains("edge/") {
72        BrowserFamily::Edge
73    } else if lower.contains("opr/") || lower.contains("opera") {
74        BrowserFamily::Opera
75    } else if lower.contains("firefox") {
76        BrowserFamily::Firefox
77    } else if lower.contains("chrome") {
78        BrowserFamily::Chrome
79    } else if lower.contains("safari") {
80        BrowserFamily::Safari
81    } else if lower.contains("msie") || lower.contains("trident") {
82        BrowserFamily::InternetExplorer
83    } else if lower.contains("bot") || lower.contains("crawler") {
84        BrowserFamily::Bot
85    } else {
86        BrowserFamily::Unknown
87    }
88}
89
90fn detect_os(lower: &str) -> OsFamily {
91    if lower.contains("android") {
92        OsFamily::Android
93    } else if lower.contains("iphone") || lower.contains("ipad") {
94        OsFamily::Ios
95    } else if lower.contains("windows") {
96        OsFamily::Windows
97    } else if lower.contains("mac os") || lower.contains("macos") {
98        OsFamily::MacOs
99    } else if lower.contains("linux") {
100        OsFamily::Linux
101    } else {
102        OsFamily::Unknown
103    }
104}
105
106fn extract_version(ua: &str) -> Option<String> {
107    /* attempt to extract version from "Version/X.Y" or "Firefox/X.Y" patterns */
108    for prefix in &["Version/", "Firefox/", "Chrome/", "OPR/", "Edg/"] {
109        if let Some(pos) = ua.find(prefix) {
110            let rest = &ua[pos + prefix.len()..];
111            let end = rest
112                .find(|c: char| !c.is_ascii_digit() && c != '.')
113                .unwrap_or(rest.len());
114            if end > 0 {
115                return Some(rest[..end].into());
116            }
117        }
118    }
119    None
120}
121
122/// Returns a display name string for the browser family.
123pub fn browser_name(family: &BrowserFamily) -> &'static str {
124    match family {
125        BrowserFamily::Chrome => "Chrome",
126        BrowserFamily::Firefox => "Firefox",
127        BrowserFamily::Safari => "Safari",
128        BrowserFamily::Edge => "Edge",
129        BrowserFamily::Opera => "Opera",
130        BrowserFamily::InternetExplorer => "Internet Explorer",
131        BrowserFamily::Bot => "Bot",
132        BrowserFamily::Unknown => "Unknown",
133    }
134}
135
136/// Returns a display name for the OS family.
137pub fn os_name(family: &OsFamily) -> &'static str {
138    match family {
139        OsFamily::Windows => "Windows",
140        OsFamily::MacOs => "macOS",
141        OsFamily::Linux => "Linux",
142        OsFamily::Android => "Android",
143        OsFamily::Ios => "iOS",
144        OsFamily::Unknown => "Unknown",
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_detect_firefox() {
154        let ua = parse_user_agent(
155            "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/109.0",
156        );
157        assert_eq!(ua.browser, BrowserFamily::Firefox);
158    }
159
160    #[test]
161    fn test_detect_chrome() {
162        let ua = parse_user_agent("Mozilla/5.0 Chrome/112.0.0.0 Safari/537.36");
163        assert_eq!(ua.browser, BrowserFamily::Chrome);
164    }
165
166    #[test]
167    fn test_detect_edge() {
168        let ua = parse_user_agent("Mozilla/5.0 Edg/112.0.1722.58");
169        assert_eq!(ua.browser, BrowserFamily::Edge);
170    }
171
172    #[test]
173    fn test_detect_windows() {
174        let ua = parse_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
175        assert_eq!(ua.os, OsFamily::Windows);
176    }
177
178    #[test]
179    fn test_detect_linux() {
180        let ua = parse_user_agent("Mozilla/5.0 (X11; Linux x86_64)");
181        assert_eq!(ua.os, OsFamily::Linux);
182    }
183
184    #[test]
185    fn test_detect_android_mobile() {
186        let ua = parse_user_agent("Mozilla/5.0 (Linux; Android 13) Mobile");
187        assert_eq!(ua.os, OsFamily::Android);
188        assert!(ua.is_mobile);
189    }
190
191    #[test]
192    fn test_detect_bot() {
193        let ua = parse_user_agent("Googlebot/2.1 (+http://www.google.com/bot.html)");
194        assert!(ua.is_bot);
195        assert_eq!(ua.browser, BrowserFamily::Bot);
196    }
197
198    #[test]
199    fn test_extract_firefox_version() {
200        let ua = parse_user_agent("Mozilla/5.0 Firefox/109.0");
201        assert_eq!(ua.version.as_deref(), Some("109.0"));
202    }
203
204    #[test]
205    fn test_browser_name_display() {
206        assert_eq!(browser_name(&BrowserFamily::Safari), "Safari");
207        assert_eq!(os_name(&OsFamily::Ios), "iOS");
208    }
209
210    #[test]
211    fn test_unknown_user_agent() {
212        let ua = parse_user_agent("CustomClient/1.0");
213        assert_eq!(ua.browser, BrowserFamily::Unknown);
214        assert_eq!(ua.os, OsFamily::Unknown);
215        assert!(!ua.is_mobile);
216        assert!(!ua.is_bot);
217    }
218}