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#[derive(Debug, Clone, PartialEq)]
47pub enum S3Object {
48 NotVersioning(Object),
50 Versioning(ObjectVersion),
52 DeleteMarker(DeleteMarkerEntry),
54}
55
56impl S3Object {
57 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 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
171pub type ObjectKeyMap = Arc<Mutex<HashMap<String, S3Object>>>;
176
177#[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#[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 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 pub fn increment_failed(&self) {
222 self.stats_failed_objects.fetch_add(1, Ordering::Relaxed);
223 }
224
225 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#[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#[derive(Debug, Clone, PartialEq)]
264pub enum DeletionOutcome {
265 Success {
267 key: String,
268 version_id: Option<String>,
269 },
270 Failed {
272 key: String,
273 version_id: Option<String>,
274 error: DeletionError,
275 retry_count: u32,
276 },
277}
278
279impl DeletionOutcome {
280 pub fn is_success(&self) -> bool {
282 matches!(self, DeletionOutcome::Success { .. })
283 }
284
285 pub fn key(&self) -> &str {
287 match self {
288 DeletionOutcome::Success { key, .. } => key,
289 DeletionOutcome::Failed { key, .. } => key,
290 }
291 }
292
293 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#[derive(Debug, Clone, PartialEq)]
308pub enum DeletionError {
309 NotFound,
311 AccessDenied,
313 PreconditionFailed,
315 Throttled,
317 NetworkError(String),
319 ServiceError(String),
321}
322
323impl DeletionError {
324 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#[derive(Debug, Clone, PartialEq)]
362pub enum DeletionEvent {
363 PipelineStart,
365 ObjectDeleted {
367 key: String,
368 version_id: Option<String>,
369 size: u64,
370 },
371 ObjectFailed {
373 key: String,
374 version_id: Option<String>,
375 error: DeletionError,
376 },
377 PipelineEnd,
379 PipelineError { message: String },
381}
382
383#[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 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..]; 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#[derive(Debug, Clone)]
480pub enum StoragePath {
481 S3 { bucket: String, prefix: String },
482}
483
484#[derive(Debug, Clone)]
486pub struct ClientConfigLocation {
487 pub aws_config_file: Option<PathBuf>,
488 pub aws_shared_credentials_file: Option<PathBuf>,
489}
490
491#[derive(Debug, Clone)]
495pub enum S3Credentials {
496 Profile(String),
497 Credentials { access_keys: AccessKeys },
498 FromEnvironment,
499}
500
501#[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#[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}