1use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::campaign::CampaignId;
9use super::common::Budget;
10
11#[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#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AdSet {
30 pub id: AdSetId,
32 pub provider: String,
34 pub campaign_id: CampaignId,
36 pub name: String,
38 pub status: AdSetStatus,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub targeting: Option<serde_json::Value>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub budget: Option<Budget>,
46 pub created_at: DateTime<Utc>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub updated_at: Option<DateTime<Utc>>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub raw: Option<serde_json::Value>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum AdSetStatus {
60 Active,
62 Paused,
64 Archived,
66 Deleted,
68 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#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CreateAdSetInput {
100 pub campaign_id: CampaignId,
102 pub name: String,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub status: Option<AdSetStatus>,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub targeting: Option<serde_json::Value>,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub budget: Option<Budget>,
113 #[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}