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    pub rules: Vec<LifecycleRule>,
17}
18
19/// A single lifecycle rule
20#[derive(Debug, Clone, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct LifecycleRule {
23    /// Rule identifier
24    pub id: String,
25
26    /// Whether the rule is enabled or disabled
27    pub status: LifecycleRuleStatus,
28
29    /// Key prefix filter
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub prefix: Option<String>,
32
33    /// Tag-based filter (key=value pairs)
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub tags: Option<HashMap<String, String>>,
36
37    /// Minimum object size in bytes for the rule filter.
38    #[serde(
39        skip_serializing_if = "Option::is_none",
40        alias = "ObjectSizeGreaterThan",
41        alias = "object_size_greater_than"
42    )]
43    pub object_size_greater_than: Option<i64>,
44
45    /// Maximum object size in bytes for the rule filter.
46    #[serde(
47        skip_serializing_if = "Option::is_none",
48        alias = "ObjectSizeLessThan",
49        alias = "object_size_less_than"
50    )]
51    pub object_size_less_than: Option<i64>,
52
53    /// Expiration settings for current object versions
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub expiration: Option<LifecycleExpiration>,
56
57    /// Expire delete-marker history after the configured number of days.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub del_marker_expiration: Option<LifecycleDelMarkerExpiration>,
60
61    /// Transition settings for current object versions
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub transition: Option<LifecycleTransition>,
64
65    /// Additional transition settings for current object versions.
66    ///
67    /// `transition` remains the compatibility field for the first action;
68    /// this collection carries any subsequent actions returned by S3.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub transitions: Vec<LifecycleTransition>,
71
72    /// Expiration settings for noncurrent object versions
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub noncurrent_version_expiration: Option<NoncurrentVersionExpiration>,
75
76    /// Transition settings for noncurrent object versions
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub noncurrent_version_transition: Option<NoncurrentVersionTransition>,
79
80    /// Additional transition settings for noncurrent object versions.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub noncurrent_version_transitions: Vec<NoncurrentVersionTransition>,
83
84    /// Days after initiation to abort incomplete multipart uploads
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub abort_incomplete_multipart_upload_days: Option<i32>,
87
88    /// Whether to remove expired delete markers
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub expired_object_delete_marker: Option<bool>,
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(rename_all = "camelCase")]
95struct LifecycleRuleInput {
96    #[serde(alias = "ID")]
97    id: String,
98    #[serde(alias = "Status")]
99    status: LifecycleRuleStatus,
100    #[serde(default, alias = "Prefix")]
101    prefix: Option<String>,
102    #[serde(default, alias = "Tags")]
103    tags: Option<HashMap<String, String>>,
104    #[serde(
105        default,
106        alias = "ObjectSizeGreaterThan",
107        alias = "object_size_greater_than"
108    )]
109    object_size_greater_than: Option<i64>,
110    #[serde(default, alias = "ObjectSizeLessThan", alias = "object_size_less_than")]
111    object_size_less_than: Option<i64>,
112    #[serde(default, alias = "Expiration")]
113    expiration: Option<LifecycleExpirationInput>,
114    #[serde(default, alias = "Transition")]
115    transition: Option<LifecycleTransition>,
116    #[serde(default, alias = "Transitions")]
117    transitions: Vec<LifecycleTransition>,
118    #[serde(default, alias = "NoncurrentVersionExpiration")]
119    noncurrent_version_expiration: Option<NoncurrentVersionExpiration>,
120    #[serde(default, alias = "NoncurrentVersionTransition")]
121    noncurrent_version_transition: Option<NoncurrentVersionTransition>,
122    #[serde(default, alias = "NoncurrentVersionTransitions")]
123    noncurrent_version_transitions: Vec<NoncurrentVersionTransition>,
124    #[serde(default, alias = "AbortIncompleteMultipartUploadDays")]
125    abort_incomplete_multipart_upload_days: Option<i32>,
126    #[serde(
127        default,
128        alias = "ExpiredObjectDeleteMarker",
129        alias = "expired_object_delete_marker"
130    )]
131    expired_object_delete_marker: Option<bool>,
132    #[serde(
133        default,
134        alias = "DelMarkerExpiration",
135        alias = "del_marker_expiration"
136    )]
137    del_marker_expiration: Option<LifecycleDelMarkerInput>,
138}
139
140#[derive(Debug, Deserialize)]
141#[serde(rename_all = "camelCase")]
142struct LifecycleExpirationInput {
143    #[serde(default, alias = "Days")]
144    days: Option<i32>,
145    #[serde(default, alias = "Date")]
146    date: Option<String>,
147    #[serde(
148        default,
149        alias = "ExpiredObjectAllVersions",
150        alias = "expired_object_all_versions"
151    )]
152    expired_object_all_versions: Option<bool>,
153    #[serde(
154        default,
155        alias = "DelMarkerExpiration",
156        alias = "del_marker_expiration"
157    )]
158    del_marker_expiration: Option<LifecycleDelMarkerInput>,
159}
160
161#[derive(Debug, Deserialize)]
162#[serde(untagged)]
163enum LifecycleDelMarkerInput {
164    Flag(bool),
165    Configuration(LifecycleDelMarkerExpiration),
166}
167
168#[derive(Debug)]
169struct NormalizedDelMarkerInput {
170    enabled: bool,
171    configuration: Option<LifecycleDelMarkerExpiration>,
172}
173
174impl<'de> Deserialize<'de> for LifecycleRule {
175    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
176    where
177        D: Deserializer<'de>,
178    {
179        let LifecycleRuleInput {
180            id,
181            status,
182            prefix,
183            tags,
184            object_size_greater_than,
185            object_size_less_than,
186            expiration: expiration_input,
187            transition,
188            transitions: mut transitions_input,
189            noncurrent_version_expiration,
190            noncurrent_version_transition,
191            noncurrent_version_transitions: mut noncurrent_version_transitions_input,
192            abort_incomplete_multipart_upload_days,
193            expired_object_delete_marker,
194            del_marker_expiration: top_level_del_marker_input,
195        } = LifecycleRuleInput::deserialize(deserializer)?;
196
197        let (expiration, nested_del_marker) = match expiration_input {
198            Some(expiration) => {
199                let nested_del_marker = expiration.del_marker_expiration;
200                (
201                    Some(LifecycleExpiration {
202                        days: expiration.days,
203                        date: expiration.date,
204                        expired_object_all_versions: expiration.expired_object_all_versions,
205                    }),
206                    nested_del_marker,
207                )
208            }
209            None => (None, None),
210        };
211
212        let fallback_days = expiration.as_ref().and_then(|expiration| expiration.days);
213        let top_level_del_marker =
214            normalize_del_marker_input(top_level_del_marker_input, fallback_days)
215                .map_err(D::Error::custom)?;
216        let nested_del_marker = normalize_del_marker_input(nested_del_marker, fallback_days)
217            .map_err(D::Error::custom)?;
218        let del_marker_expiration =
219            merge_del_marker_inputs(top_level_del_marker, nested_del_marker)
220                .map_err(D::Error::custom)?;
221
222        if let Some(transition) = transition {
223            transitions_input.insert(0, transition);
224        }
225        let transition = transitions_input.first().cloned();
226        let additional_transitions = transitions_input.into_iter().skip(1).collect();
227
228        if let Some(transition) = noncurrent_version_transition {
229            noncurrent_version_transitions_input.insert(0, transition);
230        }
231        let noncurrent_version_transition = noncurrent_version_transitions_input.first().cloned();
232        let additional_noncurrent_version_transitions = noncurrent_version_transitions_input
233            .into_iter()
234            .skip(1)
235            .collect();
236
237        Ok(Self {
238            id,
239            status,
240            prefix,
241            tags,
242            object_size_greater_than,
243            object_size_less_than,
244            expiration,
245            del_marker_expiration,
246            transition,
247            transitions: additional_transitions,
248            noncurrent_version_expiration,
249            noncurrent_version_transition,
250            noncurrent_version_transitions: additional_noncurrent_version_transitions,
251            abort_incomplete_multipart_upload_days,
252            expired_object_delete_marker,
253        })
254    }
255}
256
257fn normalize_del_marker_input(
258    input: Option<LifecycleDelMarkerInput>,
259    fallback_days: Option<i32>,
260) -> std::result::Result<Option<NormalizedDelMarkerInput>, String> {
261    match input {
262        None => Ok(None),
263        Some(LifecycleDelMarkerInput::Flag(false)) => Ok(Some(NormalizedDelMarkerInput {
264            enabled: false,
265            configuration: None,
266        })),
267        Some(LifecycleDelMarkerInput::Flag(true)) => fallback_days
268            .map(|days| {
269                Some(NormalizedDelMarkerInput {
270                    enabled: true,
271                    configuration: Some(LifecycleDelMarkerExpiration { days: Some(days) }),
272                })
273            })
274            .ok_or_else(|| {
275                "DelMarkerExpiration=true requires expiration.days for compatibility input"
276                    .to_string()
277            }),
278        Some(LifecycleDelMarkerInput::Configuration(configuration)) => {
279            Ok(Some(NormalizedDelMarkerInput {
280                enabled: true,
281                configuration: Some(configuration),
282            }))
283        }
284    }
285}
286
287fn merge_del_marker_inputs(
288    top_level: Option<NormalizedDelMarkerInput>,
289    nested: Option<NormalizedDelMarkerInput>,
290) -> std::result::Result<Option<LifecycleDelMarkerExpiration>, String> {
291    match (top_level, nested) {
292        (None, None) => Ok(None),
293        (Some(value), None) | (None, Some(value)) => Ok(value.enabled.then(|| {
294            value
295                .configuration
296                .unwrap_or(LifecycleDelMarkerExpiration { days: None })
297        })),
298        (Some(primary), Some(secondary)) => {
299            if primary.enabled != secondary.enabled {
300                return Err(
301                    "conflicting DelMarkerExpiration values in lifecycle rule: explicit false cannot be combined with an enabled value".to_string(),
302                );
303            }
304            if !primary.enabled {
305                return Ok(None);
306            }
307
308            let primary_configuration = primary
309                .configuration
310                .unwrap_or(LifecycleDelMarkerExpiration { days: None });
311            let secondary_configuration = secondary
312                .configuration
313                .unwrap_or(LifecycleDelMarkerExpiration { days: None });
314            if primary_configuration.days.is_some()
315                && secondary_configuration.days.is_some()
316                && primary_configuration.days != secondary_configuration.days
317            {
318                return Err("conflicting DelMarkerExpiration values in lifecycle rule".to_string());
319            }
320
321            Ok(Some(LifecycleDelMarkerExpiration {
322                days: primary_configuration.days.or(secondary_configuration.days),
323            }))
324        }
325    }
326}
327
328/// Rule status
329#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
330pub enum LifecycleRuleStatus {
331    Enabled,
332    Disabled,
333}
334
335impl fmt::Display for LifecycleRuleStatus {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        match self {
338            LifecycleRuleStatus::Enabled => write!(f, "Enabled"),
339            LifecycleRuleStatus::Disabled => write!(f, "Disabled"),
340        }
341    }
342}
343
344impl std::str::FromStr for LifecycleRuleStatus {
345    type Err = String;
346
347    fn from_str(s: &str) -> Result<Self, Self::Err> {
348        match s.to_lowercase().as_str() {
349            "enabled" => Ok(LifecycleRuleStatus::Enabled),
350            "disabled" => Ok(LifecycleRuleStatus::Disabled),
351            _ => Err(format!("Invalid lifecycle rule status: {s}")),
352        }
353    }
354}
355
356/// Expiration settings for current object versions
357#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct LifecycleExpiration {
360    /// Number of days after creation to expire
361    #[serde(skip_serializing_if = "Option::is_none", alias = "Days")]
362    pub days: Option<i32>,
363
364    /// Specific date to expire (ISO 8601 format)
365    #[serde(skip_serializing_if = "Option::is_none", alias = "Date")]
366    pub date: Option<String>,
367
368    /// Whether all object versions should be expired together.
369    #[serde(
370        skip_serializing_if = "Option::is_none",
371        alias = "ExpiredObjectAllVersions",
372        alias = "expired_object_all_versions"
373    )]
374    pub expired_object_all_versions: Option<bool>,
375}
376
377/// Expiration settings for delete markers and their prior object versions.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct LifecycleDelMarkerExpiration {
381    /// Number of days before delete-marker history is removed.
382    #[serde(skip_serializing_if = "Option::is_none", alias = "Days")]
383    pub days: Option<i32>,
384}
385
386/// Transition settings for current object versions
387#[derive(Debug, Clone, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase")]
389pub struct LifecycleTransition {
390    /// Number of days after creation to transition
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub days: Option<i32>,
393
394    /// Specific date to transition (ISO 8601 format)
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub date: Option<String>,
397
398    /// Target storage class (tier name)
399    pub storage_class: String,
400}
401
402/// Expiration settings for noncurrent object versions
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct NoncurrentVersionExpiration {
406    /// Number of days after becoming noncurrent to expire
407    pub noncurrent_days: i32,
408
409    /// Maximum number of noncurrent versions to retain
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub newer_noncurrent_versions: Option<i32>,
412}
413
414/// Transition settings for noncurrent object versions
415#[derive(Debug, Clone, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct NoncurrentVersionTransition {
418    /// Number of days after becoming noncurrent to transition
419    pub noncurrent_days: i32,
420
421    /// Target storage class (tier name)
422    pub storage_class: String,
423
424    /// Maximum number of newer noncurrent versions to retain.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub newer_noncurrent_versions: Option<i32>,
427}
428
429impl fmt::Display for LifecycleRule {
430    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
431        write!(f, "{} ({})", self.id, self.status)
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_lifecycle_rule_status_display() {
441        assert_eq!(LifecycleRuleStatus::Enabled.to_string(), "Enabled");
442        assert_eq!(LifecycleRuleStatus::Disabled.to_string(), "Disabled");
443    }
444
445    #[test]
446    fn test_lifecycle_rule_status_from_str() {
447        assert_eq!(
448            "enabled".parse::<LifecycleRuleStatus>().unwrap(),
449            LifecycleRuleStatus::Enabled
450        );
451        assert_eq!(
452            "Disabled".parse::<LifecycleRuleStatus>().unwrap(),
453            LifecycleRuleStatus::Disabled
454        );
455        assert!("invalid".parse::<LifecycleRuleStatus>().is_err());
456    }
457
458    #[test]
459    fn test_lifecycle_rule_serialization() {
460        let rule = LifecycleRule {
461            id: "rule-1".to_string(),
462            status: LifecycleRuleStatus::Enabled,
463            prefix: Some("logs/".to_string()),
464            tags: None,
465            object_size_greater_than: None,
466            object_size_less_than: None,
467            expiration: Some(LifecycleExpiration {
468                days: Some(30),
469                date: None,
470                expired_object_all_versions: None,
471            }),
472            del_marker_expiration: None,
473            transition: None,
474            transitions: Vec::new(),
475            noncurrent_version_expiration: None,
476            noncurrent_version_transition: None,
477            noncurrent_version_transitions: Vec::new(),
478            abort_incomplete_multipart_upload_days: Some(7),
479            expired_object_delete_marker: None,
480        };
481
482        let json = serde_json::to_string(&rule).unwrap();
483        let decoded: LifecycleRule = serde_json::from_str(&json).unwrap();
484        assert_eq!(decoded.id, "rule-1");
485        assert_eq!(decoded.status, LifecycleRuleStatus::Enabled);
486        assert_eq!(decoded.prefix.as_deref(), Some("logs/"));
487        assert_eq!(decoded.expiration.as_ref().unwrap().days, Some(30));
488        assert_eq!(decoded.abort_incomplete_multipart_upload_days, Some(7));
489    }
490
491    #[test]
492    fn test_lifecycle_transition_serialization() {
493        let transition = LifecycleTransition {
494            days: Some(90),
495            date: None,
496            storage_class: "WARM_TIER".to_string(),
497        };
498
499        let json = serde_json::to_string(&transition).unwrap();
500        assert!(json.contains("storageClass"));
501        assert!(json.contains("WARM_TIER"));
502
503        let decoded: LifecycleTransition = serde_json::from_str(&json).unwrap();
504        assert_eq!(decoded.storage_class, "WARM_TIER");
505    }
506
507    #[test]
508    fn test_lifecycle_configuration_serialization() {
509        let config = LifecycleConfiguration {
510            rules: vec![LifecycleRule {
511                id: "expire-old".to_string(),
512                status: LifecycleRuleStatus::Enabled,
513                prefix: None,
514                tags: None,
515                object_size_greater_than: None,
516                object_size_less_than: None,
517                expiration: Some(LifecycleExpiration {
518                    days: Some(365),
519                    date: None,
520                    expired_object_all_versions: None,
521                }),
522                del_marker_expiration: None,
523                transition: None,
524                transitions: Vec::new(),
525                noncurrent_version_expiration: Some(NoncurrentVersionExpiration {
526                    noncurrent_days: 30,
527                    newer_noncurrent_versions: Some(3),
528                }),
529                noncurrent_version_transition: None,
530                noncurrent_version_transitions: Vec::new(),
531                abort_incomplete_multipart_upload_days: None,
532                expired_object_delete_marker: Some(true),
533            }],
534        };
535
536        let json = serde_json::to_string_pretty(&config).unwrap();
537        let decoded: LifecycleConfiguration = serde_json::from_str(&json).unwrap();
538        assert_eq!(decoded.rules.len(), 1);
539        assert_eq!(decoded.rules[0].id, "expire-old");
540        assert_eq!(
541            decoded.rules[0]
542                .noncurrent_version_expiration
543                .as_ref()
544                .unwrap()
545                .newer_noncurrent_versions,
546            Some(3)
547        );
548    }
549
550    #[test]
551    fn test_noncurrent_version_transition_serialization() {
552        let nvt = NoncurrentVersionTransition {
553            noncurrent_days: 60,
554            storage_class: "COLD_TIER".to_string(),
555            newer_noncurrent_versions: None,
556        };
557
558        let json = serde_json::to_string(&nvt).unwrap();
559        let decoded: NoncurrentVersionTransition = serde_json::from_str(&json).unwrap();
560        assert_eq!(decoded.noncurrent_days, 60);
561        assert_eq!(decoded.storage_class, "COLD_TIER");
562    }
563
564    #[test]
565    fn test_lifecycle_extensions_use_canonical_json_shape() {
566        let config = LifecycleConfiguration {
567            rules: vec![LifecycleRule {
568                id: "all-versions".to_string(),
569                status: LifecycleRuleStatus::Enabled,
570                prefix: Some("test/".to_string()),
571                tags: None,
572                object_size_greater_than: None,
573                object_size_less_than: None,
574                expiration: Some(LifecycleExpiration {
575                    days: Some(1),
576                    date: None,
577                    expired_object_all_versions: Some(true),
578                }),
579                del_marker_expiration: Some(LifecycleDelMarkerExpiration { days: Some(1) }),
580                transition: None,
581                transitions: Vec::new(),
582                noncurrent_version_expiration: None,
583                noncurrent_version_transition: None,
584                noncurrent_version_transitions: Vec::new(),
585                abort_incomplete_multipart_upload_days: None,
586                expired_object_delete_marker: None,
587            }],
588        };
589
590        let value = serde_json::to_value(&config).expect("serialize lifecycle extensions");
591        assert_eq!(
592            value["rules"][0]["expiration"]["expiredObjectAllVersions"],
593            true
594        );
595        assert_eq!(value["rules"][0]["delMarkerExpiration"]["days"], 1);
596        assert!(
597            value["rules"][0]["expiration"]
598                .get("DelMarkerExpiration")
599                .is_none()
600        );
601        let decoded: LifecycleConfiguration =
602            serde_json::from_value(value).expect("canonical lifecycle extensions should parse");
603        assert_eq!(
604            decoded.rules[0]
605                .expiration
606                .as_ref()
607                .and_then(|expiration| expiration.expired_object_all_versions),
608            Some(true)
609        );
610        assert_eq!(
611            decoded.rules[0]
612                .del_marker_expiration
613                .as_ref()
614                .and_then(|expiration| expiration.days),
615            Some(1)
616        );
617    }
618
619    #[test]
620    fn test_lifecycle_extensions_accept_issue_6334_compatibility_shape() {
621        let input = r#"
622        {
623          "rules": [{
624            "id": "rule-delayed-deletion",
625            "status": "Enabled",
626            "prefix": "test/",
627            "expiration": {
628              "ExpiredObjectAllVersions": true,
629              "DelMarkerExpiration": true,
630              "days": 1
631            }
632          }]
633        }
634        "#;
635
636        let config: LifecycleConfiguration =
637            serde_json::from_str(input).expect("issue compatibility shape should parse");
638        let rule = &config.rules[0];
639        assert_eq!(
640            rule.expiration
641                .as_ref()
642                .and_then(|expiration| expiration.expired_object_all_versions),
643            Some(true)
644        );
645        assert_eq!(
646            rule.del_marker_expiration
647                .as_ref()
648                .and_then(|expiration| expiration.days),
649            Some(1)
650        );
651    }
652
653    #[test]
654    fn test_lifecycle_extensions_reject_ambiguous_compatibility_shape() {
655        let input = r#"
656        {
657          "rules": [{
658            "id": "conflicting",
659            "status": "Enabled",
660            "expiration": {"days": 1, "DelMarkerExpiration": true},
661            "delMarkerExpiration": {"days": 2}
662          }]
663        }
664        "#;
665
666        let result = serde_json::from_str::<LifecycleConfiguration>(input);
667        assert!(result.is_err());
668    }
669
670    #[test]
671    fn test_lifecycle_extensions_reject_explicit_false_conflicts() {
672        let inputs = [
673            r#"
674            {
675              "rules": [{
676                "id": "conflicting-false-top-level",
677                "status": "Enabled",
678                "expiration": {"days": 1, "DelMarkerExpiration": true},
679                "delMarkerExpiration": false
680              }]
681            }
682            "#,
683            r#"
684            {
685              "rules": [{
686                "id": "conflicting-false-nested",
687                "status": "Enabled",
688                "expiration": {"days": 1, "DelMarkerExpiration": false},
689                "delMarkerExpiration": {"days": 1}
690              }]
691            }
692            "#,
693        ];
694
695        for input in inputs {
696            assert!(
697                serde_json::from_str::<LifecycleConfiguration>(input).is_err(),
698                "explicit false must not be silently overridden by an enabled representation"
699            );
700        }
701
702        let explicit_false = r#"
703        {
704          "rules": [{
705            "id": "explicit-false",
706            "status": "Enabled",
707            "delMarkerExpiration": false
708          }]
709        }
710        "#;
711        let config: LifecycleConfiguration =
712            serde_json::from_str(explicit_false).expect("standalone false is compatible");
713        assert!(config.rules[0].del_marker_expiration.is_none());
714    }
715
716    #[test]
717    fn test_lifecycle_rule_accepts_additional_actions_and_size_filters() {
718        let input = r#"
719        {
720          "rules": [{
721            "id": "full-rule",
722            "status": "Enabled",
723            "objectSizeGreaterThan": 500,
724            "objectSizeLessThan": 64000,
725            "transition": {"days": 30, "storageClass": "WARM"},
726            "transitions": [{"days": 60, "storageClass": "COLD"}],
727            "noncurrentVersionTransition": {
728              "noncurrentDays": 90,
729              "storageClass": "WARM",
730              "newerNoncurrentVersions": 2
731            },
732            "noncurrentVersionTransitions": [{
733              "noncurrentDays": 180,
734              "storageClass": "COLD",
735              "newerNoncurrentVersions": 1
736            }]
737          }]
738        }
739        "#;
740
741        let config: LifecycleConfiguration =
742            serde_json::from_str(input).expect("full lifecycle rule should parse");
743        let rule = &config.rules[0];
744        assert_eq!(rule.object_size_greater_than, Some(500));
745        assert_eq!(rule.object_size_less_than, Some(64000));
746        assert_eq!(rule.transitions.len(), 1);
747        assert_eq!(rule.noncurrent_version_transitions.len(), 1);
748        assert_eq!(
749            rule.noncurrent_version_transition
750                .as_ref()
751                .and_then(|transition| transition.newer_noncurrent_versions),
752            Some(2)
753        );
754        assert_eq!(
755            rule.noncurrent_version_transitions[0].newer_noncurrent_versions,
756            Some(1)
757        );
758
759        let serialized =
760            serde_json::to_value(&config).expect("full lifecycle rule should serialize");
761        let decoded: LifecycleConfiguration =
762            serde_json::from_value(serialized).expect("full lifecycle rule should round-trip");
763        assert_eq!(decoded.rules[0].transitions.len(), 1);
764        assert_eq!(decoded.rules[0].object_size_less_than, Some(64000));
765    }
766}