Skip to main content

mkt_core/models/
insight.rs

1//! Insights and reporting domain model.
2
3use std::collections::HashMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use super::common::DateRange;
9
10/// Query parameters for fetching insights.
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct InsightsQuery {
13    /// Optional date range to constrain the report.
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub date_range: Option<DateRange>,
16    /// List of metric names to retrieve (e.g. "impressions", "clicks").
17    pub metrics: Vec<String>,
18    /// Breakdown dimensions (e.g. "age", "gender").
19    pub breakdowns: Vec<String>,
20    /// The entity level at which to aggregate data.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub level: Option<InsightsLevel>,
23    /// IDs of entities to include in the report.
24    pub entity_ids: Vec<String>,
25    /// Maximum number of rows to return.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub limit: Option<u32>,
28}
29
30/// The aggregation level for an insights report.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum InsightsLevel {
34    /// Aggregated at the account level.
35    Account,
36    /// Aggregated at the campaign level.
37    Campaign,
38    /// Aggregated at the ad set level.
39    AdSet,
40    /// Aggregated at the individual ad level.
41    Ad,
42}
43
44impl fmt::Display for InsightsLevel {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Account => write!(f, "account"),
48            Self::Campaign => write!(f, "campaign"),
49            Self::AdSet => write!(f, "ad_set"),
50            Self::Ad => write!(f, "ad"),
51        }
52    }
53}
54
55/// A complete insights report returned by a provider.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct InsightsReport {
58    /// Which provider generated this report.
59    pub provider: String,
60    /// The date range the report covers, if applicable.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub date_range: Option<DateRange>,
63    /// Individual data rows.
64    pub rows: Vec<InsightsRow>,
65    /// Original API response for debugging and raw access.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub raw: Option<serde_json::Value>,
68}
69
70/// A single row in an insights report.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct InsightsRow {
73    /// Dimension values keyed by dimension name (e.g. `{"age": "25-34"}`).
74    pub dimensions: HashMap<String, String>,
75    /// Metric values keyed by metric name (e.g. `{"clicks": 42}`).
76    pub metrics: HashMap<String, MetricValue>,
77}
78
79/// A single metric value, optionally with a pre-formatted string.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct MetricValue {
82    /// Numeric metric value.
83    pub value: f64,
84    /// Human-readable formatted string (e.g. "$1,234.56").
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub formatted: Option<String>,
87}
88
89impl crate::output::Formattable for InsightsRow {
90    fn headers() -> Vec<String> {
91        vec!["Dimensions".into(), "Metrics".into()]
92    }
93
94    fn row(&self) -> Vec<String> {
95        let dims: Vec<String> = self
96            .dimensions
97            .iter()
98            .map(|(k, v)| format!("{k}={v}"))
99            .collect();
100        let mets: Vec<String> = self
101            .metrics
102            .iter()
103            .map(|(k, v)| {
104                v.formatted
105                    .as_deref()
106                    .map_or_else(|| format!("{k}={}", v.value), |f| format!("{k}={f}"))
107            })
108            .collect();
109        vec![dims.join(", "), mets.join(", ")]
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use test_case::test_case;
117
118    #[test_case(InsightsLevel::Account, "account" ; "account")]
119    #[test_case(InsightsLevel::Campaign, "campaign" ; "campaign")]
120    #[test_case(InsightsLevel::AdSet, "ad_set" ; "adset")]
121    #[test_case(InsightsLevel::Ad, "ad" ; "ad")]
122    #[allow(clippy::needless_pass_by_value)]
123    fn insights_level_display(level: InsightsLevel, expected: &str) {
124        assert_eq!(level.to_string(), expected);
125    }
126
127    #[test]
128    #[allow(clippy::expect_used)]
129    fn insights_level_serde_roundtrip() {
130        let json = serde_json::to_string(&InsightsLevel::Campaign).expect("serialize");
131        assert_eq!(json, r#""campaign""#);
132        let back: InsightsLevel = serde_json::from_str(&json).expect("deserialize");
133        assert_eq!(back, InsightsLevel::Campaign);
134    }
135
136    #[test]
137    #[allow(clippy::expect_used)]
138    fn insights_level_adset_serde_roundtrip() {
139        let json = serde_json::to_string(&InsightsLevel::AdSet).expect("serialize");
140        assert_eq!(json, r#""ad_set""#);
141        let back: InsightsLevel = serde_json::from_str(&json).expect("deserialize");
142        assert_eq!(back, InsightsLevel::AdSet);
143    }
144
145    #[test]
146    fn insights_query_default() {
147        let query = InsightsQuery::default();
148        assert!(query.date_range.is_none());
149        assert!(query.metrics.is_empty());
150        assert!(query.breakdowns.is_empty());
151        assert!(query.level.is_none());
152        assert!(query.entity_ids.is_empty());
153        assert!(query.limit.is_none());
154    }
155
156    #[test]
157    #[allow(clippy::expect_used)]
158    fn insights_query_serde_roundtrip() {
159        let query = InsightsQuery {
160            date_range: None,
161            metrics: vec!["impressions".into(), "clicks".into()],
162            breakdowns: vec!["age".into()],
163            level: Some(InsightsLevel::Campaign),
164            entity_ids: vec!["camp_1".into()],
165            limit: Some(100),
166        };
167        let json = serde_json::to_string(&query).expect("serialize InsightsQuery");
168        let back: InsightsQuery = serde_json::from_str(&json).expect("deserialize InsightsQuery");
169        assert_eq!(back.metrics, vec!["impressions", "clicks"]);
170        assert_eq!(back.breakdowns, vec!["age"]);
171        assert_eq!(back.level, Some(InsightsLevel::Campaign));
172        assert_eq!(back.limit, Some(100));
173    }
174
175    #[test]
176    #[allow(clippy::expect_used)]
177    fn metric_value_serde_roundtrip() {
178        let mv = MetricValue {
179            value: 1234.56,
180            formatted: Some("$1,234.56".into()),
181        };
182        let json = serde_json::to_string(&mv).expect("serialize MetricValue");
183        let back: MetricValue = serde_json::from_str(&json).expect("deserialize MetricValue");
184        assert!((back.value - 1234.56).abs() < f64::EPSILON);
185        assert_eq!(back.formatted.as_deref(), Some("$1,234.56"));
186    }
187
188    #[test]
189    #[allow(clippy::expect_used)]
190    fn metric_value_skips_none_formatted() {
191        let mv = MetricValue {
192            value: 42.0,
193            formatted: None,
194        };
195        let json = serde_json::to_string(&mv).expect("serialize MetricValue");
196        assert!(!json.contains("formatted"));
197    }
198
199    #[test]
200    #[allow(clippy::expect_used)]
201    fn insights_row_serde_roundtrip() {
202        let mut dimensions = HashMap::new();
203        dimensions.insert("age".into(), "25-34".into());
204        let mut metrics = HashMap::new();
205        metrics.insert(
206            "clicks".into(),
207            MetricValue {
208                value: 42.0,
209                formatted: None,
210            },
211        );
212        let row = InsightsRow {
213            dimensions,
214            metrics,
215        };
216        let json = serde_json::to_string(&row).expect("serialize InsightsRow");
217        let back: InsightsRow = serde_json::from_str(&json).expect("deserialize InsightsRow");
218        assert_eq!(
219            back.dimensions.get("age").map(String::as_str),
220            Some("25-34")
221        );
222        assert!((back.metrics["clicks"].value - 42.0).abs() < f64::EPSILON);
223    }
224
225    #[test]
226    #[allow(clippy::expect_used)]
227    fn insights_report_skips_none_fields() {
228        let report = InsightsReport {
229            provider: "meta".into(),
230            date_range: None,
231            rows: vec![],
232            raw: None,
233        };
234        let json = serde_json::to_string(&report).expect("serialize InsightsReport");
235        assert!(!json.contains("date_range"));
236        assert!(!json.contains("raw"));
237    }
238}