1use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::adset::AdSetId;
9use super::creative::CreativeId;
10
11#[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#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Ad {
30 pub id: AdId,
32 pub provider: String,
34 pub adset_id: AdSetId,
36 pub name: String,
38 pub status: AdStatus,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub creative_id: Option<CreativeId>,
43 pub created_at: DateTime<Utc>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub updated_at: Option<DateTime<Utc>>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub raw: Option<serde_json::Value>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum AdStatus {
57 Active,
59 Paused,
61 Archived,
63 Deleted,
65 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}