use std::ops::RangeInclusive;
use chrono::{DateTime, Duration, Utc};
use roaring::RoaringBitmap;
use crate::{coverage::Bucket, metadata::table_metadata::TimeBucket};
const SECONDS_PER_MINUTE: i64 = 60;
const SECONDS_PER_HOUR: i64 = 60 * 60;
const SECONDS_PER_DAY: i64 = 24 * 60 * 60;
fn bucket_len_secs(spec: &TimeBucket) -> i64 {
match *spec {
TimeBucket::Seconds(n) => n as i64,
TimeBucket::Minutes(n) => (n as i64) * SECONDS_PER_MINUTE,
TimeBucket::Hours(n) => (n as i64) * SECONDS_PER_HOUR,
TimeBucket::Days(n) => (n as i64) * SECONDS_PER_DAY,
}
}
pub fn bucket_id(spec: &TimeBucket, ts: DateTime<Utc>) -> u64 {
let len_secs = bucket_len_secs(spec);
debug_assert!(len_secs > 0, "TimeBucket width must be positive");
let secs_since_epoch = ts.timestamp();
let bucket_i64 = secs_since_epoch.div_euclid(len_secs);
debug_assert!(
bucket_i64 >= 0,
"bucket_id received pre-epoch timestamp: {ts:?} -> bucket {bucket_i64}"
);
if bucket_i64 < 0 {
0
} else {
bucket_i64 as u64
}
}
pub fn bucket_id_from_epoch_secs(spec: &TimeBucket, secs_since_epoch: i64) -> u64 {
let len_secs = bucket_len_secs(spec);
debug_assert!(len_secs > 0);
let bucket_i64 = secs_since_epoch.div_euclid(len_secs);
if bucket_i64 < 0 { 0 } else { bucket_i64 as u64 }
}
pub fn bucket_range(
spec: &TimeBucket,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> RangeInclusive<u64> {
debug_assert!(
start < end,
"bucket_range expects start < end; got start={start:?}, end={end:?}"
);
let first = bucket_id(spec, start);
let end_adj = end - Duration::nanoseconds(1);
let last = bucket_id(spec, end_adj);
first..=last
}
pub fn expected_buckets_for_range(
spec: &TimeBucket,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> RoaringBitmap {
let range = bucket_range(spec, start, end);
RoaringBitmap::from_iter(range.map(|b| {
debug_assert!(b <= u32::MAX as u64, "bucket ID {} exceeds u32::MAX", b);
b as Bucket
}))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn bucket_id_from_epoch_secs_clamps_negative() {
let spec = TimeBucket::Seconds(60);
assert_eq!(bucket_id_from_epoch_secs(&spec, -1), 0);
assert_eq!(bucket_id_from_epoch_secs(&spec, -120), 0);
}
#[test]
fn bucket_id_from_epoch_secs_respects_boundaries() {
let spec = TimeBucket::Minutes(1); assert_eq!(bucket_id_from_epoch_secs(&spec, 0), 0);
assert_eq!(bucket_id_from_epoch_secs(&spec, 59), 0);
assert_eq!(bucket_id_from_epoch_secs(&spec, 60), 1);
assert_eq!(bucket_id_from_epoch_secs(&spec, 119), 1);
assert_eq!(bucket_id_from_epoch_secs(&spec, 120), 2);
}
#[test]
fn bucket_id_from_epoch_secs_varies_with_spec() {
let seconds_spec = TimeBucket::Seconds(10);
let minutes_spec = TimeBucket::Minutes(1); let hours_spec = TimeBucket::Hours(1);
let ts = 450;
assert_eq!(bucket_id_from_epoch_secs(&seconds_spec, ts), 45);
assert_eq!(bucket_id_from_epoch_secs(&minutes_spec, ts), 7);
assert_eq!(bucket_id_from_epoch_secs(&hours_spec, ts), 0);
let ts = 3_600;
assert_eq!(bucket_id_from_epoch_secs(&seconds_spec, ts), 360);
assert_eq!(bucket_id_from_epoch_secs(&minutes_spec, ts), 60);
assert_eq!(bucket_id_from_epoch_secs(&hours_spec, ts), 1);
}
#[test]
fn bucket_id_monotonic_seconds() {
let spec = TimeBucket::Seconds(60); let base = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let t0 = base;
let t1 = base + Duration::seconds(1);
let t2 = base + Duration::seconds(60);
let t3 = base + Duration::seconds(61);
let b0 = bucket_id(&spec, t0);
let b1 = bucket_id(&spec, t1);
let b2 = bucket_id(&spec, t2);
let b3 = bucket_id(&spec, t3);
assert!(b0 <= b1);
assert!(b1 <= b2);
assert!(b2 <= b3);
assert_eq!(b0, b1); assert_eq!(b2, b3); assert_eq!(b2, b0 + 1); }
#[test]
fn bucket_range_simple_minutes() {
let spec = TimeBucket::Minutes(1);
let start = Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 10).unwrap();
let end = Utc.with_ymd_and_hms(2020, 1, 1, 10, 3, 0).unwrap();
let range = bucket_range(&spec, start, end);
let first = *range.start();
let last = *range.end();
assert_eq!(last, first + 2);
}
#[test]
fn bucket_range_single_bucket() {
let spec = TimeBucket::Hours(1);
let start = Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 0).unwrap();
let end = start + Duration::seconds(30);
let range = bucket_range(&spec, start, end);
assert_eq!(*range.start(), *range.end());
}
#[test]
fn bucket_len_secs_covers_variants() {
assert_eq!(bucket_len_secs(&TimeBucket::Seconds(7)), 7);
assert_eq!(bucket_len_secs(&TimeBucket::Minutes(3)), 3 * 60);
assert_eq!(bucket_len_secs(&TimeBucket::Hours(2)), 2 * 60 * 60);
assert_eq!(bucket_len_secs(&TimeBucket::Days(1)), 24 * 60 * 60);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
fn bucket_id_pre_epoch_panics_in_debug() {
let spec = TimeBucket::Minutes(1);
let pre_epoch = Utc.with_ymd_and_hms(1969, 12, 31, 23, 59, 0).unwrap();
let _ = bucket_id(&spec, pre_epoch);
}
#[cfg(not(debug_assertions))]
#[test]
fn bucket_id_pre_epoch_clamps_to_zero_in_release() {
let spec = TimeBucket::Minutes(1);
let pre_epoch = Utc.with_ymd_and_hms(1969, 12, 31, 23, 59, 0).unwrap();
assert_eq!(bucket_id(&spec, pre_epoch), 0);
}
#[test]
fn bucket_range_excludes_end_bucket_on_boundary() {
let spec = TimeBucket::Minutes(1);
let start = Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(2020, 1, 1, 10, 2, 0).unwrap();
let range = bucket_range(&spec, start, end);
let first = *range.start();
let last = *range.end();
assert_eq!(last, first + 1);
}
#[test]
fn bucket_range_minimal_interval_inside_bucket() {
let spec = TimeBucket::Minutes(1);
let start = Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 0).unwrap();
let end = start + Duration::nanoseconds(1);
let range = bucket_range(&spec, start, end);
assert_eq!(*range.start(), *range.end());
}
#[test]
fn bucket_range_multi_bucket_with_wider_granularity() {
let spec = TimeBucket::Minutes(5);
let start = Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(2020, 1, 1, 10, 17, 0).unwrap();
let range = bucket_range(&spec, start, end);
let first = *range.start();
let last = *range.end();
assert_eq!(last, first + 3);
}
#[test]
fn expected_buckets_matches_bucket_range_minutes() {
let spec = TimeBucket::Minutes(1);
let start = Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 10).unwrap();
let end = Utc.with_ymd_and_hms(2020, 1, 1, 10, 3, 0).unwrap();
let range = bucket_range(&spec, start, end);
let bitmap = expected_buckets_for_range(&spec, start, end);
let manual: RoaringBitmap = range.clone().map(|b| b as Bucket).collect();
assert_eq!(bitmap, manual);
assert_eq!(bitmap.len(), 3);
assert!(bitmap.contains(*range.start() as Bucket));
assert!(bitmap.contains(*range.end() as Bucket));
}
#[test]
fn expected_buckets_respects_end_boundary() {
let spec = TimeBucket::Minutes(1);
let start = Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(2020, 1, 1, 10, 2, 0).unwrap();
let bitmap = expected_buckets_for_range(&spec, start, end);
let buckets_present: Vec<Bucket> = bitmap.iter().collect();
assert_eq!(buckets_present.len(), 2);
let first = bucket_id(&spec, start) as Bucket; let second = bucket_id(&spec, start + Duration::minutes(1)) as Bucket; let excluded = bucket_id(&spec, end) as Bucket;
assert!(bitmap.contains(first));
assert!(bitmap.contains(second));
assert!(!bitmap.contains(excluded));
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
fn bucket_range_panics_when_start_not_before_end_in_debug() {
let spec = TimeBucket::Minutes(1);
let t = Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 0).unwrap();
let _ = bucket_range(&spec, t, t);
}
}