Skip to main content

simple_useragent/
lib.rs

1/*
2 * Copyright (c) 2024-2025 Bastiaan van der Plaat
3 *
4 * SPDX-License-Identifier: MIT
5 */
6
7#![doc = include_str!("../README.md")]
8
9use regex::Regex;
10
11// MARK: Rules
12mod rules_data {
13    include!(concat!(env!("OUT_DIR"), "/rules_data.rs"));
14}
15
16struct Rules {
17    user_agent: Vec<UserAgentRule>,
18    os: Vec<OsRule>,
19}
20
21struct UserAgentRule {
22    regex: Regex,
23    family_replacement: Option<&'static str>,
24    v1_replacement: Option<&'static str>,
25    v2_replacement: Option<&'static str>,
26    v3_replacement: Option<&'static str>,
27}
28
29struct OsRule {
30    regex: Regex,
31    os_replacement: Option<&'static str>,
32    os_v1_replacement: Option<&'static str>,
33    os_v2_replacement: Option<&'static str>,
34    os_v3_replacement: Option<&'static str>,
35}
36
37impl Rules {
38    fn parse() -> Self {
39        Self {
40            user_agent: rules_data::USER_AGENT_RULES
41                .iter()
42                .map(|rule| UserAgentRule {
43                    regex: Regex::new(rule.regex).expect("Invalid regex"),
44                    family_replacement: rule.family_replacement,
45                    v1_replacement: rule.v1_replacement,
46                    v2_replacement: rule.v2_replacement,
47                    v3_replacement: rule.v3_replacement,
48                })
49                .collect(),
50            os: rules_data::OS_RULES
51                .iter()
52                .map(|rule| OsRule {
53                    regex: Regex::new(rule.regex).expect("Invalid regex"),
54                    os_replacement: rule.os_replacement,
55                    os_v1_replacement: rule.os_v1_replacement,
56                    os_v2_replacement: rule.os_v2_replacement,
57                    os_v3_replacement: rule.os_v3_replacement,
58                })
59                .collect(),
60        }
61    }
62}
63
64// MARK: UserAgent
65/// User agent
66#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
67pub struct UserAgent {
68    /// Client
69    pub client: Client,
70    /// Operating System
71    pub os: OS,
72}
73
74/// Client
75#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
76pub struct Client {
77    /// Family
78    pub family: String,
79    /// Version
80    pub version: Option<String>,
81}
82
83/// Operating System
84#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
85pub struct OS {
86    /// Family
87    pub family: String,
88    /// Version
89    pub version: Option<String>,
90}
91
92// MARK: UserAgentParser
93/// User agent parser
94pub struct UserAgentParser {
95    rules: Rules,
96}
97
98impl Default for UserAgentParser {
99    fn default() -> Self {
100        Self {
101            rules: Rules::parse(),
102        }
103    }
104}
105
106impl UserAgentParser {
107    /// Create new user agent parser
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Parse user agent
113    pub fn parse(&self, user_agent: &str) -> UserAgent {
114        UserAgent {
115            client: self.parse_client(user_agent),
116            os: self.parse_os(user_agent),
117        }
118    }
119
120    // https://github.com/ua-parser/uap-core/blob/master/docs/specification.md#user_agent_parsers
121    fn parse_client(&self, user_agent: &str) -> Client {
122        for rule in &self.rules.user_agent {
123            if let Some(captures) = rule.regex.captures(user_agent) {
124                let family = rule
125                    .family_replacement
126                    .map(|s| Self::map_replacement(s, &captures))
127                    .unwrap_or_else(|| captures[1].to_string());
128                let major = rule
129                    .v1_replacement
130                    .map(|s| Self::map_replacement(s, &captures))
131                    .or_else(|| captures.get(2).map(|m| m.as_str().to_string()));
132                let minor = rule
133                    .v2_replacement
134                    .map(|s| Self::map_replacement(s, &captures))
135                    .or_else(|| captures.get(3).map(|m| m.as_str().to_string()));
136                let patch = rule
137                    .v3_replacement
138                    .map(|s| Self::map_replacement(s, &captures))
139                    .or_else(|| captures.get(4).map(|m| m.as_str().to_string()));
140                return Client {
141                    family,
142                    version: Self::concat_version(major, minor, patch),
143                };
144            }
145        }
146        Client {
147            family: "Other".to_string(),
148            version: None,
149        }
150    }
151
152    // https://github.com/ua-parser/uap-core/blob/master/docs/specification.md#user_agent_parsers
153    fn parse_os(&self, user_agent: &str) -> OS {
154        for rule in &self.rules.os {
155            if let Some(captures) = rule.regex.captures(user_agent) {
156                let family = rule
157                    .os_replacement
158                    .map(|s| Self::map_replacement(s, &captures))
159                    .unwrap_or_else(|| captures[1].to_string());
160                let major = rule
161                    .os_v1_replacement
162                    .map(|s| Self::map_replacement(s, &captures))
163                    .or_else(|| captures.get(2).map(|m| m.as_str().to_string()));
164                let minor = rule
165                    .os_v2_replacement
166                    .map(|s| Self::map_replacement(s, &captures))
167                    .or_else(|| captures.get(3).map(|m| m.as_str().to_string()));
168                let patch = rule
169                    .os_v3_replacement
170                    .map(|s| Self::map_replacement(s, &captures))
171                    .or_else(|| captures.get(4).map(|m| m.as_str().to_string()));
172                return OS {
173                    family,
174                    version: Self::concat_version(major, minor, patch),
175                };
176            }
177        }
178        OS {
179            family: "Other".to_string(),
180            version: None,
181        }
182    }
183
184    fn map_replacement(replacement: &str, captures: &regex::Captures) -> String {
185        let mut result = replacement.to_string();
186        if result.contains("$1") {
187            result = result.replace("$1", &captures[1]);
188        }
189        if result.contains("$2") {
190            result = result.replace("$2", &captures[2]);
191        }
192        if result.contains("$3") {
193            result = result.replace("$3", &captures[3]);
194        }
195        result
196    }
197
198    fn concat_version(
199        major: Option<String>,
200        minor: Option<String>,
201        patch: Option<String>,
202    ) -> Option<String> {
203        let mut version = String::new();
204        if let Some(major) = major {
205            version.push_str(&major);
206        }
207        if let Some(minor) = minor {
208            version.push('.');
209            version.push_str(&minor);
210        }
211        if let Some(patch) = patch {
212            version.push('.');
213            version.push_str(&patch);
214        }
215        if version.is_empty() {
216            None
217        } else {
218            Some(version)
219        }
220    }
221}
222
223// MARK: Tests
224#[cfg(test)]
225mod test {
226    use super::*;
227
228    #[test]
229    fn test_parser() {
230        let parser = UserAgentParser::new();
231
232        let ua = parser.parse(
233            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
234        );
235        assert_eq!(ua.client.family, "Firefox");
236        assert_eq!(ua.client.version.as_deref(), Some("133.0"));
237        assert_eq!(ua.os.family, "Mac OS X");
238        assert_eq!(ua.os.version.as_deref(), Some("10.15"));
239
240        let ua = parser.parse(
241                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
242            );
243        assert_eq!(ua.client.family, "Chrome");
244        assert_eq!(ua.client.version.as_deref(), Some("91.0.4472"));
245        assert_eq!(ua.os.family, "Windows");
246        assert_eq!(ua.os.version.as_deref(), Some("10"));
247
248        let ua = parser.parse(
249                "Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.1 Mobile/15E148 Safari/604.1",
250            );
251        assert_eq!(ua.client.family, "Mobile Safari");
252        assert_eq!(ua.client.version.as_deref(), Some("14.0.1"));
253        assert_eq!(ua.os.family, "iOS");
254        assert_eq!(ua.os.version.as_deref(), Some("14.6"));
255
256        let ua = parser.parse(
257                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59",
258            );
259        assert_eq!(ua.client.family, "Edge");
260        assert_eq!(ua.client.version.as_deref(), Some("91.0.864"));
261        assert_eq!(ua.os.family, "Windows");
262        assert_eq!(ua.os.version.as_deref(), Some("10"));
263
264        let ua = parser.parse("Telegram-Android/11.4.2");
265        assert_eq!(ua.client.family, "Telegram");
266        assert_eq!(ua.client.version.as_deref(), Some("11.4.2"));
267
268        let ua = parser.parse("UnknownUserAgent/1.0");
269        assert_eq!(ua.client.family, "Other");
270        assert_eq!(ua.client.version, None);
271        assert_eq!(ua.os.family, "Other");
272        assert_eq!(ua.os.version, None);
273    }
274}