1use 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
16pub const CAMPAIGN_GAQL_FIELDS: &str = "campaign.id, campaign.name, campaign.status, \
18 campaign.advertising_channel_type, campaign.start_date, campaign_budget.amount_micros";
19
20const MICROS_PER_UNIT: f64 = 1_000_000.0;
22
23pub 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
35pub 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
48pub 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 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
96pub fn campaign_resource_name(customer_id: &str, campaign_id: &str) -> String {
100 format!("customers/{customer_id}/campaigns/{campaign_id}")
101}
102
103#[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
124const 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
144fn 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
186const TEMP_BUDGET_ID: &str = "-1";
190
191pub 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 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
241pub 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
267pub 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#[allow(clippy::unnecessary_wraps)] pub 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
376fn 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}