use semver::Version;
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use super::RolloutStep;
pub fn bucket(rollout_id: &str, version: &Version) -> u8 {
let digest = Sha256::digest(format!("{rollout_id} {version}").as_bytes());
let first = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
u8::try_from(first % 100).unwrap_or(0)
}
pub fn share_now(schedule: &[RolloutStep], now: OffsetDateTime) -> u8 {
if schedule.is_empty() {
return 100;
}
schedule
.iter()
.filter(|step| step.from <= now)
.map(|step| step.percent.min(100))
.max()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use time::macros::datetime;
use super::*;
#[test]
fn a_bucket_stays_put_for_a_version_and_moves_for_the_next() {
let one = Version::new(0, 1, 1);
let id = "5b0e7c4e-1f53-4c55-9d43-0c1d7f0f2a10";
assert_eq!(bucket(id, &one), bucket(id, &one));
assert!(bucket(id, &one) < 100);
let moved = (2..40).any(|patch| bucket(id, &Version::new(0, 1, patch)) != bucket(id, &one));
assert!(moved, "another version draws another bucket");
}
#[test]
fn buckets_spread_over_the_hundred() {
let version = Version::new(0, 1, 1);
let mut counts = [0u32; 10];
for n in 0..2000 {
counts[usize::from(bucket(&format!("install-{n}"), &version) / 10)] += 1;
}
assert!(
counts.iter().all(|&count| (140..=260).contains(&count)),
"{counts:?}"
);
}
#[test]
fn the_share_widens_with_the_clock() {
let schedule = [
RolloutStep {
from: datetime!(2026-11-02 16:00 UTC),
percent: 10,
},
RolloutStep {
from: datetime!(2026-11-03 16:00 UTC),
percent: 50,
},
RolloutStep {
from: datetime!(2026-11-04 16:00 UTC),
percent: 100,
},
];
assert_eq!(share_now(&schedule, datetime!(2026-11-02 15:00 UTC)), 0);
assert_eq!(share_now(&schedule, datetime!(2026-11-02 16:00 UTC)), 10);
assert_eq!(share_now(&schedule, datetime!(2026-11-03 20:00 UTC)), 50);
assert_eq!(share_now(&schedule, datetime!(2026-12-01 00:00 UTC)), 100);
assert_eq!(share_now(&[], datetime!(2026-11-02 15:00 UTC)), 100);
}
}