Skip to main content

mkt_core/models/
campaign.rs

1//! Campaign domain model.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::common::Budget;
9
10/// Opaque campaign identifier.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct CampaignId(pub String);
13
14impl fmt::Display for CampaignId {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        write!(f, "{}", self.0)
17    }
18}
19
20impl<T: Into<String>> From<T> for CampaignId {
21    fn from(s: T) -> Self {
22        Self(s.into())
23    }
24}
25
26/// A unified campaign representation across all providers.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Campaign {
29    /// Unique identifier.
30    pub id: CampaignId,
31    /// Which provider this campaign belongs to.
32    pub provider: String,
33    /// Campaign name.
34    pub name: String,
35    /// Current status.
36    pub status: CampaignStatus,
37    /// Campaign objective (e.g. `OUTCOME_LEADS`).
38    pub objective: String,
39    /// Budget configuration, if set.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub budget: Option<Budget>,
42    /// When the campaign was created.
43    pub created_at: DateTime<Utc>,
44    /// When the campaign was last updated.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub updated_at: Option<DateTime<Utc>>,
47    /// Original API response for debugging and raw access.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub raw: Option<serde_json::Value>,
50}
51
52/// The lifecycle status of a campaign.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum CampaignStatus {
56    /// Campaign is running.
57    Active,
58    /// Campaign is paused.
59    Paused,
60    /// Campaign is archived.
61    Archived,
62    /// Campaign is in draft state.
63    Draft,
64    /// Campaign has been deleted.
65    Deleted,
66    /// Platform-specific status not mapped to a known variant.
67    Other(String),
68}
69
70impl fmt::Display for CampaignStatus {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::Active => write!(f, "active"),
74            Self::Paused => write!(f, "paused"),
75            Self::Archived => write!(f, "archived"),
76            Self::Draft => write!(f, "draft"),
77            Self::Deleted => write!(f, "deleted"),
78            Self::Other(s) => write!(f, "{s}"),
79        }
80    }
81}
82
83impl<'de> Deserialize<'de> for CampaignStatus {
84    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
85        let s = String::deserialize(deserializer)?;
86        Ok(match s.as_str() {
87            "active" => Self::Active,
88            "paused" => Self::Paused,
89            "archived" => Self::Archived,
90            "draft" => Self::Draft,
91            "deleted" => Self::Deleted,
92            _ => Self::Other(s),
93        })
94    }
95}
96
97/// Input for creating a new campaign.
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct CreateCampaignInput {
100    /// Campaign name.
101    pub name: String,
102    /// Campaign objective.
103    pub objective: String,
104    /// Initial status (defaults to provider default).
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub status: Option<CampaignStatus>,
107    /// Budget configuration.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub budget: Option<Budget>,
110    /// Provider-specific extra fields.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub extra: Option<serde_json::Value>,
113}
114
115/// Input for updating an existing campaign.
116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117pub struct UpdateCampaignInput {
118    /// New name, if changing.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub name: Option<String>,
121    /// New status, if changing.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub status: Option<CampaignStatus>,
124    /// New budget, if changing.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub budget: Option<Budget>,
127    /// Provider-specific extra fields.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub extra: Option<serde_json::Value>,
130}
131
132/// Filters for listing campaigns.
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134pub struct CampaignFilters {
135    /// Filter by status.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub status: Option<CampaignStatus>,
138    /// Filter by name substring.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub name_contains: Option<String>,
141    /// Maximum number of results per page.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub limit: Option<u32>,
144    /// Pagination cursor.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub cursor: Option<String>,
147}
148
149impl crate::output::Formattable for Campaign {
150    fn headers() -> Vec<String> {
151        vec![
152            "ID".into(),
153            "Name".into(),
154            "Status".into(),
155            "Objective".into(),
156            "Provider".into(),
157        ]
158    }
159
160    fn row(&self) -> Vec<String> {
161        vec![
162            self.id.to_string(),
163            self.name.clone(),
164            self.status.to_string(),
165            self.objective.clone(),
166            self.provider.clone(),
167        ]
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use test_case::test_case;
175
176    #[test]
177    fn campaign_id_display() {
178        let id = CampaignId("camp_123".into());
179        assert_eq!(id.to_string(), "camp_123");
180    }
181
182    #[test]
183    fn campaign_id_from_str() {
184        let id = CampaignId::from("camp_456");
185        assert_eq!(id.0, "camp_456");
186    }
187
188    #[test_case(CampaignStatus::Active, "active" ; "active")]
189    #[test_case(CampaignStatus::Paused, "paused" ; "paused")]
190    #[test_case(CampaignStatus::Archived, "archived" ; "archived")]
191    #[test_case(CampaignStatus::Draft, "draft" ; "draft")]
192    #[test_case(CampaignStatus::Deleted, "deleted" ; "deleted")]
193    #[allow(clippy::needless_pass_by_value)]
194    fn campaign_status_display(status: CampaignStatus, expected: &str) {
195        assert_eq!(status.to_string(), expected);
196    }
197
198    #[test]
199    #[allow(clippy::expect_used)]
200    fn campaign_status_serde_roundtrip() {
201        let json = serde_json::to_string(&CampaignStatus::Active).expect("serialize");
202        assert_eq!(json, r#""active""#);
203        let back: CampaignStatus = serde_json::from_str(&json).expect("deserialize");
204        assert_eq!(back, CampaignStatus::Active);
205    }
206
207    #[test]
208    #[allow(clippy::expect_used)]
209    fn unknown_status_deserializes_as_other() {
210        let status: CampaignStatus =
211            serde_json::from_str(r#""something_new""#).expect("deserialize");
212        assert_eq!(status, CampaignStatus::Other("something_new".into()));
213    }
214
215    #[test]
216    fn campaign_filters_default_is_empty() {
217        let filters = CampaignFilters::default();
218        assert!(filters.status.is_none());
219        assert!(filters.name_contains.is_none());
220        assert!(filters.limit.is_none());
221        assert!(filters.cursor.is_none());
222    }
223
224    #[test]
225    fn create_campaign_input_default() {
226        let input = CreateCampaignInput::default();
227        assert!(input.name.is_empty());
228        assert!(input.objective.is_empty());
229        assert!(input.status.is_none());
230    }
231}