Skip to main content

mkt_core/models/
adset.rs

1//! Ad set domain model.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::campaign::CampaignId;
9use super::common::Budget;
10
11/// Opaque ad set identifier.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct AdSetId(pub String);
14
15impl fmt::Display for AdSetId {
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 AdSetId {
22    fn from(s: T) -> Self {
23        Self(s.into())
24    }
25}
26
27/// A unified ad set representation across all providers.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AdSet {
30    /// Unique identifier.
31    pub id: AdSetId,
32    /// Which provider this ad set belongs to.
33    pub provider: String,
34    /// The parent campaign identifier.
35    pub campaign_id: CampaignId,
36    /// Ad set name.
37    pub name: String,
38    /// Current status.
39    pub status: AdSetStatus,
40    /// Targeting configuration, if set.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub targeting: Option<serde_json::Value>,
43    /// Budget configuration, if set.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub budget: Option<Budget>,
46    /// When the ad set was created.
47    pub created_at: DateTime<Utc>,
48    /// When the ad set was last updated.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub updated_at: Option<DateTime<Utc>>,
51    /// Original API response for debugging and raw access.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub raw: Option<serde_json::Value>,
54}
55
56/// The lifecycle status of an ad set.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum AdSetStatus {
60    /// Ad set is running.
61    Active,
62    /// Ad set is paused.
63    Paused,
64    /// Ad set is archived.
65    Archived,
66    /// Ad set has been deleted.
67    Deleted,
68    /// Platform-specific status not mapped to a known variant.
69    Other(String),
70}
71
72impl fmt::Display for AdSetStatus {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::Active => write!(f, "active"),
76            Self::Paused => write!(f, "paused"),
77            Self::Archived => write!(f, "archived"),
78            Self::Deleted => write!(f, "deleted"),
79            Self::Other(s) => write!(f, "{s}"),
80        }
81    }
82}
83
84impl<'de> Deserialize<'de> for AdSetStatus {
85    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
86        let s = String::deserialize(deserializer)?;
87        Ok(match s.as_str() {
88            "active" => Self::Active,
89            "paused" => Self::Paused,
90            "archived" => Self::Archived,
91            "deleted" => Self::Deleted,
92            _ => Self::Other(s),
93        })
94    }
95}
96
97/// Input for creating a new ad set.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CreateAdSetInput {
100    /// The parent campaign identifier.
101    pub campaign_id: CampaignId,
102    /// Ad set name.
103    pub name: String,
104    /// Initial status (defaults to provider default).
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub status: Option<AdSetStatus>,
107    /// Targeting configuration.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub targeting: Option<serde_json::Value>,
110    /// Budget configuration.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub budget: Option<Budget>,
113    /// Provider-specific extra fields.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub extra: Option<serde_json::Value>,
116}
117
118impl crate::output::Formattable for AdSet {
119    fn headers() -> Vec<String> {
120        vec![
121            "ID".into(),
122            "Name".into(),
123            "Status".into(),
124            "Campaign".into(),
125            "Budget".into(),
126            "Provider".into(),
127        ]
128    }
129
130    fn row(&self) -> Vec<String> {
131        let budget = self
132            .budget
133            .as_ref()
134            .map(|b| format!("{} {} ({})", b.amount, b.currency, b.kind))
135            .unwrap_or_default();
136        vec![
137            self.id.to_string(),
138            self.name.clone(),
139            self.status.to_string(),
140            self.campaign_id.to_string(),
141            budget,
142            self.provider.clone(),
143        ]
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use test_case::test_case;
151
152    #[test]
153    fn adset_id_display() {
154        let id = AdSetId("adset_123".into());
155        assert_eq!(id.to_string(), "adset_123");
156    }
157
158    #[test]
159    fn adset_id_from_str() {
160        let id = AdSetId::from("adset_456");
161        assert_eq!(id.0, "adset_456");
162    }
163
164    #[test]
165    #[allow(clippy::expect_used)]
166    fn adset_id_serde_roundtrip() {
167        let id = AdSetId("adset_789".into());
168        let json = serde_json::to_string(&id).expect("serialize AdSetId");
169        let back: AdSetId = serde_json::from_str(&json).expect("deserialize AdSetId");
170        assert_eq!(id, back);
171    }
172
173    #[test_case(AdSetStatus::Active, "active" ; "active")]
174    #[test_case(AdSetStatus::Paused, "paused" ; "paused")]
175    #[test_case(AdSetStatus::Archived, "archived" ; "archived")]
176    #[test_case(AdSetStatus::Deleted, "deleted" ; "deleted")]
177    #[allow(clippy::needless_pass_by_value)]
178    fn adset_status_display(status: AdSetStatus, expected: &str) {
179        assert_eq!(status.to_string(), expected);
180    }
181
182    #[test]
183    fn adset_status_other_display() {
184        let status = AdSetStatus::Other("pending_review".into());
185        assert_eq!(status.to_string(), "pending_review");
186    }
187
188    #[test]
189    #[allow(clippy::expect_used)]
190    fn adset_status_serde_roundtrip() {
191        let json = serde_json::to_string(&AdSetStatus::Active).expect("serialize");
192        assert_eq!(json, r#""active""#);
193        let back: AdSetStatus = serde_json::from_str(&json).expect("deserialize");
194        assert_eq!(back, AdSetStatus::Active);
195    }
196
197    #[test]
198    #[allow(clippy::expect_used)]
199    fn unknown_status_deserializes_as_other() {
200        let status: AdSetStatus =
201            serde_json::from_str(r#""some_new_status""#).expect("deserialize");
202        assert_eq!(status, AdSetStatus::Other("some_new_status".into()));
203    }
204
205    #[test]
206    fn create_adset_input_construction() {
207        let input = CreateAdSetInput {
208            campaign_id: CampaignId::from("camp_1"),
209            name: String::new(),
210            status: None,
211            targeting: None,
212            budget: None,
213            extra: None,
214        };
215        assert!(input.name.is_empty());
216        assert!(input.status.is_none());
217        assert!(input.targeting.is_none());
218        assert!(input.budget.is_none());
219        assert!(input.extra.is_none());
220    }
221
222    #[test]
223    fn adset_formattable_headers_match_row_len() {
224        use crate::output::Formattable;
225        let now = chrono::Utc::now();
226        let adset = AdSet {
227            id: AdSetId("adset_1".into()),
228            provider: "meta".into(),
229            campaign_id: CampaignId::from("camp_1"),
230            name: "Test Ad Set".into(),
231            status: AdSetStatus::Active,
232            targeting: None,
233            budget: Some(Budget {
234                amount: 1500.0,
235                currency: "USD".into(),
236                kind: crate::models::BudgetKind::Daily,
237            }),
238            created_at: now,
239            updated_at: None,
240            raw: None,
241        };
242        let headers = AdSet::headers();
243        let row = adset.row();
244        assert_eq!(headers.len(), row.len());
245        assert_eq!(row[0], "adset_1");
246        assert_eq!(row[2], "active");
247        assert_eq!(row[3], "camp_1");
248        assert!(row[4].contains("1500"), "budget cell should show amount");
249    }
250
251    #[test]
252    fn adset_formattable_row_without_budget_is_empty_cell() {
253        use crate::output::Formattable;
254        let now = chrono::Utc::now();
255        let adset = AdSet {
256            id: AdSetId("adset_2".into()),
257            provider: "meta".into(),
258            campaign_id: CampaignId::from("camp_1"),
259            name: "No Budget".into(),
260            status: AdSetStatus::Paused,
261            targeting: None,
262            budget: None,
263            created_at: now,
264            updated_at: None,
265            raw: None,
266        };
267        assert_eq!(adset.row()[4], "");
268    }
269
270    #[test]
271    #[allow(clippy::expect_used)]
272    fn adset_optional_fields_skip_serializing_if_none() {
273        let now = chrono::Utc::now();
274        let adset = AdSet {
275            id: AdSetId("adset_1".into()),
276            provider: "meta".into(),
277            campaign_id: CampaignId::from("camp_1"),
278            name: "Test Ad Set".into(),
279            status: AdSetStatus::Active,
280            targeting: None,
281            budget: None,
282            created_at: now,
283            updated_at: None,
284            raw: None,
285        };
286        let json = serde_json::to_string(&adset).expect("serialize AdSet");
287        assert!(!json.contains("targeting"));
288        assert!(!json.contains("budget"));
289        assert!(!json.contains("updated_at"));
290        assert!(!json.contains("raw"));
291    }
292}