use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::campaign::CampaignId;
use super::common::Budget;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AdSetId(pub String);
impl fmt::Display for AdSetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl<T: Into<String>> From<T> for AdSetId {
fn from(s: T) -> Self {
Self(s.into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdSet {
pub id: AdSetId,
pub provider: String,
pub campaign_id: CampaignId,
pub name: String,
pub status: AdSetStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub targeting: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget: Option<Budget>,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AdSetStatus {
Active,
Paused,
Archived,
Deleted,
Other(String),
}
impl fmt::Display for AdSetStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Active => write!(f, "active"),
Self::Paused => write!(f, "paused"),
Self::Archived => write!(f, "archived"),
Self::Deleted => write!(f, "deleted"),
Self::Other(s) => write!(f, "{s}"),
}
}
}
impl<'de> Deserialize<'de> for AdSetStatus {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"active" => Self::Active,
"paused" => Self::Paused,
"archived" => Self::Archived,
"deleted" => Self::Deleted,
_ => Self::Other(s),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateAdSetInput {
pub campaign_id: CampaignId,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<AdSetStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub targeting: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget: Option<Budget>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
impl crate::output::Formattable for AdSet {
fn headers() -> Vec<String> {
vec![
"ID".into(),
"Name".into(),
"Status".into(),
"Campaign".into(),
"Budget".into(),
"Provider".into(),
]
}
fn row(&self) -> Vec<String> {
let budget = self
.budget
.as_ref()
.map(|b| format!("{} {} ({})", b.amount, b.currency, b.kind))
.unwrap_or_default();
vec![
self.id.to_string(),
self.name.clone(),
self.status.to_string(),
self.campaign_id.to_string(),
budget,
self.provider.clone(),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;
#[test]
fn adset_id_display() {
let id = AdSetId("adset_123".into());
assert_eq!(id.to_string(), "adset_123");
}
#[test]
fn adset_id_from_str() {
let id = AdSetId::from("adset_456");
assert_eq!(id.0, "adset_456");
}
#[test]
#[allow(clippy::expect_used)]
fn adset_id_serde_roundtrip() {
let id = AdSetId("adset_789".into());
let json = serde_json::to_string(&id).expect("serialize AdSetId");
let back: AdSetId = serde_json::from_str(&json).expect("deserialize AdSetId");
assert_eq!(id, back);
}
#[test_case(AdSetStatus::Active, "active" ; "active")]
#[test_case(AdSetStatus::Paused, "paused" ; "paused")]
#[test_case(AdSetStatus::Archived, "archived" ; "archived")]
#[test_case(AdSetStatus::Deleted, "deleted" ; "deleted")]
#[allow(clippy::needless_pass_by_value)]
fn adset_status_display(status: AdSetStatus, expected: &str) {
assert_eq!(status.to_string(), expected);
}
#[test]
fn adset_status_other_display() {
let status = AdSetStatus::Other("pending_review".into());
assert_eq!(status.to_string(), "pending_review");
}
#[test]
#[allow(clippy::expect_used)]
fn adset_status_serde_roundtrip() {
let json = serde_json::to_string(&AdSetStatus::Active).expect("serialize");
assert_eq!(json, r#""active""#);
let back: AdSetStatus = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, AdSetStatus::Active);
}
#[test]
#[allow(clippy::expect_used)]
fn unknown_status_deserializes_as_other() {
let status: AdSetStatus =
serde_json::from_str(r#""some_new_status""#).expect("deserialize");
assert_eq!(status, AdSetStatus::Other("some_new_status".into()));
}
#[test]
fn create_adset_input_construction() {
let input = CreateAdSetInput {
campaign_id: CampaignId::from("camp_1"),
name: String::new(),
status: None,
targeting: None,
budget: None,
extra: None,
};
assert!(input.name.is_empty());
assert!(input.status.is_none());
assert!(input.targeting.is_none());
assert!(input.budget.is_none());
assert!(input.extra.is_none());
}
#[test]
fn adset_formattable_headers_match_row_len() {
use crate::output::Formattable;
let now = chrono::Utc::now();
let adset = AdSet {
id: AdSetId("adset_1".into()),
provider: "meta".into(),
campaign_id: CampaignId::from("camp_1"),
name: "Test Ad Set".into(),
status: AdSetStatus::Active,
targeting: None,
budget: Some(Budget {
amount: 1500.0,
currency: "USD".into(),
kind: crate::models::BudgetKind::Daily,
}),
created_at: now,
updated_at: None,
raw: None,
};
let headers = AdSet::headers();
let row = adset.row();
assert_eq!(headers.len(), row.len());
assert_eq!(row[0], "adset_1");
assert_eq!(row[2], "active");
assert_eq!(row[3], "camp_1");
assert!(row[4].contains("1500"), "budget cell should show amount");
}
#[test]
fn adset_formattable_row_without_budget_is_empty_cell() {
use crate::output::Formattable;
let now = chrono::Utc::now();
let adset = AdSet {
id: AdSetId("adset_2".into()),
provider: "meta".into(),
campaign_id: CampaignId::from("camp_1"),
name: "No Budget".into(),
status: AdSetStatus::Paused,
targeting: None,
budget: None,
created_at: now,
updated_at: None,
raw: None,
};
assert_eq!(adset.row()[4], "");
}
#[test]
#[allow(clippy::expect_used)]
fn adset_optional_fields_skip_serializing_if_none() {
let now = chrono::Utc::now();
let adset = AdSet {
id: AdSetId("adset_1".into()),
provider: "meta".into(),
campaign_id: CampaignId::from("camp_1"),
name: "Test Ad Set".into(),
status: AdSetStatus::Active,
targeting: None,
budget: None,
created_at: now,
updated_at: None,
raw: None,
};
let json = serde_json::to_string(&adset).expect("serialize AdSet");
assert!(!json.contains("targeting"));
assert!(!json.contains("budget"));
assert!(!json.contains("updated_at"));
assert!(!json.contains("raw"));
}
}