1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn validate_label(
8 value: impl AsRef<str>,
9 field: &'static str,
10) -> Result<String, CampaignValueError> {
11 let trimmed = value.as_ref().trim();
12 if trimmed.is_empty() {
13 Err(CampaignValueError::Empty { field })
14 } else {
15 Ok(trimmed.to_string())
16 }
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum CampaignValueError {
22 Empty { field: &'static str },
24}
25
26impl fmt::Display for CampaignValueError {
27 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
30 }
31 }
32}
33
34impl Error for CampaignValueError {}
35
36macro_rules! campaign_label {
37 ($name:ident, $field:literal) => {
38 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
39 pub struct $name(String);
40
41 impl $name {
42 pub fn new(value: impl AsRef<str>) -> Result<Self, CampaignValueError> {
48 validate_label(value, $field).map(Self)
49 }
50
51 #[must_use]
53 pub fn as_str(&self) -> &str {
54 &self.0
55 }
56 }
57
58 impl AsRef<str> for $name {
59 fn as_ref(&self) -> &str {
60 self.as_str()
61 }
62 }
63
64 impl fmt::Display for $name {
65 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66 formatter.write_str(self.as_str())
67 }
68 }
69
70 impl FromStr for $name {
71 type Err = CampaignValueError;
72
73 fn from_str(value: &str) -> Result<Self, Self::Err> {
74 Self::new(value)
75 }
76 }
77 };
78}
79
80campaign_label!(CampaignId, "campaign ID");
81campaign_label!(CampaignName, "campaign name");
82campaign_label!(CampaignMedium, "campaign medium");
83campaign_label!(CampaignLabel, "campaign label");
84
85#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
87pub enum CampaignChannel {
88 OrganicSearch,
90 PaidSearch,
92 Social,
94 Email,
96 Referral,
98 Direct,
100 Display,
102 Other(String),
104}
105
106impl CampaignChannel {
107 pub fn other(value: impl AsRef<str>) -> Result<Self, CampaignValueError> {
113 validate_label(value, "campaign channel").map(Self::Other)
114 }
115
116 #[must_use]
118 pub fn as_str(&self) -> &str {
119 match self {
120 Self::OrganicSearch => "organic-search",
121 Self::PaidSearch => "paid-search",
122 Self::Social => "social",
123 Self::Email => "email",
124 Self::Referral => "referral",
125 Self::Direct => "direct",
126 Self::Display => "display",
127 Self::Other(value) => value,
128 }
129 }
130}
131
132#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
134pub enum CampaignStatus {
135 Draft,
137 Scheduled,
139 Active,
141 Paused,
143 Ended,
145 Archived,
147}
148
149impl CampaignStatus {
150 #[must_use]
152 pub const fn as_str(self) -> &'static str {
153 match self {
154 Self::Draft => "draft",
155 Self::Scheduled => "scheduled",
156 Self::Active => "active",
157 Self::Paused => "paused",
158 Self::Ended => "ended",
159 Self::Archived => "archived",
160 }
161 }
162}
163
164#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct CampaignFlight {
167 start_label: String,
168 end_label: Option<String>,
169}
170
171impl CampaignFlight {
172 pub fn new(start_label: impl AsRef<str>) -> Result<Self, CampaignValueError> {
178 Ok(Self {
179 start_label: validate_label(start_label, "campaign flight start")?,
180 end_label: None,
181 })
182 }
183
184 pub fn with_end_label(
190 mut self,
191 end_label: impl AsRef<str>,
192 ) -> Result<Self, CampaignValueError> {
193 self.end_label = Some(validate_label(end_label, "campaign flight end")?);
194 Ok(self)
195 }
196
197 #[must_use]
199 pub fn start_label(&self) -> &str {
200 &self.start_label
201 }
202
203 #[must_use]
205 pub fn end_label(&self) -> Option<&str> {
206 self.end_label.as_deref()
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::{
213 CampaignChannel, CampaignFlight, CampaignId, CampaignLabel, CampaignMedium, CampaignName,
214 CampaignStatus,
215 };
216
217 #[test]
218 fn validates_campaign_labels() {
219 assert_eq!(
220 CampaignId::new("spring-2026").unwrap().as_str(),
221 "spring-2026"
222 );
223 assert!(CampaignName::new(" ").is_err());
224 assert_eq!(CampaignMedium::new("email").unwrap().as_str(), "email");
225 assert_eq!(CampaignLabel::new("launch").unwrap().as_str(), "launch");
226 }
227
228 #[test]
229 fn exposes_channel_and_status_labels() {
230 assert_eq!(CampaignChannel::Email.as_str(), "email");
231 assert_eq!(
232 CampaignChannel::other("affiliate").unwrap().as_str(),
233 "affiliate"
234 );
235 assert_eq!(CampaignStatus::Paused.as_str(), "paused");
236 }
237
238 #[test]
239 fn builds_campaign_flights() {
240 let flight = CampaignFlight::new("2026-03-01")
241 .unwrap()
242 .with_end_label("2026-03-31")
243 .unwrap();
244
245 assert_eq!(flight.start_label(), "2026-03-01");
246 assert_eq!(flight.end_label(), Some("2026-03-31"));
247 }
248}