1use crate::manifest::HotswapMeta;
19use semver::Version;
20use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22
23pub trait BinaryCachePolicy: Send + Sync + 'static {
32 fn should_discard(
37 &self,
38 current_binary: &Version,
39 cached_meta: &HotswapMeta,
40 previous_binary: Option<&Version>,
41 ) -> bool;
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum BinaryCachePolicyKind {
56 KeepCompatible,
59 #[default]
66 DiscardOnUpgrade,
67 NeverDiscard,
70}
71
72impl BinaryCachePolicy for BinaryCachePolicyKind {
73 fn should_discard(
74 &self,
75 current_binary: &Version,
76 cached_meta: &HotswapMeta,
77 _previous_binary: Option<&Version>,
78 ) -> bool {
79 match self {
80 BinaryCachePolicyKind::KeepCompatible => false,
81 BinaryCachePolicyKind::DiscardOnUpgrade => {
82 if let Ok(required) = Version::parse(&cached_meta.min_binary_version) {
83 current_binary > &required
84 } else {
85 false
86 }
87 }
88 BinaryCachePolicyKind::NeverDiscard => false,
89 }
90 }
91}
92
93pub trait ConfirmationPolicy: Send + Sync + 'static {
101 fn on_startup_unconfirmed(&self, meta: &HotswapMeta) -> ConfirmationDecision;
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum ConfirmationDecision {
109 KeepForNow,
111 RollbackNow,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
130#[serde(rename_all = "snake_case")]
131pub enum ConfirmationPolicyKind {
132 #[default]
134 SingleLaunch,
135 GracePeriod {
137 max_unconfirmed_launches: u32,
140 },
141}
142
143impl ConfirmationPolicy for ConfirmationPolicyKind {
144 fn on_startup_unconfirmed(&self, meta: &HotswapMeta) -> ConfirmationDecision {
145 match self {
146 ConfirmationPolicyKind::SingleLaunch => ConfirmationDecision::RollbackNow,
147 ConfirmationPolicyKind::GracePeriod {
148 max_unconfirmed_launches,
149 } => {
150 if *max_unconfirmed_launches == 0
151 || meta.unconfirmed_launch_count >= *max_unconfirmed_launches
152 {
153 ConfirmationDecision::RollbackNow
154 } else {
155 ConfirmationDecision::KeepForNow
156 }
157 }
158 }
159 }
160}
161
162pub trait RollbackPolicy: Send + Sync + 'static {
166 fn select_target(
169 &self,
170 current_sequence: Option<u64>,
171 candidates_desc: &[HotswapMeta],
172 ) -> Option<u64>;
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
184#[serde(rename_all = "snake_case")]
185pub enum RollbackPolicyKind {
186 #[default]
188 LatestConfirmed,
189 ImmediatePreviousConfirmed,
191 EmbeddedOnly,
193}
194
195impl RollbackPolicy for RollbackPolicyKind {
196 fn select_target(
197 &self,
198 current_sequence: Option<u64>,
199 candidates_desc: &[HotswapMeta],
200 ) -> Option<u64> {
201 match self {
202 RollbackPolicyKind::LatestConfirmed => candidates_desc.first().map(|m| m.sequence),
203 RollbackPolicyKind::ImmediatePreviousConfirmed => {
204 let current = current_sequence?;
205 candidates_desc
206 .iter()
207 .find(|m| m.sequence < current)
208 .map(|m| m.sequence)
209 }
210 RollbackPolicyKind::EmbeddedOnly => None,
211 }
212 }
213}
214
215pub trait RetentionPolicy: Send + Sync + 'static {
222 fn select_kept_sequences(
225 &self,
226 current_sequence: Option<u64>,
227 rollback_candidate: Option<u64>,
228 available_desc: &[HotswapMeta],
229 ) -> HashSet<u64>;
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct RetentionConfig {
252 #[serde(default = "default_max_retained")]
254 pub max_retained_versions: u32,
255}
256
257fn default_max_retained() -> u32 {
258 2
259}
260
261impl Default for RetentionConfig {
262 fn default() -> Self {
263 Self {
264 max_retained_versions: default_max_retained(),
265 }
266 }
267}
268
269impl RetentionConfig {
270 pub fn effective_max(&self) -> u32 {
272 self.max_retained_versions.max(2)
273 }
274}
275
276impl RetentionPolicy for RetentionConfig {
277 fn select_kept_sequences(
278 &self,
279 current_sequence: Option<u64>,
280 rollback_candidate: Option<u64>,
281 available_desc: &[HotswapMeta],
282 ) -> HashSet<u64> {
283 let max = self.effective_max() as usize;
284 let mut kept = HashSet::new();
285
286 if let Some(seq) = current_sequence {
288 kept.insert(seq);
289 }
290 if let Some(seq) = rollback_candidate {
291 kept.insert(seq);
292 }
293
294 for meta in available_desc {
296 if kept.len() >= max {
297 break;
298 }
299 kept.insert(meta.sequence);
300 }
301
302 kept
303 }
304}
305
306#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::manifest::HotswapMeta;
312
313 fn meta(min_bin: &str, seq: u64, confirmed: bool) -> HotswapMeta {
314 HotswapMeta {
315 version: format!("1.0.0-ota.{}", seq),
316 sequence: seq,
317 min_binary_version: min_bin.into(),
318 confirmed,
319 unconfirmed_launch_count: 0,
320 }
321 }
322
323 fn v(s: &str) -> Version {
324 Version::parse(s).unwrap()
325 }
326
327 #[test]
330 fn keep_compatible_keeps_when_binary_matches() {
331 let p = BinaryCachePolicyKind::KeepCompatible;
332 assert!(!p.should_discard(&v("1.0.0"), &meta("1.0.0", 1, true), None));
333 }
334
335 #[test]
336 fn keep_compatible_keeps_when_binary_newer() {
337 let p = BinaryCachePolicyKind::KeepCompatible;
338 assert!(!p.should_discard(&v("2.0.0"), &meta("1.0.0", 1, true), None));
339 }
340
341 #[test]
342 fn discard_on_upgrade_discards_when_binary_newer() {
343 let p = BinaryCachePolicyKind::DiscardOnUpgrade;
344 assert!(p.should_discard(&v("2.0.0"), &meta("1.0.0", 1, true), None));
345 }
346
347 #[test]
348 fn discard_on_upgrade_keeps_when_binary_matches() {
349 let p = BinaryCachePolicyKind::DiscardOnUpgrade;
350 assert!(!p.should_discard(&v("1.0.0"), &meta("1.0.0", 1, true), None));
351 }
352
353 #[test]
354 fn discard_on_upgrade_keeps_on_invalid_semver() {
355 let p = BinaryCachePolicyKind::DiscardOnUpgrade;
356 assert!(!p.should_discard(&v("2.0.0"), &meta("not-semver", 1, true), None));
357 }
358
359 #[test]
360 fn never_discard_keeps_always() {
361 let p = BinaryCachePolicyKind::NeverDiscard;
362 assert!(!p.should_discard(&v("99.0.0"), &meta("1.0.0", 1, true), None));
363 }
364
365 #[test]
366 fn binary_cache_policy_default_is_discard_on_upgrade() {
367 assert_eq!(
368 BinaryCachePolicyKind::default(),
369 BinaryCachePolicyKind::DiscardOnUpgrade
370 );
371 }
372
373 #[test]
374 fn binary_cache_policy_serde_roundtrip() {
375 for (json, expected) in [
376 ("\"keep_compatible\"", BinaryCachePolicyKind::KeepCompatible),
377 (
378 "\"discard_on_upgrade\"",
379 BinaryCachePolicyKind::DiscardOnUpgrade,
380 ),
381 ("\"never_discard\"", BinaryCachePolicyKind::NeverDiscard),
382 ] {
383 let parsed: BinaryCachePolicyKind = serde_json::from_str(json).unwrap();
384 assert_eq!(parsed, expected);
385 let serialized = serde_json::to_string(&expected).unwrap();
386 let reparsed: BinaryCachePolicyKind = serde_json::from_str(&serialized).unwrap();
387 assert_eq!(reparsed, expected);
388 }
389 }
390
391 #[test]
394 fn single_launch_always_rollbacks() {
395 let p = ConfirmationPolicyKind::SingleLaunch;
396 let m = meta("1.0.0", 1, false);
397 assert_eq!(
398 p.on_startup_unconfirmed(&m),
399 ConfirmationDecision::RollbackNow
400 );
401 }
402
403 #[test]
404 fn grace_period_keeps_when_under_threshold() {
405 let p = ConfirmationPolicyKind::GracePeriod {
406 max_unconfirmed_launches: 3,
407 };
408 let mut m = meta("1.0.0", 1, false);
409 m.unconfirmed_launch_count = 0;
410 assert_eq!(
411 p.on_startup_unconfirmed(&m),
412 ConfirmationDecision::KeepForNow
413 );
414
415 m.unconfirmed_launch_count = 2;
416 assert_eq!(
417 p.on_startup_unconfirmed(&m),
418 ConfirmationDecision::KeepForNow
419 );
420 }
421
422 #[test]
423 fn grace_period_rollbacks_at_threshold() {
424 let p = ConfirmationPolicyKind::GracePeriod {
425 max_unconfirmed_launches: 3,
426 };
427 let mut m = meta("1.0.0", 1, false);
428 m.unconfirmed_launch_count = 3;
429 assert_eq!(
430 p.on_startup_unconfirmed(&m),
431 ConfirmationDecision::RollbackNow
432 );
433 }
434
435 #[test]
436 fn grace_period_rollbacks_above_threshold() {
437 let p = ConfirmationPolicyKind::GracePeriod {
438 max_unconfirmed_launches: 3,
439 };
440 let mut m = meta("1.0.0", 1, false);
441 m.unconfirmed_launch_count = 10;
442 assert_eq!(
443 p.on_startup_unconfirmed(&m),
444 ConfirmationDecision::RollbackNow
445 );
446 }
447
448 #[test]
449 fn grace_period_zero_is_immediate_rollback() {
450 let p = ConfirmationPolicyKind::GracePeriod {
451 max_unconfirmed_launches: 0,
452 };
453 let m = meta("1.0.0", 1, false);
454 assert_eq!(
455 p.on_startup_unconfirmed(&m),
456 ConfirmationDecision::RollbackNow
457 );
458 }
459
460 #[test]
461 fn confirmation_policy_default_is_single_launch() {
462 assert_eq!(
463 ConfirmationPolicyKind::default(),
464 ConfirmationPolicyKind::SingleLaunch
465 );
466 }
467
468 #[test]
469 fn confirmation_policy_serde_roundtrip() {
470 let single: ConfirmationPolicyKind = serde_json::from_str("\"single_launch\"").unwrap();
471 assert_eq!(single, ConfirmationPolicyKind::SingleLaunch);
472
473 let grace: ConfirmationPolicyKind =
474 serde_json::from_str(r#"{"grace_period":{"max_unconfirmed_launches":5}}"#).unwrap();
475 assert_eq!(
476 grace,
477 ConfirmationPolicyKind::GracePeriod {
478 max_unconfirmed_launches: 5
479 }
480 );
481 }
482
483 fn confirmed_candidates() -> Vec<HotswapMeta> {
486 vec![
487 meta("1.0.0", 10, true),
488 meta("1.0.0", 7, true),
489 meta("1.0.0", 3, true),
490 ]
491 }
492
493 #[test]
494 fn latest_confirmed_picks_highest() {
495 let p = RollbackPolicyKind::LatestConfirmed;
496 assert_eq!(p.select_target(Some(15), &confirmed_candidates()), Some(10));
497 }
498
499 #[test]
500 fn latest_confirmed_with_empty_candidates() {
501 let p = RollbackPolicyKind::LatestConfirmed;
502 assert_eq!(p.select_target(Some(15), &[]), None);
503 }
504
505 #[test]
506 fn immediate_previous_picks_just_below_current() {
507 let p = RollbackPolicyKind::ImmediatePreviousConfirmed;
508 assert_eq!(p.select_target(Some(10), &confirmed_candidates()), Some(7));
509 }
510
511 #[test]
512 fn immediate_previous_skips_equal_sequence() {
513 let p = RollbackPolicyKind::ImmediatePreviousConfirmed;
514 assert_eq!(p.select_target(Some(10), &confirmed_candidates()), Some(7));
516 }
517
518 #[test]
519 fn immediate_previous_none_when_no_lower() {
520 let p = RollbackPolicyKind::ImmediatePreviousConfirmed;
521 let candidates = vec![meta("1.0.0", 10, true)];
522 assert_eq!(p.select_target(Some(10), &candidates), None);
523 }
524
525 #[test]
526 fn immediate_previous_none_when_no_current() {
527 let p = RollbackPolicyKind::ImmediatePreviousConfirmed;
528 assert_eq!(p.select_target(None, &confirmed_candidates()), None);
529 }
530
531 #[test]
532 fn embedded_only_always_none() {
533 let p = RollbackPolicyKind::EmbeddedOnly;
534 assert_eq!(p.select_target(Some(10), &confirmed_candidates()), None);
535 }
536
537 #[test]
538 fn rollback_policy_default_is_latest_confirmed() {
539 assert_eq!(
540 RollbackPolicyKind::default(),
541 RollbackPolicyKind::LatestConfirmed
542 );
543 }
544
545 #[test]
546 fn rollback_policy_serde_roundtrip() {
547 for (json, expected) in [
548 ("\"latest_confirmed\"", RollbackPolicyKind::LatestConfirmed),
549 (
550 "\"immediate_previous_confirmed\"",
551 RollbackPolicyKind::ImmediatePreviousConfirmed,
552 ),
553 ("\"embedded_only\"", RollbackPolicyKind::EmbeddedOnly),
554 ] {
555 let parsed: RollbackPolicyKind = serde_json::from_str(json).unwrap();
556 assert_eq!(parsed, expected);
557 }
558 }
559
560 fn available_versions() -> Vec<HotswapMeta> {
563 vec![
564 meta("1.0.0", 10, true),
565 meta("1.0.0", 7, true),
566 meta("1.0.0", 5, true),
567 meta("1.0.0", 3, true),
568 meta("1.0.0", 1, true),
569 ]
570 }
571
572 #[test]
573 fn retention_default_keeps_two() {
574 let r = RetentionConfig::default();
575 let kept = r.select_kept_sequences(Some(10), Some(7), &available_versions());
576 assert_eq!(kept.len(), 2);
577 assert!(kept.contains(&10));
578 assert!(kept.contains(&7));
579 }
580
581 #[test]
582 fn retention_three_keeps_three() {
583 let r = RetentionConfig {
584 max_retained_versions: 3,
585 };
586 let kept = r.select_kept_sequences(Some(10), Some(7), &available_versions());
587 assert_eq!(kept.len(), 3);
588 assert!(kept.contains(&10));
589 assert!(kept.contains(&7));
590 assert!(kept.contains(&5));
592 }
593
594 #[test]
595 fn retention_five_keeps_five() {
596 let r = RetentionConfig {
597 max_retained_versions: 5,
598 };
599 let kept = r.select_kept_sequences(Some(10), Some(7), &available_versions());
600 assert_eq!(kept.len(), 5);
601 }
602
603 #[test]
604 fn retention_preserves_current_and_rollback_even_if_not_in_available() {
605 let r = RetentionConfig::default();
606 let available = vec![
608 meta("1.0.0", 5, true),
609 meta("1.0.0", 3, true),
610 meta("1.0.0", 1, true),
611 ];
612 let kept = r.select_kept_sequences(Some(10), Some(7), &available);
613 assert!(kept.contains(&10));
614 assert!(kept.contains(&7));
615 }
616
617 #[test]
618 fn retention_clamps_below_two() {
619 let r = RetentionConfig {
620 max_retained_versions: 0,
621 };
622 assert_eq!(r.effective_max(), 2);
623
624 let r = RetentionConfig {
625 max_retained_versions: 1,
626 };
627 assert_eq!(r.effective_max(), 2);
628 }
629
630 #[test]
631 fn retention_no_current_no_rollback() {
632 let r = RetentionConfig::default();
633 let kept = r.select_kept_sequences(None, None, &available_versions());
634 assert_eq!(kept.len(), 2);
636 assert!(kept.contains(&10));
637 assert!(kept.contains(&7));
638 }
639
640 #[test]
641 fn retention_config_serde_roundtrip() {
642 let json = r#"{"max_retained_versions":4}"#;
643 let parsed: RetentionConfig = serde_json::from_str(json).unwrap();
644 assert_eq!(parsed.max_retained_versions, 4);
645
646 let empty: RetentionConfig = serde_json::from_str("{}").unwrap();
648 assert_eq!(empty.max_retained_versions, 2);
649 }
650
651 #[test]
652 fn retention_config_default() {
653 let r = RetentionConfig::default();
654 assert_eq!(r.max_retained_versions, 2);
655 }
656}