Skip to main content

mkt_core/models/
ad.rs

1//! Ad domain model.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::adset::AdSetId;
9use super::creative::CreativeId;
10
11/// Opaque ad identifier.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct AdId(pub String);
14
15impl fmt::Display for AdId {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        write!(f, "{}", self.0)
18    }
19}
20
21impl<T: Into<String>> From<T> for AdId {
22    fn from(s: T) -> Self {
23        Self(s.into())
24    }
25}
26
27/// A unified ad representation across all providers.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Ad {
30    /// Unique identifier.
31    pub id: AdId,
32    /// Which provider this ad belongs to.
33    pub provider: String,
34    /// The parent ad set identifier.
35    pub adset_id: AdSetId,
36    /// Ad name.
37    pub name: String,
38    /// Current status.
39    pub status: AdStatus,
40    /// Creative associated with this ad, if any.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub creative_id: Option<CreativeId>,
43    /// When the ad was created.
44    pub created_at: DateTime<Utc>,
45    /// When the ad was last updated.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub updated_at: Option<DateTime<Utc>>,
48    /// Original API response for debugging and raw access.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub raw: Option<serde_json::Value>,
51}
52
53/// The lifecycle status of an ad.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum AdStatus {
57    /// Ad is running.
58    Active,
59    /// Ad is paused.
60    Paused,
61    /// Ad is archived.
62    Archived,
63    /// Ad has been deleted.
64    Deleted,
65    /// Platform-specific status not mapped to a known variant.
66    Other(String),
67}
68
69impl fmt::Display for AdStatus {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            Self::Active => write!(f, "active"),
73            Self::Paused => write!(f, "paused"),
74            Self::Archived => write!(f, "archived"),
75            Self::Deleted => write!(f, "deleted"),
76            Self::Other(s) => write!(f, "{s}"),
77        }
78    }
79}
80
81impl<'de> Deserialize<'de> for AdStatus {
82    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
83        let s = String::deserialize(deserializer)?;
84        Ok(match s.as_str() {
85            "active" => Self::Active,
86            "paused" => Self::Paused,
87            "archived" => Self::Archived,
88            "deleted" => Self::Deleted,
89            _ => Self::Other(s),
90        })
91    }
92}
93
94impl crate::output::Formattable for Ad {
95    fn headers() -> Vec<String> {
96        vec![
97            "ID".into(),
98            "Name".into(),
99            "Status".into(),
100            "Ad Set".into(),
101            "Creative".into(),
102            "Provider".into(),
103        ]
104    }
105
106    fn row(&self) -> Vec<String> {
107        vec![
108            self.id.to_string(),
109            self.name.clone(),
110            self.status.to_string(),
111            self.adset_id.to_string(),
112            self.creative_id
113                .as_ref()
114                .map_or_else(|| "-".to_string(), ToString::to_string),
115            self.provider.clone(),
116        ]
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use test_case::test_case;
124
125    #[test]
126    fn ad_formattable_headers_match_row_len() {
127        use crate::output::Formattable;
128        let ad = Ad {
129            id: AdId("ad_1".into()),
130            provider: "meta".into(),
131            adset_id: AdSetId::from("adset_1"),
132            name: "Boost".into(),
133            status: AdStatus::Paused,
134            creative_id: Some(CreativeId("c_1".into())),
135            created_at: chrono::Utc::now(),
136            updated_at: None,
137            raw: None,
138        };
139        assert_eq!(Ad::headers().len(), ad.row().len());
140        assert_eq!(ad.row()[2], "paused");
141        assert_eq!(ad.row()[4], "c_1");
142    }
143
144    #[test]
145    fn ad_formattable_no_creative_shows_dash() {
146        use crate::output::Formattable;
147        let ad = Ad {
148            id: AdId("ad_2".into()),
149            provider: "meta".into(),
150            adset_id: AdSetId::from("adset_1"),
151            name: "No creative".into(),
152            status: AdStatus::Active,
153            creative_id: None,
154            created_at: chrono::Utc::now(),
155            updated_at: None,
156            raw: None,
157        };
158        assert_eq!(ad.row()[4], "-");
159    }
160
161    #[test]
162    fn ad_id_display() {
163        let id = AdId("ad_123".into());
164        assert_eq!(id.to_string(), "ad_123");
165    }
166
167    #[test]
168    fn ad_id_from_str() {
169        let id = AdId::from("ad_456");
170        assert_eq!(id.0, "ad_456");
171    }
172
173    #[test]
174    #[allow(clippy::expect_used)]
175    fn ad_id_serde_roundtrip() {
176        let id = AdId("ad_789".into());
177        let json = serde_json::to_string(&id).expect("serialize AdId");
178        let back: AdId = serde_json::from_str(&json).expect("deserialize AdId");
179        assert_eq!(id, back);
180    }
181
182    #[test_case(AdStatus::Active, "active" ; "active")]
183    #[test_case(AdStatus::Paused, "paused" ; "paused")]
184    #[test_case(AdStatus::Archived, "archived" ; "archived")]
185    #[test_case(AdStatus::Deleted, "deleted" ; "deleted")]
186    #[allow(clippy::needless_pass_by_value)]
187    fn ad_status_display(status: AdStatus, expected: &str) {
188        assert_eq!(status.to_string(), expected);
189    }
190
191    #[test]
192    fn ad_status_other_display() {
193        let status = AdStatus::Other("in_review".into());
194        assert_eq!(status.to_string(), "in_review");
195    }
196
197    #[test]
198    #[allow(clippy::expect_used)]
199    fn ad_status_serde_roundtrip() {
200        let json = serde_json::to_string(&AdStatus::Paused).expect("serialize");
201        assert_eq!(json, r#""paused""#);
202        let back: AdStatus = serde_json::from_str(&json).expect("deserialize");
203        assert_eq!(back, AdStatus::Paused);
204    }
205
206    #[test]
207    #[allow(clippy::expect_used)]
208    fn unknown_status_deserializes_as_other() {
209        let status: AdStatus = serde_json::from_str(r#""pending_review""#).expect("deserialize");
210        assert_eq!(status, AdStatus::Other("pending_review".into()));
211    }
212
213    #[test]
214    #[allow(clippy::expect_used)]
215    fn ad_optional_fields_skip_serializing_if_none() {
216        let now = chrono::Utc::now();
217        let ad = Ad {
218            id: AdId("ad_1".into()),
219            provider: "meta".into(),
220            adset_id: AdSetId::from("adset_1"),
221            name: "Test Ad".into(),
222            status: AdStatus::Active,
223            creative_id: None,
224            created_at: now,
225            updated_at: None,
226            raw: None,
227        };
228        let json = serde_json::to_string(&ad).expect("serialize Ad");
229        assert!(!json.contains("creative_id"));
230        assert!(!json.contains("updated_at"));
231        assert!(!json.contains("raw"));
232    }
233}