Skip to main content

rc_core/
lifecycle.rs

1//! Lifecycle (ILM) configuration types
2//!
3//! Domain types for S3 bucket lifecycle rules including expiration,
4//! transition, and noncurrent version management.
5
6use std::collections::HashMap;
7use std::fmt;
8
9use serde::de::Error as _;
10use serde::{Deserialize, Deserializer, Serialize};
11
12/// Full lifecycle configuration for a bucket
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LifecycleConfiguration {
15    /// Lifecycle rules
16    #[serde(alias = "Rules")]
17    pub rules: Vec<LifecycleRule>,
18}
19
20/// A single lifecycle rule
21#[derive(Debug, Clone, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct LifecycleRule {
24    /// Rule identifier
25    pub id: String,
26
27    /// Whether the rule is enabled or disabled
28    pub status: LifecycleRuleStatus,
29
30    /// Key prefix filter
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub prefix: Option<String>,
33
34    /// Tag-based filter (key=value pairs)
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub tags: Option<HashMap<String, String>>,
37
38    /// Minimum object size in bytes for the rule filter.
39    #[serde(
40        skip_serializing_if = "Option::is_none",
41        alias = "ObjectSizeGreaterThan",
42        alias = "object_size_greater_than"
43    )]
44    pub object_size_greater_than: Option<i64>,
45
46    /// Maximum object size in bytes for the rule filter.
47    #[serde(
48        skip_serializing_if = "Option::is_none",
49        alias = "ObjectSizeLessThan",
50        alias = "object_size_less_than"
51    )]
52    pub object_size_less_than: Option<i64>,
53
54    /// Expiration settings for current object versions
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub expiration: Option<LifecycleExpiration>,
57
58    /// Expire delete-marker history after the configured number of days.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub del_marker_expiration: Option<LifecycleDelMarkerExpiration>,
61
62    /// Transition settings for current object versions
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub transition: Option<LifecycleTransition>,
65
66    /// Additional transition settings for current object versions.
67    ///
68    /// `transition` remains the compatibility field for the first action;
69    /// this collection carries any subsequent actions returned by S3.
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub transitions: Vec<LifecycleTransition>,
72
73    /// Expiration settings for noncurrent object versions
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub noncurrent_version_expiration: Option<NoncurrentVersionExpiration>,
76
77    /// Transition settings for noncurrent object versions
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub noncurrent_version_transition: Option<NoncurrentVersionTransition>,
80
81    /// Additional transition settings for noncurrent object versions.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub noncurrent_version_transitions: Vec<NoncurrentVersionTransition>,
84
85    /// Days after initiation to abort incomplete multipart uploads
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub abort_incomplete_multipart_upload_days: Option<i32>,
88
89    /// Whether to remove expired delete markers
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub expired_object_delete_marker: Option<bool>,
92}
93
94#[derive(Debug, Deserialize)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96struct LifecycleRuleInput {
97    #[serde(alias = "ID")]
98    id: String,
99    #[serde(alias = "Status")]
100    status: LifecycleRuleStatus,
101    #[serde(default, alias = "Prefix")]
102    prefix: Option<String>,
103    #[serde(default, alias = "Tags")]
104    tags: Option<HashMap<String, String>>,
105    #[serde(
106        default,
107        alias = "ObjectSizeGreaterThan",
108        alias = "object_size_greater_than"
109    )]
110    object_size_greater_than: Option<i64>,
111    #[serde(default, alias = "ObjectSizeLessThan", alias = "object_size_less_than")]
112    object_size_less_than: Option<i64>,
113    /// S3-shaped nested filter (`Filter.Prefix` / `Filter.Tag` / `Filter.And`).
114    #[serde(default, alias = "Filter")]
115    filter: Option<LifecycleFilterInput>,
116    #[serde(default, alias = "Expiration")]
117    expiration: Option<LifecycleExpirationInput>,
118    #[serde(default, alias = "Transition")]
119    transition: Option<LifecycleTransition>,
120    #[serde(default, alias = "Transitions")]
121    transitions: Vec<LifecycleTransition>,
122    #[serde(default, alias = "NoncurrentVersionExpiration")]
123    noncurrent_version_expiration: Option<NoncurrentVersionExpiration>,
124    #[serde(default, alias = "NoncurrentVersionTransition")]
125    noncurrent_version_transition: Option<NoncurrentVersionTransition>,
126    #[serde(default, alias = "NoncurrentVersionTransitions")]
127    noncurrent_version_transitions: Vec<NoncurrentVersionTransition>,
128    #[serde(
129        default,
130        alias = "AbortIncompleteMultipartUploadDays",
131        alias = "abort_incomplete_multipart_upload_days"
132    )]
133    abort_incomplete_multipart_upload_days: Option<i32>,
134    /// S3-shaped nested abort action (`AbortIncompleteMultipartUpload.DaysAfterInitiation`).
135    #[serde(
136        default,
137        alias = "AbortIncompleteMultipartUpload",
138        alias = "abort_incomplete_multipart_upload"
139    )]
140    abort_incomplete_multipart_upload: Option<AbortIncompleteMultipartUploadInput>,
141
142    #[serde(
143        default,
144        alias = "ExpiredObjectDeleteMarker",
145        alias = "expired_object_delete_marker"
146    )]
147    expired_object_delete_marker: Option<bool>,
148
149    #[serde(
150        default,
151        alias = "DelMarkerExpiration",
152        alias = "del_marker_expiration"
153    )]
154    del_marker_expiration: Option<LifecycleDelMarkerInput>,
155}
156
157/// S3 `Filter` predicate: `Prefix`, a single `Tag`, an `And` combination, or a
158/// standalone object-size bound.
159#[derive(Debug, Deserialize)]
160#[serde(rename_all = "camelCase", deny_unknown_fields)]
161struct LifecycleFilterInput {
162    #[serde(default, alias = "Prefix")]
163    prefix: Option<String>,
164    #[serde(default, alias = "Tag")]
165    tag: Option<LifecycleTagInput>,
166    #[serde(default, alias = "And")]
167    and: Option<LifecycleFilterAndInput>,
168    #[serde(
169        default,
170        alias = "ObjectSizeGreaterThan",
171        alias = "object_size_greater_than"
172    )]
173    object_size_greater_than: Option<i64>,
174    #[serde(default, alias = "ObjectSizeLessThan", alias = "object_size_less_than")]
175    object_size_less_than: Option<i64>,
176}
177
178/// S3 `Filter.And` combination of prefix, tags, and object-size bounds.
179#[derive(Debug, Deserialize)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181struct LifecycleFilterAndInput {
182    #[serde(default, alias = "Prefix")]
183    prefix: Option<String>,
184    #[serde(default, alias = "Tag")]
185    tag: Option<LifecycleTagInput>,
186    #[serde(default, alias = "Tags")]
187    tags: Option<Vec<LifecycleTagInput>>,
188    #[serde(
189        default,
190        alias = "ObjectSizeGreaterThan",
191        alias = "object_size_greater_than"
192    )]
193    object_size_greater_than: Option<i64>,
194    #[serde(default, alias = "ObjectSizeLessThan", alias = "object_size_less_than")]
195    object_size_less_than: Option<i64>,
196}
197
198/// A single S3 tag predicate.
199#[derive(Debug, Deserialize)]
200#[serde(rename_all = "camelCase", deny_unknown_fields)]
201struct LifecycleTagInput {
202    #[serde(alias = "Key")]
203    key: String,
204    #[serde(alias = "Value")]
205    value: String,
206}
207
208/// S3-shaped `AbortIncompleteMultipartUpload` action.
209#[derive(Debug, Deserialize)]
210#[serde(rename_all = "camelCase", deny_unknown_fields)]
211struct AbortIncompleteMultipartUploadInput {
212    #[serde(
213        default,
214        alias = "DaysAfterInitiation",
215        alias = "days_after_initiation"
216    )]
217    days_after_initiation: Option<i32>,
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(rename_all = "camelCase", deny_unknown_fields)]
222struct LifecycleExpirationInput {
223    #[serde(default, alias = "Days")]
224    days: Option<i32>,
225    #[serde(default, alias = "Date")]
226    date: Option<String>,
227    #[serde(
228        default,
229        alias = "ExpiredObjectAllVersions",
230        alias = "expired_object_all_versions"
231    )]
232    expired_object_all_versions: Option<bool>,
233    #[serde(
234        default,
235        alias = "DelMarkerExpiration",
236        alias = "del_marker_expiration"
237    )]
238    del_marker_expiration: Option<LifecycleDelMarkerInput>,
239    #[serde(
240        default,
241        alias = "ExpiredObjectDeleteMarker",
242        alias = "expired_object_delete_marker"
243    )]
244    expired_object_delete_marker: Option<bool>,
245}
246
247#[derive(Debug, Deserialize)]
248#[serde(untagged)]
249enum LifecycleDelMarkerInput {
250    Flag(bool),
251    Configuration(LifecycleDelMarkerExpiration),
252}
253
254#[derive(Debug)]
255struct NormalizedDelMarkerInput {
256    enabled: bool,
257    configuration: Option<LifecycleDelMarkerExpiration>,
258}
259
260impl<'de> Deserialize<'de> for LifecycleRule {
261    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
262    where
263        D: Deserializer<'de>,
264    {
265        let LifecycleRuleInput {
266            id,
267            status,
268            prefix,
269            tags,
270            object_size_greater_than,
271            object_size_less_than,
272            filter,
273            expiration: expiration_input,
274            transition,
275            transitions: mut transitions_input,
276            noncurrent_version_expiration,
277            noncurrent_version_transition,
278            noncurrent_version_transitions: mut noncurrent_version_transitions_input,
279            abort_incomplete_multipart_upload_days,
280            abort_incomplete_multipart_upload,
281            expired_object_delete_marker,
282            del_marker_expiration: top_level_del_marker_input,
283        } = LifecycleRuleInput::deserialize(deserializer)?;
284
285        let filter_fields = resolve_filter_fields(filter).map_err(D::Error::custom)?;
286        let prefix = merge_exclusive(prefix, filter_fields.prefix, "prefix", "Filter.Prefix")
287            .map_err(D::Error::custom)?;
288        let tags = merge_exclusive(tags, filter_fields.tags, "tags", "Filter tags")
289            .map_err(D::Error::custom)?;
290        let object_size_greater_than = merge_exclusive(
291            object_size_greater_than,
292            filter_fields.object_size_greater_than,
293            "objectSizeGreaterThan",
294            "Filter.ObjectSizeGreaterThan",
295        )
296        .map_err(D::Error::custom)?;
297        let object_size_less_than = merge_exclusive(
298            object_size_less_than,
299            filter_fields.object_size_less_than,
300            "objectSizeLessThan",
301            "Filter.ObjectSizeLessThan",
302        )
303        .map_err(D::Error::custom)?;
304
305        let nested_expired_object_delete_marker = expiration_input
306            .as_ref()
307            .and_then(|expiration| expiration.expired_object_delete_marker);
308        let expired_object_delete_marker = merge_equal_optional(
309            expired_object_delete_marker,
310            nested_expired_object_delete_marker,
311            "expiredObjectDeleteMarker",
312        )
313        .map_err(D::Error::custom)?;
314
315        let abort_incomplete_multipart_upload_days = merge_equal_optional(
316            abort_incomplete_multipart_upload_days,
317            abort_incomplete_multipart_upload.and_then(|action| action.days_after_initiation),
318            "abortIncompleteMultipartUploadDays",
319        )
320        .map_err(D::Error::custom)?;
321
322        let (expiration, nested_del_marker) = match expiration_input {
323            Some(expiration) => {
324                let nested_del_marker = expiration.del_marker_expiration;
325                (
326                    Some(LifecycleExpiration {
327                        days: expiration.days,
328                        date: expiration.date,
329                        expired_object_all_versions: expiration.expired_object_all_versions,
330                    }),
331                    nested_del_marker,
332                )
333            }
334            None => (None, None),
335        };
336
337        let fallback_days = expiration.as_ref().and_then(|expiration| expiration.days);
338        let top_level_del_marker =
339            normalize_del_marker_input(top_level_del_marker_input, fallback_days)
340                .map_err(D::Error::custom)?;
341        let nested_del_marker = normalize_del_marker_input(nested_del_marker, fallback_days)
342            .map_err(D::Error::custom)?;
343        let del_marker_expiration =
344            merge_del_marker_inputs(top_level_del_marker, nested_del_marker)
345                .map_err(D::Error::custom)?;
346
347        if let Some(transition) = transition {
348            transitions_input.insert(0, transition);
349        }
350        let transition = transitions_input.first().cloned();
351        let additional_transitions = transitions_input.into_iter().skip(1).collect();
352
353        if let Some(transition) = noncurrent_version_transition {
354            noncurrent_version_transitions_input.insert(0, transition);
355        }
356        let noncurrent_version_transition = noncurrent_version_transitions_input.first().cloned();
357        let additional_noncurrent_version_transitions = noncurrent_version_transitions_input
358            .into_iter()
359            .skip(1)
360            .collect();
361
362        Ok(Self {
363            id,
364            status,
365            prefix,
366            tags,
367            object_size_greater_than,
368            object_size_less_than,
369            expiration,
370            del_marker_expiration,
371            transition,
372            transitions: additional_transitions,
373            noncurrent_version_expiration,
374            noncurrent_version_transition,
375            noncurrent_version_transitions: additional_noncurrent_version_transitions,
376            abort_incomplete_multipart_upload_days,
377            expired_object_delete_marker,
378        })
379    }
380}
381
382/// Filter predicates resolved from an S3-shaped `Filter` object.
383#[derive(Default)]
384struct FilterFields {
385    prefix: Option<String>,
386    tags: Option<HashMap<String, String>>,
387    object_size_greater_than: Option<i64>,
388    object_size_less_than: Option<i64>,
389}
390
391/// Flatten an S3 `Filter` (one of `Prefix`, `Tag`, `And`, or a standalone
392/// object-size bound) into rc's flat fields.
393///
394/// An empty `Filter` object stays valid: the server documents it as "applies to
395/// every object in the bucket".
396fn resolve_filter_fields(
397    filter: Option<LifecycleFilterInput>,
398) -> std::result::Result<FilterFields, String> {
399    let Some(filter) = filter else {
400        return Ok(FilterFields::default());
401    };
402    let predicate_count = usize::from(filter.prefix.is_some())
403        + usize::from(filter.tag.is_some())
404        + usize::from(filter.and.is_some())
405        + usize::from(filter.object_size_greater_than.is_some())
406        + usize::from(filter.object_size_less_than.is_some());
407    if predicate_count > 1 {
408        return Err(
409            "S3 Filter allows exactly one of Prefix, Tag, And, ObjectSizeGreaterThan, or ObjectSizeLessThan"
410                .to_string(),
411        );
412    }
413    if let Some(and) = filter.and {
414        let mut tags = HashMap::new();
415        if let Some(tag) = and.tag {
416            tags.insert(tag.key, tag.value);
417        }
418        if let Some(tag_list) = and.tags {
419            for tag in tag_list {
420                if tags.insert(tag.key.clone(), tag.value).is_some() {
421                    let key = tag.key;
422                    return Err(format!("duplicate tag key in Filter.And: {key}"));
423                }
424            }
425        }
426        return Ok(FilterFields {
427            prefix: and.prefix,
428            tags: if tags.is_empty() { None } else { Some(tags) },
429            object_size_greater_than: and.object_size_greater_than,
430            object_size_less_than: and.object_size_less_than,
431        });
432    }
433    if let Some(prefix) = filter.prefix {
434        return Ok(FilterFields {
435            prefix: Some(prefix),
436            ..FilterFields::default()
437        });
438    }
439    if let Some(tag) = filter.tag {
440        return Ok(FilterFields {
441            tags: Some(HashMap::from([(tag.key, tag.value)])),
442            ..FilterFields::default()
443        });
444    }
445    if filter.object_size_greater_than.is_some() || filter.object_size_less_than.is_some() {
446        return Ok(FilterFields {
447            object_size_greater_than: filter.object_size_greater_than,
448            object_size_less_than: filter.object_size_less_than,
449            ..FilterFields::default()
450        });
451    }
452    Ok(FilterFields::default())
453}
454
455/// Combine a flat field with its nested equivalent; both set at once is ambiguous.
456fn merge_exclusive<T>(
457    flat: Option<T>,
458    nested: Option<T>,
459    flat_name: &str,
460    nested_name: &str,
461) -> std::result::Result<Option<T>, String> {
462    match (flat, nested) {
463        (Some(_), Some(_)) => Err(format!(
464            "lifecycle rule sets both {flat_name} and {nested_name}; specify one"
465        )),
466        (value, None) | (None, value) => Ok(value),
467    }
468}
469
470/// Combine two optional spellings of the same value; equal duplicates are fine.
471fn merge_equal_optional<T: PartialEq>(
472    primary: Option<T>,
473    secondary: Option<T>,
474    field_name: &str,
475) -> std::result::Result<Option<T>, String> {
476    match (primary, secondary) {
477        (Some(primary_value), Some(secondary_value)) if primary_value != secondary_value => {
478            Err(format!("conflicting {field_name} values in lifecycle rule"))
479        }
480        (value, None) | (None, value) => Ok(value),
481        (Some(value), Some(_)) => Ok(Some(value)),
482    }
483}
484
485fn normalize_del_marker_input(
486    input: Option<LifecycleDelMarkerInput>,
487    fallback_days: Option<i32>,
488) -> std::result::Result<Option<NormalizedDelMarkerInput>, String> {
489    match input {
490        None => Ok(None),
491        Some(LifecycleDelMarkerInput::Flag(false)) => Ok(Some(NormalizedDelMarkerInput {
492            enabled: false,
493            configuration: None,
494        })),
495        Some(LifecycleDelMarkerInput::Flag(true)) => fallback_days
496            .map(|days| {
497                Some(NormalizedDelMarkerInput {
498                    enabled: true,
499                    configuration: Some(LifecycleDelMarkerExpiration { days: Some(days) }),
500                })
501            })
502            .ok_or_else(|| {
503                "DelMarkerExpiration=true requires expiration.days for compatibility input"
504                    .to_string()
505            }),
506        Some(LifecycleDelMarkerInput::Configuration(configuration)) => {
507            Ok(Some(NormalizedDelMarkerInput {
508                enabled: true,
509                configuration: Some(configuration),
510            }))
511        }
512    }
513}
514
515fn merge_del_marker_inputs(
516    top_level: Option<NormalizedDelMarkerInput>,
517    nested: Option<NormalizedDelMarkerInput>,
518) -> std::result::Result<Option<LifecycleDelMarkerExpiration>, String> {
519    match (top_level, nested) {
520        (None, None) => Ok(None),
521        (Some(value), None) | (None, Some(value)) => Ok(value.enabled.then(|| {
522            value
523                .configuration
524                .unwrap_or(LifecycleDelMarkerExpiration { days: None })
525        })),
526        (Some(primary), Some(secondary)) => {
527            if primary.enabled != secondary.enabled {
528                return Err(
529                    "conflicting DelMarkerExpiration values in lifecycle rule: explicit false cannot be combined with an enabled value".to_string(),
530                );
531            }
532            if !primary.enabled {
533                return Ok(None);
534            }
535
536            let primary_configuration = primary
537                .configuration
538                .unwrap_or(LifecycleDelMarkerExpiration { days: None });
539            let secondary_configuration = secondary
540                .configuration
541                .unwrap_or(LifecycleDelMarkerExpiration { days: None });
542            if primary_configuration.days.is_some()
543                && secondary_configuration.days.is_some()
544                && primary_configuration.days != secondary_configuration.days
545            {
546                return Err("conflicting DelMarkerExpiration values in lifecycle rule".to_string());
547            }
548
549            Ok(Some(LifecycleDelMarkerExpiration {
550                days: primary_configuration.days.or(secondary_configuration.days),
551            }))
552        }
553    }
554}
555
556/// Rule status
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
558pub enum LifecycleRuleStatus {
559    Enabled,
560    Disabled,
561}
562
563impl fmt::Display for LifecycleRuleStatus {
564    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565        match self {
566            LifecycleRuleStatus::Enabled => write!(f, "Enabled"),
567            LifecycleRuleStatus::Disabled => write!(f, "Disabled"),
568        }
569    }
570}
571
572impl std::str::FromStr for LifecycleRuleStatus {
573    type Err = String;
574
575    fn from_str(s: &str) -> Result<Self, Self::Err> {
576        match s.to_lowercase().as_str() {
577            "enabled" => Ok(LifecycleRuleStatus::Enabled),
578            "disabled" => Ok(LifecycleRuleStatus::Disabled),
579            _ => Err(format!("Invalid lifecycle rule status: {s}")),
580        }
581    }
582}
583
584/// Expiration settings for current object versions
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(rename_all = "camelCase")]
587pub struct LifecycleExpiration {
588    /// Number of days after creation to expire
589    #[serde(skip_serializing_if = "Option::is_none", alias = "Days")]
590    pub days: Option<i32>,
591
592    /// Specific date to expire (ISO 8601 format)
593    #[serde(skip_serializing_if = "Option::is_none", alias = "Date")]
594    pub date: Option<String>,
595
596    /// Whether all object versions should be expired together.
597    #[serde(
598        skip_serializing_if = "Option::is_none",
599        alias = "ExpiredObjectAllVersions",
600        alias = "expired_object_all_versions"
601    )]
602    pub expired_object_all_versions: Option<bool>,
603}
604
605/// Expiration settings for delete markers and their prior object versions.
606#[derive(Debug, Clone, Serialize, Deserialize)]
607#[serde(rename_all = "camelCase")]
608pub struct LifecycleDelMarkerExpiration {
609    /// Number of days before delete-marker history is removed.
610    #[serde(skip_serializing_if = "Option::is_none", alias = "Days")]
611    pub days: Option<i32>,
612}
613
614/// Transition settings for current object versions
615#[derive(Debug, Clone, Serialize, Deserialize)]
616#[serde(rename_all = "camelCase")]
617pub struct LifecycleTransition {
618    /// Number of days after creation to transition
619    #[serde(skip_serializing_if = "Option::is_none")]
620    pub days: Option<i32>,
621
622    /// Specific date to transition (ISO 8601 format)
623    #[serde(skip_serializing_if = "Option::is_none")]
624    pub date: Option<String>,
625
626    /// Target storage class (tier name)
627    pub storage_class: String,
628}
629
630/// Expiration settings for noncurrent object versions
631#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(rename_all = "camelCase")]
633pub struct NoncurrentVersionExpiration {
634    /// Number of days after becoming noncurrent to expire
635    pub noncurrent_days: i32,
636
637    /// Maximum number of noncurrent versions to retain
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub newer_noncurrent_versions: Option<i32>,
640}
641
642/// Transition settings for noncurrent object versions
643#[derive(Debug, Clone, Serialize, Deserialize)]
644#[serde(rename_all = "camelCase")]
645pub struct NoncurrentVersionTransition {
646    /// Number of days after becoming noncurrent to transition
647    pub noncurrent_days: i32,
648
649    /// Target storage class (tier name)
650    pub storage_class: String,
651
652    /// Maximum number of newer noncurrent versions to retain.
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub newer_noncurrent_versions: Option<i32>,
655}
656
657impl fmt::Display for LifecycleRule {
658    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
659        write!(f, "{} ({})", self.id, self.status)
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn test_lifecycle_rule_status_display() {
669        assert_eq!(LifecycleRuleStatus::Enabled.to_string(), "Enabled");
670        assert_eq!(LifecycleRuleStatus::Disabled.to_string(), "Disabled");
671    }
672
673    #[test]
674    fn test_lifecycle_rule_status_from_str() {
675        assert_eq!(
676            "enabled".parse::<LifecycleRuleStatus>().unwrap(),
677            LifecycleRuleStatus::Enabled
678        );
679        assert_eq!(
680            "Disabled".parse::<LifecycleRuleStatus>().unwrap(),
681            LifecycleRuleStatus::Disabled
682        );
683        assert!("invalid".parse::<LifecycleRuleStatus>().is_err());
684    }
685
686    #[test]
687    fn test_lifecycle_rule_serialization() {
688        let rule = LifecycleRule {
689            id: "rule-1".to_string(),
690            status: LifecycleRuleStatus::Enabled,
691            prefix: Some("logs/".to_string()),
692            tags: None,
693            object_size_greater_than: None,
694            object_size_less_than: None,
695            expiration: Some(LifecycleExpiration {
696                days: Some(30),
697                date: None,
698                expired_object_all_versions: None,
699            }),
700            del_marker_expiration: None,
701            transition: None,
702            transitions: Vec::new(),
703            noncurrent_version_expiration: None,
704            noncurrent_version_transition: None,
705            noncurrent_version_transitions: Vec::new(),
706            abort_incomplete_multipart_upload_days: Some(7),
707            expired_object_delete_marker: None,
708        };
709
710        let json = serde_json::to_string(&rule).unwrap();
711        let decoded: LifecycleRule = serde_json::from_str(&json).unwrap();
712        assert_eq!(decoded.id, "rule-1");
713        assert_eq!(decoded.status, LifecycleRuleStatus::Enabled);
714        assert_eq!(decoded.prefix.as_deref(), Some("logs/"));
715        assert_eq!(decoded.expiration.as_ref().unwrap().days, Some(30));
716        assert_eq!(decoded.abort_incomplete_multipart_upload_days, Some(7));
717    }
718
719    #[test]
720    fn test_lifecycle_transition_serialization() {
721        let transition = LifecycleTransition {
722            days: Some(90),
723            date: None,
724            storage_class: "WARM_TIER".to_string(),
725        };
726
727        let json = serde_json::to_string(&transition).unwrap();
728        assert!(json.contains("storageClass"));
729        assert!(json.contains("WARM_TIER"));
730
731        let decoded: LifecycleTransition = serde_json::from_str(&json).unwrap();
732        assert_eq!(decoded.storage_class, "WARM_TIER");
733    }
734
735    #[test]
736    fn test_lifecycle_configuration_serialization() {
737        let config = LifecycleConfiguration {
738            rules: vec![LifecycleRule {
739                id: "expire-old".to_string(),
740                status: LifecycleRuleStatus::Enabled,
741                prefix: None,
742                tags: None,
743                object_size_greater_than: None,
744                object_size_less_than: None,
745                expiration: Some(LifecycleExpiration {
746                    days: Some(365),
747                    date: None,
748                    expired_object_all_versions: None,
749                }),
750                del_marker_expiration: None,
751                transition: None,
752                transitions: Vec::new(),
753                noncurrent_version_expiration: Some(NoncurrentVersionExpiration {
754                    noncurrent_days: 30,
755                    newer_noncurrent_versions: Some(3),
756                }),
757                noncurrent_version_transition: None,
758                noncurrent_version_transitions: Vec::new(),
759                abort_incomplete_multipart_upload_days: None,
760                expired_object_delete_marker: Some(true),
761            }],
762        };
763
764        let json = serde_json::to_string_pretty(&config).unwrap();
765        let decoded: LifecycleConfiguration = serde_json::from_str(&json).unwrap();
766        assert_eq!(decoded.rules.len(), 1);
767        assert_eq!(decoded.rules[0].id, "expire-old");
768        assert_eq!(
769            decoded.rules[0]
770                .noncurrent_version_expiration
771                .as_ref()
772                .unwrap()
773                .newer_noncurrent_versions,
774            Some(3)
775        );
776    }
777
778    #[test]
779    fn test_noncurrent_version_transition_serialization() {
780        let nvt = NoncurrentVersionTransition {
781            noncurrent_days: 60,
782            storage_class: "COLD_TIER".to_string(),
783            newer_noncurrent_versions: None,
784        };
785
786        let json = serde_json::to_string(&nvt).unwrap();
787        let decoded: NoncurrentVersionTransition = serde_json::from_str(&json).unwrap();
788        assert_eq!(decoded.noncurrent_days, 60);
789        assert_eq!(decoded.storage_class, "COLD_TIER");
790    }
791
792    #[test]
793    fn test_lifecycle_extensions_use_canonical_json_shape() {
794        let config = LifecycleConfiguration {
795            rules: vec![LifecycleRule {
796                id: "all-versions".to_string(),
797                status: LifecycleRuleStatus::Enabled,
798                prefix: Some("test/".to_string()),
799                tags: None,
800                object_size_greater_than: None,
801                object_size_less_than: None,
802                expiration: Some(LifecycleExpiration {
803                    days: Some(1),
804                    date: None,
805                    expired_object_all_versions: Some(true),
806                }),
807                del_marker_expiration: Some(LifecycleDelMarkerExpiration { days: Some(1) }),
808                transition: None,
809                transitions: Vec::new(),
810                noncurrent_version_expiration: None,
811                noncurrent_version_transition: None,
812                noncurrent_version_transitions: Vec::new(),
813                abort_incomplete_multipart_upload_days: None,
814                expired_object_delete_marker: None,
815            }],
816        };
817
818        let value = serde_json::to_value(&config).expect("serialize lifecycle extensions");
819        assert_eq!(
820            value["rules"][0]["expiration"]["expiredObjectAllVersions"],
821            true
822        );
823        assert_eq!(value["rules"][0]["delMarkerExpiration"]["days"], 1);
824        assert!(
825            value["rules"][0]["expiration"]
826                .get("DelMarkerExpiration")
827                .is_none()
828        );
829        let decoded: LifecycleConfiguration =
830            serde_json::from_value(value).expect("canonical lifecycle extensions should parse");
831        assert_eq!(
832            decoded.rules[0]
833                .expiration
834                .as_ref()
835                .and_then(|expiration| expiration.expired_object_all_versions),
836            Some(true)
837        );
838        assert_eq!(
839            decoded.rules[0]
840                .del_marker_expiration
841                .as_ref()
842                .and_then(|expiration| expiration.days),
843            Some(1)
844        );
845    }
846
847    #[test]
848    fn test_lifecycle_extensions_accept_issue_6334_compatibility_shape() {
849        let input = r#"
850        {
851          "rules": [{
852            "id": "rule-delayed-deletion",
853            "status": "Enabled",
854            "prefix": "test/",
855            "expiration": {
856              "ExpiredObjectAllVersions": true,
857              "DelMarkerExpiration": true,
858              "days": 1
859            }
860          }]
861        }
862        "#;
863
864        let config: LifecycleConfiguration =
865            serde_json::from_str(input).expect("issue compatibility shape should parse");
866        let rule = &config.rules[0];
867        assert_eq!(
868            rule.expiration
869                .as_ref()
870                .and_then(|expiration| expiration.expired_object_all_versions),
871            Some(true)
872        );
873        assert_eq!(
874            rule.del_marker_expiration
875                .as_ref()
876                .and_then(|expiration| expiration.days),
877            Some(1)
878        );
879    }
880
881    #[test]
882    fn test_lifecycle_extensions_reject_ambiguous_compatibility_shape() {
883        let input = r#"
884        {
885          "rules": [{
886            "id": "conflicting",
887            "status": "Enabled",
888            "expiration": {"days": 1, "DelMarkerExpiration": true},
889            "delMarkerExpiration": {"days": 2}
890          }]
891        }
892        "#;
893
894        let result = serde_json::from_str::<LifecycleConfiguration>(input);
895        assert!(result.is_err());
896    }
897
898    #[test]
899    fn test_lifecycle_extensions_reject_explicit_false_conflicts() {
900        let inputs = [
901            r#"
902            {
903              "rules": [{
904                "id": "conflicting-false-top-level",
905                "status": "Enabled",
906                "expiration": {"days": 1, "DelMarkerExpiration": true},
907                "delMarkerExpiration": false
908              }]
909            }
910            "#,
911            r#"
912            {
913              "rules": [{
914                "id": "conflicting-false-nested",
915                "status": "Enabled",
916                "expiration": {"days": 1, "DelMarkerExpiration": false},
917                "delMarkerExpiration": {"days": 1}
918              }]
919            }
920            "#,
921        ];
922
923        for input in inputs {
924            assert!(
925                serde_json::from_str::<LifecycleConfiguration>(input).is_err(),
926                "explicit false must not be silently overridden by an enabled representation"
927            );
928        }
929
930        let explicit_false = r#"
931        {
932          "rules": [{
933            "id": "explicit-false",
934            "status": "Enabled",
935            "delMarkerExpiration": false
936          }]
937        }
938        "#;
939        let config: LifecycleConfiguration =
940            serde_json::from_str(explicit_false).expect("standalone false is compatible");
941        assert!(config.rules[0].del_marker_expiration.is_none());
942    }
943
944    #[test]
945    fn test_lifecycle_rule_accepts_additional_actions_and_size_filters() {
946        let input = r#"
947        {
948          "rules": [{
949            "id": "full-rule",
950            "status": "Enabled",
951            "objectSizeGreaterThan": 500,
952            "objectSizeLessThan": 64000,
953            "transition": {"days": 30, "storageClass": "WARM"},
954            "transitions": [{"days": 60, "storageClass": "COLD"}],
955            "noncurrentVersionTransition": {
956              "noncurrentDays": 90,
957              "storageClass": "WARM",
958              "newerNoncurrentVersions": 2
959            },
960            "noncurrentVersionTransitions": [{
961              "noncurrentDays": 180,
962              "storageClass": "COLD",
963              "newerNoncurrentVersions": 1
964            }]
965          }]
966        }
967        "#;
968
969        let config: LifecycleConfiguration =
970            serde_json::from_str(input).expect("full lifecycle rule should parse");
971        let rule = &config.rules[0];
972        assert_eq!(rule.object_size_greater_than, Some(500));
973        assert_eq!(rule.object_size_less_than, Some(64000));
974        assert_eq!(rule.transitions.len(), 1);
975        assert_eq!(rule.noncurrent_version_transitions.len(), 1);
976        assert_eq!(
977            rule.noncurrent_version_transition
978                .as_ref()
979                .and_then(|transition| transition.newer_noncurrent_versions),
980            Some(2)
981        );
982        assert_eq!(
983            rule.noncurrent_version_transitions[0].newer_noncurrent_versions,
984            Some(1)
985        );
986
987        let serialized =
988            serde_json::to_value(&config).expect("full lifecycle rule should serialize");
989        let decoded: LifecycleConfiguration =
990            serde_json::from_value(serialized).expect("full lifecycle rule should round-trip");
991        assert_eq!(decoded.rules[0].transitions.len(), 1);
992        assert_eq!(decoded.rules[0].object_size_less_than, Some(64000));
993    }
994}