1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn non_empty(value: impl AsRef<str>, field: &'static str) -> Result<String, ReferrerValueError> {
8 let trimmed = value.as_ref().trim();
9 if trimmed.is_empty() {
10 Err(ReferrerValueError::Empty { field })
11 } else {
12 Ok(trimmed.to_string())
13 }
14}
15
16fn host_from_url(value: &str) -> Option<String> {
17 let after_scheme = value.split_once("://")?.1;
18 let authority = after_scheme
19 .split(['/', '?', '#'])
20 .next()
21 .filter(|part| !part.is_empty())?;
22 let host_port = authority
23 .rsplit_once('@')
24 .map_or(authority, |(_, host)| host);
25 let host = host_port
26 .strip_prefix('[')
27 .and_then(|tail| tail.split_once(']').map(|(host, _)| host))
28 .unwrap_or_else(|| {
29 host_port
30 .split_once(':')
31 .map_or(host_port, |(host, _)| host)
32 });
33 (!host.is_empty()).then(|| host.to_ascii_lowercase())
34}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum ReferrerValueError {
39 Empty { field: &'static str },
41 InvalidUrl,
43}
44
45impl fmt::Display for ReferrerValueError {
46 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
49 Self::InvalidUrl => formatter.write_str("referrer URL must include a host"),
50 }
51 }
52}
53
54impl Error for ReferrerValueError {}
55
56#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub struct ReferrerUrl(String);
59
60impl ReferrerUrl {
61 pub fn new(value: impl AsRef<str>) -> Result<Self, ReferrerValueError> {
67 let value = non_empty(value, "referrer URL")?;
68 if host_from_url(&value).is_some() {
69 Ok(Self(value))
70 } else {
71 Err(ReferrerValueError::InvalidUrl)
72 }
73 }
74
75 #[must_use]
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80
81 #[must_use]
83 pub fn host(&self) -> ReferrerHost {
84 ReferrerHost(host_from_url(&self.0).unwrap_or_default())
85 }
86}
87
88impl AsRef<str> for ReferrerUrl {
89 fn as_ref(&self) -> &str {
90 self.as_str()
91 }
92}
93
94impl FromStr for ReferrerUrl {
95 type Err = ReferrerValueError;
96
97 fn from_str(value: &str) -> Result<Self, Self::Err> {
98 Self::new(value)
99 }
100}
101
102#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct ReferrerHost(String);
105
106impl ReferrerHost {
107 pub fn new(value: impl AsRef<str>) -> Result<Self, ReferrerValueError> {
113 non_empty(value, "referrer host").map(|value| Self(value.to_ascii_lowercase()))
114 }
115
116 #[must_use]
118 pub fn as_str(&self) -> &str {
119 &self.0
120 }
121}
122
123impl AsRef<str> for ReferrerHost {
124 fn as_ref(&self) -> &str {
125 self.as_str()
126 }
127}
128
129#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
131pub enum ReferrerKind {
132 Direct,
134 Organic,
136 Paid,
138 Social,
140 Email,
142 Referral,
144 Unknown,
146}
147
148#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
150pub enum SourceKind {
151 Direct,
153 Search,
155 Social,
157 Email,
159 Paid,
161 Referral,
163 Other,
165}
166
167#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub struct DirectTraffic;
170
171#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct OrganicTraffic {
174 source: String,
175}
176
177impl OrganicTraffic {
178 pub fn new(source: impl AsRef<str>) -> Result<Self, ReferrerValueError> {
184 Ok(Self {
185 source: non_empty(source, "organic source")?,
186 })
187 }
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
192pub struct PaidTraffic {
193 source: String,
194 medium: String,
195}
196
197impl PaidTraffic {
198 pub fn new(
204 source: impl AsRef<str>,
205 medium: impl AsRef<str>,
206 ) -> Result<Self, ReferrerValueError> {
207 Ok(Self {
208 source: non_empty(source, "paid source")?,
209 medium: non_empty(medium, "paid medium")?,
210 })
211 }
212}
213
214#[derive(Clone, Debug, Eq, PartialEq)]
216pub struct SocialTraffic {
217 network: String,
218}
219
220impl SocialTraffic {
221 pub fn new(network: impl AsRef<str>) -> Result<Self, ReferrerValueError> {
227 Ok(Self {
228 network: non_empty(network, "social network")?,
229 })
230 }
231}
232
233#[derive(Clone, Debug, Eq, PartialEq)]
235pub struct EmailTraffic {
236 source: String,
237}
238
239impl EmailTraffic {
240 pub fn new(source: impl AsRef<str>) -> Result<Self, ReferrerValueError> {
246 Ok(Self {
247 source: non_empty(source, "email source")?,
248 })
249 }
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct ReferralTraffic {
255 host: ReferrerHost,
256}
257
258impl ReferralTraffic {
259 #[must_use]
261 pub const fn new(host: ReferrerHost) -> Self {
262 Self { host }
263 }
264
265 #[must_use]
267 pub const fn host(&self) -> &ReferrerHost {
268 &self.host
269 }
270}
271
272#[must_use]
274pub fn classify_source_medium(source: Option<&str>, medium: Option<&str>) -> ReferrerKind {
275 let source = source.unwrap_or_default().trim().to_ascii_lowercase();
276 let medium = medium.unwrap_or_default().trim().to_ascii_lowercase();
277
278 if source.is_empty() && medium.is_empty() || source == "direct" || medium == "none" {
279 ReferrerKind::Direct
280 } else if matches!(medium.as_str(), "organic" | "seo") {
281 ReferrerKind::Organic
282 } else if matches!(
283 medium.as_str(),
284 "cpc" | "ppc" | "paid" | "paid-search" | "display" | "cpm"
285 ) {
286 ReferrerKind::Paid
287 } else if matches!(
288 medium.as_str(),
289 "social" | "social-media" | "social-network"
290 ) {
291 ReferrerKind::Social
292 } else if matches!(medium.as_str(), "email" | "newsletter") {
293 ReferrerKind::Email
294 } else if matches!(medium.as_str(), "referral" | "referrer") {
295 ReferrerKind::Referral
296 } else {
297 ReferrerKind::Unknown
298 }
299}
300
301#[must_use]
303pub fn classify_source_kind(source: &str) -> SourceKind {
304 let source = source.trim().to_ascii_lowercase();
305 if source.is_empty() || source == "direct" {
306 SourceKind::Direct
307 } else if is_search_host(&source) || source.contains("search") {
308 SourceKind::Search
309 } else if is_social_host(&source) {
310 SourceKind::Social
311 } else if source.contains("mail") || source.contains("newsletter") {
312 SourceKind::Email
313 } else {
314 SourceKind::Other
315 }
316}
317
318#[must_use]
320pub fn classify_referrer_host(host: &str) -> ReferrerKind {
321 let host = host.trim().to_ascii_lowercase();
322 if host.is_empty() {
323 ReferrerKind::Direct
324 } else if is_search_host(&host) {
325 ReferrerKind::Organic
326 } else if is_social_host(&host) {
327 ReferrerKind::Social
328 } else if host.contains("mail") {
329 ReferrerKind::Email
330 } else {
331 ReferrerKind::Referral
332 }
333}
334
335fn is_search_host(host: &str) -> bool {
336 ["google.", "bing.", "duckduckgo.", "yahoo.", "baidu."]
337 .iter()
338 .any(|needle| host.contains(needle))
339}
340
341fn is_social_host(host: &str) -> bool {
342 [
343 "facebook.",
344 "instagram.",
345 "linkedin.",
346 "twitter.",
347 "x.com",
348 "t.co",
349 "pinterest.",
350 "reddit.",
351 ]
352 .iter()
353 .any(|needle| host.contains(needle))
354}
355
356#[cfg(test)]
357mod tests {
358 use super::{
359 ReferrerHost, ReferrerKind, ReferrerUrl, classify_referrer_host, classify_source_kind,
360 classify_source_medium,
361 };
362
363 #[test]
364 fn extracts_referrer_hosts() {
365 let url = ReferrerUrl::new("https://www.google.com/search?q=rustuse").unwrap();
366
367 assert_eq!(url.host().as_str(), "www.google.com");
368 assert!(ReferrerUrl::new("not-a-url").is_err());
369 }
370
371 #[test]
372 fn classifies_source_and_medium_labels() {
373 assert_eq!(classify_source_medium(None, None), ReferrerKind::Direct);
374 assert_eq!(
375 classify_source_medium(Some("newsletter"), Some("email")),
376 ReferrerKind::Email
377 );
378 assert_eq!(
379 classify_source_medium(Some("google"), Some("cpc")),
380 ReferrerKind::Paid
381 );
382 }
383
384 #[test]
385 fn classifies_hosts_and_sources() {
386 assert_eq!(
387 classify_referrer_host("www.google.com"),
388 ReferrerKind::Organic
389 );
390 assert_eq!(classify_referrer_host("facebook.com"), ReferrerKind::Social);
391 assert_eq!(classify_source_kind("direct"), super::SourceKind::Direct);
392 assert_eq!(
393 ReferrerHost::new("Example.com").unwrap().as_str(),
394 "example.com"
395 );
396 }
397}