use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CreativeId(pub String);
impl fmt::Display for CreativeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl<T: Into<String>> From<T> for CreativeId {
fn from(s: T) -> Self {
Self(s.into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Creative {
pub id: CreativeId,
pub provider: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub link_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_to_action: Option<String>,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateCreativeInput {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub link_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_to_action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateDarkPostInput {
pub page_id: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_to_action: Option<String>,
}
impl crate::output::Formattable for Creative {
fn headers() -> Vec<String> {
vec![
"ID".into(),
"Name".into(),
"Body".into(),
"Link URL".into(),
"Provider".into(),
]
}
fn row(&self) -> Vec<String> {
vec![
self.id.to_string(),
self.name.clone(),
self.body
.as_deref()
.unwrap_or("-")
.chars()
.take(40)
.collect(),
self.link_url.clone().unwrap_or_else(|| "-".to_string()),
self.provider.clone(),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creative_id_display() {
let id = CreativeId("creative_123".into());
assert_eq!(id.to_string(), "creative_123");
}
#[test]
fn creative_id_from_str() {
let id = CreativeId::from("creative_456");
assert_eq!(id.0, "creative_456");
}
#[test]
#[allow(clippy::expect_used)]
fn creative_id_serde_roundtrip() {
let id = CreativeId("creative_789".into());
let json = serde_json::to_string(&id).expect("serialize CreativeId");
let back: CreativeId = serde_json::from_str(&json).expect("deserialize CreativeId");
assert_eq!(id, back);
}
#[test]
fn create_creative_input_default() {
let input = CreateCreativeInput::default();
assert!(input.name.is_empty());
assert!(input.body.is_none());
assert!(input.image_url.is_none());
assert!(input.video_url.is_none());
assert!(input.link_url.is_none());
assert!(input.call_to_action.is_none());
assert!(input.extra.is_none());
}
#[test]
#[allow(clippy::expect_used)]
fn creative_optional_fields_skip_serializing_if_none() {
let now = chrono::Utc::now();
let creative = Creative {
id: CreativeId("c_1".into()),
provider: "meta".into(),
name: "Test Creative".into(),
body: None,
image_url: None,
video_url: None,
link_url: None,
call_to_action: None,
created_at: now,
raw: None,
};
let json = serde_json::to_string(&creative).expect("serialize Creative");
assert!(!json.contains("body"));
assert!(!json.contains("image_url"));
assert!(!json.contains("video_url"));
assert!(!json.contains("link_url"));
assert!(!json.contains("call_to_action"));
assert!(!json.contains("raw"));
}
#[test]
#[allow(clippy::expect_used)]
fn create_dark_post_input_serde_roundtrip() {
let input = CreateDarkPostInput {
page_id: "page_1".into(),
message: "Hello world".into(),
link: Some("https://example.com".into()),
image_url: None,
call_to_action: Some("LEARN_MORE".into()),
};
let json = serde_json::to_string(&input).expect("serialize CreateDarkPostInput");
let back: CreateDarkPostInput =
serde_json::from_str(&json).expect("deserialize CreateDarkPostInput");
assert_eq!(back.page_id, "page_1");
assert_eq!(back.message, "Hello world");
assert_eq!(back.link.as_deref(), Some("https://example.com"));
assert!(back.image_url.is_none());
assert_eq!(back.call_to_action.as_deref(), Some("LEARN_MORE"));
}
#[test]
#[allow(clippy::expect_used)]
fn create_dark_post_input_skips_none_fields() {
let input = CreateDarkPostInput {
page_id: "page_1".into(),
message: "Hello".into(),
link: None,
image_url: None,
call_to_action: None,
};
let json = serde_json::to_string(&input).expect("serialize");
assert!(!json.contains("link"));
assert!(!json.contains("image_url"));
assert!(!json.contains("call_to_action"));
}
}