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, AttributionValueError> {
11 let trimmed = value.as_ref().trim();
12 if trimmed.is_empty() {
13 Err(AttributionValueError::Empty { field })
14 } else {
15 Ok(trimmed.to_string())
16 }
17}
18
19#[derive(Clone, Copy, Debug, PartialEq)]
21pub enum AttributionValueError {
22 Empty { field: &'static str },
24 InvalidWindow(u16),
26 InvalidCredit(f32),
28}
29
30impl fmt::Display for AttributionValueError {
31 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 match self {
33 Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
34 Self::InvalidWindow(days) => write!(formatter, "invalid attribution window {days}"),
35 Self::InvalidCredit(value) => write!(formatter, "invalid attribution credit {value}"),
36 }
37 }
38}
39
40impl Error for AttributionValueError {}
41
42macro_rules! attribution_label {
43 ($name:ident, $field:literal) => {
44 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45 pub struct $name(String);
46
47 impl $name {
48 pub fn new(value: impl AsRef<str>) -> Result<Self, AttributionValueError> {
54 validate_label(value, $field).map(Self)
55 }
56
57 #[must_use]
59 pub fn as_str(&self) -> &str {
60 &self.0
61 }
62 }
63
64 impl AsRef<str> for $name {
65 fn as_ref(&self) -> &str {
66 self.as_str()
67 }
68 }
69
70 impl fmt::Display for $name {
71 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72 formatter.write_str(self.as_str())
73 }
74 }
75
76 impl FromStr for $name {
77 type Err = AttributionValueError;
78
79 fn from_str(value: &str) -> Result<Self, Self::Err> {
80 Self::new(value)
81 }
82 }
83 };
84}
85
86attribution_label!(AttributionSource, "attribution source");
87attribution_label!(AttributionMedium, "attribution medium");
88attribution_label!(ConversionLabel, "conversion label");
89
90#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub struct AttributionWindow {
93 days: u16,
94}
95
96impl AttributionWindow {
97 pub const fn from_days(days: u16) -> Result<Self, AttributionValueError> {
103 if days == 0 {
104 Err(AttributionValueError::InvalidWindow(days))
105 } else {
106 Ok(Self { days })
107 }
108 }
109
110 #[must_use]
112 pub const fn days(self) -> u16 {
113 self.days
114 }
115}
116
117#[derive(Clone, Copy, Debug, PartialEq)]
119pub struct AttributionCredit {
120 value: f32,
121}
122
123impl AttributionCredit {
124 pub fn new(value: f32) -> Result<Self, AttributionValueError> {
130 if value.is_finite() && (0.0..=1.0).contains(&value) {
131 Ok(Self { value })
132 } else {
133 Err(AttributionValueError::InvalidCredit(value))
134 }
135 }
136
137 #[must_use]
139 pub const fn value(self) -> f32 {
140 self.value
141 }
142}
143
144#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
146pub enum AttributionModelKind {
147 FirstTouch,
149 LastTouch,
151 Linear,
153 TimeDecay,
155 PositionBased,
157 Custom(String),
159}
160
161impl AttributionModelKind {
162 pub fn custom(value: impl AsRef<str>) -> Result<Self, AttributionValueError> {
168 validate_label(value, "attribution model").map(Self::Custom)
169 }
170
171 #[must_use]
173 pub fn as_str(&self) -> &str {
174 match self {
175 Self::FirstTouch => "first-touch",
176 Self::LastTouch => "last-touch",
177 Self::Linear => "linear",
178 Self::TimeDecay => "time-decay",
179 Self::PositionBased => "position-based",
180 Self::Custom(value) => value,
181 }
182 }
183}
184
185#[derive(Clone, Debug, PartialEq)]
187pub struct Touchpoint {
188 source: AttributionSource,
189 medium: AttributionMedium,
190 conversion_label: Option<ConversionLabel>,
191 window: Option<AttributionWindow>,
192 credit: Option<AttributionCredit>,
193 model_kind: Option<AttributionModelKind>,
194}
195
196impl Touchpoint {
197 #[must_use]
199 pub const fn new(source: AttributionSource, medium: AttributionMedium) -> Self {
200 Self {
201 source,
202 medium,
203 conversion_label: None,
204 window: None,
205 credit: None,
206 model_kind: None,
207 }
208 }
209
210 #[must_use]
212 pub fn with_conversion_label(mut self, label: ConversionLabel) -> Self {
213 self.conversion_label = Some(label);
214 self
215 }
216
217 #[must_use]
219 pub const fn with_window(mut self, window: AttributionWindow) -> Self {
220 self.window = Some(window);
221 self
222 }
223
224 #[must_use]
226 pub const fn with_credit(mut self, credit: AttributionCredit) -> Self {
227 self.credit = Some(credit);
228 self
229 }
230
231 #[must_use]
233 pub fn with_model_kind(mut self, model_kind: AttributionModelKind) -> Self {
234 self.model_kind = Some(model_kind);
235 self
236 }
237
238 #[must_use]
240 pub const fn source(&self) -> &AttributionSource {
241 &self.source
242 }
243
244 #[must_use]
246 pub const fn medium(&self) -> &AttributionMedium {
247 &self.medium
248 }
249
250 #[must_use]
252 pub const fn credit(&self) -> Option<AttributionCredit> {
253 self.credit
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::{
260 AttributionCredit, AttributionMedium, AttributionModelKind, AttributionSource,
261 AttributionWindow, ConversionLabel, Touchpoint,
262 };
263
264 #[test]
265 fn validates_labels_windows_and_credit() {
266 assert!(AttributionSource::new("newsletter").is_ok());
267 assert!(AttributionWindow::from_days(0).is_err());
268 assert!(AttributionCredit::new(1.5).is_err());
269 }
270
271 #[test]
272 fn composes_touchpoints() {
273 let touchpoint = Touchpoint::new(
274 AttributionSource::new("newsletter").unwrap(),
275 AttributionMedium::new("email").unwrap(),
276 )
277 .with_conversion_label(ConversionLabel::new("signup").unwrap())
278 .with_window(AttributionWindow::from_days(30).unwrap())
279 .with_credit(AttributionCredit::new(0.5).unwrap())
280 .with_model_kind(AttributionModelKind::LastTouch);
281
282 assert_eq!(touchpoint.source().as_str(), "newsletter");
283 assert!((touchpoint.credit().unwrap().value() - 0.5).abs() < f32::EPSILON);
284 assert_eq!(
285 AttributionModelKind::custom("weighted").unwrap().as_str(),
286 "weighted"
287 );
288 }
289}