Skip to main content

use_attribution/
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, 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/// Error returned by attribution primitive constructors.
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub enum AttributionValueError {
22    /// The supplied value was empty after trimming whitespace.
23    Empty { field: &'static str },
24    /// Attribution window days must be greater than zero.
25    InvalidWindow(u16),
26    /// Attribution credit must be in the inclusive `0.0..=1.0` range.
27    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            /// Creates an attribution label.
49            ///
50            /// # Errors
51            ///
52            /// Returns [`AttributionValueError::Empty`] when the label is empty.
53            pub fn new(value: impl AsRef<str>) -> Result<Self, AttributionValueError> {
54                validate_label(value, $field).map(Self)
55            }
56
57            /// Returns the label text.
58            #[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/// Attribution window represented in days.
91#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub struct AttributionWindow {
93    days: u16,
94}
95
96impl AttributionWindow {
97    /// Creates an attribution window.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`AttributionValueError::InvalidWindow`] when days is zero.
102    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    /// Returns the window length in days.
111    #[must_use]
112    pub const fn days(self) -> u16 {
113        self.days
114    }
115}
116
117/// Attribution credit represented as a `0.0..=1.0` share.
118#[derive(Clone, Copy, Debug, PartialEq)]
119pub struct AttributionCredit {
120    value: f32,
121}
122
123impl AttributionCredit {
124    /// Creates an attribution credit value.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`AttributionValueError::InvalidCredit`] when the value is outside `0.0..=1.0`.
129    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    /// Returns the credit share.
138    #[must_use]
139    pub const fn value(self) -> f32 {
140        self.value
141    }
142}
143
144/// Attribution model kind label.
145#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
146pub enum AttributionModelKind {
147    /// First-touch model label.
148    FirstTouch,
149    /// Last-touch model label.
150    LastTouch,
151    /// Linear model label.
152    Linear,
153    /// Time-decay model label.
154    TimeDecay,
155    /// Position-based model label.
156    PositionBased,
157    /// Custom model label.
158    Custom(String),
159}
160
161impl AttributionModelKind {
162    /// Creates a custom model label.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`AttributionValueError::Empty`] when the label is empty.
167    pub fn custom(value: impl AsRef<str>) -> Result<Self, AttributionValueError> {
168        validate_label(value, "attribution model").map(Self::Custom)
169    }
170
171    /// Returns the model kind label.
172    #[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/// Attribution touchpoint primitive.
186#[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    /// Creates an attribution touchpoint.
198    #[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    /// Sets the conversion label.
211    #[must_use]
212    pub fn with_conversion_label(mut self, label: ConversionLabel) -> Self {
213        self.conversion_label = Some(label);
214        self
215    }
216
217    /// Sets the attribution window.
218    #[must_use]
219    pub const fn with_window(mut self, window: AttributionWindow) -> Self {
220        self.window = Some(window);
221        self
222    }
223
224    /// Sets the attribution credit.
225    #[must_use]
226    pub const fn with_credit(mut self, credit: AttributionCredit) -> Self {
227        self.credit = Some(credit);
228        self
229    }
230
231    /// Sets the model-kind label.
232    #[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    /// Returns the attribution source.
239    #[must_use]
240    pub const fn source(&self) -> &AttributionSource {
241        &self.source
242    }
243
244    /// Returns the attribution medium.
245    #[must_use]
246    pub const fn medium(&self) -> &AttributionMedium {
247        &self.medium
248    }
249
250    /// Returns attribution credit.
251    #[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}