1use crate::constants::*;
8use chrono::{Duration, Local, NaiveTime, Utc};
9use expiration_date::error::ExpirationDateError;
10use positive::{Positive, PositiveError};
11use rust_decimal_macros::dec;
12use serde::{Deserialize, Serialize};
13use std::fmt;
14
15#[cfg(test)]
16use positive::pos_or_panic;
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
45#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
46pub enum TimeFrame {
47 Microsecond,
49 Millisecond,
51 Second,
53 Minute,
55 Hour,
57 Day,
59 Week,
61 Month,
63 Quarter,
65 Year,
67 Custom(Positive),
69}
70
71impl TimeFrame {
72 #[must_use]
99 pub fn periods_per_year(&self) -> Positive {
100 match self {
110 TimeFrame::Microsecond => pos_lit(dec!(5896800000000.0000)),
112 TimeFrame::Millisecond => pos_lit(dec!(5896800000.000)),
114 TimeFrame::Second => pos_lit(dec!(5896800.000)),
116 TimeFrame::Minute => pos_lit(dec!(98280.00)),
118 TimeFrame::Hour => pos_lit(dec!(1638.00)),
120 TimeFrame::Day => *TRADING_DAYS, TimeFrame::Week => *WEEKS_PER_YEAR, TimeFrame::Month => *MONTHS_PER_YEAR, TimeFrame::Quarter => QUARTERS_PER_YEAR, TimeFrame::Year => Positive::ONE, TimeFrame::Custom(periods) => *periods, }
127 }
128}
129
130impl fmt::Display for TimeFrame {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 match self {
133 TimeFrame::Microsecond => write!(f, "microsecond"),
134 TimeFrame::Millisecond => write!(f, "millisecond"),
135 TimeFrame::Second => write!(f, "second"),
136 TimeFrame::Minute => write!(f, "minute"),
137 TimeFrame::Hour => write!(f, "hour"),
138 TimeFrame::Day => write!(f, "day"),
139 TimeFrame::Week => write!(f, "week"),
140 TimeFrame::Month => write!(f, "month"),
141 TimeFrame::Quarter => write!(f, "quarter"),
142 TimeFrame::Year => write!(f, "year"),
143 TimeFrame::Custom(periods) => write!(f, "custom ({periods})"),
144 }
145 }
146}
147
148fn pos_lit(d: rust_decimal::Decimal) -> Positive {
158 Positive::new_decimal(d).unwrap_or(Positive::ZERO)
159}
160
161#[must_use]
165pub fn units_per_year(time_frame: &TimeFrame) -> Positive {
166 match time_frame {
167 TimeFrame::Microsecond => pos_lit(dec!(31536000000000.0)), TimeFrame::Millisecond => pos_lit(dec!(31536000000.0)), TimeFrame::Second => pos_lit(dec!(31536000.0)), TimeFrame::Minute => pos_lit(dec!(525600.0)), TimeFrame::Hour => pos_lit(dec!(8760.0)), TimeFrame::Day => pos_lit(dec!(365.0)), TimeFrame::Week => pos_lit(dec!(52.142857142857142857142857143)),
181 TimeFrame::Month => pos_lit(dec!(12.0)), TimeFrame::Quarter => pos_lit(dec!(4.0)), TimeFrame::Year => Positive::ONE, TimeFrame::Custom(periods) => *periods, }
186}
187
188pub fn convert_time_frame(
226 value: Positive,
227 from_time_frame: &TimeFrame,
228 to_time_frame: &TimeFrame,
229) -> Result<Positive, PositiveError> {
230 if from_time_frame == to_time_frame {
232 return Ok(value);
233 }
234
235 if value.is_zero() {
236 return Ok(Positive::ZERO);
237 }
238
239 let from_units_per_year = units_per_year(from_time_frame);
241 let to_units_per_year = units_per_year(to_time_frame);
242
243 let conversion_factor = to_units_per_year.checked_div(&from_units_per_year)?;
249 value.checked_mul(&conversion_factor)
251}
252
253pub fn get_tomorrow_formatted() -> Result<String, ExpirationDateError> {
270 get_x_days_formatted(1)
271}
272
273pub fn get_x_days_formatted(days: i64) -> Result<String, ExpirationDateError> {
296 let target = Duration::try_days(days)
297 .and_then(|delta| Local::now().date_naive().checked_add_signed(delta))
298 .ok_or_else(|| {
299 ExpirationDateError::ArithmeticOverflow(format!(
300 "a date {days} days from today is outside the representable calendar range"
301 ))
302 })?;
303 Ok(target.format("%d-%b-%Y").to_string().to_lowercase())
304}
305
306pub fn get_x_days_formatted_pos(days: Positive) -> Result<String, ExpirationDateError> {
336 let ceiling = days
339 .checked_ceiling()
340 .ok()
341 .and_then(|whole| whole.to_i64_checked())
342 .ok_or_else(|| unrepresentable_day_offset(days))?;
343 let today = Local::now().date_naive();
344 let target = Duration::try_days(ceiling)
345 .and_then(|delta| today.checked_add_signed(delta))
346 .ok_or_else(|| unrepresentable_day_offset(days))?;
347 Ok(target.format("%d-%b-%Y").to_string().to_lowercase())
348}
349
350#[cold]
352#[inline(never)]
353fn unrepresentable_day_offset(days: Positive) -> ExpirationDateError {
354 ExpirationDateError::ArithmeticOverflow(format!(
355 "a date {days} days from today is outside the representable calendar range"
356 ))
357}
358
359#[must_use]
372pub fn get_today_formatted() -> String {
373 let today = Local::now().date_naive();
374 today.format("%d-%b-%Y").to_string().to_lowercase()
375}
376
377const TODAY_CUTOFF: NaiveTime = match NaiveTime::from_hms_opt(18, 30, 0) {
385 Some(t) => t,
386 None => unreachable!(), };
388
389#[must_use]
411pub fn get_today_or_tomorrow_formatted() -> String {
412 let cutoff_time = TODAY_CUTOFF;
413 let now = Utc::now();
414 let target_date = if now.time() > cutoff_time {
416 now.date_naive()
417 .succ_opt()
418 .unwrap_or_else(|| now.date_naive()) } else {
420 now.date_naive()
421 };
422 target_date.format("%d-%b-%Y").to_string().to_lowercase()
423}
424
425#[cfg(test)]
426mod tests_timeframe {
427 use super::*;
428 use positive::assert_pos_relative_eq;
429
430 #[test]
431 fn test_microsecond_periods() {
432 let expected =
433 *TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR * *MICROSECONDS_PER_SECOND;
434 assert_eq!(TimeFrame::Microsecond.periods_per_year(), expected);
435 }
436
437 #[test]
438 fn test_millisecond_periods() {
439 let expected = *TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR * MILLISECONDS_PER_SECOND;
440 assert_eq!(TimeFrame::Millisecond.periods_per_year(), expected);
441 }
442
443 #[test]
444 fn test_second_periods() {
445 let expected = *TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR;
446 assert_eq!(TimeFrame::Second.periods_per_year(), expected);
447 }
448
449 #[test]
450 fn test_minute_periods() {
451 let expected = *TRADING_DAYS * *TRADING_HOURS * MINUTES_PER_HOUR;
452 assert_eq!(TimeFrame::Minute.periods_per_year(), expected);
453 }
454
455 #[test]
456 fn test_hour_periods() {
457 let expected = *TRADING_DAYS * *TRADING_HOURS;
458 assert_eq!(TimeFrame::Hour.periods_per_year(), expected);
459 }
460
461 #[test]
462 fn test_day_periods() {
463 assert_eq!(TimeFrame::Day.periods_per_year(), *TRADING_DAYS);
464 }
465
466 #[test]
470 fn test_periods_per_year_matches_products() {
471 let hours = *TRADING_DAYS * *TRADING_HOURS;
472 let seconds = hours * *SECONDS_PER_HOUR;
473 let cases = [
474 (TimeFrame::Microsecond, seconds * *MICROSECONDS_PER_SECOND),
475 (TimeFrame::Millisecond, seconds * MILLISECONDS_PER_SECOND),
476 (TimeFrame::Second, seconds),
477 (TimeFrame::Minute, hours * MINUTES_PER_HOUR),
478 (TimeFrame::Hour, hours),
479 ];
480 for (frame, product) in cases {
481 assert_eq!(
482 frame.periods_per_year().to_dec().to_string(),
483 product.to_dec().to_string(),
484 "{frame}"
485 );
486 }
487 }
488
489 #[test]
490 fn test_units_per_year_week_is_365_over_7() {
491 assert_eq!(
492 units_per_year(&TimeFrame::Week).to_dec().to_string(),
493 (dec!(365.0) / dec!(7.0)).to_string()
494 );
495 }
496
497 #[test]
498 fn test_week_periods() {
499 assert_eq!(TimeFrame::Week.periods_per_year(), 52.0);
500 }
501
502 #[test]
503 fn test_month_periods() {
504 assert_eq!(TimeFrame::Month.periods_per_year(), 12.0);
505 }
506
507 #[test]
508 fn test_quarter_periods() {
509 assert_eq!(TimeFrame::Quarter.periods_per_year(), 4.0);
510 }
511
512 #[test]
513 fn test_year_periods() {
514 assert_eq!(TimeFrame::Year.periods_per_year(), 1.0);
515 }
516
517 #[test]
518 fn test_custom_periods() {
519 let custom_periods = pos_or_panic!(123.45);
520 assert_eq!(
521 TimeFrame::Custom(custom_periods).periods_per_year(),
522 custom_periods
523 );
524 }
525
526 #[test]
527 fn test_relative_period_relationships() {
528 assert!(
530 TimeFrame::Microsecond.periods_per_year() > TimeFrame::Millisecond.periods_per_year()
531 );
532 assert!(TimeFrame::Millisecond.periods_per_year() > TimeFrame::Second.periods_per_year());
533 assert!(TimeFrame::Second.periods_per_year() > TimeFrame::Minute.periods_per_year());
534 assert!(TimeFrame::Minute.periods_per_year() > TimeFrame::Hour.periods_per_year());
535 assert!(TimeFrame::Hour.periods_per_year() > TimeFrame::Day.periods_per_year());
536 assert!(TimeFrame::Day.periods_per_year() > TimeFrame::Week.periods_per_year());
537 assert!(TimeFrame::Week.periods_per_year() > TimeFrame::Month.periods_per_year());
538 assert!(TimeFrame::Month.periods_per_year() > TimeFrame::Quarter.periods_per_year());
539 assert!(TimeFrame::Quarter.periods_per_year() > TimeFrame::Year.periods_per_year());
540 }
541
542 #[test]
543 fn test_specific_conversion_ratios() {
544 assert_pos_relative_eq!(
546 TimeFrame::Hour.periods_per_year() / TimeFrame::Day.periods_per_year(),
547 *TRADING_HOURS,
548 pos_or_panic!(1e-10)
549 );
550
551 assert_pos_relative_eq!(
552 TimeFrame::Minute.periods_per_year() / TimeFrame::Hour.periods_per_year(),
553 MINUTES_PER_HOUR,
554 pos_or_panic!(1e-10)
555 );
556
557 assert_pos_relative_eq!(
558 TimeFrame::Second.periods_per_year() / TimeFrame::Minute.periods_per_year(),
559 MINUTES_PER_HOUR,
560 pos_or_panic!(1e-10)
561 );
562 }
563
564 #[test]
565 fn test_trading_days_relationship() {
566 assert_pos_relative_eq!(
568 TimeFrame::Day.periods_per_year(),
569 *TRADING_DAYS,
570 pos_or_panic!(1e-10)
571 );
572
573 assert_pos_relative_eq!(
574 TimeFrame::Hour.periods_per_year() / *TRADING_HOURS,
575 *TRADING_DAYS,
576 pos_or_panic!(1e-10)
577 );
578 }
579
580 #[test]
581 fn test_custom_edge_cases() {
582 assert_eq!(TimeFrame::Custom(Positive::ZERO).periods_per_year(), 0.0);
584 assert_eq!(
585 TimeFrame::Custom(Positive::MAX).periods_per_year(),
586 Positive::MAX
587 );
588 }
589
590 #[test]
591 fn test_timeframe_debug() {
592 assert_eq!(format!("{:?}", TimeFrame::Day), "Day");
593 assert_eq!(
594 format!("{:?}", TimeFrame::Custom(pos_or_panic!(1.5))),
595 "Custom(1.5)"
596 );
597 }
598
599 #[test]
600 fn test_timeframe_clone() {
601 let tf = TimeFrame::Day;
602 let cloned = tf;
603 assert_eq!(tf.periods_per_year(), cloned.periods_per_year());
604 }
605
606 #[test]
607 fn test_timeframe_copy() {
608 let tf = TimeFrame::Day;
609 let copied = tf;
610 assert_eq!(tf.periods_per_year(), copied.periods_per_year());
611 }
612}
613
614#[cfg(test)]
615mod tests_timeframe_convert {
616 use super::*;
617 use positive::assert_pos_relative_eq;
618
619 #[test]
620 fn test_convert_seconds_to_minutes() {
621 let result =
622 convert_time_frame(pos_or_panic!(60.0), &TimeFrame::Second, &TimeFrame::Minute)
623 .unwrap();
624 assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
625 }
626
627 #[test]
628 fn test_convert_hours_to_days() {
629 let result =
630 convert_time_frame(pos_or_panic!(12.0), &TimeFrame::Hour, &TimeFrame::Day).unwrap();
631 assert_pos_relative_eq!(result, pos_or_panic!(0.5), pos_or_panic!(1e-10));
632 }
633
634 #[test]
635 fn test_convert_days_to_weeks() {
636 let result =
637 convert_time_frame(pos_or_panic!(7.0), &TimeFrame::Day, &TimeFrame::Week).unwrap();
638 assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
639 }
640
641 #[test]
642 fn test_convert_weeks_to_days() {
643 let result = convert_time_frame(Positive::TWO, &TimeFrame::Week, &TimeFrame::Day).unwrap();
644 assert_pos_relative_eq!(result, pos_or_panic!(14.0), pos_or_panic!(1e-10));
645 }
646
647 #[test]
648 fn test_convert_months_to_quarters() {
649 let result =
650 convert_time_frame(pos_or_panic!(3.0), &TimeFrame::Month, &TimeFrame::Quarter).unwrap();
651 assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
652 }
653
654 #[test]
655 fn test_convert_minutes_to_hours() {
656 let result =
657 convert_time_frame(pos_or_panic!(120.0), &TimeFrame::Minute, &TimeFrame::Hour).unwrap();
658 assert_pos_relative_eq!(result, Positive::TWO, pos_or_panic!(1e-10));
659 }
660
661 #[test]
662 fn test_convert_custom_to_day() {
663 let result = convert_time_frame(
664 pos_or_panic!(10.0),
665 &TimeFrame::Custom(pos_or_panic!(365.0)),
666 &TimeFrame::Day,
667 )
668 .unwrap();
669 assert_pos_relative_eq!(result, pos_or_panic!(10.0), pos_or_panic!(1e-10));
670 }
671
672 #[test]
673 fn test_convert_day_to_custom() {
674 let result = convert_time_frame(
675 Positive::TWO,
676 &TimeFrame::Day,
677 &TimeFrame::Custom(pos_or_panic!(365.0)),
678 )
679 .unwrap();
680 assert_pos_relative_eq!(result, Positive::TWO, pos_or_panic!(1e-10));
681 }
682
683 #[test]
684 fn test_convert_same_timeframe() {
685 let result =
686 convert_time_frame(pos_or_panic!(42.0), &TimeFrame::Hour, &TimeFrame::Hour).unwrap();
687 assert_pos_relative_eq!(result, pos_or_panic!(42.0), pos_or_panic!(1e-10));
688 }
689
690 #[test]
691 fn test_convert_weeks_to_months() {
692 let result =
693 convert_time_frame(pos_or_panic!(4.0), &TimeFrame::Week, &TimeFrame::Month).unwrap();
694 assert_pos_relative_eq!(
696 result,
697 pos_or_panic!(0.920_547_945_255_920_4),
698 pos_or_panic!(1e-10)
699 );
700 }
701
702 #[test]
703 fn test_convert_milliseconds_to_seconds() {
704 let result = convert_time_frame(
705 pos_or_panic!(1000.0),
706 &TimeFrame::Millisecond,
707 &TimeFrame::Second,
708 )
709 .unwrap();
710 assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
711 }
712
713 #[test]
714 fn test_zero() {
715 let result =
716 convert_time_frame(Positive::ZERO, &TimeFrame::Millisecond, &TimeFrame::Second)
717 .unwrap();
718 assert_pos_relative_eq!(result, Positive::ZERO, pos_or_panic!(1e-10));
719 }
720}
721
722#[cfg(test)]
723mod tests_x_days_formatted_pos {
724 use super::*;
725
726 #[test]
727 fn test_get_x_days_formatted_pos_rounds_up_and_matches_the_integer_form() {
728 let date = get_x_days_formatted_pos(pos_or_panic!(6.2)).expect("seven days ahead");
729 assert_eq!(Some(date), get_x_days_formatted(7).ok());
730 }
731
732 #[test]
733 fn test_get_x_days_formatted_pos_zero_days_is_today() {
734 let date = get_x_days_formatted_pos(Positive::ZERO).expect("today");
735 assert_eq!(date, get_today_formatted());
736 }
737
738 #[test]
739 fn test_get_x_days_formatted_pos_beyond_the_calendar_reports_overflow() {
740 let result = get_x_days_formatted_pos(pos_or_panic!(1_000_000_000.0));
742 assert!(matches!(
743 result,
744 Err(ExpirationDateError::ArithmeticOverflow(_))
745 ));
746 }
747
748 #[test]
749 fn test_get_x_days_formatted_pos_beyond_i64_reports_overflow() {
750 let result = get_x_days_formatted_pos(Positive::MAX);
751 assert!(matches!(
752 result,
753 Err(ExpirationDateError::ArithmeticOverflow(_))
754 ));
755 }
756}
757
758#[cfg(test)]
759mod tests_panic_paths {
760 use super::*;
761 use chrono::{Duration, Local};
762
763 #[test]
766 fn test_convert_time_frame_from_custom_zero_is_error() {
767 let result = convert_time_frame(
768 Positive::ONE,
769 &TimeFrame::Custom(Positive::ZERO),
770 &TimeFrame::Day,
771 );
772 assert!(result.is_err());
773 }
774
775 #[test]
778 fn test_convert_time_frame_overflow_is_error() {
779 let result = convert_time_frame(Positive::MAX, &TimeFrame::Year, &TimeFrame::Day);
780 assert!(result.is_err());
781 }
782
783 #[test]
784 fn test_convert_time_frame_values_unchanged() {
785 let result = convert_time_frame(Positive::TWO, &TimeFrame::Week, &TimeFrame::Day);
786 assert_eq!(
787 result.ok(),
788 Some(
789 Positive::TWO * units_per_year(&TimeFrame::Day) / units_per_year(&TimeFrame::Week)
790 )
791 );
792 }
793
794 #[test]
797 fn test_get_x_days_formatted_i64_max_is_error() {
798 assert!(matches!(
799 get_x_days_formatted(i64::MAX),
800 Err(ExpirationDateError::ArithmeticOverflow(_))
801 ));
802 assert!(get_x_days_formatted(i64::MIN).is_err());
803 }
804
805 #[test]
808 fn test_get_x_days_formatted_billion_days_is_error() {
809 assert!(matches!(
810 get_x_days_formatted(1_000_000_000),
811 Err(ExpirationDateError::ArithmeticOverflow(_))
812 ));
813 }
814
815 #[test]
816 fn test_get_x_days_formatted_values_unchanged() {
817 let expected = (Local::now().date_naive() + Duration::days(2))
818 .format("%d-%b-%Y")
819 .to_string()
820 .to_lowercase();
821 assert_eq!(get_x_days_formatted(2).ok(), Some(expected));
822 let tomorrow = (Local::now().date_naive() + Duration::days(1))
823 .format("%d-%b-%Y")
824 .to_string()
825 .to_lowercase();
826 assert_eq!(get_tomorrow_formatted().ok(), Some(tomorrow));
827 }
828}