@@ -32,6 +32,14 @@ pub enum TimeParseError {
3232 StartTimeAfterEndTime ,
3333}
3434
35+ #[ derive( Debug , thiserror:: Error ) ]
36+ pub enum TimeBinError {
37+ #[ error( "num_bins must be greater than 0" ) ]
38+ InvalidNumBins ,
39+ #[ error( "time bin interval is out of range" ) ]
40+ IntervalOutOfRange ,
41+ }
42+
3543type Prefix = String ;
3644
3745#[ derive( Clone , Copy ) ]
@@ -71,35 +79,71 @@ pub fn count_api_bin_interval(start: &DateTime<Utc>, end: &DateTime<Utc>) -> &'s
7179 }
7280}
7381
74- pub fn interval_for_num_bins ( start : & DateTime < Utc > , end : & DateTime < Utc > , num_bins : u64 ) -> String {
82+ pub fn interval_for_num_bins (
83+ start : & DateTime < Utc > ,
84+ end : & DateTime < Utc > ,
85+ num_bins : u64 ,
86+ ) -> Result < String , TimeBinError > {
87+ if num_bins == 0 {
88+ return Err ( TimeBinError :: InvalidNumBins ) ;
89+ }
90+
7591 let total_seconds = end. signed_duration_since ( * start) . num_seconds ( ) . max ( 1 ) as u64 ;
7692 let bin_seconds = ( total_seconds / num_bins) . max ( 1 ) ;
77- format ! ( "{bin_seconds} seconds" )
93+ Ok ( format ! ( "{bin_seconds} seconds" ) )
7894}
7995
80- pub fn expected_time_bins ( time_range : & TimeRange , num_bins : u64 ) -> Vec < ( String , String ) > {
96+ pub fn expected_time_bins (
97+ time_range : & TimeRange ,
98+ num_bins : u64 ,
99+ ) -> Result < Vec < ( String , String ) > , TimeBinError > {
100+ if num_bins == 0 {
101+ return Err ( TimeBinError :: InvalidNumBins ) ;
102+ }
103+
81104 let total_seconds = time_range
82105 . end
83106 . signed_duration_since ( time_range. start )
84107 . num_seconds ( )
85108 . max ( 1 ) as u64 ;
86109 let bin_seconds = ( total_seconds / num_bins) . max ( 1 ) ;
110+ let bin_seconds_i64 =
111+ i64:: try_from ( bin_seconds) . map_err ( |_| TimeBinError :: IntervalOutOfRange ) ?;
112+ let first_bin_start =
113+ time_range. start . timestamp ( ) . div_euclid ( bin_seconds_i64) * bin_seconds_i64;
114+ let first_bin_start =
115+ DateTime :: from_timestamp ( first_bin_start, 0 ) . ok_or ( TimeBinError :: IntervalOutOfRange ) ?;
87116
88117 ( 0 ..num_bins)
89118 . map ( |i| {
90- let bin_start = time_range. start + chrono:: Duration :: seconds ( ( i * bin_seconds) as i64 ) ;
119+ let offset = i64:: try_from ( i)
120+ . ok ( )
121+ . and_then ( |i| i. checked_mul ( bin_seconds_i64) )
122+ . ok_or ( TimeBinError :: IntervalOutOfRange ) ?;
123+ let bin_start = first_bin_start
124+ . checked_add_signed ( chrono:: Duration :: seconds ( offset) )
125+ . ok_or ( TimeBinError :: IntervalOutOfRange ) ?;
91126 let bin_end = if i == num_bins - 1 {
92127 time_range. end
93128 } else {
94- time_range. start + chrono:: Duration :: seconds ( ( ( i + 1 ) * bin_seconds) as i64 )
129+ bin_start
130+ . checked_add_signed ( chrono:: Duration :: seconds ( bin_seconds_i64) )
131+ . ok_or ( TimeBinError :: IntervalOutOfRange ) ?
95132 } ;
96- ( bin_start. to_rfc3339 ( ) , bin_end. to_rfc3339 ( ) )
133+ Ok ( ( bin_start. to_rfc3339 ( ) , bin_end. to_rfc3339 ( ) ) )
97134 } )
98135 . collect ( )
99136}
100137
101138pub fn match_time_bin_key ( sql_bin_start : & str , expected_bins : & [ ( String , String ) ] ) -> String {
102- let normalized = if !sql_bin_start. contains ( '+' ) && !sql_bin_start. ends_with ( 'Z' ) {
139+ let has_timezone = sql_bin_start. ends_with ( 'Z' )
140+ || DateTime :: parse_from_rfc3339 ( sql_bin_start) . is_ok ( )
141+ || sql_bin_start
142+ . rsplit_once ( [ '+' , '-' ] )
143+ . is_some_and ( |( _, suffix) | {
144+ suffix. len ( ) == 5 && suffix. as_bytes ( ) . get ( 2 ) == Some ( & b':' )
145+ } ) ;
146+ let normalized = if !has_timezone {
103147 format ! ( "{sql_bin_start}+00:00" )
104148 } else {
105149 sql_bin_start. to_string ( )
@@ -507,6 +551,49 @@ mod tests {
507551 assert ! ( matches!( result, Err ( TimeParseError :: HumanTime ( _) ) ) ) ;
508552 }
509553
554+ #[ test]
555+ fn interval_for_num_bins_rejects_zero_bins ( ) {
556+ let start = Utc . with_ymd_and_hms ( 2023 , 1 , 1 , 12 , 0 , 0 ) . unwrap ( ) ;
557+ let end = Utc . with_ymd_and_hms ( 2023 , 1 , 1 , 12 , 10 , 0 ) . unwrap ( ) ;
558+
559+ let result = interval_for_num_bins ( & start, & end, 0 ) ;
560+
561+ assert ! ( matches!( result, Err ( TimeBinError :: InvalidNumBins ) ) ) ;
562+ }
563+
564+ #[ test]
565+ fn expected_time_bins_rejects_zero_bins ( ) {
566+ let time_range = time_period_from_str ( "2023-01-01T12:00:00Z" , "2023-01-01T12:10:00Z" ) ;
567+
568+ let result = expected_time_bins ( & time_range, 0 ) ;
569+
570+ assert ! ( matches!( result, Err ( TimeBinError :: InvalidNumBins ) ) ) ;
571+ }
572+
573+ #[ test]
574+ fn expected_time_bins_aligns_to_epoch_anchor ( ) {
575+ let time_range = time_period_from_str ( "2023-01-01T12:02:00Z" , "2023-01-01T12:12:00Z" ) ;
576+
577+ let bins = expected_time_bins ( & time_range, 2 ) . unwrap ( ) ;
578+
579+ assert_eq ! ( bins[ 0 ] . 0 , "2023-01-01T12:00:00+00:00" ) ;
580+ assert_eq ! ( bins[ 0 ] . 1 , "2023-01-01T12:05:00+00:00" ) ;
581+ assert_eq ! ( bins[ 1 ] . 0 , "2023-01-01T12:05:00+00:00" ) ;
582+ assert_eq ! ( bins[ 1 ] . 1 , "2023-01-01T12:12:00+00:00" ) ;
583+ }
584+
585+ #[ test]
586+ fn match_time_bin_key_handles_negative_timezone_offsets ( ) {
587+ let expected_bins = vec ! [ (
588+ "2023-01-01T05:00:00+00:00" . to_string( ) ,
589+ "2023-01-01T06:00:00+00:00" . to_string( ) ,
590+ ) ] ;
591+
592+ let matched = match_time_bin_key ( "2023-01-01T00:00:00-05:00" , & expected_bins) ;
593+
594+ assert_eq ! ( matched, "2023-01-01T05:00:00+00:00" ) ;
595+ }
596+
510597 fn time_period_from_str ( start : & str , end : & str ) -> TimeRange {
511598 TimeRange {
512599 start : DateTime :: parse_from_rfc3339 ( start) . unwrap ( ) . into ( ) ,
0 commit comments