diff --git a/src/items/builder.rs b/src/items/builder.rs index c9778bf..3b07699 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -333,3 +333,146 @@ impl TryFrom> for DateTimeBuilder { Ok(builder) } } + +#[cfg(test)] +mod tests { + use super::*; + + // Helper functions to create test items by parsing + fn timestamp() -> epoch::Timestamp { + let mut input = "@1234567890"; + epoch::parse(&mut input).unwrap() + } + + fn date() -> date::Date { + let mut input = "2023-06-15"; + date::parse(&mut input).unwrap() + } + + fn time() -> time::Time { + let mut input = "12:30:00"; + time::parse(&mut input).unwrap() + } + + fn time_with_offset() -> time::Time { + let mut input = "12:30:00+05:00"; + time::parse(&mut input).unwrap() + } + + fn weekday() -> weekday::Weekday { + let mut input = "monday"; + weekday::parse(&mut input).unwrap() + } + + fn offset() -> offset::Offset { + let mut input = "+05:00"; + offset::timezone_offset(&mut input).unwrap() + } + + fn relative() -> relative::Relative { + let mut input = "1 day"; + relative::parse(&mut input).unwrap() + } + + fn timezone() -> jiff::tz::TimeZone { + jiff::tz::TimeZone::UTC + } + + #[test] + fn test_duplicate_items_errors() { + let test_cases = vec![ + ( + vec![Item::TimeZone(timezone()), Item::TimeZone(timezone())], + "timezone rule cannot appear more than once", + ), + ( + vec![Item::Timestamp(timestamp()), Item::Timestamp(timestamp())], + "timestamp cannot appear more than once", + ), + ( + vec![Item::Date(date()), Item::Date(date())], + "date cannot appear more than once", + ), + ( + vec![Item::Time(time()), Item::Time(time())], + "time cannot appear more than once", + ), + ( + vec![Item::Weekday(weekday()), Item::Weekday(weekday())], + "weekday cannot appear more than once", + ), + ( + vec![Item::Offset(offset()), Item::Offset(offset())], + "time offset cannot appear more than once", + ), + ]; + + for (items, expected_err) in test_cases { + let result = DateTimeBuilder::try_from(items); + assert_eq!(result.unwrap_err(), expected_err); + } + } + + #[test] + fn test_timestamp_cannot_be_combined_with_other_items() { + let test_cases = vec![ + vec![Item::Date(date()), Item::Timestamp(timestamp())], + vec![Item::Time(time()), Item::Timestamp(timestamp())], + vec![Item::Weekday(weekday()), Item::Timestamp(timestamp())], + vec![Item::Offset(offset()), Item::Timestamp(timestamp())], + vec![Item::Relative(relative()), Item::Timestamp(timestamp())], + vec![Item::Timestamp(timestamp()), Item::Date(date())], + vec![Item::Timestamp(timestamp()), Item::Time(time())], + vec![Item::Timestamp(timestamp()), Item::Weekday(weekday())], + vec![Item::Timestamp(timestamp()), Item::Relative(relative())], + vec![Item::Timestamp(timestamp()), Item::Offset(offset())], + vec![Item::Timestamp(timestamp()), Item::Pure("2023".to_string())], + ]; + + for items in test_cases { + let result = DateTimeBuilder::try_from(items); + assert_eq!( + result.unwrap_err(), + "timestamp cannot be combined with other date/time items" + ); + } + } + + #[test] + fn test_time_offset_conflicts() { + // Time with offset followed by separate Offset item + let items1 = vec![Item::Time(time_with_offset()), Item::Offset(offset())]; + assert_eq!( + DateTimeBuilder::try_from(items1).unwrap_err(), + "time offset cannot appear more than once" + ); + + // Offset item followed by Time with offset + let items2 = vec![Item::Offset(offset()), Item::Time(time_with_offset())]; + assert_eq!( + DateTimeBuilder::try_from(items2).unwrap_err(), + "time offset and timezone are mutually exclusive" + ); + } + + #[test] + fn test_valid_combination_date_time() { + let items = vec![Item::Date(date()), Item::Time(time())]; + let result = DateTimeBuilder::try_from(items); + assert!(result.is_ok()); + } + + #[test] + fn test_valid_combination_date_weekday() { + let items = vec![Item::Date(date()), Item::Weekday(weekday())]; + let result = DateTimeBuilder::try_from(items); + assert!(result.is_ok()); + } + + #[test] + fn test_valid_timestamp_alone() { + let items = vec![Item::Timestamp(timestamp())]; + let result = DateTimeBuilder::try_from(items); + assert!(result.is_ok()); + } +} diff --git a/src/items/primitive.rs b/src/items/primitive.rs index d4e13b3..ba7d32e 100644 --- a/src/items/primitive.rs +++ b/src/items/primitive.rs @@ -142,3 +142,64 @@ pub(super) fn ctx_err(reason: &'static str) -> ContextError { err.push(StrContext::Expected(StrContextValue::Description(reason))); err } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_dec_int() { + for (input, expected) in [ + ("123", 123), // positive without sign + ("+123", 123), // positive with '+' sign + ("-123", -123), // negative with '-' sign + ("0", 0), // zero + ("+0", 0), // zero with '+' sign + ("-0", 0), // zero with '-' sign (parses as 0) + ("012", 12), // zero-prefixed (the main reason for this function) + ("+012", 12), // zero-prefixed with '+' sign + ("-012", -12), // zero-prefixed with '-' sign + ("00123", 123), // multiple leading zeros + ("2147483647", 2147483647), // i32::MAX + ("-2147483648", -2147483648), // i32::MIN + ] { + let mut s = input; + assert_eq!( + dec_int::(&mut s).unwrap(), + expected, + "{input}" + ); + } + + for input in [ + "", // empty string + "+", // sign without digits + "-", // sign without digits + "abc", // non-numeric + "12a", // starts with digits but has non-digit after (but should parse "12" successfully) + ] { + let mut s = input; + let result = dec_int::(&mut s); + // Note: "12a" will actually succeed and parse "12", leaving "a" unparsed + if input == "12a" { + assert_eq!(result.unwrap(), 12, "{input}"); + assert_eq!(s, "a", "Should leave 'a' unparsed"); + } else { + assert!(result.is_err(), "{input} should fail"); + } + } + + // Test overflow cases + for input in [ + "2147483648", // i32::MAX + 1 + "-2147483649", // i32::MIN - 1 + "99999999999", // way too large + ] { + let mut s = input; + assert!( + dec_int::(&mut s).is_err(), + "{input} should overflow" + ); + } + } +} diff --git a/tests/date.rs b/tests/date.rs index 0f12cc0..c57f782 100644 --- a/tests/date.rs +++ b/tests/date.rs @@ -112,3 +112,176 @@ fn test_tz_prefix_with_base_date(#[case] input: &str, #[case] expected: &str) { .unwrap(); check_relative(base, input, expected); } + +// Test leap year overflow: Feb 29 + years → non-leap year should overflow to March 1 +// This matches GNU date behavior +#[rstest] +#[case::feb29_1996_plus_1year("1996-02-29 00:00:00", "1 year", "1997-03-01 00:00:00+00:00")] +#[case::feb29_2020_plus_1year("2020-02-29 00:00:00", "1 year", "2021-03-01 00:00:00+00:00")] +#[case::feb29_2000_plus_1year("2000-02-29 00:00:00", "1 year", "2001-03-01 00:00:00+00:00")] +// Edge case: 0 years should return the same date +#[case::zero_years("2024-01-15 12:30:45", "0 years", "2024-01-15 12:30:45+00:00")] +#[case::zero_years_feb29("2020-02-29 00:00:00", "0 years", "2020-02-29 00:00:00+00:00")] +fn test_leap_year_overflow(#[case] base: &str, #[case] input: &str, #[case] expected: &str) { + let now = base + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + check_relative(now, input, expected); +} + +// Test month arithmetic with day overflow +// Matches GNU date behavior: when adding months causes day clamping, +// overflow to next month (e.g., Jan 31 + 1 month = March 2/3, not Feb 28/29) +#[rstest] +#[case::jan31_plus_1month_leap("2024-01-31 00:00:00", "1 month", "2024-03-02 00:00:00+00:00")] +#[case::jan31_plus_1month_nonleap("2023-01-31 00:00:00", "1 month", "2023-03-03 00:00:00+00:00")] +#[case::mar31_plus_1month("2024-03-31 00:00:00", "1 month", "2024-05-01 00:00:00+00:00")] +#[case::may31_plus_1month("2024-05-31 00:00:00", "1 month", "2024-07-01 00:00:00+00:00")] +#[case::rel_2b("1997-01-19 08:17:48", "7 months ago", "1996-06-19 08:17:48+00:00")] +// Edge case: 0 months should return the same date +#[case::zero_months("2024-01-31 12:30:45", "0 months", "2024-01-31 12:30:45+00:00")] +#[case::zero_months_feb29("2020-02-29 00:00:00", "0 months", "2020-02-29 00:00:00+00:00")] +fn test_month_overflow(#[case] base: &str, #[case] input: &str, #[case] expected: &str) { + let now = base + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + check_relative(now, input, expected); +} + +// Test negative year operations with leap year edge cases +#[rstest] +#[case::feb29_minus_1year("2020-02-29 00:00:00", "1 year ago", "2019-03-01 00:00:00+00:00")] +#[case::feb29_minus_4years("2020-02-29 00:00:00", "4 years ago", "2016-02-29 00:00:00+00:00")] +#[case::march1_minus_1year("2021-03-01 00:00:00", "1 year ago", "2020-03-01 00:00:00+00:00")] +fn test_negative_year_operations(#[case] base: &str, #[case] input: &str, #[case] expected: &str) { + let now = base + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + check_relative(now, input, expected); +} + +// Test negative month operations with day overflow +#[rstest] +#[case::march31_minus_1month("2024-03-31 00:00:00", "1 month ago", "2024-03-02 00:00:00+00:00")] +#[case::march31_minus_1month_nonleap( + "2023-03-31 00:00:00", + "1 month ago", + "2023-03-03 00:00:00+00:00" +)] +#[case::may31_minus_1month("2024-05-31 00:00:00", "1 month ago", "2024-05-01 00:00:00+00:00")] +#[case::jan31_minus_1month("2024-01-31 00:00:00", "1 month ago", "2023-12-31 00:00:00+00:00")] +fn test_negative_month_operations(#[case] base: &str, #[case] input: &str, #[case] expected: &str) { + let now = base + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + check_relative(now, input, expected); +} + +// Test chained operations (multiple relative adjustments in one parse) +// These ensure that year and month overflow logic works correctly when combined +#[rstest] +// Feb 29, 2020 + 1 year = March 1, 2021; + 1 month = April 1, 2021 +#[case::feb29_plus_year_plus_month( + "2020-02-29 00:00:00", + "1 year 1 month", + "2021-04-01 00:00:00+00:00" +)] +// Jan 31, 2024 + 1 month = March 2, 2024; + 1 year = March 2, 2025 +#[case::jan31_plus_month_plus_year( + "2024-01-31 00:00:00", + "1 month 1 year", + "2025-03-02 00:00:00+00:00" +)] +// Jan 31 + 2 months + 1 day +#[case::jan31_plus_2months_1day( + "2024-01-31 00:00:00", + "2 months 1 day", + "2024-04-01 00:00:00+00:00" +)] +// Feb 29 - 1 year + 1 month (March 1, 2019 + 1 month = April 1, 2019) +#[case::feb29_minus_year_plus_month( + "2020-02-29 00:00:00", + "1 year ago 1 month", + "2019-04-01 00:00:00+00:00" +)] +// Multiple operations with days +#[case::complex_chain( + "2024-01-31 12:30:45", + "1 year 2 months 3 days 4 hours", + "2025-04-03 16:30:45+00:00" +)] +fn test_chained_operations(#[case] base: &str, #[case] input: &str, #[case] expected: &str) { + let now = base + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + check_relative(now, input, expected); +} + +// Test multiple month additions +// Verifies correct handling when adding multiple months at once +#[rstest] +// Jan 31 + 2 months: Jan 31 -> March 31 (no clamping, month has 31 days) +#[case::jan31_plus_2months("2024-01-31 00:00:00", "2 months", "2024-03-31 00:00:00+00:00")] +// Jan 31 + 3 months: Jan 31 -> April 30 (clamps), overflow to May 1 +#[case::jan31_plus_3months("2024-01-31 00:00:00", "3 months", "2024-05-01 00:00:00+00:00")] +// Jan 31 + 6 months: Jan 31 -> July 31 (no overflow) +#[case::jan31_plus_6months("2024-01-31 00:00:00", "6 months", "2024-07-31 00:00:00+00:00")] +// Jan 31 + 7 months: Jan 31 -> Aug 31 (no overflow) +#[case::jan31_plus_7months("2024-01-31 00:00:00", "7 months", "2024-08-31 00:00:00+00:00")] +// Aug 31 + 6 months: Aug 31 -> Feb 28 (2025 non-leap), overflow to March 3 +#[case::aug31_plus_6months("2024-08-31 00:00:00", "6 months", "2025-03-03 00:00:00+00:00")] +// May 31 - 3 months: May 31 -> Feb 29 (2024 leap), overflow to March 2 +#[case::may31_minus_3months_leap( + "2024-05-31 00:00:00", + "3 months ago", + "2024-03-02 00:00:00+00:00" +)] +// Oct 31 - 8 months: Oct 31 -> Feb 29 (2024 leap), overflow to March 2 +#[case::oct31_minus_8months_leap( + "2024-10-31 00:00:00", + "8 months ago", + "2024-03-02 00:00:00+00:00" +)] +fn test_multiple_month_skip(#[case] base: &str, #[case] input: &str, #[case] expected: &str) { + let now = base + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + check_relative(now, input, expected); +} + +// Test embedded timezone handling (cross-TZ-mishandled) +// When TZ="..." is specified in input with a base date, apply the timezone to the base +// https://bugs.debian.org/851934#10 +// +// NOTE: These tests were added without implementation changes. +// The timezone handling was already working correctly from previous commits. +// These tests document and verify the expected behavior for this edge case. +#[rstest] +#[case::utc_explicit(r#"TZ="UTC0" 1970-01-01 00:00"#, "1970-01-01 00:00:00+00:00")] +#[case::with_time(r#"TZ="EST5" 1970-01-01 12:30:45"#, "1970-01-01 12:30:45-05:00")] +#[case::iana_timezone( + r#"TZ="America/New_York" 1970-01-01 00:00"#, + "1970-01-01 00:00:00-05:00" +)] +// Bug #851934: timezone conversion case +// Parse date in Australia/Perth (AWST, UTC+8) and output should reflect that timezone +// Input: 2016-08-15 07:00 in Australia/Perth -> expected: 2016-08-15 07:00:00+08:00 +#[case::perth_to_london( + r#"TZ="Australia/Perth" 2016-08-15 07:00"#, + "2016-08-15 07:00:00+08:00" +)] +fn test_embedded_timezone(#[case] input: &str, #[case] expected: &str) { + check_absolute(input, expected); +}