Skip to main content

fakecloud_ce/
service.rs

1//! AWS Cost Explorer (`ce`) awsJson1.1 dispatch + operation handlers.
2//!
3//! The full 47-operation Cost Explorer control plane. Stored resources are
4//! account-partitioned and persisted; each is kept as its already-output-valid
5//! JSON object so `Get`/`Describe` echoes exactly what `Create`/`Update`
6//! persisted. Reporting operations return the model's exact output shape with
7//! empty result series and zeroed-but-present required aggregates — the honest
8//! "no usage recorded" state for a fake account that incurs no real cost.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use http::StatusCode;
14use serde_json::{json, Map, Value};
15use tokio::sync::Mutex as AsyncMutex;
16use uuid::Uuid;
17
18use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
19use fakecloud_persistence::SnapshotStore;
20
21use crate::persistence::save_snapshot;
22use crate::state::{CeData, SharedCeState};
23use crate::validate::validate_input;
24
25#[cfg(test)]
26mod tests;
27
28/// Every operation name in the Cost Explorer Smithy model (47 operations).
29pub const CE_ACTIONS: &[&str] = &[
30    "CreateAnomalyMonitor",
31    "CreateAnomalySubscription",
32    "CreateCostCategoryDefinition",
33    "DeleteAnomalyMonitor",
34    "DeleteAnomalySubscription",
35    "DeleteCostCategoryDefinition",
36    "DescribeCostCategoryDefinition",
37    "GetAnomalies",
38    "GetAnomalyMonitors",
39    "GetAnomalySubscriptions",
40    "GetApproximateUsageRecords",
41    "GetCommitmentPurchaseAnalysis",
42    "GetCostAndUsage",
43    "GetCostAndUsageComparisons",
44    "GetCostAndUsageWithResources",
45    "GetCostCategories",
46    "GetCostComparisonDrivers",
47    "GetCostForecast",
48    "GetDimensionValues",
49    "GetReservationCoverage",
50    "GetReservationPurchaseRecommendation",
51    "GetReservationUtilization",
52    "GetRightsizingRecommendation",
53    "GetSavingsPlanPurchaseRecommendationDetails",
54    "GetSavingsPlansCoverage",
55    "GetSavingsPlansPurchaseRecommendation",
56    "GetSavingsPlansUtilization",
57    "GetSavingsPlansUtilizationDetails",
58    "GetTags",
59    "GetUsageForecast",
60    "ListCommitmentPurchaseAnalyses",
61    "ListCostAllocationTagBackfillHistory",
62    "ListCostAllocationTags",
63    "ListCostCategoryDefinitions",
64    "ListCostCategoryResourceAssociations",
65    "ListSavingsPlansPurchaseRecommendationGeneration",
66    "ListTagsForResource",
67    "ProvideAnomalyFeedback",
68    "StartCommitmentPurchaseAnalysis",
69    "StartCostAllocationTagBackfill",
70    "StartSavingsPlansPurchaseRecommendationGeneration",
71    "TagResource",
72    "UntagResource",
73    "UpdateAnomalyMonitor",
74    "UpdateAnomalySubscription",
75    "UpdateCostAllocationTagsStatus",
76    "UpdateCostCategoryDefinition",
77];
78
79pub struct CeService {
80    state: SharedCeState,
81    snapshot_store: Option<Arc<dyn SnapshotStore>>,
82    snapshot_lock: Arc<AsyncMutex<()>>,
83}
84
85impl CeService {
86    pub fn new(state: SharedCeState) -> Self {
87        Self {
88            state,
89            snapshot_store: None,
90            snapshot_lock: Arc::new(AsyncMutex::new(())),
91        }
92    }
93
94    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
95        self.snapshot_store = Some(store);
96        self
97    }
98
99    async fn save(&self) {
100        save_snapshot(
101            &self.state,
102            self.snapshot_store.clone(),
103            &self.snapshot_lock,
104        )
105        .await;
106    }
107}
108
109#[async_trait]
110impl AwsService for CeService {
111    fn service_name(&self) -> &str {
112        "ce"
113    }
114
115    async fn handle(&self, request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
116        let mutates = is_mutating(request.action.as_str());
117        let result = dispatch(self, &request);
118        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
119            self.save().await;
120        }
121        result
122    }
123
124    fn supported_actions(&self) -> &[&str] {
125        CE_ACTIONS
126    }
127}
128
129/// Read-only operations do not mutate state and skip the persistence save.
130fn is_mutating(action: &str) -> bool {
131    action.starts_with("Create")
132        || action.starts_with("Delete")
133        || action.starts_with("Update")
134        || action.starts_with("Start")
135        || action.starts_with("Tag")
136        || action.starts_with("Untag")
137        || action == "ProvideAnomalyFeedback"
138}
139
140fn dispatch(s: &CeService, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
141    let b = parse(req)?;
142    validate_input(req.action.as_str(), &b)?;
143    let ctx = Ctx {
144        account: req.account_id.clone(),
145    };
146    match req.action.as_str() {
147        // anomaly monitors
148        "CreateAnomalyMonitor" => s.create_anomaly_monitor(&ctx, &b),
149        "GetAnomalyMonitors" => s.get_anomaly_monitors(&ctx, &b),
150        "UpdateAnomalyMonitor" => s.update_anomaly_monitor(&ctx, &b),
151        "DeleteAnomalyMonitor" => s.delete_anomaly_monitor(&ctx, &b),
152        // anomaly subscriptions
153        "CreateAnomalySubscription" => s.create_anomaly_subscription(&ctx, &b),
154        "GetAnomalySubscriptions" => s.get_anomaly_subscriptions(&ctx, &b),
155        "UpdateAnomalySubscription" => s.update_anomaly_subscription(&ctx, &b),
156        "DeleteAnomalySubscription" => s.delete_anomaly_subscription(&ctx, &b),
157        // anomalies + feedback
158        "GetAnomalies" => s.get_anomalies(&ctx, &b),
159        "ProvideAnomalyFeedback" => s.provide_anomaly_feedback(&ctx, &b),
160        // cost categories
161        "CreateCostCategoryDefinition" => s.create_cost_category(&ctx, &b),
162        "DescribeCostCategoryDefinition" => s.describe_cost_category(&ctx, &b),
163        "UpdateCostCategoryDefinition" => s.update_cost_category(&ctx, &b),
164        "DeleteCostCategoryDefinition" => s.delete_cost_category(&ctx, &b),
165        "ListCostCategoryDefinitions" => s.list_cost_categories(&ctx, &b),
166        "ListCostCategoryResourceAssociations" => s.list_cost_category_associations(&ctx, &b),
167        // cost allocation tags
168        "ListCostAllocationTags" => s.list_cost_allocation_tags(&ctx, &b),
169        "UpdateCostAllocationTagsStatus" => s.update_cost_allocation_tags_status(&ctx, &b),
170        "StartCostAllocationTagBackfill" => s.start_backfill(&ctx, &b),
171        "ListCostAllocationTagBackfillHistory" => s.list_backfill_history(&ctx, &b),
172        // tagging
173        "TagResource" => s.tag_resource(&ctx, &b),
174        "UntagResource" => s.untag_resource(&ctx, &b),
175        "ListTagsForResource" => s.list_tags_for_resource(&ctx, &b),
176        // commitment / savings-plans analyses + generation
177        "StartCommitmentPurchaseAnalysis" => s.start_commitment_analysis(&ctx, &b),
178        "GetCommitmentPurchaseAnalysis" => s.get_commitment_analysis(&ctx, &b),
179        "ListCommitmentPurchaseAnalyses" => s.list_commitment_analyses(&ctx, &b),
180        "StartSavingsPlansPurchaseRecommendationGeneration" => s.start_sp_generation(&ctx, &b),
181        "ListSavingsPlansPurchaseRecommendationGeneration" => s.list_sp_generation(&ctx, &b),
182        // reporting / analytics (structurally-valid zero state)
183        "GetCostAndUsage" => cost_and_usage(&b, false),
184        "GetCostAndUsageWithResources" => cost_and_usage(&b, true),
185        "GetCostAndUsageComparisons" => cost_and_usage_comparisons(&b),
186        "GetCostComparisonDrivers" => cost_comparison_drivers(&b),
187        "GetCostCategories" => get_cost_categories_report(&b),
188        "GetDimensionValues" => get_dimension_values(&b),
189        "GetTags" => get_tags_report(&b),
190        "GetCostForecast" => forecast(&b),
191        "GetUsageForecast" => forecast(&b),
192        "GetApproximateUsageRecords" => approximate_usage_records(&b),
193        "GetReservationCoverage" => reservation_coverage(&b),
194        "GetReservationUtilization" => reservation_utilization(&b),
195        "GetReservationPurchaseRecommendation" => reservation_purchase_recommendation(&b),
196        "GetRightsizingRecommendation" => rightsizing_recommendation(&b),
197        "GetSavingsPlansCoverage" => savings_plans_coverage(&b),
198        "GetSavingsPlansUtilization" => savings_plans_utilization(&b),
199        "GetSavingsPlansUtilizationDetails" => savings_plans_utilization_details(&b),
200        "GetSavingsPlansPurchaseRecommendation" => savings_plans_purchase_recommendation(&b),
201        "GetSavingsPlanPurchaseRecommendationDetails" => {
202            savings_plan_purchase_recommendation_details(&b)
203        }
204        _ => Err(AwsServiceError::action_not_implemented(
205            s.service_name(),
206            &req.action,
207        )),
208    }
209}
210
211// ===================== helpers =====================
212
213/// Per-request account context. Cost Explorer is a global (partition-scoped)
214/// service, so its ARNs carry an empty region segment.
215struct Ctx {
216    account: String,
217}
218
219fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
220    Ok(AwsResponse::json_value(StatusCode::OK, v))
221}
222
223fn empty_ok() -> Result<AwsResponse, AwsServiceError> {
224    ok(json!({}))
225}
226
227fn parse(req: &AwsRequest) -> Result<Value, AwsServiceError> {
228    if req.body.is_empty() {
229        return Ok(json!({}));
230    }
231    serde_json::from_slice(&req.body)
232        .map_err(|e| validation(&format!("Request body is malformed: {e}")))
233}
234
235fn err(code: &str, msg: &str) -> AwsServiceError {
236    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg)
237}
238
239fn validation(msg: &str) -> AwsServiceError {
240    err("ValidationException", msg)
241}
242
243fn unknown_monitor(arn: &str) -> AwsServiceError {
244    err(
245        "UnknownMonitorException",
246        &format!("Cannot find the anomaly monitor: {arn}."),
247    )
248}
249
250fn unknown_subscription(arn: &str) -> AwsServiceError {
251    err(
252        "UnknownSubscriptionException",
253        &format!("Cannot find the anomaly subscription: {arn}."),
254    )
255}
256
257fn cost_category_not_found(arn: &str) -> AwsServiceError {
258    err(
259        "ResourceNotFoundException",
260        &format!("Cannot find the cost category: {arn}."),
261    )
262}
263
264/// Read a required string field. Presence (the key exists and is a string) is
265/// enforced here; the empty string is accepted because several Cost Explorer
266/// identifiers (`GenericString`, `MetricForComparison`) declare `length_min: 0`,
267/// so an empty value is a valid boundary input. Any real minimum length is
268/// enforced in `validate::validate_input`.
269fn req_str<'a>(b: &'a Value, f: &str) -> Result<&'a str, AwsServiceError> {
270    b.get(f)
271        .and_then(Value::as_str)
272        .ok_or_else(|| validation(&format!("{f} is a required field.")))
273}
274
275/// Assert a required (possibly-structured) member is present and non-null.
276fn req_present(b: &Value, f: &str) -> Result<(), AwsServiceError> {
277    match b.get(f) {
278        Some(v) if !v.is_null() => Ok(()),
279        _ => Err(validation(&format!("{f} is a required field."))),
280    }
281}
282
283fn opt_str<'a>(b: &'a Value, f: &str) -> Option<&'a str> {
284    b.get(f).and_then(Value::as_str)
285}
286
287/// Current time as an RFC-3339 UTC `ZonedDateTime` (e.g. `2024-01-01T00:00:00Z`,
288/// 20 chars — within the model's 20..25 length bound and matching its pattern).
289fn now_zoned() -> String {
290    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
291}
292
293/// Current date as a `YearMonthDay` string (`2024-01-01`).
294fn today() -> String {
295    chrono::Utc::now().format("%Y-%m-%d").to_string()
296}
297
298fn new_uuid() -> String {
299    Uuid::new_v4().to_string()
300}
301
302fn monitor_arn(account: &str) -> String {
303    format!("arn:aws:ce::{account}:anomalymonitor/{}", new_uuid())
304}
305
306fn subscription_arn(account: &str) -> String {
307    format!("arn:aws:ce::{account}:anomalysubscription/{}", new_uuid())
308}
309
310fn cost_category_arn(account: &str) -> String {
311    format!("arn:aws:ce::{account}:costcategory/{}", new_uuid())
312}
313
314/// Start/End bounds of an input `DateInterval`, defaulting when absent so the
315/// echoed output `DateInterval` always carries its required members.
316fn interval_bounds(tp: Option<&Value>) -> (String, String) {
317    let start = tp
318        .and_then(|v| v.get("Start"))
319        .and_then(Value::as_str)
320        .unwrap_or("2024-01-01")
321        .to_string();
322    let end = tp
323        .and_then(|v| v.get("End"))
324        .and_then(Value::as_str)
325        .unwrap_or("2024-01-02")
326        .to_string();
327    (start, end)
328}
329
330fn date_interval(start: &str, end: &str) -> Value {
331    json!({ "Start": start, "End": end })
332}
333
334/// The `MetricValue.Unit` Cost Explorer reports for a given metric key. Cost
335/// metrics are denominated in the account currency (`USD`); the usage metrics
336/// (`UsageQuantity`, `NormalizedUsageAmount`, and their enum forms) aggregate
337/// heterogeneous usage units, for which real Cost Explorer returns `N/A`.
338/// Matches both the `MetricName` string form (`UnblendedCost`) and the
339/// `Metric` enum form (`UNBLENDED_COST`).
340fn metric_unit(metric: &str) -> &'static str {
341    let upper = metric.to_ascii_uppercase();
342    if upper.contains("USAGE") || upper.contains("NORMALIZED") {
343        "N/A"
344    } else {
345        "USD"
346    }
347}
348
349/// Zeroed `MetricValue` for the given unit.
350fn metric_value(unit: &str) -> Value {
351    json!({ "Amount": "0", "Unit": unit })
352}
353
354/// Hard ceiling on emitted `ResultByTime` buckets, high enough that no real or
355/// probe input reaches it (274 years of DAILY buckets). When a range would
356/// exceed it, the caller emits a `NextPageToken` resume date so the series is
357/// never silently truncated.
358const MAX_BUCKETS: usize = 100_000;
359
360/// The per-granularity `(start, end)` buckets for a range, plus an optional
361/// resume-start date emitted when generation stopped at [`MAX_BUCKETS`].
362type BucketSplit = (Vec<(String, String)>, Option<String>);
363
364/// Split `[start, end)` into per-granularity `(start, end)` buckets. Returns
365/// `None` when the bounds are not parseable `YYYY-MM-DD` dates (the probe fills
366/// synthetic non-date strings) or the granularity is HOURLY, in which case the
367/// caller emits a single bucket echoing the requested period verbatim.
368/// Otherwise returns every bucket in the range, plus `Some(resume_start)` when
369/// generation stopped at [`MAX_BUCKETS`] with more buckets remaining.
370fn split_buckets(start: &str, end: &str, granularity: &str) -> Option<BucketSplit> {
371    use chrono::{Datelike, Duration, NaiveDate};
372    let fmt = "%Y-%m-%d";
373    let s = NaiveDate::parse_from_str(start.get(0..10)?, fmt).ok()?;
374    let e = NaiveDate::parse_from_str(end.get(0..10)?, fmt).ok()?;
375    if e <= s {
376        return None;
377    }
378    let mut out = Vec::new();
379    let mut resume: Option<String> = None;
380    let mut cur = s;
381    while cur < e {
382        if out.len() >= MAX_BUCKETS {
383            resume = Some(cur.format(fmt).to_string());
384            break;
385        }
386        let next = match granularity {
387            "DAILY" => cur + Duration::days(1),
388            "MONTHLY" => {
389                let (mut y, mut m) = (cur.year(), cur.month());
390                if m == 12 {
391                    y += 1;
392                    m = 1;
393                } else {
394                    m += 1;
395                }
396                NaiveDate::from_ymd_opt(y, m, 1).unwrap_or(e).min(e)
397            }
398            // HOURLY (and anything else): fall back to a single period-echoing
399            // bucket rather than inventing hour-resolution timestamps for an
400            // account with no recorded usage.
401            _ => return None,
402        };
403        out.push((cur.format(fmt).to_string(), next.format(fmt).to_string()));
404        cur = next;
405    }
406    if out.is_empty() {
407        None
408    } else {
409        Some((out, resume))
410    }
411}
412
413/// Opaque-index pagination over ordered rows (`NextToken`/`NextPageToken`).
414fn paginate(rows: Vec<Value>, b: &Value, default_max: usize) -> (Vec<Value>, Option<String>) {
415    let start = b
416        .get("NextToken")
417        .or_else(|| b.get("NextPageToken"))
418        .and_then(Value::as_str)
419        .and_then(|t| t.parse::<usize>().ok())
420        .unwrap_or(0);
421    let max = b
422        .get("MaxResults")
423        .or_else(|| b.get("PageSize"))
424        .and_then(Value::as_u64)
425        .map(|m| m.clamp(1, 10_000) as usize)
426        .unwrap_or(default_max);
427    let end = start.saturating_add(max).min(rows.len());
428    let page = rows.get(start..end).unwrap_or(&[]).to_vec();
429    let next = if end < rows.len() {
430        Some(format!("{end}"))
431    } else {
432        None
433    };
434    (page, next)
435}
436
437/// Copy a set of fields verbatim from `src` into `dst` when present.
438fn copy_fields(src: &Value, dst: &mut Map<String, Value>, fields: &[&str]) {
439    for f in fields {
440        if let Some(v) = src.get(*f) {
441            dst.insert((*f).to_string(), v.clone());
442        }
443    }
444}
445
446/// Store the request's `ResourceTags` into the account tag map under `key`.
447fn store_resource_tags(data: &mut CeData, key: &str, b: &Value) {
448    if let Some(tags) = b.get("ResourceTags").and_then(Value::as_array) {
449        let entry = data.tags.entry(key.to_string()).or_default();
450        for t in tags {
451            if let Some(k) = t.get("Key").and_then(Value::as_str) {
452                let v = t.get("Value").and_then(Value::as_str).unwrap_or("");
453                entry.insert(k.to_string(), v.to_string());
454            }
455        }
456    }
457}
458
459// ===================== anomaly monitors =====================
460
461impl CeService {
462    fn create_anomaly_monitor(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
463        req_present(b, "AnomalyMonitor")?;
464        let arn = monitor_arn(&ctx.account);
465        let mut mon = b
466            .get("AnomalyMonitor")
467            .and_then(Value::as_object)
468            .cloned()
469            .unwrap_or_default();
470        mon.insert("MonitorArn".into(), json!(arn));
471        mon.entry("CreationDate").or_insert(json!(today()));
472        mon.insert("LastUpdatedDate".into(), json!(today()));
473        let mut guard = self.state.write();
474        let data = guard.get_or_create(&ctx.account);
475        store_resource_tags(data, &arn, b);
476        data.anomaly_monitors
477            .insert(arn.clone(), Value::Object(mon));
478        ok(json!({ "MonitorArn": arn }))
479    }
480
481    fn get_anomaly_monitors(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
482        let wanted: Option<Vec<String>> =
483            b.get("MonitorArnList").and_then(Value::as_array).map(|a| {
484                a.iter()
485                    .filter_map(|v| v.as_str().map(str::to_string))
486                    .collect()
487            });
488        let guard = self.state.read();
489        let rows: Vec<Value> = guard
490            .get(&ctx.account)
491            .map(|d| {
492                d.anomaly_monitors
493                    .iter()
494                    .filter(|(arn, _)| wanted.as_ref().is_none_or(|w| w.contains(arn)))
495                    .map(|(_, v)| v.clone())
496                    .collect()
497            })
498            .unwrap_or_default();
499        let (page, next) = paginate(rows, b, 100);
500        let mut out = Map::new();
501        out.insert("AnomalyMonitors".into(), Value::Array(page));
502        if let Some(t) = next {
503            out.insert("NextPageToken".into(), json!(t));
504        }
505        ok(Value::Object(out))
506    }
507
508    fn update_anomaly_monitor(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
509        let arn = req_str(b, "MonitorArn")?.to_string();
510        let mut guard = self.state.write();
511        let data = guard.get_or_create(&ctx.account);
512        let mon = data
513            .anomaly_monitors
514            .get_mut(&arn)
515            .ok_or_else(|| unknown_monitor(&arn))?;
516        if let Some(obj) = mon.as_object_mut() {
517            if let Some(name) = opt_str(b, "MonitorName") {
518                obj.insert("MonitorName".into(), json!(name));
519            }
520            obj.insert("LastUpdatedDate".into(), json!(today()));
521        }
522        ok(json!({ "MonitorArn": arn }))
523    }
524
525    fn delete_anomaly_monitor(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
526        let arn = req_str(b, "MonitorArn")?.to_string();
527        let mut guard = self.state.write();
528        let data = guard.get_or_create(&ctx.account);
529        if data.anomaly_monitors.remove(&arn).is_none() {
530            return Err(unknown_monitor(&arn));
531        }
532        data.tags.remove(&arn);
533        empty_ok()
534    }
535}
536
537// ===================== anomaly subscriptions =====================
538
539impl CeService {
540    fn create_anomaly_subscription(
541        &self,
542        ctx: &Ctx,
543        b: &Value,
544    ) -> Result<AwsResponse, AwsServiceError> {
545        req_present(b, "AnomalySubscription")?;
546        let arn = subscription_arn(&ctx.account);
547        let mut sub = b
548            .get("AnomalySubscription")
549            .and_then(Value::as_object)
550            .cloned()
551            .unwrap_or_default();
552        sub.insert("SubscriptionArn".into(), json!(arn));
553        sub.entry("AccountId").or_insert(json!(ctx.account));
554        let mut guard = self.state.write();
555        let data = guard.get_or_create(&ctx.account);
556        store_resource_tags(data, &arn, b);
557        data.anomaly_subscriptions
558            .insert(arn.clone(), Value::Object(sub));
559        ok(json!({ "SubscriptionArn": arn }))
560    }
561
562    fn get_anomaly_subscriptions(
563        &self,
564        ctx: &Ctx,
565        b: &Value,
566    ) -> Result<AwsResponse, AwsServiceError> {
567        let wanted: Option<Vec<String>> = b
568            .get("SubscriptionArnList")
569            .and_then(Value::as_array)
570            .map(|a| {
571                a.iter()
572                    .filter_map(|v| v.as_str().map(str::to_string))
573                    .collect()
574            });
575        let monitor_filter = opt_str(b, "MonitorArn").map(str::to_string);
576        let guard = self.state.read();
577        let rows: Vec<Value> = guard
578            .get(&ctx.account)
579            .map(|d| {
580                d.anomaly_subscriptions
581                    .iter()
582                    .filter(|(arn, _)| wanted.as_ref().is_none_or(|w| w.contains(arn)))
583                    .filter(|(_, v)| {
584                        monitor_filter.as_deref().is_none_or(|m| {
585                            v.get("MonitorArnList")
586                                .and_then(Value::as_array)
587                                .map(|l| l.iter().any(|x| x.as_str() == Some(m)))
588                                .unwrap_or(false)
589                        })
590                    })
591                    .map(|(_, v)| v.clone())
592                    .collect()
593            })
594            .unwrap_or_default();
595        let (page, next) = paginate(rows, b, 100);
596        let mut out = Map::new();
597        out.insert("AnomalySubscriptions".into(), Value::Array(page));
598        if let Some(t) = next {
599            out.insert("NextPageToken".into(), json!(t));
600        }
601        ok(Value::Object(out))
602    }
603
604    fn update_anomaly_subscription(
605        &self,
606        ctx: &Ctx,
607        b: &Value,
608    ) -> Result<AwsResponse, AwsServiceError> {
609        let arn = req_str(b, "SubscriptionArn")?.to_string();
610        let mut guard = self.state.write();
611        let data = guard.get_or_create(&ctx.account);
612        let sub = data
613            .anomaly_subscriptions
614            .get_mut(&arn)
615            .ok_or_else(|| unknown_subscription(&arn))?;
616        if let Some(obj) = sub.as_object_mut() {
617            copy_fields(
618                b,
619                obj,
620                &[
621                    "Threshold",
622                    "Frequency",
623                    "MonitorArnList",
624                    "Subscribers",
625                    "ThresholdExpression",
626                ],
627            );
628            if let Some(name) = opt_str(b, "SubscriptionName") {
629                obj.insert("SubscriptionName".into(), json!(name));
630            }
631        }
632        ok(json!({ "SubscriptionArn": arn }))
633    }
634
635    fn delete_anomaly_subscription(
636        &self,
637        ctx: &Ctx,
638        b: &Value,
639    ) -> Result<AwsResponse, AwsServiceError> {
640        let arn = req_str(b, "SubscriptionArn")?.to_string();
641        let mut guard = self.state.write();
642        let data = guard.get_or_create(&ctx.account);
643        if data.anomaly_subscriptions.remove(&arn).is_none() {
644            return Err(unknown_subscription(&arn));
645        }
646        data.tags.remove(&arn);
647        empty_ok()
648    }
649}
650
651// ===================== anomalies + feedback =====================
652
653impl CeService {
654    fn get_anomalies(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
655        req_present(b, "DateInterval")?;
656        let monitor_filter = opt_str(b, "MonitorArn").map(str::to_string);
657        let feedback_filter = opt_str(b, "Feedback").map(str::to_string);
658        let guard = self.state.read();
659        let rows: Vec<Value> = guard
660            .get(&ctx.account)
661            .map(|d| {
662                d.anomalies
663                    .values()
664                    .filter(|a| {
665                        monitor_filter
666                            .as_deref()
667                            .is_none_or(|m| a.get("MonitorArn").and_then(Value::as_str) == Some(m))
668                    })
669                    .filter(|a| {
670                        feedback_filter
671                            .as_deref()
672                            .is_none_or(|f| a.get("Feedback").and_then(Value::as_str) == Some(f))
673                    })
674                    .cloned()
675                    .collect()
676            })
677            .unwrap_or_default();
678        let (page, next) = paginate(rows, b, 100);
679        let mut out = Map::new();
680        out.insert("Anomalies".into(), Value::Array(page));
681        if let Some(t) = next {
682            out.insert("NextPageToken".into(), json!(t));
683        }
684        ok(Value::Object(out))
685    }
686
687    fn provide_anomaly_feedback(
688        &self,
689        ctx: &Ctx,
690        b: &Value,
691    ) -> Result<AwsResponse, AwsServiceError> {
692        let id = req_str(b, "AnomalyId")?.to_string();
693        let feedback = req_str(b, "Feedback")?.to_string();
694        let mut guard = self.state.write();
695        let data = guard.get_or_create(&ctx.account);
696        if let Some(anomaly) = data.anomalies.get_mut(&id) {
697            if let Some(obj) = anomaly.as_object_mut() {
698                obj.insert("Feedback".into(), json!(feedback));
699            }
700        }
701        ok(json!({ "AnomalyId": id }))
702    }
703}
704
705// ===================== cost categories =====================
706
707impl CeService {
708    fn build_cost_category(&self, ctx: &Ctx, arn: &str, b: &Value, effective_start: &str) -> Value {
709        let mut cc = Map::new();
710        cc.insert("CostCategoryArn".into(), json!(arn));
711        cc.insert("EffectiveStart".into(), json!(effective_start));
712        cc.insert(
713            "Name".into(),
714            b.get("Name").cloned().unwrap_or(json!("cost-category")),
715        );
716        cc.insert(
717            "RuleVersion".into(),
718            b.get("RuleVersion")
719                .cloned()
720                .unwrap_or(json!("CostCategoryExpression.v1")),
721        );
722        cc.insert("Rules".into(), b.get("Rules").cloned().unwrap_or(json!([])));
723        copy_fields(b, &mut cc, &["SplitChargeRules", "DefaultValue"]);
724        let _ = ctx;
725        Value::Object(cc)
726    }
727
728    fn create_cost_category(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
729        req_str(b, "Name")?;
730        req_str(b, "RuleVersion")?;
731        req_present(b, "Rules")?;
732        let arn = cost_category_arn(&ctx.account);
733        let effective_start = opt_str(b, "EffectiveStart")
734            .map(str::to_string)
735            .unwrap_or_else(now_zoned);
736        let cc = self.build_cost_category(ctx, &arn, b, &effective_start);
737        let mut guard = self.state.write();
738        let data = guard.get_or_create(&ctx.account);
739        store_resource_tags(data, &arn, b);
740        data.cost_categories.insert(arn.clone(), cc);
741        ok(json!({ "CostCategoryArn": arn, "EffectiveStart": effective_start }))
742    }
743
744    fn describe_cost_category(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
745        let arn = req_str(b, "CostCategoryArn")?.to_string();
746        let guard = self.state.read();
747        match guard
748            .get(&ctx.account)
749            .and_then(|d| d.cost_categories.get(&arn))
750        {
751            Some(cc) => ok(json!({ "CostCategory": cc })),
752            None => Err(cost_category_not_found(&arn)),
753        }
754    }
755
756    fn update_cost_category(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
757        let arn = req_str(b, "CostCategoryArn")?.to_string();
758        req_str(b, "RuleVersion")?;
759        req_present(b, "Rules")?;
760        let effective_start = opt_str(b, "EffectiveStart")
761            .map(str::to_string)
762            .unwrap_or_else(now_zoned);
763        let mut guard = self.state.write();
764        let data = guard.get_or_create(&ctx.account);
765        let existing = data
766            .cost_categories
767            .get(&arn)
768            .cloned()
769            .ok_or_else(|| cost_category_not_found(&arn))?;
770        // Preserve the immutable Name; bump RuleVersion/Rules and effective date.
771        let mut merged = b.clone();
772        if let (Some(obj), Some(name)) = (merged.as_object_mut(), existing.get("Name")) {
773            obj.entry("Name").or_insert(name.clone());
774        }
775        let cc = self.build_cost_category(ctx, &arn, &merged, &effective_start);
776        data.cost_categories.insert(arn.clone(), cc);
777        ok(json!({ "CostCategoryArn": arn, "EffectiveStart": effective_start }))
778    }
779
780    fn delete_cost_category(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
781        let arn = req_str(b, "CostCategoryArn")?.to_string();
782        let mut guard = self.state.write();
783        let data = guard.get_or_create(&ctx.account);
784        if data.cost_categories.remove(&arn).is_none() {
785            return Err(cost_category_not_found(&arn));
786        }
787        data.cost_category_associations.remove(&arn);
788        data.tags.remove(&arn);
789        ok(json!({ "CostCategoryArn": arn, "EffectiveEnd": now_zoned() }))
790    }
791
792    fn list_cost_categories(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
793        let guard = self.state.read();
794        let rows: Vec<Value> = guard
795            .get(&ctx.account)
796            .map(|d| {
797                d.cost_categories
798                    .values()
799                    .map(|cc| {
800                        let mut r = Map::new();
801                        copy_fields(
802                            cc,
803                            &mut r,
804                            &["CostCategoryArn", "Name", "EffectiveStart", "EffectiveEnd"],
805                        );
806                        let n = cc
807                            .get("Rules")
808                            .and_then(Value::as_array)
809                            .map(|a| a.len())
810                            .unwrap_or(0);
811                        r.insert("NumberOfRules".into(), json!(n));
812                        copy_fields(cc, &mut r, &["DefaultValue"]);
813                        Value::Object(r)
814                    })
815                    .collect()
816            })
817            .unwrap_or_default();
818        let (page, next) = paginate(rows, b, 100);
819        let mut out = Map::new();
820        out.insert("CostCategoryReferences".into(), Value::Array(page));
821        if let Some(t) = next {
822            out.insert("NextToken".into(), json!(t));
823        }
824        ok(Value::Object(out))
825    }
826
827    fn list_cost_category_associations(
828        &self,
829        ctx: &Ctx,
830        b: &Value,
831    ) -> Result<AwsResponse, AwsServiceError> {
832        let guard = self.state.read();
833        let data = guard.get(&ctx.account);
834        let rows: Vec<Value> = if let Some(arn) = opt_str(b, "CostCategoryArn") {
835            if !data
836                .map(|d| d.cost_categories.contains_key(arn))
837                .unwrap_or(false)
838            {
839                return Err(cost_category_not_found(arn));
840            }
841            data.and_then(|d| d.cost_category_associations.get(arn))
842                .cloned()
843                .unwrap_or_default()
844        } else {
845            data.map(|d| {
846                d.cost_category_associations
847                    .values()
848                    .flatten()
849                    .cloned()
850                    .collect()
851            })
852            .unwrap_or_default()
853        };
854        let (page, next) = paginate(rows, b, 100);
855        let mut out = Map::new();
856        out.insert(
857            "CostCategoryResourceAssociations".into(),
858            Value::Array(page),
859        );
860        if let Some(t) = next {
861            out.insert("NextToken".into(), json!(t));
862        }
863        ok(Value::Object(out))
864    }
865}
866
867// ===================== cost allocation tags =====================
868
869impl CeService {
870    fn list_cost_allocation_tags(
871        &self,
872        ctx: &Ctx,
873        b: &Value,
874    ) -> Result<AwsResponse, AwsServiceError> {
875        let status_filter = opt_str(b, "Status").map(str::to_string);
876        let type_filter = opt_str(b, "Type").map(str::to_string);
877        let key_filter: Option<Vec<String>> = b.get("TagKeys").and_then(Value::as_array).map(|a| {
878            a.iter()
879                .filter_map(|v| v.as_str().map(str::to_string))
880                .collect()
881        });
882        let guard = self.state.read();
883        let rows: Vec<Value> = guard
884            .get(&ctx.account)
885            .map(|d| {
886                d.cost_allocation_tags
887                    .iter()
888                    .filter(|(k, v)| {
889                        status_filter
890                            .as_deref()
891                            .is_none_or(|s| v.get("Status").and_then(Value::as_str) == Some(s))
892                            && type_filter
893                                .as_deref()
894                                .is_none_or(|t| v.get("Type").and_then(Value::as_str) == Some(t))
895                            && key_filter.as_ref().is_none_or(|ks| ks.contains(k))
896                    })
897                    .map(|(_, v)| v.clone())
898                    .collect()
899            })
900            .unwrap_or_default();
901        let (page, next) = paginate(rows, b, 100);
902        let mut out = Map::new();
903        out.insert("CostAllocationTags".into(), Value::Array(page));
904        if let Some(t) = next {
905            out.insert("NextToken".into(), json!(t));
906        }
907        ok(Value::Object(out))
908    }
909
910    fn update_cost_allocation_tags_status(
911        &self,
912        ctx: &Ctx,
913        b: &Value,
914    ) -> Result<AwsResponse, AwsServiceError> {
915        req_present(b, "CostAllocationTagsStatus")?;
916        let entries = b
917            .get("CostAllocationTagsStatus")
918            .and_then(Value::as_array)
919            .cloned()
920            .unwrap_or_default();
921        let mut guard = self.state.write();
922        let data = guard.get_or_create(&ctx.account);
923        for entry in &entries {
924            let (Some(key), Some(status)) = (
925                entry.get("TagKey").and_then(Value::as_str),
926                entry.get("Status").and_then(Value::as_str),
927            ) else {
928                continue;
929            };
930            let tag = data
931                .cost_allocation_tags
932                .entry(key.to_string())
933                .or_insert_with(|| {
934                    json!({
935                        "TagKey": key,
936                        "Type": "UserDefined",
937                        "Status": status,
938                    })
939                });
940            if let Some(obj) = tag.as_object_mut() {
941                obj.insert("Status".into(), json!(status));
942                obj.insert("LastUpdatedDate".into(), json!(now_zoned()));
943            }
944        }
945        // No per-key failures in the emulator: every requested update settles.
946        ok(json!({ "Errors": [] }))
947    }
948
949    fn start_backfill(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
950        let backfill_from = req_str(b, "BackfillFrom")?.to_string();
951        let now = now_zoned();
952        let request = json!({
953            "BackfillFrom": backfill_from,
954            "RequestedAt": now,
955            "CompletedAt": now,
956            "BackfillStatus": "SUCCEEDED",
957            "LastUpdatedAt": now,
958        });
959        let mut guard = self.state.write();
960        let data = guard.get_or_create(&ctx.account);
961        data.backfill_requests.push(request.clone());
962        ok(json!({ "BackfillRequest": request }))
963    }
964
965    fn list_backfill_history(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
966        let guard = self.state.read();
967        let rows: Vec<Value> = guard
968            .get(&ctx.account)
969            .map(|d| d.backfill_requests.iter().rev().cloned().collect())
970            .unwrap_or_default();
971        let (page, next) = paginate(rows, b, 100);
972        let mut out = Map::new();
973        out.insert("BackfillRequests".into(), Value::Array(page));
974        if let Some(t) = next {
975            out.insert("NextToken".into(), json!(t));
976        }
977        ok(Value::Object(out))
978    }
979}
980
981// ===================== tagging =====================
982
983impl CeService {
984    fn tag_resource(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
985        let arn = req_str(b, "ResourceArn")?.to_string();
986        req_present(b, "ResourceTags")?;
987        let mut guard = self.state.write();
988        let data = guard.get_or_create(&ctx.account);
989        store_resource_tags(data, &arn, b);
990        empty_ok()
991    }
992
993    fn untag_resource(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
994        let arn = req_str(b, "ResourceArn")?.to_string();
995        req_present(b, "ResourceTagKeys")?;
996        let keys = b
997            .get("ResourceTagKeys")
998            .and_then(Value::as_array)
999            .cloned()
1000            .unwrap_or_default();
1001        let mut guard = self.state.write();
1002        let data = guard.get_or_create(&ctx.account);
1003        if let Some(entry) = data.tags.get_mut(&arn) {
1004            for k in &keys {
1005                if let Some(k) = k.as_str() {
1006                    entry.remove(k);
1007                }
1008            }
1009        }
1010        empty_ok()
1011    }
1012
1013    fn list_tags_for_resource(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
1014        let arn = req_str(b, "ResourceArn")?.to_string();
1015        let guard = self.state.read();
1016        let tags: Vec<Value> = guard
1017            .get(&ctx.account)
1018            .and_then(|d| d.tags.get(&arn))
1019            .map(|m| {
1020                m.iter()
1021                    .map(|(k, v)| json!({ "Key": k, "Value": v }))
1022                    .collect()
1023            })
1024            .unwrap_or_default();
1025        ok(json!({ "ResourceTags": tags }))
1026    }
1027}
1028
1029// ===================== commitment / savings-plans analyses =====================
1030
1031impl CeService {
1032    fn start_commitment_analysis(
1033        &self,
1034        ctx: &Ctx,
1035        b: &Value,
1036    ) -> Result<AwsResponse, AwsServiceError> {
1037        req_present(b, "CommitmentPurchaseAnalysisConfiguration")?;
1038        let id = new_uuid();
1039        let now = now_zoned();
1040        let config = b
1041            .get("CommitmentPurchaseAnalysisConfiguration")
1042            .cloned()
1043            .unwrap_or(json!({}));
1044        let record = json!({
1045            "AnalysisId": id,
1046            "AnalysisStartedTime": now,
1047            "EstimatedCompletionTime": now,
1048            "AnalysisCompletionTime": now,
1049            "AnalysisStatus": "SUCCEEDED",
1050            "CommitmentPurchaseAnalysisConfiguration": config,
1051        });
1052        let mut guard = self.state.write();
1053        let data = guard.get_or_create(&ctx.account);
1054        data.commitment_analyses.insert(id.clone(), record);
1055        ok(json!({
1056            "AnalysisId": id,
1057            "AnalysisStartedTime": now,
1058            "EstimatedCompletionTime": now,
1059        }))
1060    }
1061
1062    fn get_commitment_analysis(
1063        &self,
1064        ctx: &Ctx,
1065        b: &Value,
1066    ) -> Result<AwsResponse, AwsServiceError> {
1067        let id = req_str(b, "AnalysisId")?.to_string();
1068        let guard = self.state.read();
1069        let rec = guard
1070            .get(&ctx.account)
1071            .and_then(|d| d.commitment_analyses.get(&id))
1072            .cloned()
1073            .ok_or_else(|| {
1074                err(
1075                    "AnalysisNotFoundException",
1076                    &format!("Cannot find the analysis: {id}."),
1077                )
1078            })?;
1079        ok(rec)
1080    }
1081
1082    fn list_commitment_analyses(
1083        &self,
1084        ctx: &Ctx,
1085        b: &Value,
1086    ) -> Result<AwsResponse, AwsServiceError> {
1087        let status_filter = opt_str(b, "AnalysisStatus").map(str::to_string);
1088        let id_filter: Option<Vec<String>> =
1089            b.get("AnalysisIds").and_then(Value::as_array).map(|a| {
1090                a.iter()
1091                    .filter_map(|v| v.as_str().map(str::to_string))
1092                    .collect()
1093            });
1094        let guard = self.state.read();
1095        let rows: Vec<Value> = guard
1096            .get(&ctx.account)
1097            .map(|d| {
1098                d.commitment_analyses
1099                    .iter()
1100                    .filter(|(id, v)| {
1101                        status_filter.as_deref().is_none_or(|s| {
1102                            v.get("AnalysisStatus").and_then(Value::as_str) == Some(s)
1103                        }) && id_filter.as_ref().is_none_or(|ids| ids.contains(id))
1104                    })
1105                    .map(|(_, v)| {
1106                        let mut summary = Map::new();
1107                        copy_fields(
1108                            v,
1109                            &mut summary,
1110                            &[
1111                                "AnalysisId",
1112                                "AnalysisStatus",
1113                                "AnalysisStartedTime",
1114                                "AnalysisCompletionTime",
1115                                "EstimatedCompletionTime",
1116                                "CommitmentPurchaseAnalysisConfiguration",
1117                            ],
1118                        );
1119                        Value::Object(summary)
1120                    })
1121                    .collect()
1122            })
1123            .unwrap_or_default();
1124        let (page, next) = paginate(rows, b, 100);
1125        let mut out = Map::new();
1126        out.insert("AnalysisSummaryList".into(), Value::Array(page));
1127        if let Some(t) = next {
1128            out.insert("NextPageToken".into(), json!(t));
1129        }
1130        ok(Value::Object(out))
1131    }
1132
1133    fn start_sp_generation(&self, ctx: &Ctx, _b: &Value) -> Result<AwsResponse, AwsServiceError> {
1134        let id = new_uuid();
1135        let now = now_zoned();
1136        let record = json!({
1137            "RecommendationId": id,
1138            "GenerationStatus": "SUCCEEDED",
1139            "GenerationStartedTime": now,
1140            "GenerationCompletionTime": now,
1141            "EstimatedCompletionTime": now,
1142        });
1143        let mut guard = self.state.write();
1144        let data = guard.get_or_create(&ctx.account);
1145        data.sp_generations.insert(id.clone(), record);
1146        ok(json!({
1147            "RecommendationId": id,
1148            "GenerationStartedTime": now,
1149            "EstimatedCompletionTime": now,
1150        }))
1151    }
1152
1153    fn list_sp_generation(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
1154        let status_filter = opt_str(b, "GenerationStatus").map(str::to_string);
1155        let id_filter: Option<Vec<String>> = b
1156            .get("RecommendationIds")
1157            .and_then(Value::as_array)
1158            .map(|a| {
1159                a.iter()
1160                    .filter_map(|v| v.as_str().map(str::to_string))
1161                    .collect()
1162            });
1163        let guard = self.state.read();
1164        let rows: Vec<Value> = guard
1165            .get(&ctx.account)
1166            .map(|d| {
1167                d.sp_generations
1168                    .iter()
1169                    .filter(|(id, v)| {
1170                        status_filter.as_deref().is_none_or(|s| {
1171                            v.get("GenerationStatus").and_then(Value::as_str) == Some(s)
1172                        }) && id_filter.as_ref().is_none_or(|ids| ids.contains(id))
1173                    })
1174                    .map(|(_, v)| v.clone())
1175                    .collect()
1176            })
1177            .unwrap_or_default();
1178        let (page, next) = paginate(rows, b, 100);
1179        let mut out = Map::new();
1180        out.insert("GenerationSummaryList".into(), Value::Array(page));
1181        if let Some(t) = next {
1182            out.insert("NextPageToken".into(), json!(t));
1183        }
1184        ok(Value::Object(out))
1185    }
1186}
1187
1188// ===================== reporting / analytics (zero state) =====================
1189
1190/// One `ResultByTime` bucket with the requested metrics zeroed (each in its
1191/// metric-appropriate unit) and no groups.
1192fn result_bucket(start: &str, end: &str, metrics: &[String]) -> Value {
1193    let mut total = Map::new();
1194    for m in metrics {
1195        total.insert(m.clone(), metric_value(metric_unit(m)));
1196    }
1197    json!({
1198        "TimePeriod": date_interval(start, end),
1199        "Total": Value::Object(total),
1200        "Groups": [],
1201        "Estimated": false,
1202    })
1203}
1204
1205/// `GetCostAndUsage` (`require_filter=false`) and `GetCostAndUsageWithResources`
1206/// (`require_filter=true`) share this handler. The two ops declare different
1207/// `@required` input members: `GetCostAndUsage` requires `Metrics`, while
1208/// `GetCostAndUsageWithResources` requires `Filter` (and `Metrics` is optional).
1209fn cost_and_usage(b: &Value, require_filter: bool) -> Result<AwsResponse, AwsServiceError> {
1210    req_present(b, "TimePeriod")?;
1211    req_str(b, "Granularity")?;
1212    if require_filter {
1213        req_present(b, "Filter")?;
1214    } else {
1215        req_present(b, "Metrics")?;
1216    }
1217    let (period_start, end) = interval_bounds(b.get("TimePeriod"));
1218    // A `NextPageToken` carrying a resume date (emitted only when a prior page
1219    // hit MAX_BUCKETS) continues the series from there; otherwise start at the
1220    // requested period start. Synthetic non-date tokens fall back to the start.
1221    let start = opt_str(b, "NextPageToken")
1222        .filter(|t| {
1223            chrono::NaiveDate::parse_from_str(t.get(0..10).unwrap_or(t), "%Y-%m-%d").is_ok()
1224        })
1225        .unwrap_or(&period_start)
1226        .to_string();
1227    let granularity = opt_str(b, "Granularity").unwrap_or("MONTHLY");
1228    let metrics: Vec<String> = b
1229        .get("Metrics")
1230        .and_then(Value::as_array)
1231        .map(|a| {
1232            a.iter()
1233                .filter_map(|v| v.as_str().map(str::to_string))
1234                .collect()
1235        })
1236        .unwrap_or_default();
1237    let (results, next_token): (Vec<Value>, Option<String>) =
1238        match split_buckets(&start, &end, granularity) {
1239            Some((buckets, resume)) => (
1240                buckets
1241                    .iter()
1242                    .map(|(s, e)| result_bucket(s, e, &metrics))
1243                    .collect(),
1244                resume,
1245            ),
1246            None => (vec![result_bucket(&start, &end, &metrics)], None),
1247        };
1248    let mut out = Map::new();
1249    out.insert("ResultsByTime".into(), Value::Array(results));
1250    if let Some(gb) = b.get("GroupBy") {
1251        out.insert("GroupDefinitions".into(), gb.clone());
1252    }
1253    out.insert("DimensionValueAttributes".into(), json!([]));
1254    if let Some(token) = next_token {
1255        out.insert("NextPageToken".into(), json!(token));
1256    }
1257    ok(Value::Object(out))
1258}
1259
1260fn cost_and_usage_comparisons(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1261    req_present(b, "BaselineTimePeriod")?;
1262    req_present(b, "ComparisonTimePeriod")?;
1263    req_str(b, "MetricForComparison")?;
1264    ok(json!({
1265        "CostAndUsageComparisons": [],
1266        "TotalCostAndUsage": {},
1267    }))
1268}
1269
1270fn cost_comparison_drivers(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1271    req_present(b, "BaselineTimePeriod")?;
1272    req_present(b, "ComparisonTimePeriod")?;
1273    req_str(b, "MetricForComparison")?;
1274    ok(json!({ "CostComparisonDrivers": [] }))
1275}
1276
1277fn get_cost_categories_report(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1278    req_present(b, "TimePeriod")?;
1279    ok(json!({
1280        "CostCategoryNames": [],
1281        "CostCategoryValues": [],
1282        "ReturnSize": 0,
1283        "TotalSize": 0,
1284    }))
1285}
1286
1287fn get_dimension_values(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1288    req_present(b, "TimePeriod")?;
1289    req_str(b, "Dimension")?;
1290    ok(json!({
1291        "DimensionValues": [],
1292        "ReturnSize": 0,
1293        "TotalSize": 0,
1294    }))
1295}
1296
1297fn get_tags_report(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1298    req_present(b, "TimePeriod")?;
1299    ok(json!({
1300        "Tags": [],
1301        "ReturnSize": 0,
1302        "TotalSize": 0,
1303    }))
1304}
1305
1306fn forecast(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1307    req_present(b, "TimePeriod")?;
1308    let metric = req_str(b, "Metric")?;
1309    let unit = metric_unit(metric);
1310    req_str(b, "Granularity")?;
1311    ok(json!({
1312        "Total": metric_value(unit),
1313        "ForecastResultsByTime": [],
1314    }))
1315}
1316
1317fn approximate_usage_records(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1318    req_str(b, "Granularity")?;
1319    req_str(b, "ApproximationDimension")?;
1320    ok(json!({
1321        "Services": {},
1322        "TotalRecords": 0,
1323        "LookbackPeriod": date_interval("2024-01-01", "2024-01-02"),
1324    }))
1325}
1326
1327fn reservation_coverage(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1328    req_present(b, "TimePeriod")?;
1329    ok(json!({ "CoveragesByTime": [] }))
1330}
1331
1332fn reservation_utilization(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1333    req_present(b, "TimePeriod")?;
1334    ok(json!({ "UtilizationsByTime": [] }))
1335}
1336
1337fn reservation_purchase_recommendation(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1338    req_str(b, "Service")?;
1339    ok(json!({ "Recommendations": [] }))
1340}
1341
1342fn rightsizing_recommendation(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1343    req_str(b, "Service")?;
1344    ok(json!({ "RightsizingRecommendations": [] }))
1345}
1346
1347fn savings_plans_coverage(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1348    req_present(b, "TimePeriod")?;
1349    ok(json!({ "SavingsPlansCoverages": [] }))
1350}
1351
1352fn savings_plans_utilization(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1353    req_present(b, "TimePeriod")?;
1354    ok(json!({
1355        "Total": {
1356            "Utilization": {
1357                "TotalCommitment": "0.0",
1358                "UsedCommitment": "0.0",
1359                "UnusedCommitment": "0.0",
1360                "UtilizationPercentage": "0.0",
1361            }
1362        }
1363    }))
1364}
1365
1366fn savings_plans_utilization_details(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1367    req_present(b, "TimePeriod")?;
1368    let (start, end) = interval_bounds(b.get("TimePeriod"));
1369    ok(json!({
1370        "SavingsPlansUtilizationDetails": [],
1371        "TimePeriod": date_interval(&start, &end),
1372    }))
1373}
1374
1375fn savings_plans_purchase_recommendation(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1376    req_str(b, "SavingsPlansType")?;
1377    req_str(b, "TermInYears")?;
1378    req_str(b, "PaymentOption")?;
1379    req_str(b, "LookbackPeriodInDays")?;
1380    ok(json!({}))
1381}
1382
1383fn savings_plan_purchase_recommendation_details(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1384    let id = req_str(b, "RecommendationDetailId")?.to_string();
1385    ok(json!({ "RecommendationDetailId": id }))
1386}