date_component 0.4.8

the missed date_component with chrono. calculate date interval with chrono.
Documentation
#![forbid(unsafe_code)]

pub mod date_component {
    use chrono::prelude::*;

    #[derive(Debug, Copy, Clone, PartialEq, Eq)]
    pub struct DateComponent {
        /// Number of years.
        pub year: isize,
        /// Number of months.
        pub month: isize,
        /// Number of weeks.
        pub week: isize,
        /// Number of days remaining when using weeks.
        pub modulo_days: isize,
        /// Number of days.
        pub day: isize,
        /// Number of hours.
        pub hour: isize,
        /// Number of minutes.
        pub minute: isize,
        /// Number of seconds.
        pub second: isize,
        /// total number of seconds between the start and end dates.
        pub interval_seconds: isize,
        /// total number of minutes between the start and end dates.
        pub interval_minutes: isize,
        /// total number of hours between the start and end dates.
        pub interval_hours: isize,
        /// total number of days between the start and end dates
        pub interval_days: isize,
        /// Is true if the interval represents a negative time period and false otherwise
        pub invert: bool,
    }

    /// Returns a DateComponent object that represents the difference between the from and to datetime.
    pub fn calculate<T: chrono::TimeZone>(
        from_datetime: &DateTime<T>,
        to_datetime: &DateTime<T>,
    ) -> DateComponent {
        let timezone = from_datetime.timezone();
        let to_datetime_in_from_tz = to_datetime.with_timezone(&timezone);

        let duration = from_datetime
            .clone()
            .signed_duration_since(to_datetime.clone());
        let seconds = duration.num_seconds();
        let (start, end, invert) = match seconds {
            x if x <= 0 => (from_datetime.clone(), to_datetime_in_from_tz, false),
            _ => (to_datetime_in_from_tz, from_datetime.clone(), true),
        };

        // Use mutable variables for interval components
        let mut year = end.year() as i64 - start.year() as i64;
        let mut month = end.month() as i64 - start.month() as i64;
        let mut day = end.day() as i64 - start.day() as i64;

        // For DST handling, we need to use duration for time components
        // instead of calculating differences directly
        let duration_hours = duration.num_hours().abs() % 24;
        let duration_minutes = duration.num_minutes().abs() % 60;
        let duration_seconds = duration.num_seconds().abs() % 60;

        // Now handle date borrowing (days -> months -> years)
        let (previous_year, previous_month) = if end.month() == 1 {
            (end.year() - 1, 12)
        } else {
            (end.year(), end.month() - 1)
        };

        if day < 0 {
            month -= 1;
            // Add days in the month *before* the end date's month.
            // Use get_nearest_day_before to find the last day of that month.
            let last_day_of_prev_month = get_nearest_day_before(
                previous_year,
                previous_month,
                31, // Try 31, it will be adjusted down correctly
                0,
                0,
                0, // Time doesn't matter for finding the last day
                &timezone,
            );
            day += last_day_of_prev_month.day() as i64;
        }

        if month < 0 {
            month += 12;
            year -= 1;
        };

        // Calculate week and modulo_days based on the final adjusted day value
        let final_year = year;
        let final_month = month;
        let mut final_day = day;
        let mut final_hour = duration_hours;
        let mut final_minute = duration_minutes;
        let mut final_second = duration_seconds;

        // Consistency check: fix cases where small time differences are incorrectly
        // calculated as larger units due to crossing date boundaries
        let total_seconds = duration.num_seconds().abs();

        // If total time is less than 1 day but we calculated days, adjust
        if total_seconds < 86400 && final_day > 0 {
            // For small time differences that cross boundaries,
            // use the total duration directly instead of calculated day components
            final_day = 0;
            final_hour = total_seconds / 3600;
            final_minute = (total_seconds % 3600) / 60;
            final_second = total_seconds % 60;
        }

        // Similar check for hours when total time is less than 1 hour
        if total_seconds < 3600 && final_hour > 0 {
            let hour_seconds = final_hour * 3600;
            let remaining_seconds = hour_seconds + final_minute * 60 + final_second;

            final_hour = 0;
            final_minute = remaining_seconds / 60;
            final_second = remaining_seconds % 60;
        }

        // Similar check for minutes when total time is less than 1 minute
        if total_seconds < 60 && final_minute > 0 {
            let minute_seconds = final_minute * 60;
            final_second += minute_seconds;
            final_minute = 0;
        }

        let final_week = final_day / 7;
        let final_modulo_days = final_day % 7;

        // Return the final DateComponent
        DateComponent {
            year: final_year as isize,
            month: final_month as isize,
            week: final_week as isize,
            modulo_days: final_modulo_days as isize,
            day: final_day as isize,
            hour: final_hour as isize,
            minute: final_minute as isize,
            second: final_second as isize,
            interval_seconds: duration.num_seconds().abs() as isize,
            interval_minutes: duration.num_minutes().abs() as isize,
            interval_hours: duration.num_hours().abs() as isize,
            interval_days: duration.num_days().abs() as isize,
            invert,
        }
    }

    /// Given date specified by year / month / day where the `day` may be invalid,
    /// (e.g. 2021-02-30), return the nearest valid day before it
    /// (e.g. 2021-02-28).
    pub(crate) fn get_nearest_day_before<T: TimeZone>(
        year: i32,
        month: u32,
        day: u32,
        hour: u32,
        min: u32,
        sec: u32,
        timezone: &T,
    ) -> DateTime<T> {
        let mut subtract = 0;
        loop {
            match timezone.with_ymd_and_hms(year, month, day - subtract, hour, min, sec) {
                chrono::LocalResult::None => subtract += 1,
                chrono::LocalResult::Single(d) => {
                    return d;
                }
                chrono::LocalResult::Ambiguous(d, _) => {
                    return d;
                }
            }
        }
    }
}

#[cfg(test)]
mod internal_tests {
    use chrono::prelude::*;

    use crate::date_component::get_nearest_day_before;

    #[test]
    fn test_get_nearest_day_before_regular() {
        let dt = get_nearest_day_before(2023, 2, 30, 0, 0, 0, &Utc);
        assert_eq!(dt.day(), 28);
    }

    #[test]
    fn test_get_nearest_day_before_leap() {
        let dt = get_nearest_day_before(2024, 2, 30, 0, 0, 0, &Utc);
        assert_eq!(dt.day(), 29);
    }

    #[test]
    fn test_get_nearest_day_before_big_month() {
        let dt = get_nearest_day_before(2023, 1, 32, 0, 0, 0, &Utc);
        assert_eq!(dt.day(), 31);
    }

    #[test]
    fn test_get_nearest_day_before_edge_case() {
        // Test with day = 1, should return valid date
        let dt = get_nearest_day_before(2023, 2, 1, 0, 0, 0, &Utc);
        assert_eq!(dt.day(), 1);
    }

    #[test]
    fn test_potential_infinite_loop_prevention() {
        // This tests if the function would handle extremely large day values gracefully
        // It should not cause infinite loop even with very large subtract values
        let dt = get_nearest_day_before(2023, 2, 100, 0, 0, 0, &Utc);
        assert_eq!(dt.day(), 28); // February 2023 has 28 days
    }
}