Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/items/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,146 @@ impl TryFrom<Vec<Item>> 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());
}
}
61 changes: 61 additions & 0 deletions src/items/primitive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ContextError>(&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::<ContextError>(&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::<ContextError>(&mut s).is_err(),
"{input} should overflow"
);
}
}
}
173 changes: 173 additions & 0 deletions tests/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<DateTime>()
.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::<DateTime>()
.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::<DateTime>()
.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::<DateTime>()
.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::<DateTime>()
.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::<DateTime>()
.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);
}