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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum UtmValueError {
32 Empty { field: &'static str },
34 Invalid { field: &'static str },
36 MissingField(&'static str),
38 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 pub fn new(value: impl AsRef<str>) -> Result<Self, UtmValueError> {
69 validate_component(value, $field).map(Self)
70 }
71
72 #[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#[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 #[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 #[must_use]
132 pub fn with_term(mut self, term: UtmTerm) -> Self {
133 self.term = Some(term);
134 self
135 }
136
137 #[must_use]
139 pub fn with_content(mut self, content: UtmContent) -> Self {
140 self.content = Some(content);
141 self
142 }
143
144 #[must_use]
146 pub const fn source(&self) -> &UtmSource {
147 &self.source
148 }
149
150 #[must_use]
152 pub const fn medium(&self) -> &UtmMedium {
153 &self.medium
154 }
155
156 #[must_use]
158 pub const fn campaign(&self) -> &UtmCampaign {
159 &self.campaign
160 }
161
162 #[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
180pub 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#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct UtmUrl {
230 base_url: String,
231 parameters: UtmParameters,
232}
233
234impl UtmUrl {
235 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 #[must_use]
256 pub fn base_url(&self) -> &str {
257 &self.base_url
258 }
259
260 #[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(¶meters.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}