Skip to main content

use_referrer/
lib.rs

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/// Error returned by referrer primitive constructors.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum ReferrerValueError {
39    /// The supplied value was empty after trimming whitespace.
40    Empty { field: &'static str },
41    /// The URL did not contain a recognizable host.
42    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/// Referrer URL label.
57#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub struct ReferrerUrl(String);
59
60impl ReferrerUrl {
61    /// Creates a referrer URL label.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`ReferrerValueError`] when the URL is empty or lacks a host.
66    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    /// Returns the URL string.
76    #[must_use]
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80
81    /// Extracts the host from the URL.
82    #[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/// Referrer host label.
103#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct ReferrerHost(String);
105
106impl ReferrerHost {
107    /// Creates a referrer host label.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`ReferrerValueError::Empty`] when the host is empty.
112    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    /// Returns the host label.
117    #[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/// Referrer classification label.
130#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
131pub enum ReferrerKind {
132    /// Direct traffic.
133    Direct,
134    /// Organic search traffic.
135    Organic,
136    /// Paid traffic.
137    Paid,
138    /// Social traffic.
139    Social,
140    /// Email traffic.
141    Email,
142    /// Referral traffic.
143    Referral,
144    /// Unknown traffic.
145    Unknown,
146}
147
148/// Source classification label.
149#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
150pub enum SourceKind {
151    /// Direct source.
152    Direct,
153    /// Search source.
154    Search,
155    /// Social source.
156    Social,
157    /// Email source.
158    Email,
159    /// Paid source.
160    Paid,
161    /// Referral source.
162    Referral,
163    /// Other source.
164    Other,
165}
166
167/// Direct traffic marker.
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub struct DirectTraffic;
170
171/// Organic traffic label.
172#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct OrganicTraffic {
174    source: String,
175}
176
177impl OrganicTraffic {
178    /// Creates an organic traffic label.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`ReferrerValueError::Empty`] when the source is empty.
183    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/// Paid traffic label.
191#[derive(Clone, Debug, Eq, PartialEq)]
192pub struct PaidTraffic {
193    source: String,
194    medium: String,
195}
196
197impl PaidTraffic {
198    /// Creates a paid traffic label.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`ReferrerValueError::Empty`] when the source or medium is empty.
203    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/// Social traffic label.
215#[derive(Clone, Debug, Eq, PartialEq)]
216pub struct SocialTraffic {
217    network: String,
218}
219
220impl SocialTraffic {
221    /// Creates a social traffic label.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ReferrerValueError::Empty`] when the network is empty.
226    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/// Email traffic label.
234#[derive(Clone, Debug, Eq, PartialEq)]
235pub struct EmailTraffic {
236    source: String,
237}
238
239impl EmailTraffic {
240    /// Creates an email traffic label.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`ReferrerValueError::Empty`] when the source is empty.
245    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/// Referral traffic label.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct ReferralTraffic {
255    host: ReferrerHost,
256}
257
258impl ReferralTraffic {
259    /// Creates a referral traffic label.
260    #[must_use]
261    pub const fn new(host: ReferrerHost) -> Self {
262        Self { host }
263    }
264
265    /// Returns the referral host.
266    #[must_use]
267    pub const fn host(&self) -> &ReferrerHost {
268        &self.host
269    }
270}
271
272/// Classifies source and medium labels into a referrer kind.
273#[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/// Classifies a source label.
302#[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/// Classifies a referrer host.
319#[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}