spacecell 0.1.0

Datetime library with ISO8601/RFC3339 parsing, calendar arithmetic, and business day calculations
Documentation
//! # **Civil Calendar Module** - *Gregorian calendar algorithms*
//!
//! Core proleptic Gregorian calendar calculations using Howard Hinnant's public domain
//! algorithms. All calculations use days-since-Unix-epoch (1970-01-01) for efficiency.
//!
//! ## Epoch
//! Unix epoch: January 1, 1970 00:00:00 UTC (day 0)
//!
//! ## Algorithms
//! Based on Howard Hinnant's date algorithms:
//! <http://howardhinnant.github.io/date_algorithms.html>

/// Unix epoch: January 1, 1970
const UNIX_EPOCH_DAYS: i64 = 719468; // Days from 0000-03-01 to 1970-01-01

/// Checks if a year is a leap year in the proleptic Gregorian calendar.
///
/// # Rules
/// - Divisible by 4: leap year
/// - Divisible by 100: not a leap year
/// - Divisible by 400: leap year
///
/// # Examples
/// ```
/// use spacecell::is_leap_year;
///
/// assert!(is_leap_year(2000));  // divisible by 400
/// assert!(is_leap_year(2004));  // divisible by 4
/// assert!(!is_leap_year(1900)); // divisible by 100 but not 400
/// assert!(!is_leap_year(2001)); // not divisible by 4
/// ```
#[inline]
pub fn is_leap_year(year: i32) -> bool {
    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}

/// Returns the number of days in a given month, accounting for leap years.
///
/// # Arguments
/// - `year`: The year (for leap year detection)
/// - `month`: Month number (1-12)
///
/// # Returns
/// Number of days (28-31)
#[inline]
pub fn days_in_month(year: i32, month: u32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 => {
            if is_leap_year(year) {
                29
            } else {
                28
            }
        }
        _ => 0, // Invalid month
    }
}

/// Converts a civil date (year, month, day) to days since Unix epoch.
///
/// # Arguments
/// - `year`: Year (proleptic Gregorian)
/// - `month`: Month (1-12)
/// - `day`: Day of month (1-31)
///
/// # Returns
/// Number of days since 1970-01-01 (can be negative for dates before epoch)
///
/// # Algorithm
/// Uses Howard Hinnant's algorithm for efficient conversion without loops.
#[inline]
pub fn ymd_to_days(year: i32, month: u32, day: u32) -> i64 {
    // Adjust year and month for algorithm (March-based year)
    let y = if month <= 2 {
        year as i64 - 1
    } else {
        year as i64
    };
    let m = if month <= 2 { month + 12 } else { month } as i64;
    let d = day as i64;

    // Era: 400-year cycles starting from 0000-03-01
    let era = if y >= 0 { y } else { y - 399 } / 400;

    // Year of era (0-399)
    let yoe = y - era * 400;

    // Day of year (0-365)
    let doy = (153 * (m - 3) + 2) / 5 + d - 1;

    // Day of era (0-146096)
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;

    // Days since 0000-03-01
    let days_since_era_0 = era * 146097 + doe;

    // Convert to days since Unix epoch (1970-01-01)
    days_since_era_0 - UNIX_EPOCH_DAYS
}

/// Converts days since Unix epoch to a civil date (year, month, day).
///
/// # Arguments
/// - `days`: Days since 1970-01-01 (can be negative)
///
/// # Returns
/// Tuple of (year, month, day)
///
/// # Algorithm
/// Uses Howard Hinnant's algorithm for efficient conversion without loops.
#[inline]
pub fn days_to_ymd(days: i64) -> (i32, u32, u32) {
    // Convert to days since 0000-03-01
    let z = days + UNIX_EPOCH_DAYS;

    // Era: 400-year cycles
    let era = if z >= 0 { z } else { z - 146096 } / 146097;

    // Day of era (0-146096)
    let doe = z - era * 146097;

    // Year of era (0-399)
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;

    // Year
    let y = yoe + era * 400;

    // Day of year (0-365)
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);

    // Month (0-11, March-based)
    let mp = (5 * doy + 2) / 153;

    // Day of month (1-31)
    let d = doy - (153 * mp + 2) / 5 + 1;

    // Convert March-based month to standard month
    let m = if mp < 10 { mp + 3 } else { mp - 9 };

    // Adjust year if month is Jan or Feb
    let year = if m <= 2 { y + 1 } else { y };

    (year as i32, m as u32, d as u32)
}

/// Returns the day of week for a given date.
///
/// # Arguments
/// - `days`: Days since Unix epoch
///
/// # Returns
/// Day of week: 0=Monday, 1=Tuesday, ..., 6=Sunday
///
/// # Note
/// Unix epoch (1970-01-01) was a Thursday (day 3)
#[inline]
pub fn day_of_week(days: i64) -> u32 {
    // 1970-01-01 was a Thursday (day 3)
    // Adjust so 0=Monday
    ((days + 3).rem_euclid(7)) as u32
}

/// Returns the day of year (1-366) for a given date.
///
/// # Arguments
/// - `year`: Year
/// - `month`: Month (1-12)
/// - `day`: Day of month (1-31)
///
/// # Returns
/// Day of year: 1 for Jan 1, 365/366 for Dec 31
#[inline]
pub fn day_of_year(year: i32, month: u32, day: u32) -> u32 {
    // Days in each month before the current month
    let days_before_month = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];

    let mut doy = days_before_month[(month - 1) as usize] + day;

    // Adjust for leap year if after February
    if month > 2 && is_leap_year(year) {
        doy += 1;
    }

    doy
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_leap_year() {
        // Leap years
        assert!(is_leap_year(2000)); // Divisible by 400
        assert!(is_leap_year(2004)); // Divisible by 4
        assert!(is_leap_year(2020));
        assert!(is_leap_year(1600));

        // Not leap years
        assert!(!is_leap_year(1900)); // Divisible by 100 but not 400
        assert!(!is_leap_year(2100));
        assert!(!is_leap_year(2001)); // Not divisible by 4
        assert!(!is_leap_year(2019));
    }

    #[test]
    fn test_days_in_month() {
        // 31-day months
        assert_eq!(days_in_month(2020, 1), 31); // Jan
        assert_eq!(days_in_month(2020, 3), 31); // Mar
        assert_eq!(days_in_month(2020, 5), 31); // May
        assert_eq!(days_in_month(2020, 7), 31); // Jul
        assert_eq!(days_in_month(2020, 8), 31); // Aug
        assert_eq!(days_in_month(2020, 10), 31); // Oct
        assert_eq!(days_in_month(2020, 12), 31); // Dec

        // 30-day months
        assert_eq!(days_in_month(2020, 4), 30); // Apr
        assert_eq!(days_in_month(2020, 6), 30); // Jun
        assert_eq!(days_in_month(2020, 9), 30); // Sep
        assert_eq!(days_in_month(2020, 11), 30); // Nov

        // February
        assert_eq!(days_in_month(2020, 2), 29); // Leap year
        assert_eq!(days_in_month(2019, 2), 28); // Non-leap year
        assert_eq!(days_in_month(2000, 2), 29); // Leap year
        assert_eq!(days_in_month(1900, 2), 28); // Non-leap year
    }

    #[test]
    fn test_ymd_to_days_epoch() {
        // Unix epoch
        assert_eq!(ymd_to_days(1970, 1, 1), 0);
    }

    #[test]
    fn test_ymd_to_days_roundtrip() {
        // Test round-trip conversion
        let test_dates = [
            (1970, 1, 1),
            (2000, 1, 1),
            (2020, 2, 29), // Leap day
            (1900, 3, 1),
            (2100, 12, 31),
            (1600, 1, 1),
            (1969, 12, 31), // Day before epoch
            (1950, 6, 15),
        ];

        for &(y, m, d) in &test_dates {
            let days = ymd_to_days(y, m, d);
            let (y2, m2, d2) = days_to_ymd(days);
            assert_eq!(
                (y, m, d),
                (y2, m2, d2),
                "Round-trip failed for {}-{:02}-{:02}",
                y,
                m,
                d
            );
        }
    }

    #[test]
    fn test_days_to_ymd_epoch() {
        // Unix epoch
        assert_eq!(days_to_ymd(0), (1970, 1, 1));
    }

    #[test]
    fn test_day_of_week() {
        // 1970-01-01 was a Thursday
        assert_eq!(day_of_week(0), 3); // Thursday

        // 1970-01-05 was a Monday
        assert_eq!(day_of_week(4), 0); // Monday

        // Test full week
        assert_eq!(day_of_week(4), 0); // 1970-01-05 Monday
        assert_eq!(day_of_week(5), 1); // Tuesday
        assert_eq!(day_of_week(6), 2); // Wednesday
        assert_eq!(day_of_week(0), 3); // Thursday (epoch)
        assert_eq!(day_of_week(1), 4); // Friday
        assert_eq!(day_of_week(2), 5); // Saturday
        assert_eq!(day_of_week(3), 6); // Sunday
    }

    #[test]
    fn test_day_of_year() {
        // First day
        assert_eq!(day_of_year(2020, 1, 1), 1);

        // Last day non-leap
        assert_eq!(day_of_year(2019, 12, 31), 365);

        // Last day leap year
        assert_eq!(day_of_year(2020, 12, 31), 366);

        // Feb 29 in leap year
        assert_eq!(day_of_year(2020, 2, 29), 60);

        // Mar 1 in leap year
        assert_eq!(day_of_year(2020, 3, 1), 61);

        // Mar 1 in non-leap year
        assert_eq!(day_of_year(2019, 3, 1), 60);
    }

    #[test]
    fn test_negative_years() {
        // Test BCE dates (negative years in proleptic Gregorian)
        let days = ymd_to_days(-1, 12, 31);
        let (y, m, d) = days_to_ymd(days);
        assert_eq!((y, m, d), (-1, 12, 31));
    }

    #[test]
    fn test_leap_year_boundaries() {
        // Test Feb 28 to Mar 1 transitions
        let feb28_leap = ymd_to_days(2020, 2, 28);
        let feb29_leap = ymd_to_days(2020, 2, 29);
        let mar1_leap = ymd_to_days(2020, 3, 1);

        assert_eq!(feb29_leap - feb28_leap, 1);
        assert_eq!(mar1_leap - feb29_leap, 1);

        let feb28_normal = ymd_to_days(2019, 2, 28);
        let mar1_normal = ymd_to_days(2019, 3, 1);

        assert_eq!(mar1_normal - feb28_normal, 1);
    }
}