Skip to main content

mkt_google/
mapping.rs

1//! Conversion between Google Ads API REST JSON and domain models.
2//!
3//! All functions here are pure transformations with no I/O. GAQL result
4//! rows (`{"campaign": {...}, "campaignBudget": {...}, "metrics": {...}}`)
5//! map into the unified domain models from `mkt-core`.
6
7use std::collections::HashMap;
8
9use chrono::{DateTime, NaiveDate, Utc};
10use mkt_core::error::{MktError, Result};
11use mkt_core::models::{
12    Budget, BudgetKind, Campaign, CampaignId, CampaignStatus, CreateCampaignInput, InsightsReport,
13    InsightsRow, MetricValue, UpdateCampaignInput,
14};
15
16/// The GAQL field list for campaign queries.
17pub const CAMPAIGN_GAQL_FIELDS: &str = "campaign.id, campaign.name, campaign.status, \
18     campaign.advertising_channel_type, campaign.start_date, campaign_budget.amount_micros";
19
20/// Micros per currency unit in Google Ads money fields.
21const MICROS_PER_UNIT: f64 = 1_000_000.0;
22
23// ── Status mapping ─────────────────────────────────────────
24
25/// Map a Google Ads campaign status string to a domain status.
26pub fn google_status_to_domain(status: &str) -> CampaignStatus {
27    match status {
28        "ENABLED" => CampaignStatus::Active,
29        "PAUSED" => CampaignStatus::Paused,
30        "REMOVED" => CampaignStatus::Deleted,
31        other => CampaignStatus::Other(other.to_string()),
32    }
33}
34
35/// Map a domain status to the Google Ads status string.
36///
37/// Google has no archived state; archived maps to `REMOVED` and draft to
38/// `PAUSED`.
39pub fn domain_status_to_google(status: &CampaignStatus) -> String {
40    match status {
41        CampaignStatus::Active => "ENABLED".to_string(),
42        CampaignStatus::Paused | CampaignStatus::Draft => "PAUSED".to_string(),
43        CampaignStatus::Archived | CampaignStatus::Deleted => "REMOVED".to_string(),
44        CampaignStatus::Other(s) => s.clone(),
45    }
46}
47
48// ── Campaign from API ──────────────────────────────────────
49
50/// Convert one GAQL search result row into a domain [`Campaign`].
51///
52/// # Errors
53///
54/// Returns [`MktError::ApiError`] if required fields (`campaign.id`,
55/// `campaign.name`) are missing from the row.
56pub fn google_row_to_campaign(row: &serde_json::Value) -> Result<Campaign> {
57    let campaign = &row["campaign"];
58    let id = field_str(campaign, "id")?;
59    let name = field_str(campaign, "name")?;
60    let status_str = campaign["status"].as_str().unwrap_or("UNKNOWN");
61    let objective = campaign["advertisingChannelType"]
62        .as_str()
63        .unwrap_or("")
64        .to_string();
65
66    let budget = row["campaignBudget"]["amountMicros"]
67        .as_str()
68        .and_then(|micros| micros.parse::<f64>().ok())
69        .map(|micros| Budget {
70            amount: micros / MICROS_PER_UNIT,
71            currency: "USD".to_string(),
72            kind: BudgetKind::Daily,
73        });
74
75    // Google campaigns expose no creation timestamp via GAQL; use the
76    // campaign start date at midnight UTC as the closest stable value.
77    let created_at = campaign["startDate"]
78        .as_str()
79        .and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
80        .and_then(|d| d.and_hms_opt(0, 0, 0))
81        .map_or_else(Utc::now, |dt| DateTime::from_naive_utc_and_offset(dt, Utc));
82
83    Ok(Campaign {
84        id: CampaignId(id),
85        provider: "google".to_string(),
86        name,
87        status: google_status_to_domain(status_str),
88        objective,
89        budget,
90        created_at,
91        updated_at: None,
92        raw: Some(row.clone()),
93    })
94}
95
96// ── Campaign to API ────────────────────────────────────────
97
98/// Build the campaign resource name `customers/{cid}/campaigns/{id}`.
99pub fn campaign_resource_name(customer_id: &str, campaign_id: &str) -> String {
100    format!("customers/{customer_id}/campaigns/{campaign_id}")
101}
102
103/// Escape a user-supplied value for use inside a GAQL `LIKE` pattern.
104///
105/// Backslashes and quotes are escaped, and the `LIKE` wildcards `%` and
106/// `_` are wrapped in brackets so they match literally — a campaign
107/// named "50% off" must not act as a wildcard.
108#[must_use]
109pub fn escape_gaql_like(value: &str) -> String {
110    let mut escaped = String::with_capacity(value.len());
111    for c in value.chars() {
112        match c {
113            '\\' => escaped.push_str("\\\\"),
114            '\'' => escaped.push_str("\\'"),
115            '%' => escaped.push_str("[%]"),
116            '_' => escaped.push_str("[_]"),
117            '[' => escaped.push_str("[[]"),
118            other => escaped.push(other),
119        }
120    }
121    escaped
122}
123
124/// Campaign fields that are members of the bidding-strategy oneof. An
125/// explicit strategy in `extra` must replace the `manualCpc` default —
126/// the API rejects operations carrying two members.
127const BIDDING_STRATEGY_FIELDS: &[&str] = &[
128    "biddingStrategy",
129    "commission",
130    "manualCpa",
131    "manualCpc",
132    "manualCpm",
133    "manualCpv",
134    "maximizeConversionValue",
135    "maximizeConversions",
136    "percentCpc",
137    "targetCpa",
138    "targetCpm",
139    "targetImpressionShare",
140    "targetRoas",
141    "targetSpend",
142];
143
144/// Build the campaign `create` JSON object for a mutate operation.
145///
146/// `objective` carries the advertising channel type (`SEARCH`, `DISPLAY`,
147/// `PERFORMANCE_MAX`, ...). New campaigns default to `PAUSED` so spend is
148/// an explicit decision, and to no EU political advertising — a
149/// declaration the API requires on every campaign create; declare via
150/// `extra` when the campaign does contain it.
151fn campaign_create_object(
152    input: &CreateCampaignInput,
153    budget_resource_name: &str,
154) -> serde_json::Value {
155    let status = input
156        .status
157        .as_ref()
158        .map_or_else(|| "PAUSED".to_string(), domain_status_to_google);
159
160    let mut create = serde_json::json!({
161        "name": input.name,
162        "status": status,
163        "advertisingChannelType": input.objective.to_uppercase(),
164        "campaignBudget": budget_resource_name,
165        "manualCpc": {},
166        "containsEuPoliticalAdvertising": "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING",
167    });
168
169    if let Some(extra) = &input.extra {
170        if let (Some(base), Some(overlay)) = (create.as_object_mut(), extra.as_object()) {
171            let replaces_bidding = overlay
172                .keys()
173                .any(|k| k != "manualCpc" && BIDDING_STRATEGY_FIELDS.contains(&k.as_str()));
174            if replaces_bidding && !overlay.contains_key("manualCpc") {
175                base.remove("manualCpc");
176            }
177            for (k, v) in overlay {
178                base.insert(k.clone(), v.clone());
179            }
180        }
181    }
182
183    create
184}
185
186/// Temporary resource ID linking the budget to the campaign inside one
187/// atomic `googleAds:mutate` request. Google resolves negative IDs to the
188/// resource created earlier in the same operation array.
189const TEMP_BUDGET_ID: &str = "-1";
190
191/// Build the `googleAds:mutate` operation array creating a campaign
192/// budget and its campaign atomically.
193///
194/// The budget is created under the temporary resource name
195/// `customers/{customer_id}/campaignBudgets/-1` and referenced by the
196/// campaign operation in the same request, so a campaign failure cannot
197/// leave an orphaned budget behind. The budget operation must precede
198/// the campaign operation: temporary IDs only resolve backwards within
199/// the array.
200///
201/// # Errors
202///
203/// Returns [`MktError::ValidationError`] if `input` carries no budget —
204/// Google Ads requires one per campaign.
205pub fn atomic_create_operations(
206    input: &CreateCampaignInput,
207    customer_id: &str,
208) -> Result<serde_json::Value> {
209    let budget = input
210        .budget
211        .as_ref()
212        .ok_or_else(|| MktError::ValidationError {
213            field: "budget".into(),
214            message: "a budget is required to create a Google Ads campaign".into(),
215        })?;
216
217    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
218    // budgets are positive and far below u64::MAX micros
219    let micros = (budget.amount * MICROS_PER_UNIT).round() as u64;
220    let temp_budget_resource = format!("customers/{customer_id}/campaignBudgets/{TEMP_BUDGET_ID}");
221
222    Ok(serde_json::json!([
223        {
224            "campaignBudgetOperation": {
225                "create": {
226                    "resourceName": temp_budget_resource,
227                    "name": format!("{} — budget", input.name),
228                    "amountMicros": micros.to_string(),
229                    "deliveryMethod": "STANDARD",
230                }
231            }
232        },
233        {
234            "campaignOperation": {
235                "create": campaign_create_object(input, &temp_budget_resource)
236            }
237        },
238    ]))
239}
240
241/// Build the `campaigns:mutate` update operation with its field mask.
242pub fn campaign_update_operation(
243    customer_id: &str,
244    campaign_id: &str,
245    input: &UpdateCampaignInput,
246) -> serde_json::Value {
247    let mut update = serde_json::json!({
248        "resourceName": campaign_resource_name(customer_id, campaign_id),
249    });
250    let mut mask: Vec<&str> = Vec::new();
251
252    if let Some(name) = &input.name {
253        update["name"] = serde_json::Value::String(name.clone());
254        mask.push("name");
255    }
256    if let Some(status) = &input.status {
257        update["status"] = serde_json::Value::String(domain_status_to_google(status));
258        mask.push("status");
259    }
260
261    serde_json::json!([{
262        "updateMask": mask.join(","),
263        "update": update,
264    }])
265}
266
267/// Extract the numeric campaign ID from an atomic `googleAds:mutate`
268/// response.
269///
270/// Scans `mutateOperationResponses` for the entry carrying a
271/// `campaignResult` — matching by key rather than by position keeps the
272/// extraction correct regardless of how operations were ordered.
273///
274/// # Errors
275///
276/// Returns [`MktError::ApiError`] if no entry carries a
277/// `campaignResult.resourceName`.
278pub fn campaign_id_from_atomic_mutate(resp: &serde_json::Value) -> Result<String> {
279    resp["mutateOperationResponses"]
280        .as_array()
281        .and_then(|responses| {
282            responses
283                .iter()
284                .find_map(|entry| entry["campaignResult"]["resourceName"].as_str())
285        })
286        .and_then(|name| name.rsplit('/').next())
287        .map(String::from)
288        .ok_or_else(|| MktError::ApiError {
289            provider: "google".into(),
290            status: 0,
291            message: "atomic mutate response missing campaignResult.resourceName".into(),
292            retry_after: None,
293        })
294}
295
296// ── Insights ───────────────────────────────────────────────
297
298/// Convert a metrics GAQL search response into a domain [`InsightsReport`].
299///
300/// `costMicros` is converted to currency units under the metric name
301/// `cost`. Campaign id/name and segment fields become dimensions.
302///
303/// # Errors
304///
305/// Currently infallible; `Result` is kept for parity with other mapping
306/// functions.
307#[allow(clippy::unnecessary_wraps)] // Result kept for parity with other mapping fns
308pub fn google_insights_to_domain(resp: &serde_json::Value) -> Result<InsightsReport> {
309    let rows: Vec<InsightsRow> = resp["results"]
310        .as_array()
311        .unwrap_or(&Vec::new())
312        .iter()
313        .map(|row| {
314            let mut dimensions = HashMap::new();
315            let mut metrics = HashMap::new();
316
317            if let Some(campaign) = row["campaign"].as_object() {
318                if let Some(id) = campaign.get("id").and_then(|v| v.as_str()) {
319                    dimensions.insert("campaign.id".to_string(), id.to_string());
320                }
321                if let Some(name) = campaign.get("name").and_then(|v| v.as_str()) {
322                    dimensions.insert("campaign.name".to_string(), name.to_string());
323                }
324            }
325
326            if let Some(segments) = row["segments"].as_object() {
327                for (key, val) in segments {
328                    if let Some(s) = val.as_str() {
329                        dimensions.insert(key.clone(), s.to_string());
330                    }
331                }
332            }
333
334            if let Some(metric_obj) = row["metrics"].as_object() {
335                for (key, val) in metric_obj {
336                    let parsed = val
337                        .as_f64()
338                        .or_else(|| val.as_str().and_then(|s| s.parse::<f64>().ok()));
339                    if let Some(v) = parsed {
340                        if key == "costMicros" {
341                            metrics.insert(
342                                "cost".to_string(),
343                                MetricValue {
344                                    value: v / MICROS_PER_UNIT,
345                                    formatted: None,
346                                },
347                            );
348                        } else {
349                            metrics.insert(
350                                key.clone(),
351                                MetricValue {
352                                    value: v,
353                                    formatted: None,
354                                },
355                            );
356                        }
357                    }
358                }
359            }
360
361            InsightsRow {
362                dimensions,
363                metrics,
364            }
365        })
366        .collect();
367
368    Ok(InsightsReport {
369        provider: "google".to_string(),
370        date_range: None,
371        rows,
372        raw: Some(resp.clone()),
373    })
374}
375
376// ── Helpers ────────────────────────────────────────────────
377
378/// Extract a required string field from a JSON object.
379fn field_str(value: &serde_json::Value, field: &str) -> Result<String> {
380    value[field]
381        .as_str()
382        .map(String::from)
383        .ok_or_else(|| MktError::ApiError {
384            provider: "google".into(),
385            status: 0,
386            message: format!("missing field 'campaign.{field}' in API response"),
387            retry_after: None,
388        })
389}
390
391#[cfg(test)]
392#[allow(clippy::expect_used)]
393mod tests {
394    use super::*;
395
396    fn sample_row() -> serde_json::Value {
397        serde_json::json!({
398            "campaign": {
399                "resourceName": "customers/123/campaigns/456",
400                "id": "456",
401                "name": "Test Campaign",
402                "status": "ENABLED",
403                "advertisingChannelType": "SEARCH",
404                "startDate": "2026-01-15"
405            },
406            "campaignBudget": { "amountMicros": "5000000" }
407        })
408    }
409
410    #[test]
411    fn test_google_status_to_domain() {
412        assert_eq!(google_status_to_domain("ENABLED"), CampaignStatus::Active);
413        assert_eq!(google_status_to_domain("PAUSED"), CampaignStatus::Paused);
414        assert_eq!(google_status_to_domain("REMOVED"), CampaignStatus::Deleted);
415        assert_eq!(
416            google_status_to_domain("UNKNOWN"),
417            CampaignStatus::Other("UNKNOWN".into())
418        );
419    }
420
421    #[test]
422    fn test_domain_status_to_google() {
423        assert_eq!(domain_status_to_google(&CampaignStatus::Active), "ENABLED");
424        assert_eq!(domain_status_to_google(&CampaignStatus::Paused), "PAUSED");
425        assert_eq!(domain_status_to_google(&CampaignStatus::Draft), "PAUSED");
426        assert_eq!(
427            domain_status_to_google(&CampaignStatus::Archived),
428            "REMOVED"
429        );
430        assert_eq!(domain_status_to_google(&CampaignStatus::Deleted), "REMOVED");
431    }
432
433    #[test]
434    fn test_google_row_to_campaign() {
435        let c = google_row_to_campaign(&sample_row()).expect("should parse");
436        assert_eq!(c.id.0, "456");
437        assert_eq!(c.provider, "google");
438        assert_eq!(c.name, "Test Campaign");
439        assert_eq!(c.status, CampaignStatus::Active);
440        assert_eq!(c.objective, "SEARCH");
441        let budget = c.budget.expect("budget should map");
442        assert!((budget.amount - 5.0).abs() < f64::EPSILON);
443    }
444
445    #[test]
446    fn test_google_row_missing_id_is_error() {
447        let row = serde_json::json!({ "campaign": { "name": "No ID" } });
448        assert!(google_row_to_campaign(&row).is_err());
449    }
450
451    #[test]
452    fn test_atomic_create_operations_budget_precedes_campaign() {
453        let input = CreateCampaignInput {
454            name: "X".into(),
455            objective: "search".into(),
456            status: None,
457            budget: Some(Budget {
458                amount: 12.34,
459                currency: "USD".into(),
460                kind: BudgetKind::Daily,
461            }),
462            extra: None,
463        };
464        let ops = atomic_create_operations(&input, "123").expect("budget present");
465
466        let budget_create = &ops[0]["campaignBudgetOperation"]["create"];
467        assert_eq!(
468            budget_create["resourceName"],
469            "customers/123/campaignBudgets/-1"
470        );
471        assert_eq!(budget_create["amountMicros"], "12340000");
472        assert_eq!(budget_create["name"], "X — budget");
473        assert_eq!(budget_create["deliveryMethod"], "STANDARD");
474
475        let campaign_create = &ops[1]["campaignOperation"]["create"];
476        assert_eq!(
477            campaign_create["campaignBudget"], "customers/123/campaignBudgets/-1",
478            "campaign must reference the budget's temporary resource name"
479        );
480        assert_eq!(campaign_create["status"], "PAUSED");
481        assert_eq!(campaign_create["advertisingChannelType"], "SEARCH");
482        assert_eq!(
483            campaign_create["containsEuPoliticalAdvertising"],
484            "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING"
485        );
486    }
487
488    #[test]
489    fn test_atomic_create_operations_without_budget_is_validation_error() {
490        let input = CreateCampaignInput {
491            name: "X".into(),
492            objective: "SEARCH".into(),
493            status: None,
494            budget: None,
495            extra: None,
496        };
497        let err = atomic_create_operations(&input, "123").expect_err("budget is required");
498        assert!(
499            matches!(err, MktError::ValidationError { .. }),
500            "got: {err}"
501        );
502    }
503
504    #[test]
505    fn test_atomic_create_operations_extra_bidding_replaces_manual_cpc() {
506        let input = CreateCampaignInput {
507            name: "X".into(),
508            objective: "PERFORMANCE_MAX".into(),
509            status: None,
510            budget: Some(Budget {
511                amount: 5.0,
512                currency: "USD".into(),
513                kind: BudgetKind::Daily,
514            }),
515            extra: Some(serde_json::json!({ "maximizeConversionValue": {} })),
516        };
517        let ops = atomic_create_operations(&input, "123").expect("budget present");
518        let create = &ops[1]["campaignOperation"]["create"];
519        assert!(create.get("maximizeConversionValue").is_some());
520        assert!(
521            create.get("manualCpc").is_none(),
522            "default manualCpc must yield to the explicit strategy: {create}"
523        );
524    }
525
526    #[test]
527    fn test_campaign_id_from_atomic_mutate_finds_campaign_result_by_key() {
528        let resp = serde_json::json!({
529            "mutateOperationResponses": [
530                { "campaignBudgetResult": {
531                    "resourceName": "customers/123/campaignBudgets/555"
532                }},
533                { "campaignResult": {
534                    "resourceName": "customers/123/campaigns/789"
535                }}
536            ]
537        });
538        assert_eq!(
539            campaign_id_from_atomic_mutate(&resp).expect("should extract"),
540            "789"
541        );
542    }
543
544    #[test]
545    fn test_campaign_id_from_atomic_mutate_missing_campaign_result_is_error() {
546        let resp = serde_json::json!({
547            "mutateOperationResponses": [
548                { "campaignBudgetResult": {
549                    "resourceName": "customers/123/campaignBudgets/555"
550                }}
551            ]
552        });
553        let err = campaign_id_from_atomic_mutate(&resp).expect_err("no campaignResult");
554        assert!(err.to_string().contains("campaignResult"), "got: {err}");
555    }
556
557    #[test]
558    fn test_campaign_update_operation_mask_matches_fields() {
559        let input = UpdateCampaignInput {
560            name: Some("Renamed".into()),
561            status: Some(CampaignStatus::Paused),
562            budget: None,
563            extra: None,
564        };
565        let ops = campaign_update_operation("123", "456", &input);
566        assert_eq!(ops[0]["updateMask"], "name,status");
567        assert_eq!(ops[0]["update"]["name"], "Renamed");
568        assert_eq!(ops[0]["update"]["status"], "PAUSED");
569        assert_eq!(
570            ops[0]["update"]["resourceName"],
571            "customers/123/campaigns/456"
572        );
573    }
574
575    #[test]
576    fn test_google_insights_to_domain_converts_cost_micros() {
577        let resp = serde_json::json!({
578            "results": [{
579                "campaign": { "id": "1", "name": "C" },
580                "metrics": { "impressions": "100", "costMicros": "2500000" },
581                "segments": { "date": "2026-03-01" }
582            }]
583        });
584        let report = google_insights_to_domain(&resp).expect("should parse");
585        assert_eq!(report.rows.len(), 1);
586        let row = &report.rows[0];
587        assert!((row.metrics["cost"].value - 2.5).abs() < f64::EPSILON);
588        assert!((row.metrics["impressions"].value - 100.0).abs() < f64::EPSILON);
589        assert_eq!(
590            row.dimensions.get("date").map(String::as_str),
591            Some("2026-03-01")
592        );
593    }
594
595    #[test]
596    fn test_escape_gaql_like_neutralizes_wildcards_and_quotes() {
597        assert_eq!(escape_gaql_like("50% off"), "50[%] off");
598        assert_eq!(escape_gaql_like("foo_bar"), "foo[_]bar");
599        assert_eq!(escape_gaql_like("it's"), "it\\'s");
600        assert_eq!(escape_gaql_like("a[b]"), "a[[]b]");
601        assert_eq!(escape_gaql_like("plain"), "plain");
602    }
603}