Skip to main content

use_utm/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn validate_component(
8    value: impl AsRef<str>,
9    field: &'static str,
10) -> Result<String, UtmValueError> {
11    let trimmed = value.as_ref().trim();
12    if trimmed.is_empty() {
13        return Err(UtmValueError::Empty { field });
14    }
15    if trimmed
16        .chars()
17        .any(|character| character.is_control() || matches!(character, '&' | '=' | '?' | '#'))
18    {
19        return Err(UtmValueError::Invalid { field });
20    }
21    Ok(trimmed.to_string())
22}
23
24fn is_http_url(value: &str) -> bool {
25    let lower = value.to_ascii_lowercase();
26    lower.starts_with("https://") || lower.starts_with("http://")
27}
28
29/// Error returned by UTM primitive constructors and parsers.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum UtmValueError {
32    /// The supplied value was empty after trimming whitespace.
33    Empty { field: &'static str },
34    /// The supplied value contained unsupported characters.
35    Invalid { field: &'static str },
36    /// A required UTM field was missing.
37    MissingField(&'static str),
38    /// The base URL was empty or unsupported.
39    InvalidUrl,
40}
41
42impl fmt::Display for UtmValueError {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
46            Self::Invalid { field } => {
47                write!(formatter, "{field} contains unsupported query characters")
48            },
49            Self::MissingField(field) => write!(formatter, "missing required {field}"),
50            Self::InvalidUrl => formatter.write_str("UTM URL must start with http:// or https://"),
51        }
52    }
53}
54
55impl Error for UtmValueError {}
56
57macro_rules! utm_component {
58    ($name:ident, $field:literal) => {
59        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
60        pub struct $name(String);
61
62        impl $name {
63            /// Creates a UTM component value.
64            ///
65            /// # Errors
66            ///
67            /// Returns [`UtmValueError`] when the value is empty or contains query separators.
68            pub fn new(value: impl AsRef<str>) -> Result<Self, UtmValueError> {
69                validate_component(value, $field).map(Self)
70            }
71
72            /// Returns the component text.
73            #[must_use]
74            pub fn as_str(&self) -> &str {
75                &self.0
76            }
77        }
78
79        impl AsRef<str> for $name {
80            fn as_ref(&self) -> &str {
81                self.as_str()
82            }
83        }
84
85        impl fmt::Display for $name {
86            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87                formatter.write_str(self.as_str())
88            }
89        }
90
91        impl FromStr for $name {
92            type Err = UtmValueError;
93
94            fn from_str(value: &str) -> Result<Self, Self::Err> {
95                Self::new(value)
96            }
97        }
98    };
99}
100
101utm_component!(UtmSource, "utm_source");
102utm_component!(UtmMedium, "utm_medium");
103utm_component!(UtmCampaign, "utm_campaign");
104utm_component!(UtmTerm, "utm_term");
105utm_component!(UtmContent, "utm_content");
106
107/// UTM parameter set.
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct UtmParameters {
110    source: UtmSource,
111    medium: UtmMedium,
112    campaign: UtmCampaign,
113    term: Option<UtmTerm>,
114    content: Option<UtmContent>,
115}
116
117impl UtmParameters {
118    /// Creates required UTM parameters.
119    #[must_use]
120    pub const fn new(source: UtmSource, medium: UtmMedium, campaign: UtmCampaign) -> Self {
121        Self {
122            source,
123            medium,
124            campaign,
125            term: None,
126            content: None,
127        }
128    }
129
130    /// Sets the optional term parameter.
131    #[must_use]
132    pub fn with_term(mut self, term: UtmTerm) -> Self {
133        self.term = Some(term);
134        self
135    }
136
137    /// Sets the optional content parameter.
138    #[must_use]
139    pub fn with_content(mut self, content: UtmContent) -> Self {
140        self.content = Some(content);
141        self
142    }
143
144    /// Returns the source.
145    #[must_use]
146    pub const fn source(&self) -> &UtmSource {
147        &self.source
148    }
149
150    /// Returns the medium.
151    #[must_use]
152    pub const fn medium(&self) -> &UtmMedium {
153        &self.medium
154    }
155
156    /// Returns the campaign.
157    #[must_use]
158    pub const fn campaign(&self) -> &UtmCampaign {
159        &self.campaign
160    }
161
162    /// Formats the parameters as a query string.
163    #[must_use]
164    pub fn to_query_string(&self) -> String {
165        let mut parts = vec![
166            format!("utm_source={}", self.source),
167            format!("utm_medium={}", self.medium),
168            format!("utm_campaign={}", self.campaign),
169        ];
170        if let Some(term) = &self.term {
171            parts.push(format!("utm_term={term}"));
172        }
173        if let Some(content) = &self.content {
174            parts.push(format!("utm_content={content}"));
175        }
176        parts.join("&")
177    }
178}
179
180/// Parses UTM parameters from a query string or URL.
181///
182/// # Errors
183///
184/// Returns [`UtmValueError`] when required fields are missing or values are invalid.
185pub fn parse_utm_parameters(input: &str) -> Result<UtmParameters, UtmValueError> {
186    let before_fragment = input.split_once('#').map_or(input, |(before, _)| before);
187    let query = before_fragment
188        .split_once('?')
189        .map_or(before_fragment, |(_, query)| query)
190        .strip_prefix('?')
191        .unwrap_or(before_fragment);
192
193    let mut source = None;
194    let mut medium = None;
195    let mut campaign = None;
196    let mut term = None;
197    let mut content = None;
198
199    for segment in query.split('&').filter(|segment| !segment.is_empty()) {
200        let Some((key, value)) = segment.split_once('=') else {
201            continue;
202        };
203        match key {
204            "utm_source" => source = Some(UtmSource::new(value)?),
205            "utm_medium" => medium = Some(UtmMedium::new(value)?),
206            "utm_campaign" => campaign = Some(UtmCampaign::new(value)?),
207            "utm_term" => term = Some(UtmTerm::new(value)?),
208            "utm_content" => content = Some(UtmContent::new(value)?),
209            _ => {},
210        }
211    }
212
213    let params = UtmParameters::new(
214        source.ok_or(UtmValueError::MissingField("utm_source"))?,
215        medium.ok_or(UtmValueError::MissingField("utm_medium"))?,
216        campaign.ok_or(UtmValueError::MissingField("utm_campaign"))?,
217    );
218
219    Ok(match (term, content) {
220        (Some(term), Some(content)) => params.with_term(term).with_content(content),
221        (Some(term), None) => params.with_term(term),
222        (None, Some(content)) => params.with_content(content),
223        (None, None) => params,
224    })
225}
226
227/// A URL with UTM parameters attached.
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct UtmUrl {
230    base_url: String,
231    parameters: UtmParameters,
232}
233
234impl UtmUrl {
235    /// Creates a UTM URL from a base URL and parameters.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`UtmValueError::InvalidUrl`] when the base URL is empty or unsupported.
240    pub fn new(
241        base_url: impl AsRef<str>,
242        parameters: UtmParameters,
243    ) -> Result<Self, UtmValueError> {
244        let trimmed = base_url.as_ref().trim();
245        if trimmed.is_empty() || !is_http_url(trimmed) {
246            return Err(UtmValueError::InvalidUrl);
247        }
248        Ok(Self {
249            base_url: trimmed.to_string(),
250            parameters,
251        })
252    }
253
254    /// Returns the base URL.
255    #[must_use]
256    pub fn base_url(&self) -> &str {
257        &self.base_url
258    }
259
260    /// Returns the UTM parameters.
261    #[must_use]
262    pub const fn parameters(&self) -> &UtmParameters {
263        &self.parameters
264    }
265}
266
267impl fmt::Display for UtmUrl {
268    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
269        let separator = if self.base_url.contains('?') {
270            '&'
271        } else {
272            '?'
273        };
274        write!(
275            formatter,
276            "{}{}{}",
277            self.base_url,
278            separator,
279            self.parameters.to_query_string()
280        )
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::{
287        UtmCampaign, UtmContent, UtmMedium, UtmParameters, UtmSource, UtmTerm, UtmUrl,
288        parse_utm_parameters,
289    };
290
291    fn params() -> UtmParameters {
292        UtmParameters::new(
293            UtmSource::new("newsletter").unwrap(),
294            UtmMedium::new("email").unwrap(),
295            UtmCampaign::new("spring").unwrap(),
296        )
297    }
298
299    #[test]
300    fn validates_utm_components() {
301        assert!(UtmSource::new("newsletter").is_ok());
302        assert!(UtmMedium::new("bad&value").is_err());
303    }
304
305    #[test]
306    fn formats_and_parses_query_parameters() {
307        let parameters = params()
308            .with_term(UtmTerm::new("running shoes").unwrap())
309            .with_content(UtmContent::new("hero-link").unwrap());
310        let parsed = parse_utm_parameters(&parameters.to_query_string()).unwrap();
311
312        assert_eq!(parsed.source().as_str(), "newsletter");
313        assert_eq!(
314            parameters.to_query_string(),
315            "utm_source=newsletter&utm_medium=email&utm_campaign=spring&utm_term=running shoes&utm_content=hero-link"
316        );
317    }
318
319    #[test]
320    fn formats_utm_urls() {
321        let url = UtmUrl::new("https://example.com/pricing", params()).unwrap();
322
323        assert_eq!(
324            url.to_string(),
325            "https://example.com/pricing?utm_source=newsletter&utm_medium=email&utm_campaign=spring"
326        );
327    }
328}