Skip to main content

tryaudex_core/
estimate.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::policy::ScopedPolicy;
6
7/// Cost estimate for a set of IAM actions.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CostEstimate {
10    /// Per-service cost breakdown.
11    pub services: Vec<ServiceEstimate>,
12    /// Total estimated cost for the session.
13    pub total_min: f64,
14    pub total_max: f64,
15    /// Risk level: "low", "medium", "high".
16    pub risk_level: String,
17    /// Human-readable warnings.
18    pub warnings: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ServiceEstimate {
23    pub service: String,
24    pub actions: Vec<String>,
25    pub min_cost: f64,
26    pub max_cost: f64,
27    pub notes: String,
28}
29
30/// Known cost profiles for common AWS, GCP, and Azure services.
31/// These are rough estimates based on typical usage patterns.
32/// Real costs depend on usage volume, region, and data transfer.
33///
34/// All AWS prices reflect **us-east-1** on-demand rates. Other regions
35/// typically differ by 30-50% (e.g. ap-southeast-1 is ~20-30% higher,
36/// eu-west-1 ~5-15% higher). GCP prices reflect us-central1; Azure
37/// prices reflect East US.
38fn cost_profiles() -> HashMap<&'static str, ServiceCostProfile> {
39    HashMap::from([
40        // ── AWS ──
41        ("s3", ServiceCostProfile {
42            read_actions: &["GetObject", "ListBucket", "ListAllMyBuckets", "GetBucketLocation", "HeadObject"],
43            write_actions: &["PutObject", "DeleteObject", "CreateBucket", "CopyObject"],
44            read_cost_per_1k: 0.0004,   // $0.0004 per 1,000 GET requests
45            write_cost_per_1k: 0.005,    // $0.005 per 1,000 PUT requests
46            data_cost_per_gb: 0.023,     // $0.023/GB stored
47            typical_requests: 100,
48            notes: "S3 pricing: GET $0.0004/1K, PUT $0.005/1K, storage $0.023/GB/mo",
49        }),
50        ("lambda", ServiceCostProfile {
51            read_actions: &["GetFunction", "ListFunctions", "GetFunctionConfiguration"],
52            write_actions: &["InvokeFunction", "UpdateFunctionCode", "UpdateFunctionConfiguration", "CreateFunction"],
53            read_cost_per_1k: 0.0,
54            write_cost_per_1k: 0.20,     // ~$0.20 per 1M invocations = $0.0002/1K
55            data_cost_per_gb: 0.0000167, // $0.0000166667/GB-second
56            typical_requests: 10,
57            notes: "Lambda: $0.20/1M requests + $0.0000166667/GB-s compute",
58        }),
59        ("dynamodb", ServiceCostProfile {
60            read_actions: &["GetItem", "Query", "Scan", "BatchGetItem", "DescribeTable", "ListTables"],
61            write_actions: &["PutItem", "UpdateItem", "DeleteItem", "BatchWriteItem"],
62            read_cost_per_1k: 0.00025,   // $0.25 per 1M RRU
63            write_cost_per_1k: 0.00125,  // $1.25 per 1M WRU
64            data_cost_per_gb: 0.25,
65            typical_requests: 100,
66            notes: "DynamoDB on-demand: $0.25/1M RRU, $1.25/1M WRU",
67        }),
68        ("ec2", ServiceCostProfile {
69            read_actions: &["DescribeInstances", "DescribeSecurityGroups", "DescribeSubnets", "DescribeVpcs", "DescribeImages"],
70            write_actions: &["RunInstances", "TerminateInstances", "StartInstances", "StopInstances", "CreateSecurityGroup"],
71            read_cost_per_1k: 0.0,
72            write_cost_per_1k: 0.0,      // EC2 API calls are free, instances cost
73            data_cost_per_gb: 0.0,
74            typical_requests: 10,
75            notes: "EC2 API calls are free. Instance costs: $0.0116/hr (t3.micro) to $3.84/hr (p3.16xlarge)",
76        }),
77        ("iam", ServiceCostProfile {
78            read_actions: &["GetRole", "GetPolicy", "GetPolicyVersion", "ListRolePolicies", "ListAttachedRolePolicies"],
79            write_actions: &["CreateRole", "DeleteRole", "AttachRolePolicy", "DetachRolePolicy", "PutRolePolicy", "DeleteRolePolicy", "PassRole"],
80            read_cost_per_1k: 0.0,
81            write_cost_per_1k: 0.0,
82            data_cost_per_gb: 0.0,
83            typical_requests: 10,
84            notes: "IAM API calls are free. WARNING: IAM changes affect account security",
85        }),
86        ("sqs", ServiceCostProfile {
87            read_actions: &["ReceiveMessage", "GetQueueAttributes", "ListQueues"],
88            write_actions: &["SendMessage", "DeleteMessage", "CreateQueue"],
89            read_cost_per_1k: 0.0004,
90            write_cost_per_1k: 0.0004,
91            data_cost_per_gb: 0.0,
92            typical_requests: 100,
93            notes: "SQS: $0.40/1M requests (first 1M free)",
94        }),
95        ("sns", ServiceCostProfile {
96            read_actions: &["ListTopics", "GetTopicAttributes"],
97            write_actions: &["Publish"],
98            read_cost_per_1k: 0.0,
99            write_cost_per_1k: 0.0005,
100            data_cost_per_gb: 0.0,
101            typical_requests: 10,
102            notes: "SNS: $0.50/1M publishes",
103        }),
104        ("ecr", ServiceCostProfile {
105            read_actions: &["GetAuthorizationToken", "BatchCheckLayerAvailability", "GetDownloadUrlForLayer", "BatchGetImage", "DescribeRepositories"],
106            write_actions: &["PutImage", "InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "CreateRepository"],
107            read_cost_per_1k: 0.0,
108            write_cost_per_1k: 0.0,
109            data_cost_per_gb: 0.10,
110            typical_requests: 10,
111            notes: "ECR: $0.10/GB/month storage. Data transfer charges apply",
112        }),
113        ("logs", ServiceCostProfile {
114            read_actions: &["GetLogEvents", "DescribeLogGroups", "DescribeLogStreams", "FilterLogEvents"],
115            write_actions: &["PutLogEvents", "CreateLogGroup", "CreateLogStream"],
116            read_cost_per_1k: 0.005,
117            write_cost_per_1k: 0.0,
118            data_cost_per_gb: 0.50,
119            typical_requests: 50,
120            notes: "CloudWatch Logs: $0.50/GB ingested, $0.005/1K queries",
121        }),
122        ("sts", ServiceCostProfile {
123            read_actions: &["GetCallerIdentity"],
124            write_actions: &["AssumeRole"],
125            read_cost_per_1k: 0.0,
126            write_cost_per_1k: 0.0,
127            data_cost_per_gb: 0.0,
128            typical_requests: 1,
129            notes: "STS API calls are free",
130        }),
131        ("cloudformation", ServiceCostProfile {
132            read_actions: &["DescribeStacks", "ListStacks", "GetTemplate"],
133            write_actions: &["CreateStack", "UpdateStack", "DeleteStack"],
134            read_cost_per_1k: 0.0,
135            write_cost_per_1k: 0.0,
136            data_cost_per_gb: 0.0,
137            typical_requests: 10,
138            notes: "CloudFormation: free for AWS resources, $0.0009/handler operation for third-party",
139        }),
140        // NOTE: The Bedrock cost profile below is a rough order-of-magnitude
141        // estimate based on a fixed per-invocation rate. It is NOT token-volume
142        // aware — real costs scale with prompt/completion length and vary widely
143        // by model (e.g. Claude 3 Haiku vs. Claude 3 Opus). Do not use these
144        // figures for budget enforcement; they are indicative only.
145        ("bedrock", ServiceCostProfile {
146            read_actions: &["ListFoundationModels", "GetFoundationModel"],
147            write_actions: &["InvokeModel", "InvokeModelWithResponseStream"],
148            read_cost_per_1k: 0.0,
149            write_cost_per_1k: 3.0,      // ~$0.003/1K input tokens * 1K calls (rough estimate)
150            data_cost_per_gb: 0.0,
151            typical_requests: 10,
152            notes: "Bedrock: $0.003-$0.06/1K input tokens depending on model. \
153                    WARNING: This estimate is NOT token-volume aware — actual costs scale \
154                    with prompt/completion length. Use for risk categorisation only, \
155                    not budget enforcement.",
156        }),
157        ("sagemaker", ServiceCostProfile {
158            read_actions: &["DescribeEndpoint", "ListEndpoints", "DescribeTrainingJob"],
159            write_actions: &["CreateEndpoint", "CreateTrainingJob", "InvokeEndpoint"],
160            read_cost_per_1k: 0.0,
161            write_cost_per_1k: 0.0,
162            data_cost_per_gb: 0.0,
163            typical_requests: 5,
164            notes: "SageMaker: $0.046/hr (ml.t3.medium) to $32.77/hr (ml.p3.16xlarge). Training and inference billed separately",
165        }),
166        // ── GCP (service names are dot-prefix, e.g. "storage") ──
167        ("storage", ServiceCostProfile {
168            read_actions: &["objects.get", "objects.list", "buckets.get", "buckets.list"],
169            write_actions: &["objects.create", "objects.delete", "buckets.create"],
170            read_cost_per_1k: 0.0004,    // $0.004/10K Class A ops
171            write_cost_per_1k: 0.005,    // $0.05/10K Class B ops
172            data_cost_per_gb: 0.020,     // $0.020/GB/mo Standard
173            typical_requests: 100,
174            notes: "GCS pricing: Class A $0.05/10K, Class B $0.004/10K, storage $0.020/GB/mo",
175        }),
176        ("compute", ServiceCostProfile {
177            read_actions: &["instances.get", "instances.list", "zones.list"],
178            write_actions: &["instances.create", "instances.delete", "instances.start", "instances.stop"],
179            read_cost_per_1k: 0.0,
180            write_cost_per_1k: 0.0,
181            data_cost_per_gb: 0.0,
182            typical_requests: 10,
183            notes: "GCE API calls are free. Instance costs: $0.0076/hr (e2-micro) to $2.84/hr (a2-megagpu-16g)",
184        }),
185        ("bigquery", ServiceCostProfile {
186            read_actions: &["tables.getData", "jobs.get", "datasets.get"],
187            write_actions: &["jobs.create", "tables.create", "tables.insert"],
188            read_cost_per_1k: 0.0,
189            write_cost_per_1k: 0.0,
190            data_cost_per_gb: 0.00625,    // $6.25/TB queried = $0.00625/GB
191            typical_requests: 10,
192            notes: "BigQuery on-demand: $6.25/TB queried, $0.02/GB/mo storage",
193        }),
194        ("cloudfunctions", ServiceCostProfile {
195            read_actions: &["functions.get", "functions.list"],
196            write_actions: &["functions.create", "functions.call"],
197            read_cost_per_1k: 0.0,
198            write_cost_per_1k: 0.0004,   // $0.40/1M invocations
199            data_cost_per_gb: 0.0000025, // $0.0000025/GB-s
200            typical_requests: 10,
201            notes: "Cloud Functions: $0.40/1M invocations + $0.0000025/GB-s compute",
202        }),
203        ("pubsub", ServiceCostProfile {
204            read_actions: &["subscriptions.pull", "topics.get"],
205            write_actions: &["topics.publish", "topics.create", "subscriptions.create"],
206            read_cost_per_1k: 0.0,
207            write_cost_per_1k: 0.0,
208            data_cost_per_gb: 0.04,      // $40/TiB
209            typical_requests: 100,
210            notes: "Pub/Sub: $40/TiB message delivery (first 10 GiB free)",
211        }),
212        ("run", ServiceCostProfile {
213            read_actions: &["services.get", "services.list"],
214            write_actions: &["services.create", "services.replaceService"],
215            read_cost_per_1k: 0.0,
216            write_cost_per_1k: 0.0,
217            data_cost_per_gb: 0.0,
218            typical_requests: 5,
219            notes: "Cloud Run: $0.00002400/vCPU-s, $0.00000250/GiB-s, $0.40/1M requests",
220        }),
221        // ── Azure (service names are "Microsoft.X") ──
222        ("Microsoft.Storage", ServiceCostProfile {
223            read_actions: &["storageAccounts/read", "storageAccounts/blobServices/containers/blobs/read"],
224            write_actions: &["storageAccounts/write", "storageAccounts/blobServices/containers/blobs/write"],
225            read_cost_per_1k: 0.0004,
226            write_cost_per_1k: 0.005,
227            data_cost_per_gb: 0.018,     // $0.018/GB Hot LRS
228            typical_requests: 100,
229            notes: "Azure Blob: $0.018/GB/mo Hot, PUT $0.065/10K, GET $0.005/10K",
230        }),
231        ("Microsoft.Compute", ServiceCostProfile {
232            read_actions: &["virtualMachines/read", "disks/read"],
233            write_actions: &["virtualMachines/write", "virtualMachines/start/action", "virtualMachines/delete"],
234            read_cost_per_1k: 0.0,
235            write_cost_per_1k: 0.0,
236            data_cost_per_gb: 0.0,
237            typical_requests: 10,
238            notes: "Azure VM API calls are free. Instance costs: $0.008/hr (B1ls) to $3.045/hr (NC24s_v3)",
239        }),
240        ("Microsoft.Sql", ServiceCostProfile {
241            read_actions: &["servers/read", "servers/databases/read"],
242            write_actions: &["servers/write", "servers/databases/write"],
243            read_cost_per_1k: 0.0,
244            write_cost_per_1k: 0.0,
245            data_cost_per_gb: 0.0,
246            typical_requests: 5,
247            notes: "Azure SQL: $0.0211/hr (Basic) to $174.98/hr (Business Critical). Billed per DTU or vCore",
248        }),
249        ("Microsoft.ContainerService", ServiceCostProfile {
250            read_actions: &["managedClusters/read"],
251            write_actions: &["managedClusters/write", "managedClusters/delete"],
252            read_cost_per_1k: 0.0,
253            write_cost_per_1k: 0.0,
254            data_cost_per_gb: 0.0,
255            typical_requests: 5,
256            notes: "AKS: free control plane, node costs apply. Standard tier $0.10/hr per cluster",
257        }),
258        ("Microsoft.KeyVault", ServiceCostProfile {
259            read_actions: &["vaults/read", "vaults/secrets/read"],
260            write_actions: &["vaults/write", "vaults/secrets/write"],
261            read_cost_per_1k: 0.003,     // $0.03/10K operations
262            write_cost_per_1k: 0.003,
263            data_cost_per_gb: 0.0,
264            typical_requests: 10,
265            notes: "Key Vault: $0.03/10K operations (secrets), $1/key/mo (HSM-protected keys)",
266        }),
267        ("Microsoft.Web", ServiceCostProfile {
268            read_actions: &["sites/read", "serverFarms/read"],
269            write_actions: &["sites/write", "serverFarms/write"],
270            read_cost_per_1k: 0.0,
271            write_cost_per_1k: 0.0,
272            data_cost_per_gb: 0.0,
273            typical_requests: 5,
274            notes: "App Service: $0.018/hr (B1) to $0.80/hr (P3v3). Free tier available",
275        }),
276    ])
277}
278
279struct ServiceCostProfile {
280    read_actions: &'static [&'static str],
281    write_actions: &'static [&'static str],
282    read_cost_per_1k: f64,
283    write_cost_per_1k: f64,
284    data_cost_per_gb: f64,
285    typical_requests: u32,
286    notes: &'static str,
287}
288
289/// Estimate the potential cost of running a session with the given policy.
290///
291/// NOTE: These are rough floor-price estimates based on us-east-1 / us-central1 /
292/// East US on-demand pricing. Actual costs vary by region, usage, and pricing model.
293pub fn estimate(policy: &ScopedPolicy, ttl_seconds: u64) -> CostEstimate {
294    let profiles = cost_profiles();
295    let mut services = Vec::new();
296    let mut warnings = Vec::new();
297    let disclaimer = "Cost estimates use floor prices (us-east-1 / us-central1 / East US) — \
298         actual costs may be higher depending on region, data transfer, and usage volume."
299        .to_string();
300    let mut total_min = 0.0;
301    let mut total_max = 0.0;
302
303    // Group actions by service
304    let mut by_service: HashMap<String, Vec<String>> = HashMap::new();
305    for action in &policy.actions {
306        by_service
307            .entry(action.service.clone())
308            .or_default()
309            .push(action.action.clone());
310    }
311
312    let ttl_hours = ttl_seconds as f64 / 3600.0;
313
314    for (service, actions) in &by_service {
315        // R6-M7: profile keys for Azure use canonical casing
316        // (`Microsoft.Storage`), but `parse_azure` preserves whatever
317        // case the user typed. Fall back to a case-insensitive lookup
318        // so `microsoft.storage` still matches `Microsoft.Storage`.
319        let profile_entry = profiles.get(service.as_str()).or_else(|| {
320            profiles
321                .iter()
322                .find(|(k, _)| k.eq_ignore_ascii_case(service.as_str()))
323                .map(|(_, v)| v)
324        });
325        if let Some(profile) = profile_entry {
326            let mut read_count = 0;
327            let mut write_count = 0;
328
329            for action in actions {
330                // R6-M9: detect embedded `*` (e.g. `storageAccounts/*`,
331                // `objects.*`) as a service-wide wildcard rather than
332                // falling through to the unknown-action branch. Only a
333                // bare `*` was treated as a wildcard before; anything
334                // with a prefix was charged as "unknown, assume write"
335                // and under-estimated cost against a mixed workload.
336                if action == "*" || action.contains('*') {
337                    read_count += profile.typical_requests;
338                    write_count += profile.typical_requests;
339                    warnings.push(format!(
340                        "{}:{} contains a wildcard — cost depends on actual usage",
341                        service, action
342                    ));
343                } else if profile
344                    .read_actions
345                    .iter()
346                    .any(|r| action.starts_with(r) || *r == action.as_str())
347                {
348                    read_count += profile.typical_requests;
349                } else if profile
350                    .write_actions
351                    .iter()
352                    .any(|w| action.starts_with(w) || *w == action.as_str())
353                {
354                    write_count += profile.typical_requests;
355                } else {
356                    // Unknown action, assume write
357                    write_count += profile.typical_requests / 2;
358                }
359            }
360
361            // Scale by TTL (longer sessions = more potential requests)
362            let scale = (ttl_hours * 0.5).max(1.0); // at least 1x
363            let scaled_reads = (read_count as f64 * scale) as u32;
364            let scaled_writes = (write_count as f64 * scale) as u32;
365
366            let read_cost = (scaled_reads as f64 / 1000.0) * profile.read_cost_per_1k;
367            let write_cost = (scaled_writes as f64 / 1000.0) * profile.write_cost_per_1k;
368            let min_cost = read_cost + write_cost;
369            // Max assumes 10x typical usage
370            let max_cost = min_cost * 10.0 + profile.data_cost_per_gb * 0.1;
371
372            total_min += min_cost;
373            total_max += max_cost;
374
375            services.push(ServiceEstimate {
376                service: service.clone(),
377                actions: actions.clone(),
378                min_cost,
379                max_cost,
380                notes: profile.notes.to_string(),
381            });
382
383            // High-cost warnings
384            if service == "ec2"
385                && actions
386                    .iter()
387                    .any(|a| a.contains("RunInstances") || a == "*")
388            {
389                warnings.push(
390                    "ec2:RunInstances can launch instances costing up to $3.84/hr".to_string(),
391                );
392            }
393        } else {
394            services.push(ServiceEstimate {
395                service: service.clone(),
396                actions: actions.clone(),
397                min_cost: 0.0,
398                max_cost: 0.0,
399                notes: "No cost estimate available for this service".to_string(),
400            });
401        }
402    }
403
404    // Determine risk level
405    let risk_level = if total_max > 10.0 || !warnings.is_empty() {
406        "high"
407    } else if total_max > 1.0 {
408        "medium"
409    } else {
410        "low"
411    }
412    .to_string();
413
414    // IAM-specific warnings
415    if by_service.contains_key("iam") {
416        warnings.push("IAM actions can modify account security — review carefully".to_string());
417    }
418
419    // Add persistent disclaimer (after risk calculation so it doesn't inflate risk level)
420    warnings.insert(0, disclaimer);
421
422    services.sort_by(|a, b| {
423        b.max_cost
424            .partial_cmp(&a.max_cost)
425            .unwrap_or(std::cmp::Ordering::Equal)
426    });
427
428    // Sanitize any NaN/Inf that could arise from degenerate floating-point
429    // arithmetic before serialization — serde_json rejects non-finite f64.
430    let sanitize = |v: f64| if v.is_finite() { v } else { 0.0 };
431    let total_min = sanitize(total_min);
432    let total_max = sanitize(total_max);
433    let services: Vec<ServiceEstimate> = services
434        .into_iter()
435        .map(|mut s| {
436            s.min_cost = sanitize(s.min_cost);
437            s.max_cost = sanitize(s.max_cost);
438            s
439        })
440        .collect();
441
442    CostEstimate {
443        services,
444        total_min,
445        total_max,
446        risk_level,
447        warnings,
448    }
449}
450
451/// Format a cost estimate as human-readable text.
452pub fn format_text(est: &CostEstimate) -> String {
453    let mut out = String::new();
454
455    out.push_str(&format!(
456        "Estimated cost: ${:.4} — ${:.4}\n",
457        est.total_min, est.total_max
458    ));
459    out.push_str(&format!("Risk level: {}\n\n", est.risk_level));
460
461    for svc in &est.services {
462        out.push_str(&format!(
463            "  {} (${:.4} — ${:.4})\n",
464            svc.service, svc.min_cost, svc.max_cost
465        ));
466        out.push_str(&format!("    Actions: {}\n", svc.actions.join(", ")));
467        out.push_str(&format!("    {}\n", svc.notes));
468    }
469
470    if !est.warnings.is_empty() {
471        out.push_str("\nWarnings:\n");
472        for w in &est.warnings {
473            out.push_str(&format!("  ! {}\n", w));
474        }
475    }
476
477    out
478}
479
480/// Format a cost estimate as JSON.
481pub fn format_json(est: &CostEstimate) -> String {
482    serde_json::to_string_pretty(est).unwrap_or_else(|_| "{}".to_string())
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_estimate_s3_readonly() {
491        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
492        let est = estimate(&policy, 900);
493        assert_eq!(est.services.len(), 1);
494        assert_eq!(est.services[0].service, "s3");
495        assert!(est.total_min >= 0.0);
496        assert_eq!(est.risk_level, "low");
497    }
498
499    #[test]
500    fn test_estimate_ec2_high_risk() {
501        let policy =
502            ScopedPolicy::from_allow_str("ec2:RunInstances,ec2:DescribeInstances").unwrap();
503        let est = estimate(&policy, 3600);
504        assert_eq!(est.risk_level, "high");
505        assert!(est.warnings.iter().any(|w| w.contains("RunInstances")));
506    }
507
508    #[test]
509    fn test_estimate_iam_warning() {
510        let policy = ScopedPolicy::from_allow_str("iam:CreateRole").unwrap();
511        let est = estimate(&policy, 900);
512        assert!(est.warnings.iter().any(|w| w.contains("IAM")));
513    }
514
515    #[test]
516    fn test_estimate_wildcard() {
517        let policy = ScopedPolicy::from_allow_str("s3:*").unwrap();
518        let est = estimate(&policy, 900);
519        assert!(est.warnings.iter().any(|w| w.contains("s3:*")));
520    }
521
522    #[test]
523    fn test_estimate_multi_service() {
524        let policy =
525            ScopedPolicy::from_allow_str("s3:GetObject,lambda:InvokeFunction,dynamodb:Query")
526                .unwrap();
527        let est = estimate(&policy, 900);
528        assert_eq!(est.services.len(), 3);
529    }
530
531    #[test]
532    fn test_estimate_unknown_service() {
533        let policy = ScopedPolicy::from_allow_str("xray:GetTraceSummaries").unwrap();
534        let est = estimate(&policy, 900);
535        assert_eq!(est.services.len(), 1);
536        assert!(est.services[0].notes.contains("No cost estimate"));
537    }
538
539    #[test]
540    fn test_format_text() {
541        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
542        let est = estimate(&policy, 900);
543        let text = format_text(&est);
544        assert!(text.contains("Estimated cost"));
545        assert!(text.contains("s3"));
546    }
547
548    #[test]
549    fn test_format_json() {
550        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
551        let est = estimate(&policy, 900);
552        let json = format_json(&est);
553        assert!(json.contains("total_min"));
554        assert!(json.contains("risk_level"));
555    }
556
557    #[test]
558    fn test_longer_ttl_scales_cost() {
559        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject").unwrap();
560        let short = estimate(&policy, 900); // 15 min
561        let long = estimate(&policy, 14400); // 4 hours
562                                             // Longer TTL should result in higher max cost
563        assert!(long.total_max >= short.total_max);
564    }
565
566    #[test]
567    fn test_free_services() {
568        let policy = ScopedPolicy::from_allow_str("sts:GetCallerIdentity").unwrap();
569        let est = estimate(&policy, 900);
570        assert_eq!(est.total_min, 0.0);
571    }
572}