use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use super::common::DateRange;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InsightsQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub date_range: Option<DateRange>,
pub metrics: Vec<String>,
pub breakdowns: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<InsightsLevel>,
pub entity_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InsightsLevel {
Account,
Campaign,
AdSet,
Ad,
}
impl fmt::Display for InsightsLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Account => write!(f, "account"),
Self::Campaign => write!(f, "campaign"),
Self::AdSet => write!(f, "ad_set"),
Self::Ad => write!(f, "ad"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsightsReport {
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_range: Option<DateRange>,
pub rows: Vec<InsightsRow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsightsRow {
pub dimensions: HashMap<String, String>,
pub metrics: HashMap<String, MetricValue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricValue {
pub value: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub formatted: Option<String>,
}
impl crate::output::Formattable for InsightsRow {
fn headers() -> Vec<String> {
vec!["Dimensions".into(), "Metrics".into()]
}
fn row(&self) -> Vec<String> {
let dims: Vec<String> = self
.dimensions
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
let mets: Vec<String> = self
.metrics
.iter()
.map(|(k, v)| {
v.formatted
.as_deref()
.map_or_else(|| format!("{k}={}", v.value), |f| format!("{k}={f}"))
})
.collect();
vec![dims.join(", "), mets.join(", ")]
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;
#[test_case(InsightsLevel::Account, "account" ; "account")]
#[test_case(InsightsLevel::Campaign, "campaign" ; "campaign")]
#[test_case(InsightsLevel::AdSet, "ad_set" ; "adset")]
#[test_case(InsightsLevel::Ad, "ad" ; "ad")]
#[allow(clippy::needless_pass_by_value)]
fn insights_level_display(level: InsightsLevel, expected: &str) {
assert_eq!(level.to_string(), expected);
}
#[test]
#[allow(clippy::expect_used)]
fn insights_level_serde_roundtrip() {
let json = serde_json::to_string(&InsightsLevel::Campaign).expect("serialize");
assert_eq!(json, r#""campaign""#);
let back: InsightsLevel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, InsightsLevel::Campaign);
}
#[test]
#[allow(clippy::expect_used)]
fn insights_level_adset_serde_roundtrip() {
let json = serde_json::to_string(&InsightsLevel::AdSet).expect("serialize");
assert_eq!(json, r#""ad_set""#);
let back: InsightsLevel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, InsightsLevel::AdSet);
}
#[test]
fn insights_query_default() {
let query = InsightsQuery::default();
assert!(query.date_range.is_none());
assert!(query.metrics.is_empty());
assert!(query.breakdowns.is_empty());
assert!(query.level.is_none());
assert!(query.entity_ids.is_empty());
assert!(query.limit.is_none());
}
#[test]
#[allow(clippy::expect_used)]
fn insights_query_serde_roundtrip() {
let query = InsightsQuery {
date_range: None,
metrics: vec!["impressions".into(), "clicks".into()],
breakdowns: vec!["age".into()],
level: Some(InsightsLevel::Campaign),
entity_ids: vec!["camp_1".into()],
limit: Some(100),
};
let json = serde_json::to_string(&query).expect("serialize InsightsQuery");
let back: InsightsQuery = serde_json::from_str(&json).expect("deserialize InsightsQuery");
assert_eq!(back.metrics, vec!["impressions", "clicks"]);
assert_eq!(back.breakdowns, vec!["age"]);
assert_eq!(back.level, Some(InsightsLevel::Campaign));
assert_eq!(back.limit, Some(100));
}
#[test]
#[allow(clippy::expect_used)]
fn metric_value_serde_roundtrip() {
let mv = MetricValue {
value: 1234.56,
formatted: Some("$1,234.56".into()),
};
let json = serde_json::to_string(&mv).expect("serialize MetricValue");
let back: MetricValue = serde_json::from_str(&json).expect("deserialize MetricValue");
assert!((back.value - 1234.56).abs() < f64::EPSILON);
assert_eq!(back.formatted.as_deref(), Some("$1,234.56"));
}
#[test]
#[allow(clippy::expect_used)]
fn metric_value_skips_none_formatted() {
let mv = MetricValue {
value: 42.0,
formatted: None,
};
let json = serde_json::to_string(&mv).expect("serialize MetricValue");
assert!(!json.contains("formatted"));
}
#[test]
#[allow(clippy::expect_used)]
fn insights_row_serde_roundtrip() {
let mut dimensions = HashMap::new();
dimensions.insert("age".into(), "25-34".into());
let mut metrics = HashMap::new();
metrics.insert(
"clicks".into(),
MetricValue {
value: 42.0,
formatted: None,
},
);
let row = InsightsRow {
dimensions,
metrics,
};
let json = serde_json::to_string(&row).expect("serialize InsightsRow");
let back: InsightsRow = serde_json::from_str(&json).expect("deserialize InsightsRow");
assert_eq!(
back.dimensions.get("age").map(String::as_str),
Some("25-34")
);
assert!((back.metrics["clicks"].value - 42.0).abs() < f64::EPSILON);
}
#[test]
#[allow(clippy::expect_used)]
fn insights_report_skips_none_fields() {
let report = InsightsReport {
provider: "meta".into(),
date_range: None,
rows: vec![],
raw: None,
};
let json = serde_json::to_string(&report).expect("serialize InsightsReport");
assert!(!json.contains("date_range"));
assert!(!json.contains("raw"));
}
}