Skip to main content

mkt_core/models/
creative.rs

1//! Creative domain model.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7/// Opaque creative identifier.
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct CreativeId(pub String);
10
11impl fmt::Display for CreativeId {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        write!(f, "{}", self.0)
14    }
15}
16
17impl<T: Into<String>> From<T> for CreativeId {
18    fn from(s: T) -> Self {
19        Self(s.into())
20    }
21}
22
23/// A unified creative representation across all providers.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Creative {
26    /// Unique identifier.
27    pub id: CreativeId,
28    /// Which provider this creative belongs to.
29    pub provider: String,
30    /// Creative name.
31    pub name: String,
32    /// Ad body text.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub body: Option<String>,
35    /// URL of the image asset.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub image_url: Option<String>,
38    /// URL of the video asset.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub video_url: Option<String>,
41    /// Destination link URL.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub link_url: Option<String>,
44    /// Call-to-action label (e.g. `LEARN_MORE`).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub call_to_action: Option<String>,
47    /// When the creative was created.
48    pub created_at: DateTime<Utc>,
49    /// Original API response for debugging and raw access.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub raw: Option<serde_json::Value>,
52}
53
54/// Input for creating a new creative.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct CreateCreativeInput {
57    /// Creative name.
58    pub name: String,
59    /// Ad body text.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub body: Option<String>,
62    /// URL of the image asset.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub image_url: Option<String>,
65    /// URL of the video asset.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub video_url: Option<String>,
68    /// Destination link URL.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub link_url: Option<String>,
71    /// Call-to-action label.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub call_to_action: Option<String>,
74    /// Provider-specific extra fields.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub extra: Option<serde_json::Value>,
77}
78
79/// Input for creating a dark post (unpublished page post).
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81pub struct CreateDarkPostInput {
82    /// The Facebook Page ID to post on behalf of.
83    pub page_id: String,
84    /// The post message / body text.
85    pub message: String,
86    /// Optional destination link.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub link: Option<String>,
89    /// Optional image URL to attach.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub image_url: Option<String>,
92    /// Call-to-action label.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub call_to_action: Option<String>,
95}
96
97impl crate::output::Formattable for Creative {
98    fn headers() -> Vec<String> {
99        vec![
100            "ID".into(),
101            "Name".into(),
102            "Body".into(),
103            "Link URL".into(),
104            "Provider".into(),
105        ]
106    }
107
108    fn row(&self) -> Vec<String> {
109        vec![
110            self.id.to_string(),
111            self.name.clone(),
112            self.body
113                .as_deref()
114                .unwrap_or("-")
115                .chars()
116                .take(40)
117                .collect(),
118            self.link_url.clone().unwrap_or_else(|| "-".to_string()),
119            self.provider.clone(),
120        ]
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn creative_id_display() {
130        let id = CreativeId("creative_123".into());
131        assert_eq!(id.to_string(), "creative_123");
132    }
133
134    #[test]
135    fn creative_id_from_str() {
136        let id = CreativeId::from("creative_456");
137        assert_eq!(id.0, "creative_456");
138    }
139
140    #[test]
141    #[allow(clippy::expect_used)]
142    fn creative_id_serde_roundtrip() {
143        let id = CreativeId("creative_789".into());
144        let json = serde_json::to_string(&id).expect("serialize CreativeId");
145        let back: CreativeId = serde_json::from_str(&json).expect("deserialize CreativeId");
146        assert_eq!(id, back);
147    }
148
149    #[test]
150    fn create_creative_input_default() {
151        let input = CreateCreativeInput::default();
152        assert!(input.name.is_empty());
153        assert!(input.body.is_none());
154        assert!(input.image_url.is_none());
155        assert!(input.video_url.is_none());
156        assert!(input.link_url.is_none());
157        assert!(input.call_to_action.is_none());
158        assert!(input.extra.is_none());
159    }
160
161    #[test]
162    #[allow(clippy::expect_used)]
163    fn creative_optional_fields_skip_serializing_if_none() {
164        let now = chrono::Utc::now();
165        let creative = Creative {
166            id: CreativeId("c_1".into()),
167            provider: "meta".into(),
168            name: "Test Creative".into(),
169            body: None,
170            image_url: None,
171            video_url: None,
172            link_url: None,
173            call_to_action: None,
174            created_at: now,
175            raw: None,
176        };
177        let json = serde_json::to_string(&creative).expect("serialize Creative");
178        assert!(!json.contains("body"));
179        assert!(!json.contains("image_url"));
180        assert!(!json.contains("video_url"));
181        assert!(!json.contains("link_url"));
182        assert!(!json.contains("call_to_action"));
183        assert!(!json.contains("raw"));
184    }
185
186    #[test]
187    #[allow(clippy::expect_used)]
188    fn create_dark_post_input_serde_roundtrip() {
189        let input = CreateDarkPostInput {
190            page_id: "page_1".into(),
191            message: "Hello world".into(),
192            link: Some("https://example.com".into()),
193            image_url: None,
194            call_to_action: Some("LEARN_MORE".into()),
195        };
196        let json = serde_json::to_string(&input).expect("serialize CreateDarkPostInput");
197        let back: CreateDarkPostInput =
198            serde_json::from_str(&json).expect("deserialize CreateDarkPostInput");
199        assert_eq!(back.page_id, "page_1");
200        assert_eq!(back.message, "Hello world");
201        assert_eq!(back.link.as_deref(), Some("https://example.com"));
202        assert!(back.image_url.is_none());
203        assert_eq!(back.call_to_action.as_deref(), Some("LEARN_MORE"));
204    }
205
206    #[test]
207    #[allow(clippy::expect_used)]
208    fn create_dark_post_input_skips_none_fields() {
209        let input = CreateDarkPostInput {
210            page_id: "page_1".into(),
211            message: "Hello".into(),
212            link: None,
213            image_url: None,
214            call_to_action: None,
215        };
216        let json = serde_json::to_string(&input).expect("serialize");
217        assert!(!json.contains("link"));
218        assert!(!json.contains("image_url"));
219        assert!(!json.contains("call_to_action"));
220    }
221}