Skip to main content

s3rm_rs/types/
mod.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::fmt::{Debug, Display, Formatter};
4use std::path::PathBuf;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use aws_sdk_s3::primitives::DateTime;
10use aws_sdk_s3::types::{DeleteMarkerEntry, Object, ObjectVersion};
11use zeroize_derive::{Zeroize, ZeroizeOnDrop};
12
13pub mod error;
14pub mod event_callback;
15pub mod filter_callback;
16pub mod token;
17
18// ---------------------------------------------------------------------------
19// S3 Object types (from Task 2, reused from s3sync)
20// ---------------------------------------------------------------------------
21
22/// S3 object representation used throughout the deletion pipeline.
23///
24/// Adapted from s3sync's S3syncObject enum, representing the different
25/// kinds of objects that can be listed from S3.
26///
27/// # Variants
28///
29/// - [`NotVersioning`](S3Object::NotVersioning) — An object from a non-versioned bucket
30///   (or current version from a versioned bucket listed without version info).
31/// - [`Versioning`](S3Object::Versioning) — A specific version of an object in a versioned bucket.
32/// - [`DeleteMarker`](S3Object::DeleteMarker) — A delete marker in a versioned bucket (size is always 0).
33///
34/// # Constructors
35///
36/// Use [`S3Object::new`] and [`S3Object::new_versioned`] to create instances
37/// without importing AWS SDK types directly:
38///
39/// ```
40/// use s3rm_rs::S3Object;
41///
42/// let obj = S3Object::new("my-key", 1024);
43/// assert_eq!(obj.key(), "my-key");
44/// assert_eq!(obj.size(), 1024);
45/// ```
46#[derive(Debug, Clone, PartialEq)]
47pub enum S3Object {
48    /// An object from a non-versioned bucket.
49    NotVersioning(Object),
50    /// A specific version of an object in a versioned bucket.
51    Versioning(ObjectVersion),
52    /// A delete marker in a versioned bucket (size is always 0).
53    DeleteMarker(DeleteMarkerEntry),
54}
55
56impl S3Object {
57    /// Create a non-versioned S3 object with the given key and size in bytes.
58    ///
59    /// The `last_modified` timestamp defaults to the Unix epoch. This constructor
60    /// is useful for testing filter callbacks without importing AWS SDK types.
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use s3rm_rs::S3Object;
66    ///
67    /// let obj = S3Object::new("photos/cat.jpg", 2048);
68    /// assert_eq!(obj.key(), "photos/cat.jpg");
69    /// assert_eq!(obj.size(), 2048);
70    /// assert!(obj.version_id().is_none());
71    /// ```
72    pub fn new(key: &str, size: i64) -> Self {
73        S3Object::NotVersioning(
74            Object::builder()
75                .key(key)
76                .size(size)
77                .last_modified(DateTime::from_secs(0))
78                .build(),
79        )
80    }
81
82    /// Create a versioned S3 object with the given key, version ID, and size in bytes.
83    ///
84    /// The `last_modified` timestamp defaults to the Unix epoch, and `is_latest`
85    /// is set to `true`. This constructor is useful for testing filter callbacks
86    /// on versioned objects without importing AWS SDK types.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use s3rm_rs::S3Object;
92    ///
93    /// let obj = S3Object::new_versioned("logs/app.log", "v1", 512);
94    /// assert_eq!(obj.key(), "logs/app.log");
95    /// assert_eq!(obj.version_id(), Some("v1"));
96    /// assert_eq!(obj.size(), 512);
97    /// ```
98    pub fn new_versioned(key: &str, version_id: &str, size: i64) -> Self {
99        use aws_sdk_s3::types::ObjectVersionStorageClass;
100        S3Object::Versioning(
101            ObjectVersion::builder()
102                .key(key)
103                .version_id(version_id)
104                .size(size)
105                .is_latest(true)
106                .storage_class(ObjectVersionStorageClass::Standard)
107                .last_modified(DateTime::from_secs(0))
108                .build(),
109        )
110    }
111
112    pub fn key(&self) -> &str {
113        match &self {
114            Self::Versioning(object) => object.key().expect("S3 ObjectVersion missing key"),
115            Self::NotVersioning(object) => object.key().expect("S3 Object missing key"),
116            Self::DeleteMarker(marker) => marker.key().expect("S3 DeleteMarker missing key"),
117        }
118    }
119
120    pub fn last_modified(&self) -> &DateTime {
121        match &self {
122            Self::Versioning(object) => object
123                .last_modified()
124                .expect("S3 ObjectVersion missing last_modified"),
125            Self::NotVersioning(object) => object
126                .last_modified()
127                .expect("S3 Object missing last_modified"),
128            Self::DeleteMarker(marker) => marker
129                .last_modified()
130                .expect("S3 DeleteMarker missing last_modified"),
131        }
132    }
133
134    pub fn size(&self) -> i64 {
135        match &self {
136            Self::Versioning(object) => object.size().expect("S3 ObjectVersion missing size"),
137            Self::NotVersioning(object) => object.size().expect("S3 Object missing size"),
138            Self::DeleteMarker(_) => 0,
139        }
140    }
141
142    pub fn version_id(&self) -> Option<&str> {
143        match &self {
144            Self::Versioning(object) => object.version_id(),
145            Self::NotVersioning(_) => None,
146            Self::DeleteMarker(object) => object.version_id(),
147        }
148    }
149
150    pub fn e_tag(&self) -> Option<&str> {
151        match &self {
152            Self::Versioning(object) => object.e_tag(),
153            Self::NotVersioning(object) => object.e_tag(),
154            Self::DeleteMarker(_) => None,
155        }
156    }
157
158    pub fn is_latest(&self) -> bool {
159        match &self {
160            Self::Versioning(object) => object.is_latest().unwrap_or(true),
161            Self::NotVersioning(_) => true,
162            Self::DeleteMarker(marker) => marker.is_latest().unwrap_or(true),
163        }
164    }
165
166    pub fn is_delete_marker(&self) -> bool {
167        matches!(self, Self::DeleteMarker(_))
168    }
169}
170
171/// Type alias for a thread-safe map of object keys to S3Objects.
172///
173/// Used for tracking objects during pipeline execution.
174/// Reused from s3sync's ObjectKeyMap pattern.
175pub type ObjectKeyMap = Arc<Mutex<HashMap<String, S3Object>>>;
176
177// ---------------------------------------------------------------------------
178// Statistics types (from Task 2 + Task 3)
179// ---------------------------------------------------------------------------
180
181/// Statistics sent through the stats channel during pipeline execution.
182///
183/// Each variant represents a single event that is sent from workers to the
184/// progress reporter via an async channel. Adapted from s3sync's SyncStatistics.
185#[derive(Debug, Clone, PartialEq)]
186pub enum DeletionStatistics {
187    DeleteBytes(u64),
188    DeleteComplete { key: String },
189    DeleteSkip { key: String },
190    DeleteError { key: String },
191}
192
193/// Aggregate deletion statistics report with atomic counters.
194///
195/// Used for thread-safe statistics tracking across worker threads.
196/// Workers call `increment_deleted()` and `increment_failed()` concurrently.
197/// Adapted from s3sync's SyncStatsReport.
198#[derive(Debug)]
199pub struct DeletionStatsReport {
200    pub stats_deleted_objects: AtomicU64,
201    pub stats_deleted_bytes: AtomicU64,
202    pub stats_failed_objects: AtomicU64,
203}
204
205impl DeletionStatsReport {
206    pub fn new() -> Self {
207        Self {
208            stats_deleted_objects: AtomicU64::new(0),
209            stats_deleted_bytes: AtomicU64::new(0),
210            stats_failed_objects: AtomicU64::new(0),
211        }
212    }
213
214    /// Record a successful deletion of an object with the given byte size.
215    pub fn increment_deleted(&self, bytes: u64) {
216        self.stats_deleted_objects.fetch_add(1, Ordering::Relaxed);
217        self.stats_deleted_bytes.fetch_add(bytes, Ordering::Relaxed);
218    }
219
220    /// Record a failed deletion attempt.
221    pub fn increment_failed(&self) {
222        self.stats_failed_objects.fetch_add(1, Ordering::Relaxed);
223    }
224
225    /// Take a point-in-time snapshot of the current statistics.
226    ///
227    /// The `duration` field in the returned `DeletionStats` is set to
228    /// `Duration::default()` and should be overridden by the caller.
229    pub fn snapshot(&self) -> DeletionStats {
230        DeletionStats {
231            stats_deleted_objects: self.stats_deleted_objects.load(Ordering::Relaxed),
232            stats_deleted_bytes: self.stats_deleted_bytes.load(Ordering::Relaxed),
233            stats_failed_objects: self.stats_failed_objects.load(Ordering::Relaxed),
234            duration: Duration::default(),
235        }
236    }
237}
238
239impl Default for DeletionStatsReport {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245/// Public API deletion statistics returned after pipeline completion.
246///
247/// Provides a summary of the deletion operation including counts and timing.
248#[derive(Debug, Clone, PartialEq)]
249pub struct DeletionStats {
250    pub stats_deleted_objects: u64,
251    pub stats_deleted_bytes: u64,
252    pub stats_failed_objects: u64,
253    pub duration: Duration,
254}
255
256// ---------------------------------------------------------------------------
257// Deletion outcome and error types (Task 3.3)
258// ---------------------------------------------------------------------------
259
260/// Outcome of a single object deletion attempt.
261///
262/// Returned by `BatchDeleter` and `SingleDeleter` for each object processed.
263#[derive(Debug, Clone, PartialEq)]
264pub enum DeletionOutcome {
265    /// Object was successfully deleted.
266    Success {
267        key: String,
268        version_id: Option<String>,
269    },
270    /// Object deletion failed after retries.
271    Failed {
272        key: String,
273        version_id: Option<String>,
274        error: DeletionError,
275        retry_count: u32,
276    },
277}
278
279impl DeletionOutcome {
280    /// Returns true if this outcome represents a successful deletion.
281    pub fn is_success(&self) -> bool {
282        matches!(self, DeletionOutcome::Success { .. })
283    }
284
285    /// Returns the object key for this outcome.
286    pub fn key(&self) -> &str {
287        match self {
288            DeletionOutcome::Success { key, .. } => key,
289            DeletionOutcome::Failed { key, .. } => key,
290        }
291    }
292
293    /// Returns the version ID for this outcome, if any.
294    pub fn version_id(&self) -> Option<&str> {
295        match self {
296            DeletionOutcome::Success { version_id, .. } => version_id.as_deref(),
297            DeletionOutcome::Failed { version_id, .. } => version_id.as_deref(),
298        }
299    }
300}
301
302/// Error types for individual object deletion failures.
303///
304/// These represent specific failure modes when attempting to delete
305/// a single S3 object. Used in `DeletionOutcome::Failed` and
306/// `DeletionEvent::ObjectFailed`.
307#[derive(Debug, Clone, PartialEq)]
308pub enum DeletionError {
309    /// Object was not found (404).
310    NotFound,
311    /// Access denied to delete the object (403).
312    AccessDenied,
313    /// If-Match precondition failed (ETag mismatch, 412).
314    PreconditionFailed,
315    /// Request was throttled by S3 (SlowDown/TooManyRequests).
316    Throttled,
317    /// Network-level error (connection timeout, DNS failure, etc.).
318    NetworkError(String),
319    /// S3 service error (5xx or other service-side failure).
320    ServiceError(String),
321}
322
323impl DeletionError {
324    /// Returns true if this error is retryable.
325    ///
326    /// Throttled, NetworkError, and ServiceError are retryable.
327    /// NotFound, AccessDenied, and PreconditionFailed are not.
328    pub fn is_retryable(&self) -> bool {
329        matches!(
330            self,
331            DeletionError::Throttled
332                | DeletionError::NetworkError(_)
333                | DeletionError::ServiceError(_)
334        )
335    }
336}
337
338impl Display for DeletionError {
339    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
340        match self {
341            DeletionError::NotFound => write!(f, "Object not found"),
342            DeletionError::AccessDenied => write!(f, "Access denied"),
343            DeletionError::PreconditionFailed => {
344                write!(f, "Precondition failed (ETag mismatch)")
345            }
346            DeletionError::Throttled => write!(f, "Request throttled"),
347            DeletionError::NetworkError(msg) => write!(f, "Network error: {msg}"),
348            DeletionError::ServiceError(msg) => write!(f, "Service error: {msg}"),
349        }
350    }
351}
352
353// ---------------------------------------------------------------------------
354// Deletion event types (Task 3.4)
355// ---------------------------------------------------------------------------
356
357/// Events emitted during pipeline execution for callbacks and monitoring.
358///
359/// These events are sent to registered event callbacks (Lua or Rust)
360/// to provide real-time visibility into deletion operations.
361#[derive(Debug, Clone, PartialEq)]
362pub enum DeletionEvent {
363    /// Pipeline has started processing.
364    PipelineStart,
365    /// An object was successfully deleted.
366    ObjectDeleted {
367        key: String,
368        version_id: Option<String>,
369        size: u64,
370    },
371    /// An object deletion failed.
372    ObjectFailed {
373        key: String,
374        version_id: Option<String>,
375        error: DeletionError,
376    },
377    /// Pipeline has completed all processing.
378    PipelineEnd,
379    /// A pipeline-level error occurred.
380    PipelineError { message: String },
381}
382
383// ---------------------------------------------------------------------------
384// S3 Target type (Task 3.5)
385// ---------------------------------------------------------------------------
386
387/// S3 target specification parsed from an s3:// URI.
388///
389/// Represents the target bucket and optional prefix for deletion operations,
390/// along with optional endpoint and region overrides.
391#[derive(Debug, Clone, PartialEq)]
392pub struct S3Target {
393    pub bucket: String,
394    pub prefix: Option<String>,
395    pub endpoint: Option<String>,
396    pub region: Option<String>,
397}
398
399impl S3Target {
400    /// Parse an S3 URI in the format `s3://bucket[/prefix]`.
401    ///
402    /// The endpoint and region fields are not set by this method;
403    /// they should be configured separately from CLI arguments or
404    /// environment variables.
405    ///
406    /// # Examples
407    ///
408    /// ```
409    /// use s3rm_rs::types::S3Target;
410    ///
411    /// let target = S3Target::parse("s3://my-bucket/logs/2023/").unwrap();
412    /// assert_eq!(target.bucket, "my-bucket");
413    /// assert_eq!(target.prefix.as_deref(), Some("logs/2023/"));
414    ///
415    /// let target = S3Target::parse("s3://my-bucket").unwrap();
416    /// assert_eq!(target.bucket, "my-bucket");
417    /// assert!(target.prefix.is_none());
418    /// ```
419    pub fn parse(s3_uri: &str) -> anyhow::Result<Self> {
420        if !s3_uri.starts_with("s3://") {
421            return Err(anyhow::anyhow!(error::S3rmError::InvalidUri(format!(
422                "Target URI must start with 's3://': {s3_uri}"
423            ))));
424        }
425
426        let without_scheme = &s3_uri[5..]; // Remove "s3://"
427
428        if without_scheme.is_empty() {
429            return Err(anyhow::anyhow!(error::S3rmError::InvalidUri(format!(
430                "Bucket name cannot be empty: {s3_uri}"
431            ))));
432        }
433
434        let (bucket, prefix) = match without_scheme.find('/') {
435            Some(idx) => {
436                let bucket = &without_scheme[..idx];
437                let prefix = &without_scheme[idx + 1..];
438                (
439                    bucket.to_string(),
440                    if prefix.is_empty() {
441                        None
442                    } else {
443                        Some(prefix.to_string())
444                    },
445                )
446            }
447            None => (without_scheme.to_string(), None),
448        };
449
450        if bucket.is_empty() {
451            return Err(anyhow::anyhow!(error::S3rmError::InvalidUri(format!(
452                "Bucket name cannot be empty: {s3_uri}"
453            ))));
454        }
455
456        Ok(S3Target {
457            bucket,
458            prefix,
459            endpoint: None,
460            region: None,
461        })
462    }
463}
464
465impl Display for S3Target {
466    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
467        match &self.prefix {
468            Some(prefix) => write!(f, "s3://{}/{}", self.bucket, prefix),
469            None => write!(f, "s3://{}", self.bucket),
470        }
471    }
472}
473
474// ---------------------------------------------------------------------------
475// Credential types (from Task 2, reused from s3sync)
476// ---------------------------------------------------------------------------
477
478/// S3 storage path specification.
479#[derive(Debug, Clone)]
480pub enum StoragePath {
481    S3 { bucket: String, prefix: String },
482}
483
484/// AWS configuration file locations.
485#[derive(Debug, Clone)]
486pub struct ClientConfigLocation {
487    pub aws_config_file: Option<PathBuf>,
488    pub aws_shared_credentials_file: Option<PathBuf>,
489}
490
491/// AWS credential types supported by s3rm-rs.
492///
493/// Reused from s3sync's credential handling with secure memory clearing.
494#[derive(Debug, Clone)]
495pub enum S3Credentials {
496    Profile(String),
497    Credentials { access_keys: AccessKeys },
498    FromEnvironment,
499}
500
501/// AWS access key pair with secure zeroization.
502///
503/// The secret_access_key and session_token are securely cleared from memory
504/// when this struct is dropped, using the zeroize crate.
505#[derive(Clone, Zeroize, ZeroizeOnDrop)]
506pub struct AccessKeys {
507    pub access_key: String,
508    pub secret_access_key: String,
509    pub session_token: Option<String>,
510}
511
512impl Debug for AccessKeys {
513    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
514        let mut keys = f.debug_struct("AccessKeys");
515        let session_token = self
516            .session_token
517            .as_ref()
518            .map_or("None", |_| "** redacted **");
519        keys.field("access_key", &self.access_key)
520            .field("secret_access_key", &"** redacted **")
521            .field("session_token", &session_token);
522        keys.finish()
523    }
524}
525
526// ---------------------------------------------------------------------------
527// Tests
528// ---------------------------------------------------------------------------
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::test_utils::init_dummy_tracing_subscriber;
534    use aws_sdk_s3::types::{ObjectStorageClass, ObjectVersionStorageClass, Owner};
535
536    // --- S3Object tests (existing from Task 2) ---
537
538    #[test]
539    fn non_versioning_object_getters() {
540        init_dummy_tracing_subscriber();
541
542        let object = Object::builder()
543            .key("test/key.txt")
544            .size(1024)
545            .e_tag("my-etag")
546            .storage_class(ObjectStorageClass::Standard)
547            .owner(
548                Owner::builder()
549                    .id("test_id")
550                    .display_name("test_name")
551                    .build(),
552            )
553            .last_modified(DateTime::from_secs(777))
554            .build();
555
556        let s3_object = S3Object::NotVersioning(object);
557
558        assert_eq!(s3_object.key(), "test/key.txt");
559        assert_eq!(s3_object.size(), 1024);
560        assert_eq!(s3_object.e_tag().unwrap(), "my-etag");
561        assert_eq!(*s3_object.last_modified(), DateTime::from_secs(777));
562        assert!(s3_object.version_id().is_none());
563        assert!(s3_object.is_latest());
564        assert!(!s3_object.is_delete_marker());
565    }
566
567    #[test]
568    fn versioning_object_getters() {
569        init_dummy_tracing_subscriber();
570
571        let object = ObjectVersion::builder()
572            .key("test/key.txt")
573            .version_id("version1")
574            .is_latest(true)
575            .size(2048)
576            .e_tag("my-etag-v1")
577            .storage_class(ObjectVersionStorageClass::Standard)
578            .last_modified(DateTime::from_secs(888))
579            .build();
580
581        let s3_object = S3Object::Versioning(object);
582
583        assert_eq!(s3_object.key(), "test/key.txt");
584        assert_eq!(s3_object.size(), 2048);
585        assert_eq!(s3_object.e_tag().unwrap(), "my-etag-v1");
586        assert_eq!(*s3_object.last_modified(), DateTime::from_secs(888));
587        assert_eq!(s3_object.version_id().unwrap(), "version1");
588        assert!(s3_object.is_latest());
589        assert!(!s3_object.is_delete_marker());
590    }
591
592    #[test]
593    fn delete_marker_getters() {
594        init_dummy_tracing_subscriber();
595
596        let marker = DeleteMarkerEntry::builder()
597            .key("test/deleted.txt")
598            .version_id("dm-version1")
599            .is_latest(true)
600            .last_modified(DateTime::from_secs(999))
601            .build();
602
603        let s3_object = S3Object::DeleteMarker(marker);
604
605        assert_eq!(s3_object.key(), "test/deleted.txt");
606        assert_eq!(s3_object.size(), 0);
607        assert!(s3_object.e_tag().is_none());
608        assert_eq!(*s3_object.last_modified(), DateTime::from_secs(999));
609        assert_eq!(s3_object.version_id().unwrap(), "dm-version1");
610        assert!(s3_object.is_latest());
611        assert!(s3_object.is_delete_marker());
612    }
613
614    // --- S3Object::new / S3Object::new_versioned constructor tests ---
615
616    #[test]
617    fn s3_object_new_sets_key_and_size() {
618        let obj = S3Object::new("photos/cat.jpg", 2048);
619        assert_eq!(obj.key(), "photos/cat.jpg");
620        assert_eq!(obj.size(), 2048);
621    }
622
623    #[test]
624    fn s3_object_new_is_not_versioning() {
625        let obj = S3Object::new("key.txt", 100);
626        assert!(obj.version_id().is_none());
627        assert!(obj.is_latest());
628        assert!(!obj.is_delete_marker());
629        assert!(matches!(obj, S3Object::NotVersioning(_)));
630    }
631
632    #[test]
633    fn s3_object_new_defaults_last_modified_to_epoch() {
634        let obj = S3Object::new("key.txt", 0);
635        assert_eq!(*obj.last_modified(), DateTime::from_secs(0));
636    }
637
638    #[test]
639    fn s3_object_new_zero_size() {
640        let obj = S3Object::new("empty.txt", 0);
641        assert_eq!(obj.size(), 0);
642    }
643
644    #[test]
645    fn s3_object_new_versioned_sets_key_version_size() {
646        let obj = S3Object::new_versioned("logs/app.log", "v1", 512);
647        assert_eq!(obj.key(), "logs/app.log");
648        assert_eq!(obj.version_id(), Some("v1"));
649        assert_eq!(obj.size(), 512);
650    }
651
652    #[test]
653    fn s3_object_new_versioned_is_latest() {
654        let obj = S3Object::new_versioned("key.txt", "ver-abc", 100);
655        assert!(obj.is_latest());
656        assert!(!obj.is_delete_marker());
657        assert!(matches!(obj, S3Object::Versioning(_)));
658    }
659
660    #[test]
661    fn s3_object_new_versioned_defaults_last_modified_to_epoch() {
662        let obj = S3Object::new_versioned("key.txt", "v1", 0);
663        assert_eq!(*obj.last_modified(), DateTime::from_secs(0));
664    }
665
666    #[test]
667    fn s3_object_new_versioned_has_no_etag() {
668        let obj = S3Object::new_versioned("key.txt", "v1", 100);
669        assert!(obj.e_tag().is_none());
670    }
671
672    #[test]
673    fn s3_object_new_has_no_etag() {
674        let obj = S3Object::new("key.txt", 100);
675        assert!(obj.e_tag().is_none());
676    }
677
678    #[test]
679    fn debug_print_access_keys_redacts_secrets() {
680        let access_keys = AccessKeys {
681            access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
682            secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
683            session_token: Some("session_token_value".to_string()),
684        };
685        let debug_string = format!("{access_keys:?}");
686
687        assert!(debug_string.contains("secret_access_key: \"** redacted **\""));
688        assert!(debug_string.contains("session_token: \"** redacted **\""));
689        assert!(!debug_string.contains("wJalrXUtnFEMI"));
690    }
691
692    // --- ObjectKeyMap tests (Task 3.1) ---
693
694    #[test]
695    fn object_key_map_insert_and_retrieve() {
696        let map: ObjectKeyMap = Arc::new(Mutex::new(HashMap::new()));
697        let object = S3Object::NotVersioning(
698            Object::builder()
699                .key("test/key.txt")
700                .size(100)
701                .last_modified(DateTime::from_secs(1000))
702                .build(),
703        );
704
705        map.lock()
706            .unwrap()
707            .insert("test/key.txt".to_string(), object.clone());
708        let retrieved = map.lock().unwrap().get("test/key.txt").cloned();
709        assert_eq!(retrieved, Some(object));
710    }
711
712    #[test]
713    fn object_key_map_concurrent_access() {
714        let map: ObjectKeyMap = Arc::new(Mutex::new(HashMap::new()));
715        let map_clone = Arc::clone(&map);
716
717        // Simulate concurrent access
718        map.lock().unwrap().insert(
719            "key1".to_string(),
720            S3Object::NotVersioning(
721                Object::builder()
722                    .key("key1")
723                    .size(10)
724                    .last_modified(DateTime::from_secs(1))
725                    .build(),
726            ),
727        );
728        map_clone.lock().unwrap().insert(
729            "key2".to_string(),
730            S3Object::NotVersioning(
731                Object::builder()
732                    .key("key2")
733                    .size(20)
734                    .last_modified(DateTime::from_secs(2))
735                    .build(),
736            ),
737        );
738
739        assert_eq!(map.lock().unwrap().len(), 2);
740    }
741
742    // --- DeletionStatsReport tests (Task 3.2) ---
743
744    #[test]
745    fn deletion_stats_report_new() {
746        let report = DeletionStatsReport::new();
747        assert_eq!(report.stats_deleted_objects.load(Ordering::SeqCst), 0);
748        assert_eq!(report.stats_deleted_bytes.load(Ordering::SeqCst), 0);
749        assert_eq!(report.stats_failed_objects.load(Ordering::SeqCst), 0);
750    }
751
752    #[test]
753    fn deletion_stats_report_default() {
754        let report = DeletionStatsReport::default();
755        assert_eq!(report.stats_deleted_objects.load(Ordering::SeqCst), 0);
756    }
757
758    #[test]
759    fn deletion_stats_report_increment_deleted() {
760        let report = DeletionStatsReport::new();
761        report.increment_deleted(1024);
762        report.increment_deleted(2048);
763
764        assert_eq!(report.stats_deleted_objects.load(Ordering::SeqCst), 2);
765        assert_eq!(report.stats_deleted_bytes.load(Ordering::SeqCst), 3072);
766        assert_eq!(report.stats_failed_objects.load(Ordering::SeqCst), 0);
767    }
768
769    #[test]
770    fn deletion_stats_report_increment_failed() {
771        let report = DeletionStatsReport::new();
772        report.increment_failed();
773        report.increment_failed();
774        report.increment_failed();
775
776        assert_eq!(report.stats_deleted_objects.load(Ordering::SeqCst), 0);
777        assert_eq!(report.stats_failed_objects.load(Ordering::SeqCst), 3);
778    }
779
780    #[test]
781    fn deletion_stats_report_snapshot() {
782        let report = DeletionStatsReport::new();
783        report.increment_deleted(500);
784        report.increment_deleted(300);
785        report.increment_failed();
786
787        let stats = report.snapshot();
788        assert_eq!(stats.stats_deleted_objects, 2);
789        assert_eq!(stats.stats_deleted_bytes, 800);
790        assert_eq!(stats.stats_failed_objects, 1);
791        assert_eq!(stats.duration, Duration::default());
792    }
793
794    // --- DeletionStats tests (Task 3.2) ---
795
796    #[test]
797    fn deletion_stats_clone() {
798        let stats = DeletionStats {
799            stats_deleted_objects: 100,
800            stats_deleted_bytes: 50_000,
801            stats_failed_objects: 5,
802            duration: Duration::from_secs(10),
803        };
804        let cloned = stats.clone();
805        assert_eq!(stats, cloned);
806    }
807
808    // --- DeletionOutcome tests (Task 3.3) ---
809
810    #[test]
811    fn deletion_outcome_success() {
812        let outcome = DeletionOutcome::Success {
813            key: "test/key.txt".to_string(),
814            version_id: Some("v1".to_string()),
815        };
816        assert!(outcome.is_success());
817        assert_eq!(outcome.key(), "test/key.txt");
818        assert_eq!(outcome.version_id(), Some("v1"));
819    }
820
821    #[test]
822    fn deletion_outcome_success_no_version() {
823        let outcome = DeletionOutcome::Success {
824            key: "test/key.txt".to_string(),
825            version_id: None,
826        };
827        assert!(outcome.is_success());
828        assert!(outcome.version_id().is_none());
829    }
830
831    #[test]
832    fn deletion_outcome_failed() {
833        let outcome = DeletionOutcome::Failed {
834            key: "test/key.txt".to_string(),
835            version_id: None,
836            error: DeletionError::AccessDenied,
837            retry_count: 3,
838        };
839        assert!(!outcome.is_success());
840        assert_eq!(outcome.key(), "test/key.txt");
841    }
842
843    // --- DeletionError tests (Task 3.3) ---
844
845    #[test]
846    fn deletion_error_is_retryable() {
847        assert!(!DeletionError::NotFound.is_retryable());
848        assert!(!DeletionError::AccessDenied.is_retryable());
849        assert!(!DeletionError::PreconditionFailed.is_retryable());
850        assert!(DeletionError::Throttled.is_retryable());
851        assert!(DeletionError::NetworkError("timeout".to_string()).is_retryable());
852        assert!(DeletionError::ServiceError("500".to_string()).is_retryable());
853    }
854
855    #[test]
856    fn deletion_error_display() {
857        assert_eq!(DeletionError::NotFound.to_string(), "Object not found");
858        assert_eq!(DeletionError::AccessDenied.to_string(), "Access denied");
859        assert_eq!(
860            DeletionError::PreconditionFailed.to_string(),
861            "Precondition failed (ETag mismatch)"
862        );
863        assert_eq!(DeletionError::Throttled.to_string(), "Request throttled");
864        assert_eq!(
865            DeletionError::NetworkError("conn reset".to_string()).to_string(),
866            "Network error: conn reset"
867        );
868        assert_eq!(
869            DeletionError::ServiceError("Internal".to_string()).to_string(),
870            "Service error: Internal"
871        );
872    }
873
874    // --- DeletionEvent tests (Task 3.4) ---
875
876    #[test]
877    fn deletion_event_pipeline_start() {
878        let event = DeletionEvent::PipelineStart;
879        assert_eq!(event, DeletionEvent::PipelineStart);
880    }
881
882    #[test]
883    fn deletion_event_object_deleted() {
884        let event = DeletionEvent::ObjectDeleted {
885            key: "test/key.txt".to_string(),
886            version_id: Some("v1".to_string()),
887            size: 1024,
888        };
889        if let DeletionEvent::ObjectDeleted {
890            key,
891            version_id,
892            size,
893        } = &event
894        {
895            assert_eq!(key, "test/key.txt");
896            assert_eq!(version_id.as_deref(), Some("v1"));
897            assert_eq!(*size, 1024);
898        } else {
899            panic!("Expected ObjectDeleted event");
900        }
901    }
902
903    #[test]
904    fn deletion_event_object_failed() {
905        let event = DeletionEvent::ObjectFailed {
906            key: "test/key.txt".to_string(),
907            version_id: None,
908            error: DeletionError::AccessDenied,
909        };
910        if let DeletionEvent::ObjectFailed { key, error, .. } = &event {
911            assert_eq!(key, "test/key.txt");
912            assert_eq!(*error, DeletionError::AccessDenied);
913        } else {
914            panic!("Expected ObjectFailed event");
915        }
916    }
917
918    #[test]
919    fn deletion_event_pipeline_end() {
920        let event = DeletionEvent::PipelineEnd;
921        assert_eq!(event, DeletionEvent::PipelineEnd);
922    }
923
924    #[test]
925    fn deletion_event_pipeline_error() {
926        let event = DeletionEvent::PipelineError {
927            message: "something went wrong".to_string(),
928        };
929        if let DeletionEvent::PipelineError { message } = &event {
930            assert_eq!(message, "something went wrong");
931        } else {
932            panic!("Expected PipelineError event");
933        }
934    }
935
936    #[test]
937    fn deletion_event_clone() {
938        let event = DeletionEvent::ObjectDeleted {
939            key: "key".to_string(),
940            version_id: None,
941            size: 42,
942        };
943        let cloned = event.clone();
944        assert_eq!(event, cloned);
945    }
946
947    // --- S3Target tests (Task 3.5) ---
948
949    #[test]
950    fn s3_target_parse_bucket_only() {
951        let target = S3Target::parse("s3://my-bucket").unwrap();
952        assert_eq!(target.bucket, "my-bucket");
953        assert!(target.prefix.is_none());
954        assert!(target.endpoint.is_none());
955        assert!(target.region.is_none());
956    }
957
958    #[test]
959    fn s3_target_parse_bucket_with_trailing_slash() {
960        let target = S3Target::parse("s3://my-bucket/").unwrap();
961        assert_eq!(target.bucket, "my-bucket");
962        assert!(target.prefix.is_none());
963    }
964
965    #[test]
966    fn s3_target_parse_bucket_with_prefix() {
967        let target = S3Target::parse("s3://my-bucket/logs/2023/").unwrap();
968        assert_eq!(target.bucket, "my-bucket");
969        assert_eq!(target.prefix.as_deref(), Some("logs/2023/"));
970    }
971
972    #[test]
973    fn s3_target_parse_bucket_with_simple_prefix() {
974        let target = S3Target::parse("s3://my-bucket/prefix").unwrap();
975        assert_eq!(target.bucket, "my-bucket");
976        assert_eq!(target.prefix.as_deref(), Some("prefix"));
977    }
978
979    #[test]
980    fn s3_target_parse_bucket_with_deep_prefix() {
981        let target = S3Target::parse("s3://my-bucket/a/b/c/d/e").unwrap();
982        assert_eq!(target.bucket, "my-bucket");
983        assert_eq!(target.prefix.as_deref(), Some("a/b/c/d/e"));
984    }
985
986    #[test]
987    fn s3_target_parse_invalid_no_scheme() {
988        let result = S3Target::parse("my-bucket/prefix");
989        assert!(result.is_err());
990        let err_msg = result.unwrap_err().to_string();
991        assert!(err_msg.contains("Target URI must start with 's3://'"));
992    }
993
994    #[test]
995    fn s3_target_parse_invalid_wrong_scheme() {
996        let result = S3Target::parse("http://my-bucket/prefix");
997        assert!(result.is_err());
998    }
999
1000    #[test]
1001    fn s3_target_parse_invalid_empty_bucket() {
1002        let result = S3Target::parse("s3://");
1003        assert!(result.is_err());
1004        let err_msg = result.unwrap_err().to_string();
1005        assert!(err_msg.contains("Bucket name cannot be empty"));
1006    }
1007
1008    #[test]
1009    fn s3_target_parse_invalid_empty_bucket_with_prefix() {
1010        let result = S3Target::parse("s3:///prefix");
1011        assert!(result.is_err());
1012        let err_msg = result.unwrap_err().to_string();
1013        assert!(err_msg.contains("Bucket name cannot be empty"));
1014    }
1015
1016    #[test]
1017    fn s3_target_display_bucket_only() {
1018        let target = S3Target {
1019            bucket: "my-bucket".to_string(),
1020            prefix: None,
1021            endpoint: None,
1022            region: None,
1023        };
1024        assert_eq!(target.to_string(), "s3://my-bucket");
1025    }
1026
1027    #[test]
1028    fn s3_target_display_with_prefix() {
1029        let target = S3Target {
1030            bucket: "my-bucket".to_string(),
1031            prefix: Some("logs/2023/".to_string()),
1032            endpoint: None,
1033            region: None,
1034        };
1035        assert_eq!(target.to_string(), "s3://my-bucket/logs/2023/");
1036    }
1037
1038    #[test]
1039    fn s3_target_roundtrip() {
1040        // Parse then display should give back the original URI
1041        let uri = "s3://my-bucket/some/prefix/";
1042        let target = S3Target::parse(uri).unwrap();
1043        assert_eq!(target.to_string(), uri);
1044    }
1045
1046    #[test]
1047    fn s3_target_clone_and_eq() {
1048        let target = S3Target::parse("s3://bucket/key").unwrap();
1049        let cloned = target.clone();
1050        assert_eq!(target, cloned);
1051    }
1052}