1use std::collections::HashMap;
7use std::fmt;
8
9use serde::de::Error as _;
10use serde::{Deserialize, Deserializer, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LifecycleConfiguration {
15 pub rules: Vec<LifecycleRule>,
17}
18
19#[derive(Debug, Clone, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct LifecycleRule {
23 pub id: String,
25
26 pub status: LifecycleRuleStatus,
28
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub prefix: Option<String>,
32
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub tags: Option<HashMap<String, String>>,
36
37 #[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
55 pub expiration: Option<LifecycleExpiration>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub del_marker_expiration: Option<LifecycleDelMarkerExpiration>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub transition: Option<LifecycleTransition>,
64
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub transitions: Vec<LifecycleTransition>,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub noncurrent_version_expiration: Option<NoncurrentVersionExpiration>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub noncurrent_version_transition: Option<NoncurrentVersionTransition>,
79
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub noncurrent_version_transitions: Vec<NoncurrentVersionTransition>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub abort_incomplete_multipart_upload_days: Option<i32>,
87
88 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct LifecycleExpiration {
360 #[serde(skip_serializing_if = "Option::is_none", alias = "Days")]
362 pub days: Option<i32>,
363
364 #[serde(skip_serializing_if = "Option::is_none", alias = "Date")]
366 pub date: Option<String>,
367
368 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct LifecycleDelMarkerExpiration {
381 #[serde(skip_serializing_if = "Option::is_none", alias = "Days")]
383 pub days: Option<i32>,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase")]
389pub struct LifecycleTransition {
390 #[serde(skip_serializing_if = "Option::is_none")]
392 pub days: Option<i32>,
393
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub date: Option<String>,
397
398 pub storage_class: String,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct NoncurrentVersionExpiration {
406 pub noncurrent_days: i32,
408
409 #[serde(skip_serializing_if = "Option::is_none")]
411 pub newer_noncurrent_versions: Option<i32>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct NoncurrentVersionTransition {
418 pub noncurrent_days: i32,
420
421 pub storage_class: String,
423
424 #[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}