Skip to main content

tryaudex_core/
cleanup.rs

1//! Phase 3: delete resources that `--tag-session` stamped during a session.
2//!
3//! **AWS-only**: Discovery uses the AWS Resource Groups Tagging API, and
4//! delete commands shell out to the `aws` CLI. GCP and Azure sessions do not
5//! yet support `--ephemeral` / `tryaudex cleanup`. Attempting to clean up a
6//! GCP or Azure session will return an error with guidance.
7//!
8//! Credentials: this module shells out to the user's `aws` CLI, which picks
9//! up ambient credentials (AWS_PROFILE, env vars, instance role, etc.).
10//! Cleanup runs OUTSIDE the expired session — by definition, the short-lived
11//! STS creds are already gone by the time the user asks to clean up.
12
13use crate::error::{AvError, Result};
14use serde::{Deserialize, Serialize};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18/// A single tagged resource returned by Resource Groups Tagging API.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct TaggedResource {
21    /// Full ARN as reported by AWS.
22    pub arn: String,
23    /// Parsed service name ("s3", "dynamodb", ...).
24    pub service: String,
25    /// Parsed resource type within the service ("bucket", "table", ...).
26    pub resource_type: String,
27    /// Human-friendly name/identifier extracted from the ARN.
28    pub name: String,
29}
30
31impl TaggedResource {
32    /// Parse an ARN into its (service, type, name) parts.
33    ///
34    /// ARN formats we handle:
35    /// - arn:aws:s3:::bucket-name
36    /// - arn:aws:dynamodb:us-east-1:123:table/TableName
37    /// - arn:aws:sqs:us-east-1:123:queue-name
38    /// - arn:aws:sns:us-east-1:123:topic-name
39    /// - arn:aws:lambda:us-east-1:123:function:FuncName
40    /// - arn:aws:iam::123:role/RoleName
41    /// - arn:aws:secretsmanager:us-east-1:123:secret:name-AbCdEf
42    /// - arn:aws:logs:us-east-1:123:log-group:group-name
43    /// - arn:aws:ecr:us-east-1:123:repository/repo
44    /// - arn:aws:kms:us-east-1:123:key/uuid
45    /// - arn:aws:cloudformation:us-east-1:123:stack/name/uuid
46    /// - arn:aws:rds:us-east-1:123:db:instance-name
47    pub fn from_arn(arn: &str) -> Option<Self> {
48        // Split into: ["arn", "aws", service, region, account, resource...]
49        let parts: Vec<&str> = arn.splitn(6, ':').collect();
50        if parts.len() < 6 || parts[0] != "arn" {
51            return None;
52        }
53        let service = parts[2].to_string();
54        let resource_part = parts[5];
55
56        // R6-M1: Split on whichever separator ('/' or ':') appears FIRST.
57        // Previously `/` was preferred unconditionally, so a log-group ARN
58        // like `log-group:groupname/substream` produced type=
59        // `log-group:groupname`, name=`substream` — losing the type. And
60        // Lambda function/alias ARNs or Secrets Manager secret ARNs could
61        // also drop type information. Preserve the full resource portion
62        // after the first separator by using whichever delimiter appears
63        // earliest in `resource_part`.
64        let slash_pos = resource_part.find('/');
65        let colon_pos = resource_part.find(':');
66        let separator = match (slash_pos, colon_pos) {
67            (Some(s), Some(c)) if s < c => Some(('/', s)),
68            (Some(_), Some(c)) => Some((':', c)),
69            (Some(s), None) => Some(('/', s)),
70            (None, Some(c)) => Some((':', c)),
71            (None, None) => None,
72        };
73        let (resource_type, name) = if let Some((_, idx)) = separator {
74            let (t, rest) = resource_part.split_at(idx);
75            // `rest` begins with the separator byte; skip it.
76            (t.to_string(), rest[1..].to_string())
77        } else {
78            // S3 bucket ARNs have no type prefix — the whole thing is the name.
79            let default_type = match service.as_str() {
80                "s3" => "bucket",
81                "sqs" => "queue",
82                "sns" => "topic",
83                _ => "resource",
84            };
85            (default_type.to_string(), resource_part.to_string())
86        };
87
88        Some(TaggedResource {
89            arn: arn.to_string(),
90            service,
91            resource_type,
92            name,
93        })
94    }
95}
96
97/// Query Resource Groups Tagging API for every resource bearing
98/// `tryaudex-session=<session_id>`. Returns the raw list (may be empty).
99///
100/// R6-M8: the tagging API is *regional*. A single call only returns
101/// resources in the AWS CLI's default region, so sessions that created
102/// resources in multiple regions leak everything outside the default.
103/// Callers can set `AUDEX_CLEANUP_REGIONS=us-east-1,eu-west-1,…` to
104/// scan an explicit list; the results are merged and deduplicated by
105/// ARN. Without the env var, we fall back to the ambient default region
106/// for backwards compatibility.
107pub fn discover(session_id: &str) -> Result<Vec<TaggedResource>> {
108    // R6-M2: reject comma/quote/shell-metachar before interpolating into
109    // the `Key=…,Values=…` filter expression that goes to the `aws` CLI.
110    crate::validate::session_id(session_id)?;
111
112    let regions: Vec<Option<String>> = match std::env::var("AUDEX_CLEANUP_REGIONS") {
113        Ok(csv) if !csv.trim().is_empty() => csv
114            .split(',')
115            .map(|r| r.trim().to_string())
116            .filter(|r| !r.is_empty())
117            .map(Some)
118            .collect(),
119        _ => vec![None],
120    };
121
122    let mut seen = std::collections::HashSet::new();
123    let mut all_resources = Vec::new();
124    for region in &regions {
125        let found = discover_in_region(session_id, region.as_deref())?;
126        for r in found {
127            if seen.insert(r.arn.clone()) {
128                all_resources.push(r);
129            }
130        }
131    }
132    Ok(all_resources)
133}
134
135/// Query the tagging API in a single region.
136fn discover_in_region(session_id: &str, region: Option<&str>) -> Result<Vec<TaggedResource>> {
137    let filter = format!("Key=tryaudex-session,Values={session_id}");
138
139    #[derive(Deserialize)]
140    struct Response {
141        #[serde(rename = "ResourceTagMappingList", default)]
142        mappings: Vec<Mapping>,
143        #[serde(rename = "PaginationToken", default)]
144        pagination_token: Option<String>,
145    }
146    #[derive(Deserialize)]
147    struct Mapping {
148        #[serde(rename = "ResourceARN")]
149        arn: String,
150    }
151
152    let mut all_resources = Vec::new();
153    let mut pagination_token: Option<String> = None;
154
155    loop {
156        let mut cmd = Command::new("aws");
157        if let Some(r) = region {
158            cmd.args(["--region", r]);
159        }
160        cmd.args([
161            "resourcegroupstaggingapi",
162            "get-resources",
163            "--tag-filters",
164            &filter,
165            "--output",
166            "json",
167        ]);
168        if let Some(ref token) = pagination_token {
169            cmd.args(["--pagination-token", token]);
170        }
171
172        let output = cmd.output().map_err(AvError::Io)?;
173
174        if !output.status.success() {
175            return Err(AvError::InvalidPolicy(format!(
176                "aws resourcegroupstaggingapi failed: {}",
177                String::from_utf8_lossy(&output.stderr).trim()
178            )));
179        }
180
181        let parsed: Response = serde_json::from_slice(&output.stdout).map_err(|e| {
182            AvError::InvalidPolicy(format!("Failed to parse tagging API response: {e}"))
183        })?;
184
185        all_resources.extend(
186            parsed
187                .mappings
188                .into_iter()
189                .filter_map(|m| TaggedResource::from_arn(&m.arn)),
190        );
191
192        match parsed.pagination_token {
193            Some(ref t) if !t.is_empty() => pagination_token = Some(t.clone()),
194            _ => break,
195        }
196    }
197
198    Ok(all_resources)
199}
200
201/// The outcome of attempting to delete one resource.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum DeleteOutcome {
204    Deleted,
205    DryRun,
206    Unsupported,
207    Failed(String),
208}
209
210/// Extract the region from an ARN (arn:partition:service:region:account:resource).
211fn region_from_arn(arn: &str) -> Option<&str> {
212    let parts: Vec<&str> = arn.split(':').collect();
213    if parts.len() >= 4 && !parts[3].is_empty() {
214        Some(parts[3])
215    } else {
216        None
217    }
218}
219
220/// Build the aws-CLI argv to delete this resource. None if unsupported.
221/// Includes `--region` extracted from the resource ARN so deletes target
222/// the correct region regardless of the user's default.
223pub fn delete_command(r: &TaggedResource) -> Option<Vec<String>> {
224    let s = |s: &str| s.to_string();
225    let mut cmd = match (r.service.as_str(), r.resource_type.as_str()) {
226        ("s3", "bucket") => Some(vec![
227            s("aws"),
228            s("s3"),
229            s("rb"),
230            format!("s3://{}", r.name),
231            // Intentionally no --force: non-empty buckets fail safely.
232            // Use `audex cleanup --force` to override.
233        ]),
234        ("dynamodb", "table") => Some(vec![
235            s("aws"),
236            s("dynamodb"),
237            s("delete-table"),
238            s("--table-name"),
239            r.name.clone(),
240        ]),
241        ("sqs", "queue") => Some(vec![
242            s("aws"),
243            s("sqs"),
244            s("delete-queue"),
245            s("--queue-url"),
246            // SQS delete-queue needs the URL, not the name.
247            // We rebuild it from the ARN — arn:aws:sqs:region:account:name.
248            sqs_url_from_arn(&r.arn)?,
249        ]),
250        ("sns", "topic") => Some(vec![
251            s("aws"),
252            s("sns"),
253            s("delete-topic"),
254            s("--topic-arn"),
255            r.arn.clone(),
256        ]),
257        ("lambda", "function") => Some(vec![
258            s("aws"),
259            s("lambda"),
260            s("delete-function"),
261            s("--function-name"),
262            r.name.clone(),
263        ]),
264        ("rds", "db") => {
265            // Create a final snapshot before deletion to prevent data loss.
266            // Snapshot ID must be <= 255 chars, alphanumeric + hyphens.
267            let snap_id = format!("audex-cleanup-{}", r.name);
268            let snap_id: String = snap_id
269                .chars()
270                .map(|c| {
271                    if c.is_ascii_alphanumeric() || c == '-' {
272                        c
273                    } else {
274                        '-'
275                    }
276                })
277                .take(255)
278                .collect();
279            Some(vec![
280                s("aws"),
281                s("rds"),
282                s("delete-db-instance"),
283                s("--db-instance-identifier"),
284                r.name.clone(),
285                s("--final-db-snapshot-identifier"),
286                snap_id,
287            ])
288        }
289        ("iam", "role") => Some(vec![
290            s("aws"),
291            s("iam"),
292            s("delete-role"),
293            s("--role-name"),
294            r.name.clone(),
295        ]),
296        ("iam", "user") => Some(vec![
297            s("aws"),
298            s("iam"),
299            s("delete-user"),
300            s("--user-name"),
301            r.name.clone(),
302        ]),
303        ("iam", "policy") => Some(vec![
304            s("aws"),
305            s("iam"),
306            s("delete-policy"),
307            s("--policy-arn"),
308            r.arn.clone(),
309        ]),
310        ("secretsmanager", "secret") => Some(vec![
311            s("aws"),
312            s("secretsmanager"),
313            s("delete-secret"),
314            s("--secret-id"),
315            r.arn.clone(),
316            // No --force-delete-without-recovery: uses default 30-day
317            // recovery window so accidentally tagged secrets can be restored.
318        ]),
319        ("ssm", "parameter") => Some(vec![
320            s("aws"),
321            s("ssm"),
322            s("delete-parameter"),
323            s("--name"),
324            r.name.clone(),
325        ]),
326        ("logs", "log-group") => Some(vec![
327            s("aws"),
328            s("logs"),
329            s("delete-log-group"),
330            s("--log-group-name"),
331            r.name.clone(),
332        ]),
333        ("cloudformation", "stack") => Some(vec![
334            s("aws"),
335            s("cloudformation"),
336            s("delete-stack"),
337            s("--stack-name"),
338            r.name.clone(),
339        ]),
340        ("ecr", "repository") => Some(vec![
341            s("aws"),
342            s("ecr"),
343            s("delete-repository"),
344            s("--repository-name"),
345            r.name.clone(),
346            s("--force"),
347        ]),
348        ("kms", "key") => Some(vec![
349            s("aws"),
350            s("kms"),
351            s("schedule-key-deletion"),
352            s("--key-id"),
353            r.name.clone(),
354            s("--pending-window-in-days"),
355            s("7"),
356        ]),
357        // EC2 resources — tagged by tag_inject.rs but were previously missing from cleanup
358        ("ec2", "instance") => Some(vec![
359            s("aws"),
360            s("ec2"),
361            s("terminate-instances"),
362            s("--instance-ids"),
363            r.name.clone(),
364        ]),
365        ("ec2", "volume") => Some(vec![
366            s("aws"),
367            s("ec2"),
368            s("delete-volume"),
369            s("--volume-id"),
370            r.name.clone(),
371        ]),
372        ("ec2", "security-group") => Some(vec![
373            s("aws"),
374            s("ec2"),
375            s("delete-security-group"),
376            s("--group-id"),
377            r.name.clone(),
378        ]),
379        ("ec2", "vpc") => Some(vec![
380            s("aws"),
381            s("ec2"),
382            s("delete-vpc"),
383            s("--vpc-id"),
384            r.name.clone(),
385        ]),
386        ("ec2", "subnet") => Some(vec![
387            s("aws"),
388            s("ec2"),
389            s("delete-subnet"),
390            s("--subnet-id"),
391            r.name.clone(),
392        ]),
393        ("ec2", "internet-gateway") => Some(vec![
394            s("aws"),
395            s("ec2"),
396            s("delete-internet-gateway"),
397            s("--internet-gateway-id"),
398            r.name.clone(),
399        ]),
400        ("ec2", "natgateway") => Some(vec![
401            s("aws"),
402            s("ec2"),
403            s("delete-nat-gateway"),
404            s("--nat-gateway-id"),
405            r.name.clone(),
406        ]),
407        ("ec2", "route-table") => Some(vec![
408            s("aws"),
409            s("ec2"),
410            s("delete-route-table"),
411            s("--route-table-id"),
412            r.name.clone(),
413        ]),
414        ("ec2", "key-pair") => Some(vec![
415            s("aws"),
416            s("ec2"),
417            s("delete-key-pair"),
418            s("--key-pair-id"),
419            r.name.clone(),
420        ]),
421        ("ec2", "snapshot") => Some(vec![
422            s("aws"),
423            s("ec2"),
424            s("delete-snapshot"),
425            s("--snapshot-id"),
426            r.name.clone(),
427        ]),
428        ("ec2", "image") => Some(vec![
429            s("aws"),
430            s("ec2"),
431            s("deregister-image"),
432            s("--image-id"),
433            r.name.clone(),
434        ]),
435        _ => None,
436    }?;
437
438    // Append --region from the ARN so deletes target the correct region.
439    // IAM is global (no region in ARN), so skip for IAM resources.
440    if r.service != "iam" {
441        if let Some(region) = region_from_arn(&r.arn) {
442            cmd.push("--region".to_string());
443            cmd.push(region.to_string());
444        }
445    }
446
447    Some(cmd)
448}
449
450/// Reconstruct the SQS queue URL from its ARN.
451///
452/// # Limitation (R3-L9)
453/// This reconstruction assumes the standard regional SQS endpoint format
454/// (`https://sqs.{region}.amazonaws.com/{account}/{name}`). It will produce
455/// incorrect URLs for FIPS endpoints (`sqs-fips.{region}.amazonaws.com`) and
456/// VPC interface endpoints, which use custom DNS names that cannot be derived
457/// from the ARN alone. A complete fix would require an extra
458/// `aws sqs get-queue-url --queue-name {name} --queue-owner-aws-account-id {account}`
459/// call; this function is retained as a best-effort fallback for standard endpoints.
460fn sqs_url_from_arn(arn: &str) -> Option<String> {
461    // arn:aws:sqs:us-east-1:123456789012:queue-name
462    let parts: Vec<&str> = arn.split(':').collect();
463    if parts.len() < 6 || parts[2] != "sqs" {
464        return None;
465    }
466    let region = parts[3];
467    let account = parts[4];
468    let name = parts[5];
469    // Detect partition from the ARN for correct endpoint suffix
470    let partition = if parts.len() > 1 { parts[1] } else { "aws" };
471    let suffix = match partition {
472        "aws-cn" => "amazonaws.com.cn",
473        "aws-us-gov" => "amazonaws.com",
474        "aws" => "amazonaws.com",
475        _ => "amazonaws.com",
476    };
477    Some(format!("https://sqs.{region}.{suffix}/{account}/{name}"))
478}
479
480/// Attempt to delete a single resource. `dry_run=true` skips the actual call.
481///
482/// For IAM roles we first detach attached policies + delete inline policies,
483/// since AWS rejects `delete-role` if anything is still attached. Without
484/// this pre-step users hit cryptic "cannot delete entity because it is
485/// currently attached to 1 entities" errors and have to drop into the
486/// AWS console.
487pub fn delete(r: &TaggedResource, dry_run: bool) -> DeleteOutcome {
488    let argv = match delete_command(r) {
489        Some(a) => a,
490        None => return DeleteOutcome::Unsupported,
491    };
492    if dry_run {
493        return DeleteOutcome::DryRun;
494    }
495    if r.service == "iam" && r.resource_type == "role" {
496        if let Err(msg) = detach_iam_role_policies(&r.name) {
497            // Don't hard-fail — try delete-role anyway; if the role had
498            // nothing attached the pre-step failure is irrelevant.
499            tracing::warn!(role = %r.name, error = %msg, "role policy pre-detach failed");
500        }
501    }
502    // CloudFormation: check termination protection before attempting delete.
503    // Stacks with protection enabled will silently fail otherwise, and may
504    // also contain nested resources that would be destroyed.
505    if r.service == "cloudformation" && r.resource_type == "stack" {
506        if let Ok(output) = Command::new("aws")
507            .args(["cloudformation", "describe-stacks", "--stack-name", &r.name])
508            .output()
509        {
510            // Parse the JSON response rather than relying on brittle string matching,
511            // which can produce false negatives due to whitespace or field ordering.
512            #[derive(serde::Deserialize)]
513            struct DescribeStacksResponse {
514                #[serde(rename = "Stacks")]
515                stacks: Vec<StackEntry>,
516            }
517            #[derive(serde::Deserialize)]
518            struct StackEntry {
519                #[serde(rename = "EnableTerminationProtection", default)]
520                enable_termination_protection: bool,
521            }
522            let protected = serde_json::from_slice::<DescribeStacksResponse>(&output.stdout)
523                .ok()
524                .and_then(|r| r.stacks.into_iter().next())
525                .map(|s| s.enable_termination_protection)
526                .unwrap_or(false);
527            if protected {
528                return DeleteOutcome::Failed(format!(
529                    "Stack '{}' has termination protection enabled. \
530                     Disable it first with: aws cloudformation update-termination-protection \
531                     --no-enable-termination-protection --stack-name {}",
532                    r.name, r.name
533                ));
534            }
535        }
536    }
537    // EC2: check disableApiTermination before attempting terminate-instances.
538    // Unlike CloudFormation, the EC2 API does not return a helpful error when
539    // termination protection is enabled — it silently succeeds (exit 0) but
540    // leaves the instance running, making the failure invisible to callers.
541    if r.service == "ec2" && r.resource_type == "instance" {
542        if let Ok(output) = Command::new("aws")
543            .args([
544                "ec2",
545                "describe-instance-attribute",
546                "--instance-id",
547                &r.name,
548                "--attribute",
549                "disableApiTermination",
550            ])
551            .output()
552        {
553            #[derive(serde::Deserialize)]
554            struct DisableApiTermination {
555                #[serde(rename = "Value", default)]
556                value: bool,
557            }
558            #[derive(serde::Deserialize)]
559            struct DescribeInstanceAttributeResponse {
560                #[serde(rename = "DisableApiTermination")]
561                disable_api_termination: DisableApiTermination,
562            }
563            let protected =
564                serde_json::from_slice::<DescribeInstanceAttributeResponse>(&output.stdout)
565                    .ok()
566                    .map(|r| r.disable_api_termination.value)
567                    .unwrap_or(false);
568            if protected {
569                tracing::warn!(
570                    instance_id = %r.name,
571                    "EC2 instance has termination protection enabled; skipping termination. \
572                     Disable it first with: aws ec2 modify-instance-attribute \
573                     --instance-id {} --no-disable-api-termination",
574                    r.name
575                );
576                return DeleteOutcome::Failed(format!(
577                    "Instance '{}' has termination protection enabled. \
578                     Disable it first with: aws ec2 modify-instance-attribute \
579                     --instance-id {} --no-disable-api-termination",
580                    r.name, r.name
581                ));
582            }
583        }
584    }
585    let output = match Command::new(&argv[0]).args(&argv[1..]).output() {
586        Ok(o) => o,
587        Err(e) => return DeleteOutcome::Failed(e.to_string()),
588    };
589    if output.status.success() {
590        DeleteOutcome::Deleted
591    } else {
592        DeleteOutcome::Failed(String::from_utf8_lossy(&output.stderr).trim().to_string())
593    }
594}
595
596/// For an IAM role, list+detach all managed policies and list+delete all
597/// inline policies so `delete-role` can succeed. Best-effort: partial
598/// failures return the first error but keep going.
599fn detach_iam_role_policies(role_name: &str) -> std::result::Result<(), String> {
600    // Managed policies: list-attached-role-policies → detach each (paginated).
601    {
602        #[derive(Deserialize)]
603        struct Attached {
604            #[serde(rename = "AttachedPolicies", default)]
605            policies: Vec<AttachedPolicy>,
606            #[serde(rename = "Marker")]
607            marker: Option<String>,
608        }
609        #[derive(Deserialize)]
610        struct AttachedPolicy {
611            #[serde(rename = "PolicyArn")]
612            arn: String,
613        }
614        let mut marker: Option<String> = None;
615        loop {
616            let mut args = vec![
617                "iam",
618                "list-attached-role-policies",
619                "--role-name",
620                role_name,
621                "--output",
622                "json",
623            ];
624            let marker_val;
625            if let Some(ref m) = marker {
626                marker_val = m.clone();
627                args.push("--marker");
628                args.push(&marker_val);
629            }
630            let output = Command::new("aws")
631                .args(&args)
632                .output()
633                .map_err(|e| e.to_string())?;
634            if !output.status.success() {
635                break;
636            }
637            let parsed: Attached = match serde_json::from_slice(&output.stdout) {
638                Ok(p) => p,
639                Err(_) => break,
640            };
641            for p in &parsed.policies {
642                let _ = Command::new("aws")
643                    .args([
644                        "iam",
645                        "detach-role-policy",
646                        "--role-name",
647                        role_name,
648                        "--policy-arn",
649                        &p.arn,
650                    ])
651                    .output();
652            }
653            match parsed.marker {
654                Some(m) if !m.is_empty() => marker = Some(m),
655                _ => break,
656            }
657        }
658    }
659
660    // Inline policies: list-role-policies → delete each (paginated).
661    {
662        #[derive(Deserialize)]
663        struct Inline {
664            #[serde(rename = "PolicyNames", default)]
665            names: Vec<String>,
666            #[serde(rename = "Marker")]
667            marker: Option<String>,
668        }
669        let mut marker: Option<String> = None;
670        loop {
671            let mut args = vec![
672                "iam",
673                "list-role-policies",
674                "--role-name",
675                role_name,
676                "--output",
677                "json",
678            ];
679            let marker_val;
680            if let Some(ref m) = marker {
681                marker_val = m.clone();
682                args.push("--marker");
683                args.push(&marker_val);
684            }
685            let output = Command::new("aws")
686                .args(&args)
687                .output()
688                .map_err(|e| e.to_string())?;
689            if !output.status.success() {
690                break;
691            }
692            let parsed: Inline = match serde_json::from_slice(&output.stdout) {
693                Ok(p) => p,
694                Err(_) => break,
695            };
696            for name in &parsed.names {
697                let _ = Command::new("aws")
698                    .args([
699                        "iam",
700                        "delete-role-policy",
701                        "--role-name",
702                        role_name,
703                        "--policy-name",
704                        name,
705                    ])
706                    .output();
707            }
708            match parsed.marker {
709                Some(m) if !m.is_empty() => marker = Some(m),
710                _ => break,
711            }
712        }
713    }
714
715    Ok(())
716}
717
718/// Delete priority for dependency ordering. Lower tiers are leaf resources
719/// (nothing else in our set refers to them) and get deleted first. Higher
720/// tiers sit upstream and must be torn down only after their dependents.
721///
722/// IAM policies are the main upstream case: `aws iam delete-policy` refuses
723/// if any role/user/group still holds a reference. So we delete roles
724/// (tier 1, which auto-detaches policies in `delete()`'s pre-step) before
725/// policies (tier 2). Every other resource is a leaf at tier 0.
726pub fn delete_tier(r: &TaggedResource) -> u8 {
727    match (r.service.as_str(), r.resource_type.as_str()) {
728        ("iam", "policy") => 2,
729        ("iam", "role") => 1,
730        _ => 0,
731    }
732}
733
734/// Stable-sort resources into delete order. Same-tier ordering is
735/// preserved from the caller's input (discovery order).
736pub fn sort_for_deletion(resources: &mut [TaggedResource]) {
737    resources.sort_by_key(delete_tier);
738}
739
740/// Rough daily-cost hint for a single tagged resource, used in the pre-delete
741/// preview so users can see "oh wait, that's a $40/day RDS instance".
742///
743/// These are intentionally conservative *floor* estimates based on the
744/// cheapest common SKU (e.g. RDS t3.micro, EC2 t3.micro, KMS single-region).
745/// Storage- and request-driven services (S3, DynamoDB, CloudWatch Logs)
746/// report usage-dependent because we can't see the bytes from the tag API
747/// alone — the number we show is a rock-bottom floor assuming idle.
748#[derive(Debug, Clone, Copy, PartialEq)]
749pub struct DailyCostHint {
750    /// Conservative lower bound, USD/day.
751    pub usd_per_day: f64,
752    /// True when actual cost scales with size or traffic beyond this floor.
753    pub usage_dependent: bool,
754}
755
756/// Look up a daily cost hint for the given resource, or None if it's free
757/// / unknown. Prices are approximate us-east-1 list prices circa 2025 —
758/// precise to within "right order of magnitude", nothing more.
759pub fn estimate_daily_cost(r: &TaggedResource) -> Option<DailyCostHint> {
760    let hint = match (r.service.as_str(), r.resource_type.as_str()) {
761        // RDS on-demand, t3.micro single-AZ: ~$0.017/hr = $0.41/day floor.
762        // Anything bigger or multi-AZ is dramatically more.
763        ("rds", "db") => DailyCostHint {
764            usd_per_day: 0.41,
765            usage_dependent: true,
766        },
767        // EC2 t3.micro on-demand: $0.0104/hr = $0.25/day. Plus EBS.
768        ("ec2", "instance") => DailyCostHint {
769            usd_per_day: 0.25,
770            usage_dependent: true,
771        },
772        // KMS customer-managed key: $1/month = $0.033/day flat.
773        ("kms", "key") => DailyCostHint {
774            usd_per_day: 0.033,
775            usage_dependent: false,
776        },
777        // Secrets Manager: $0.40/secret/month = $0.013/day.
778        ("secretsmanager", "secret") => DailyCostHint {
779            usd_per_day: 0.013,
780            usage_dependent: false,
781        },
782        // S3 bucket: cost scales with stored bytes. Storage is $0.023/GB/mo
783        // = $0.00077/GB/day. Show the floor as ~free and flag usage-dependent.
784        ("s3", "bucket") => DailyCostHint {
785            usd_per_day: 0.0,
786            usage_dependent: true,
787        },
788        // DynamoDB: on-demand is pay-per-request, provisioned has a base.
789        // Idle on-demand is effectively free but storage is $0.25/GB/mo.
790        ("dynamodb", "table") => DailyCostHint {
791            usd_per_day: 0.0,
792            usage_dependent: true,
793        },
794        // CloudWatch Logs: ingest $0.50/GB, storage $0.03/GB/mo.
795        ("logs", "log-group") => DailyCostHint {
796            usd_per_day: 0.0,
797            usage_dependent: true,
798        },
799        // ECR: $0.10/GB/mo storage + data transfer.
800        ("ecr", "repository") => DailyCostHint {
801            usd_per_day: 0.0,
802            usage_dependent: true,
803        },
804        // Free/idle-free services: IAM, Lambda (idle), SQS/SNS (idle),
805        // CloudFormation, SSM parameters (standard tier).
806        ("iam", _)
807        | ("lambda", _)
808        | ("sqs", _)
809        | ("sns", _)
810        | ("cloudformation", _)
811        | ("ssm", _) => return None,
812        _ => return None,
813    };
814    Some(hint)
815}
816
817/// Sum the known daily-cost floors across a set of resources.
818/// Returns (total_usd_per_day, any_usage_dependent).
819pub fn estimate_daily_cost_total(resources: &[TaggedResource]) -> (f64, bool) {
820    let mut total = 0.0;
821    let mut any_usage = false;
822    for r in resources {
823        if let Some(h) = estimate_daily_cost(r) {
824            total += h.usd_per_day;
825            any_usage |= h.usage_dependent;
826        }
827    }
828    (total, any_usage)
829}
830
831/// One stale-session entry: the session already ended (expired / completed
832/// / failed / revoked) but still has resources tagged `tryaudex-session`
833/// that are alive in the account.
834#[derive(Debug, Clone)]
835pub struct OrphanedSession {
836    pub session_id: String,
837    pub status: String,
838    pub ended_at: chrono::DateTime<chrono::Utc>,
839    pub resources: Vec<TaggedResource>,
840    pub daily_cost: f64,
841    pub usage_dependent: bool,
842}
843
844/// Cross-reference the local session store with the tagging API to find
845/// every non-active session that still has billing resources. Each entry
846/// can be drained with `tryaudex cleanup <session_id>`.
847///
848/// Hits the AWS tagging API once per non-active session in the input;
849/// callers should scope the input list (recent N, filter by status) when
850/// the store grows. Discovery errors on individual sessions are swallowed
851/// — one failing session shouldn't block orphan reporting for the rest.
852pub fn find_orphans(sessions: &[crate::session::Session]) -> Vec<OrphanedSession> {
853    use crate::session::SessionStatus;
854    let mut out = Vec::new();
855    for s in sessions {
856        if s.status == SessionStatus::Active {
857            continue;
858        }
859        let Ok(resources) = discover(&s.id) else {
860            continue;
861        };
862        if resources.is_empty() {
863            continue;
864        }
865        let (daily_cost, usage_dependent) = estimate_daily_cost_total(&resources);
866        out.push(OrphanedSession {
867            session_id: s.id.clone(),
868            status: s.status.to_string(),
869            ended_at: s.expires_at,
870            resources,
871            daily_cost,
872            usage_dependent,
873        });
874    }
875    // Sort by cost descending so the expensive orphans surface first.
876    out.sort_by(|a, b| {
877        b.daily_cost
878            .partial_cmp(&a.daily_cost)
879            .unwrap_or(std::cmp::Ordering::Equal)
880    });
881    out
882}
883
884/// Summary of a cleanup run.
885#[derive(Debug, Clone, Default)]
886pub struct CleanupReport {
887    pub deleted: Vec<TaggedResource>,
888    pub failed: Vec<(TaggedResource, String)>,
889    pub unsupported: Vec<TaggedResource>,
890    pub dry_run: bool,
891}
892
893/// Run `delete()` over every discovered resource and aggregate results.
894///
895/// Currently AWS-only. Returns an error for GCP/Azure sessions.
896pub fn cleanup_session(session_id: &str, dry_run: bool) -> Result<CleanupReport> {
897    // Check if session is non-AWS and bail with a clear message
898    let session_store = crate::session::SessionStore::new()?;
899    if let Ok(session) = session_store.load(session_id) {
900        match session.provider {
901            crate::session::CloudProvider::Gcp => {
902                return Err(AvError::InvalidPolicy(
903                    "Cleanup is not yet supported for GCP sessions. \
904                     Use `gcloud` CLI to manage GCP resources directly."
905                        .to_string(),
906                ));
907            }
908            crate::session::CloudProvider::Azure => {
909                return Err(AvError::InvalidPolicy(
910                    "Cleanup is not yet supported for Azure sessions. \
911                     Use `az` CLI to manage Azure resources directly."
912                        .to_string(),
913                ));
914            }
915            crate::session::CloudProvider::Aws => {} // proceed
916        }
917    }
918
919    let mut resources = discover(session_id)?;
920    sort_for_deletion(&mut resources);
921    let mut report = CleanupReport {
922        dry_run,
923        ..Default::default()
924    };
925    for r in resources {
926        match delete(&r, dry_run) {
927            DeleteOutcome::Deleted => report.deleted.push(r),
928            DeleteOutcome::DryRun => report.deleted.push(r),
929            DeleteOutcome::Unsupported => report.unsupported.push(r),
930            DeleteOutcome::Failed(err) => report.failed.push((r, err)),
931        }
932    }
933    Ok(report)
934}
935
936// --- Partial-cleanup state persistence ---
937//
938// Cleanup can crash mid-run (network blip, AWS throttle, user Ctrl-C). When
939// that happens we need to know which resources were already deleted on the
940// next invocation, otherwise the user either wastes API calls re-issuing
941// deletes or, worse, gets misleading "resource not found" errors for things
942// we handled last time. State files on disk give us that idempotency —
943// re-running `tryaudex cleanup <id>` picks up from where we left off.
944
945/// On-disk record of an in-flight cleanup. Written before any deletion
946/// happens, updated after each attempt, removed once nothing is left.
947#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
948pub struct CleanupState {
949    pub session_id: String,
950    pub started_at: chrono::DateTime<chrono::Utc>,
951    /// ARNs that are known to be deleted (successful outcomes from prior runs).
952    pub deleted_arns: Vec<String>,
953    /// Resources whose delete call returned an error — retry candidates.
954    pub failed: Vec<(TaggedResource, String)>,
955}
956
957impl CleanupState {
958    fn new(session_id: &str) -> Self {
959        Self {
960            session_id: session_id.to_string(),
961            started_at: chrono::Utc::now(),
962            deleted_arns: Vec::new(),
963            failed: Vec::new(),
964        }
965    }
966
967    pub fn is_deleted(&self, arn: &str) -> bool {
968        self.deleted_arns.iter().any(|a| a == arn)
969    }
970
971    pub fn record_deleted(&mut self, arn: &str) {
972        if !self.is_deleted(arn) {
973            self.deleted_arns.push(arn.to_string());
974        }
975        // If the resource was previously failed, clear it from that list.
976        self.failed.retain(|(r, _)| r.arn != arn);
977    }
978
979    pub fn record_failed(&mut self, resource: &TaggedResource, reason: &str) {
980        self.failed.retain(|(r, _)| r.arn != resource.arn);
981        self.failed.push((resource.clone(), reason.to_string()));
982    }
983}
984
985/// Manages cleanup state files keyed by session id.
986pub struct CleanupStateStore {
987    dir: PathBuf,
988}
989
990impl CleanupStateStore {
991    pub fn new() -> Result<Self> {
992        let base = dirs::data_local_dir().ok_or_else(|| {
993            AvError::InvalidPolicy(
994                "Could not determine local data directory. Set XDG_DATA_HOME or HOME.".to_string(),
995            )
996        })?;
997        let dir = base.join("audex").join("cleanup_state");
998        std::fs::create_dir_all(&dir)?;
999        Ok(Self { dir })
1000    }
1001
1002    pub fn with_dir(dir: impl AsRef<Path>) -> Result<Self> {
1003        let dir = dir.as_ref().to_path_buf();
1004        std::fs::create_dir_all(&dir)?;
1005        Ok(Self { dir })
1006    }
1007
1008    fn path_for(&self, session_id: &str) -> Result<PathBuf> {
1009        crate::validate::session_id(session_id)?;
1010        Ok(self.dir.join(format!("{session_id}.json")))
1011    }
1012
1013    /// Load state for a session, or return a freshly-initialized state if
1014    /// none exists yet.
1015    pub fn load_or_new(&self, session_id: &str) -> Result<CleanupState> {
1016        let path = self.path_for(session_id)?;
1017        if !path.exists() {
1018            return Ok(CleanupState::new(session_id));
1019        }
1020        let json = std::fs::read_to_string(&path)?;
1021        serde_json::from_str(&json).map_err(|e| {
1022            AvError::InvalidPolicy(format!("corrupt cleanup state file {path:?}: {e}"))
1023        })
1024    }
1025
1026    pub fn save(&self, state: &CleanupState) -> Result<()> {
1027        let path = self.path_for(&state.session_id)?;
1028        let json = serde_json::to_string_pretty(state)?;
1029        std::fs::write(&path, json)?;
1030        Ok(())
1031    }
1032
1033    pub fn remove(&self, session_id: &str) {
1034        if let Ok(path) = self.path_for(session_id) {
1035            let _ = std::fs::remove_file(path);
1036        }
1037    }
1038
1039    pub fn exists(&self, session_id: &str) -> bool {
1040        self.path_for(session_id)
1041            .map(|p| p.exists())
1042            .unwrap_or(false)
1043    }
1044
1045    /// Return every cleanup state currently on disk that still has at least
1046    /// one unresolved failure. Used as a "cleanup backlog" guard before
1047    /// starting a fresh --ephemeral session so users don't quietly pile up
1048    /// orphaned resources. States with only successful deletes (which is an
1049    /// interim state during cleanup) are skipped — the file would be removed
1050    /// on next full success anyway.
1051    pub fn list_pending(&self) -> Vec<CleanupState> {
1052        let mut out = Vec::new();
1053        let entries = match std::fs::read_dir(&self.dir) {
1054            Ok(e) => e,
1055            Err(_) => return out,
1056        };
1057        for entry in entries.flatten() {
1058            let path = entry.path();
1059            if path.extension().and_then(|s| s.to_str()) != Some("json") {
1060                continue;
1061            }
1062            let Ok(json) = std::fs::read_to_string(&path) else {
1063                continue;
1064            };
1065            let Ok(state) = serde_json::from_str::<CleanupState>(&json) else {
1066                continue;
1067            };
1068            if !state.failed.is_empty() {
1069                out.push(state);
1070            }
1071        }
1072        // Sort by started_at for predictable listing order.
1073        out.sort_by_key(|s| s.started_at);
1074        out
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081
1082    #[test]
1083    fn parses_s3_bucket_arn() {
1084        let r = TaggedResource::from_arn("arn:aws:s3:::my-bucket").unwrap();
1085        assert_eq!(r.service, "s3");
1086        assert_eq!(r.resource_type, "bucket");
1087        assert_eq!(r.name, "my-bucket");
1088    }
1089
1090    #[test]
1091    fn parses_dynamodb_table_arn() {
1092        let r = TaggedResource::from_arn("arn:aws:dynamodb:us-east-1:123:table/Users").unwrap();
1093        assert_eq!(r.service, "dynamodb");
1094        assert_eq!(r.resource_type, "table");
1095        assert_eq!(r.name, "Users");
1096    }
1097
1098    #[test]
1099    fn parses_lambda_function_arn() {
1100        let r = TaggedResource::from_arn("arn:aws:lambda:us-east-1:123:function:my-fn").unwrap();
1101        assert_eq!(r.service, "lambda");
1102        assert_eq!(r.resource_type, "function");
1103        assert_eq!(r.name, "my-fn");
1104    }
1105
1106    #[test]
1107    fn parses_iam_role_arn() {
1108        let r = TaggedResource::from_arn("arn:aws:iam::123:role/MyRole").unwrap();
1109        assert_eq!(r.service, "iam");
1110        assert_eq!(r.resource_type, "role");
1111        assert_eq!(r.name, "MyRole");
1112    }
1113
1114    #[test]
1115    fn parses_sqs_queue_arn() {
1116        let r = TaggedResource::from_arn("arn:aws:sqs:us-east-1:123:my-queue").unwrap();
1117        assert_eq!(r.service, "sqs");
1118        assert_eq!(r.resource_type, "queue");
1119        assert_eq!(r.name, "my-queue");
1120    }
1121
1122    #[test]
1123    fn rejects_bad_arn() {
1124        assert!(TaggedResource::from_arn("not-an-arn").is_none());
1125        assert!(TaggedResource::from_arn("arn:aws:s3").is_none());
1126    }
1127
1128    #[test]
1129    fn sqs_url_rebuilt_from_arn() {
1130        let url = sqs_url_from_arn("arn:aws:sqs:us-east-1:123456789012:foo").unwrap();
1131        assert_eq!(url, "https://sqs.us-east-1.amazonaws.com/123456789012/foo");
1132    }
1133
1134    #[test]
1135    fn sqs_url_govcloud() {
1136        let url = sqs_url_from_arn("arn:aws-us-gov:sqs:us-gov-west-1:123456789012:bar").unwrap();
1137        assert_eq!(
1138            url,
1139            "https://sqs.us-gov-west-1.amazonaws.com/123456789012/bar"
1140        );
1141    }
1142
1143    #[test]
1144    fn sqs_url_china() {
1145        let url = sqs_url_from_arn("arn:aws-cn:sqs:cn-north-1:123456789012:baz").unwrap();
1146        assert_eq!(
1147            url,
1148            "https://sqs.cn-north-1.amazonaws.com.cn/123456789012/baz"
1149        );
1150    }
1151
1152    #[test]
1153    fn delete_command_for_s3_bucket() {
1154        let r = TaggedResource::from_arn("arn:aws:s3:::mybk").unwrap();
1155        let cmd = delete_command(&r).unwrap();
1156        assert_eq!(cmd[0..3], vec!["aws", "s3", "rb"]);
1157        assert!(cmd.contains(&"s3://mybk".to_string()));
1158        // No --force: non-empty buckets fail safely
1159        assert!(!cmd.contains(&"--force".to_string()));
1160    }
1161
1162    #[test]
1163    fn delete_command_for_dynamodb() {
1164        let r = TaggedResource::from_arn("arn:aws:dynamodb:us-east-1:1:table/T").unwrap();
1165        let cmd = delete_command(&r).unwrap();
1166        assert_eq!(cmd[0..3], vec!["aws", "dynamodb", "delete-table"]);
1167        assert!(cmd.contains(&"T".to_string()));
1168    }
1169
1170    #[test]
1171    fn delete_command_for_sqs_uses_url() {
1172        let r = TaggedResource::from_arn("arn:aws:sqs:us-east-1:1:q1").unwrap();
1173        let cmd = delete_command(&r).unwrap();
1174        assert!(cmd.iter().any(|s| s.starts_with("https://sqs.")));
1175    }
1176
1177    #[test]
1178    fn delete_command_for_kms_schedules_deletion() {
1179        let r = TaggedResource::from_arn("arn:aws:kms:us-east-1:1:key/uuid").unwrap();
1180        let cmd = delete_command(&r).unwrap();
1181        assert!(cmd.contains(&"schedule-key-deletion".to_string()));
1182        assert!(cmd.contains(&"7".to_string())); // 7-day pending window
1183    }
1184
1185    #[test]
1186    fn delete_command_for_unsupported_returns_none() {
1187        let r = TaggedResource {
1188            arn: "arn:aws:exotic:us-east-1:1:thing/x".to_string(),
1189            service: "exotic".to_string(),
1190            resource_type: "thing".to_string(),
1191            name: "x".to_string(),
1192        };
1193        assert!(delete_command(&r).is_none());
1194    }
1195
1196    #[test]
1197    fn delete_dry_run_returns_dryrun_outcome() {
1198        let r = TaggedResource::from_arn("arn:aws:dynamodb:us-east-1:1:table/T").unwrap();
1199        assert_eq!(delete(&r, true), DeleteOutcome::DryRun);
1200    }
1201
1202    #[test]
1203    fn cleanup_state_records_deleted_arns() {
1204        let mut state = CleanupState::new("sess-1");
1205        assert!(!state.is_deleted("arn:aws:s3:::bk"));
1206        state.record_deleted("arn:aws:s3:::bk");
1207        assert!(state.is_deleted("arn:aws:s3:::bk"));
1208    }
1209
1210    #[test]
1211    fn cleanup_state_record_deleted_is_idempotent() {
1212        let mut state = CleanupState::new("s");
1213        state.record_deleted("arn:x");
1214        state.record_deleted("arn:x");
1215        assert_eq!(state.deleted_arns.len(), 1);
1216    }
1217
1218    #[test]
1219    fn cleanup_state_record_deleted_clears_prior_failure() {
1220        let mut state = CleanupState::new("s");
1221        let r = TaggedResource::from_arn("arn:aws:s3:::bk").unwrap();
1222        state.record_failed(&r, "temporary throttle");
1223        assert_eq!(state.failed.len(), 1);
1224        state.record_deleted(&r.arn);
1225        assert!(state.failed.is_empty());
1226        assert!(state.is_deleted(&r.arn));
1227    }
1228
1229    #[test]
1230    fn cleanup_state_record_failed_updates_reason() {
1231        let mut state = CleanupState::new("s");
1232        let r = TaggedResource::from_arn("arn:aws:s3:::bk").unwrap();
1233        state.record_failed(&r, "first error");
1234        state.record_failed(&r, "second error");
1235        assert_eq!(state.failed.len(), 1);
1236        assert_eq!(state.failed[0].1, "second error");
1237    }
1238
1239    #[test]
1240    fn cleanup_state_roundtrips_through_store() {
1241        let tmp = tempfile::tempdir().unwrap();
1242        let store = CleanupStateStore::with_dir(tmp.path()).unwrap();
1243        let mut state = store.load_or_new("sess-xyz").unwrap();
1244        assert!(state.deleted_arns.is_empty());
1245        state.record_deleted("arn:aws:s3:::bk1");
1246        store.save(&state).unwrap();
1247
1248        let reloaded = store.load_or_new("sess-xyz").unwrap();
1249        assert_eq!(reloaded.deleted_arns, vec!["arn:aws:s3:::bk1".to_string()]);
1250        assert!(store.exists("sess-xyz"));
1251
1252        store.remove("sess-xyz");
1253        assert!(!store.exists("sess-xyz"));
1254    }
1255
1256    #[test]
1257    fn cleanup_state_store_returns_fresh_state_for_unknown_session() {
1258        let tmp = tempfile::tempdir().unwrap();
1259        let store = CleanupStateStore::with_dir(tmp.path()).unwrap();
1260        let state = store.load_or_new("brand-new").unwrap();
1261        assert_eq!(state.session_id, "brand-new");
1262        assert!(state.deleted_arns.is_empty());
1263        assert!(state.failed.is_empty());
1264    }
1265
1266    #[test]
1267    fn delete_tier_puts_iam_policies_last() {
1268        let role = TaggedResource::from_arn("arn:aws:iam::1:role/R").unwrap();
1269        let policy = TaggedResource::from_arn("arn:aws:iam::1:policy/P").unwrap();
1270        let leaf = TaggedResource::from_arn("arn:aws:s3:::bk").unwrap();
1271        assert!(delete_tier(&leaf) < delete_tier(&role));
1272        assert!(delete_tier(&role) < delete_tier(&policy));
1273    }
1274
1275    #[test]
1276    fn sort_for_deletion_orders_leaves_roles_policies() {
1277        let mut items = vec![
1278            TaggedResource::from_arn("arn:aws:iam::1:policy/P").unwrap(),
1279            TaggedResource::from_arn("arn:aws:s3:::bk").unwrap(),
1280            TaggedResource::from_arn("arn:aws:iam::1:role/R").unwrap(),
1281            TaggedResource::from_arn("arn:aws:dynamodb:us-east-1:1:table/T").unwrap(),
1282        ];
1283        sort_for_deletion(&mut items);
1284        // Leaves (s3, dynamodb) first, role next, policy last.
1285        assert_eq!(items[0].service, "s3");
1286        assert_eq!(items[1].service, "dynamodb");
1287        assert_eq!(
1288            (items[2].service.as_str(), items[2].resource_type.as_str()),
1289            ("iam", "role")
1290        );
1291        assert_eq!(
1292            (items[3].service.as_str(), items[3].resource_type.as_str()),
1293            ("iam", "policy")
1294        );
1295    }
1296
1297    #[test]
1298    fn sort_for_deletion_is_stable_within_tier() {
1299        // Two leaves; sort must preserve input order when tiers are equal.
1300        let mut items = vec![
1301            TaggedResource::from_arn("arn:aws:s3:::first").unwrap(),
1302            TaggedResource::from_arn("arn:aws:s3:::second").unwrap(),
1303        ];
1304        sort_for_deletion(&mut items);
1305        assert_eq!(items[0].name, "first");
1306        assert_eq!(items[1].name, "second");
1307    }
1308
1309    #[test]
1310    fn cost_hint_rds_is_nonzero_and_usage_dependent() {
1311        let db = TaggedResource::from_arn("arn:aws:rds:us-east-1:1:db:prod").unwrap();
1312        let h = estimate_daily_cost(&db).expect("rds has a hint");
1313        assert!(h.usd_per_day > 0.0);
1314        assert!(h.usage_dependent);
1315    }
1316
1317    #[test]
1318    fn cost_hint_iam_and_lambda_are_free() {
1319        let role = TaggedResource::from_arn("arn:aws:iam::1:role/R").unwrap();
1320        let func = TaggedResource::from_arn("arn:aws:lambda:us-east-1:1:function:f").unwrap();
1321        assert!(estimate_daily_cost(&role).is_none());
1322        assert!(estimate_daily_cost(&func).is_none());
1323    }
1324
1325    #[test]
1326    fn cost_hint_kms_is_flat_and_not_usage_dependent() {
1327        let key = TaggedResource::from_arn("arn:aws:kms:us-east-1:1:key/uuid").unwrap();
1328        let h = estimate_daily_cost(&key).expect("kms has a hint");
1329        assert!(h.usd_per_day > 0.0);
1330        assert!(!h.usage_dependent);
1331    }
1332
1333    #[test]
1334    fn cost_hint_s3_is_usage_dependent_floor_zero() {
1335        let bucket = TaggedResource::from_arn("arn:aws:s3:::mybk").unwrap();
1336        let h = estimate_daily_cost(&bucket).expect("s3 has a hint");
1337        assert_eq!(h.usd_per_day, 0.0);
1338        assert!(h.usage_dependent);
1339    }
1340
1341    #[test]
1342    fn list_pending_returns_only_states_with_failures() {
1343        let tmp = tempfile::tempdir().unwrap();
1344        let store = CleanupStateStore::with_dir(tmp.path()).unwrap();
1345        // Session A: has failures → should appear.
1346        let mut a = store.load_or_new("sess-a").unwrap();
1347        let res = TaggedResource::from_arn("arn:aws:s3:::stuck").unwrap();
1348        a.record_failed(&res, "throttled");
1349        store.save(&a).unwrap();
1350        // Session B: only deleted, no failures → should NOT appear.
1351        let mut b = store.load_or_new("sess-b").unwrap();
1352        b.record_deleted("arn:aws:s3:::ok");
1353        store.save(&b).unwrap();
1354        // Session C: pristine → load_or_new doesn't persist, skip save.
1355
1356        let pending = store.list_pending();
1357        assert_eq!(pending.len(), 1);
1358        assert_eq!(pending[0].session_id, "sess-a");
1359        assert_eq!(pending[0].failed.len(), 1);
1360    }
1361
1362    #[test]
1363    fn list_pending_is_empty_when_no_state_files() {
1364        let tmp = tempfile::tempdir().unwrap();
1365        let store = CleanupStateStore::with_dir(tmp.path()).unwrap();
1366        assert!(store.list_pending().is_empty());
1367    }
1368
1369    #[test]
1370    fn total_cost_sums_hints_and_flags_usage() {
1371        let items = vec![
1372            TaggedResource::from_arn("arn:aws:kms:us-east-1:1:key/a").unwrap(),
1373            TaggedResource::from_arn("arn:aws:secretsmanager:us-east-1:1:secret:s-AbCd").unwrap(),
1374            TaggedResource::from_arn("arn:aws:iam::1:role/R").unwrap(),
1375            TaggedResource::from_arn("arn:aws:s3:::bk").unwrap(),
1376        ];
1377        let (total, any_usage) = estimate_daily_cost_total(&items);
1378        // kms ($0.033) + secret ($0.013) + iam (none) + s3 (floor 0)
1379        assert!((total - 0.046).abs() < 0.0005);
1380        // s3 flips the usage-dependent flag.
1381        assert!(any_usage);
1382    }
1383}