Skip to main content

use_campaign/
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_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/// Error returned by campaign primitive constructors.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum CampaignValueError {
22    /// The supplied value was empty after trimming whitespace.
23    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            /// Creates a campaign label.
43            ///
44            /// # Errors
45            ///
46            /// Returns [`CampaignValueError::Empty`] when the label is empty.
47            pub fn new(value: impl AsRef<str>) -> Result<Self, CampaignValueError> {
48                validate_label(value, $field).map(Self)
49            }
50
51            /// Returns the label text.
52            #[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/// Generic campaign channel label.
86#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
87pub enum CampaignChannel {
88    /// Organic search channel.
89    OrganicSearch,
90    /// Paid search channel.
91    PaidSearch,
92    /// Social channel.
93    Social,
94    /// Email channel.
95    Email,
96    /// Referral channel.
97    Referral,
98    /// Direct channel.
99    Direct,
100    /// Display channel.
101    Display,
102    /// Other custom channel label.
103    Other(String),
104}
105
106impl CampaignChannel {
107    /// Creates a custom campaign channel.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`CampaignValueError::Empty`] when the label is empty.
112    pub fn other(value: impl AsRef<str>) -> Result<Self, CampaignValueError> {
113        validate_label(value, "campaign channel").map(Self::Other)
114    }
115
116    /// Returns the channel label.
117    #[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/// Campaign status label.
133#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
134pub enum CampaignStatus {
135    /// Draft campaign.
136    Draft,
137    /// Scheduled campaign.
138    Scheduled,
139    /// Active campaign.
140    Active,
141    /// Paused campaign.
142    Paused,
143    /// Ended campaign.
144    Ended,
145    /// Archived campaign.
146    Archived,
147}
148
149impl CampaignStatus {
150    /// Returns the status label.
151    #[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/// Campaign flight labels.
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct CampaignFlight {
167    start_label: String,
168    end_label: Option<String>,
169}
170
171impl CampaignFlight {
172    /// Creates a campaign flight with a start label.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`CampaignValueError::Empty`] when the start label is empty.
177    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    /// Sets an end label.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`CampaignValueError::Empty`] when the end label is empty.
189    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    /// Returns the start label.
198    #[must_use]
199    pub fn start_label(&self) -> &str {
200        &self.start_label
201    }
202
203    /// Returns the optional end label.
204    #[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}