24 Commits

Author SHA1 Message Date
Sylvestre Ledru f68a01699e prepare release 0.14.0 2026-02-14 18:55:26 +01:00
Sylvestre Ledru 73b4b0a50c improve the code coverage 2026-02-14 18:19:31 +01:00
Sylvestre Ledru 002ca41008 tests: add edge case tests for 0 years and 0 months
Add tests to verify that adding 0 years or 0 months returns the
original date unchanged, including edge cases with Feb 29.
2026-02-14 18:19:31 +01:00
Sylvestre Ledru 818cfa4ad0 date: add more tests for cross-TZ-mishandled test - embedded timezone
NOTE: This commit only adds tests as the implementation was already
working correctly. These tests document and verify the expected
behavior for the embedded timezone edge case referenced in Debian
bug #851934.
2026-02-14 18:19:31 +01:00
Sylvestre Ledru 5cf8a6fbdd date: add more tests leap year overflow 2026-02-14 18:19:31 +01:00
Sylvestre Ledru 9688db08a8 Fix the coverage (#260) 2026-02-14 17:53:31 +01:00
cerdelen f10a950e23 parse_datetime: better variable name 2026-02-14 11:21:39 +01:00
cerdelen 14bbd4e848 parse_datetime: relative Year overflow for leap years 2026-02-14 11:21:39 +01:00
cerdelen dbfdea454f parse_datetime: Remove unnessecary import 2026-02-14 11:21:39 +01:00
cerdelen 7e528ad609 parse_datetime: Implement GNU style overflow/underflow
In GNU if using date with relative Months with the -d flag and the target Month does not have the source day (for example 31) GNU overflows the diff in days.
2026-02-14 11:21:39 +01:00
cerdelen 6306843888 parse_datetime: add regression tests 2026-02-14 11:21:39 +01:00
cerdelen d5c441bd3f parse_datetime: fix overflow issue 2026-02-14 11:21:39 +01:00
cerdelen 62cb7d9687 parse_datetime: Fix relative date adjustments for months using the actual days 2026-02-14 11:21:39 +01:00
Chris Dryden 642bbbf214 Merge pull request #255 from ChrisDryden/fix-tz-prefix-with-base-date
fix: TZ prefix should override base date timezone
2026-02-12 09:29:36 +01:00
renovate[bot] 6396c07d72 chore(deps): update rust crate jiff to v0.2.20 2026-02-12 07:06:17 +01:00
renovate[bot] 377ba0e040 chore(deps): update rust crate libfuzzer-sys to v0.4.12 2026-02-11 07:03:20 +01:00
Daniel Hofstetter ffb1ba35c5 Merge pull request #256 from uutils/renovate/jiff-0.x-lockfile
chore(deps): update rust crate jiff to v0.2.19
2026-02-06 07:52:19 +01:00
renovate[bot] eeffa242a1 chore(deps): update rust crate jiff to v0.2.19 2026-02-06 00:33:10 +00:00
Daniel Hofstetter 3a1edfb177 Merge pull request #251 from uutils/renovate/jiff-0.x-lockfile
chore(deps): update rust crate jiff to v0.2.18
2026-01-06 07:09:04 +01:00
renovate[bot] 866b6b446f chore(deps): update rust crate jiff to v0.2.18 2026-01-06 05:15:24 +00:00
Daniel Hofstetter 13e038a503 Merge pull request #250 from uutils/renovate/jiff-0.x-lockfile
chore(deps): update rust crate jiff to v0.2.17
2025-12-25 07:09:32 +01:00
renovate[bot] 7030df6cf0 chore(deps): update rust crate jiff to v0.2.17 2025-12-24 21:35:09 +00:00
Daniel Hofstetter cfbb973215 Merge pull request #249 from uutils/renovate/winnow-0.x-lockfile
chore(deps): update rust crate winnow to v0.7.14
2025-11-27 06:53:55 +01:00
renovate[bot] 88a4a718b0 chore(deps): update rust crate winnow to v0.7.14 2025-11-26 19:44:53 +00:00
8 changed files with 552 additions and 25 deletions
+4 -2
View File
@@ -134,17 +134,19 @@ jobs:
# display coverage files
grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type files --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" | sort --unique
# generate coverage report
grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type lcov --output-path "${COVERAGE_REPORT_FILE}" --branch --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()"
grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type lcov --output-path "${COVERAGE_REPORT_FILE}" --branch --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" --source-dir .
echo "report=${COVERAGE_REPORT_FILE}" >> $GITHUB_OUTPUT
- name: Upload coverage results (to Codecov.io)
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ steps.coverage.outputs.report }}
## flags: IntegrationTests, UnitTests, ${{ steps.vars.outputs.CODECOV_FLAGS }}
flags: ${{ steps.vars.outputs.CODECOV_FLAGS }}
name: codecov-umbrella
fail_ci_if_error: false
verbose: true
directory: ./
root_dir: ./
fuzz:
name: Run the fuzzers
Generated
+7 -7
View File
@@ -96,9 +96,9 @@ dependencies = [
[[package]]
name = "jiff"
version = "0.2.16"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35"
checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543"
dependencies = [
"jiff-static",
"jiff-tzdb-platform",
@@ -111,9 +111,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.16"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69"
checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5"
dependencies = [
"proc-macro2",
"quote",
@@ -158,7 +158,7 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.13.3"
version = "0.14.0"
dependencies = [
"jiff",
"num-traits",
@@ -389,9 +389,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "0.7.13"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a DateTime"
version = "0.13.3"
version = "0.14.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
+3 -3
View File
@@ -117,9 +117,9 @@ checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "libfuzzer-sys"
version = "0.4.10"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
dependencies = [
"arbitrary",
"cc",
@@ -148,7 +148,7 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.13.2"
version = "0.13.3"
dependencies = [
"jiff",
"num-traits",
+169 -12
View File
@@ -1,7 +1,7 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use jiff::{civil, Span, Zoned};
use jiff::{civil, Span, ToSpan, Zoned};
use super::{date, epoch, error, offset, relative, time, weekday, year, Item};
@@ -187,9 +187,13 @@ impl DateTimeBuilder {
/// - e. Apply final fixed offset if present.
pub(super) fn build(self) -> Result<Zoned, error::Error> {
// 1. Choose the base instant.
let base = match (self.base, &self.timezone) {
(Some(b), _) => b,
(None, Some(tz)) => jiff::Timestamp::now().to_zoned(tz.clone()),
// If a TZ="..." prefix was parsed, it should override the base's timezone
// while keeping the base's timestamp for relative date calculations.
let has_timezone = self.timezone.is_some();
let base = match (self.base, self.timezone) {
(Some(b), Some(tz)) => b.timestamp().to_zoned(tz),
(Some(b), None) => b,
(None, Some(tz)) => jiff::Timestamp::now().to_zoned(tz),
(None, None) => Zoned::now(),
};
@@ -204,7 +208,7 @@ impl DateTimeBuilder {
|| self.time.is_some()
|| self.weekday.is_some()
|| self.offset.is_some()
|| self.timezone.is_some();
|| has_timezone;
let mut dt = if need_midnight {
base.with().time(civil::time(0, 0, 0, 0)).build()?
@@ -276,13 +280,23 @@ impl DateTimeBuilder {
// 4d. Apply relative adjustments.
for rel in self.relative {
dt = dt.checked_add::<Span>(if let relative::Relative::Months(x) = rel {
// *NOTE* This is done in this way to conform to GNU behavior.
let days = dt.date().last_of_month().day() as i32;
Span::new().try_days(days.checked_mul(x).ok_or("multiplication overflow")?)?
} else {
rel.try_into()?
})?;
dt = match rel {
relative::Relative::Years(_) | relative::Relative::Months(_) => {
// GNU way of calculating relative months and years
// GNU changes the month and then checks if the target month has
// this day. If this day does not exist in the target month it overflows
// the difference
let original_day_of_month = dt.day();
dt = dt.checked_add::<Span>(rel.try_into()?)?;
if original_day_of_month != dt.day() {
dt = dt.checked_add(
(original_day_of_month.checked_sub(dt.day()).unwrap_or(0)).days(),
)?;
}
dt
}
_ => dt.checked_add::<Span>(rel.try_into()?)?,
}
}
// 4e. Apply final fixed offset.
@@ -319,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
View File
@@ -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"
);
}
}
}
+190
View File
@@ -95,3 +95,193 @@ fn test_date_omitting_year(#[case] input: &str, #[case] year: u32, #[case] expec
.unwrap();
check_relative(now, input, expected);
}
#[rstest]
#[case::tz_prefix_est5("TZ=\"EST5\" 1970-01-01 00:00", "1970-01-01 00:00:00-05:00")]
#[case::tz_prefix_pst8("TZ=\"PST8\" 1970-01-01 00:00", "1970-01-01 00:00:00-08:00")]
#[case::tz_prefix_utc("TZ=\"UTC\" 1970-01-01 12:00", "1970-01-01 12:00:00+00:00")]
#[case::tz_prefix_europe_paris(
r#"TZ="Europe/Paris" 2025-01-02 03:04:05"#,
"2025-01-02 03:04:05+01:00"
)]
fn test_tz_prefix_with_base_date(#[case] input: &str, #[case] expected: &str) {
let base = "2020-06-15 12:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.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);
}
+117
View File
@@ -129,6 +129,123 @@ fn test_time_invalid(#[case] input: &str) {
);
}
#[rstest]
#[case::months_ago_0("2026-01-12 0 Months ago", "2026-01-12")]
#[case::months_ago_negative_1("2026-01-12 -1 Months ago", "2026-02-12")]
#[case::months_ago_1("2026-01-12 1 Months ago", "2025-12-12")]
#[case::months_ago_2("2026-01-12 2 Months ago", "2025-11-12")]
#[case::months_ago_3("2026-01-12 3 Months ago", "2025-10-12")]
#[case::months_ago_4("2026-01-12 4 Months ago", "2025-09-12")]
#[case::months_ago_5("2026-01-12 5 Months ago", "2025-08-12")]
#[case::months_ago_6("2026-01-12 6 Months ago", "2025-07-12")]
#[case::months_ago_7("2026-01-12 7 Months ago", "2025-06-12")]
#[case::months_ago_8("2026-01-12 8 Months ago", "2025-05-12")]
#[case::months_ago_9("2026-01-12 9 Months ago", "2025-04-12")]
#[case::months_ago_10("2026-01-12 10 Months ago", "2025-03-12")]
#[case::months_ago_11("2026-01-12 11 Months ago", "2025-02-12")]
#[case::months_ago_12("2026-01-12 12 Months ago", "2025-01-12")]
#[case::months_ago_24("2026-01-12 24 Months ago", "2024-01-12")]
#[case::months_ago_36("2026-01-12 36 Months ago", "2023-01-12")]
#[case::months_ago_120("2026-01-12 120 Months ago", "2016-01-12")]
#[case::months_ago_240("2026-01-12 240 Months ago", "2006-01-12")]
fn test_time_months_ago(#[case] input: &str, #[case] expected: &str) {
let now = "2026-01-12"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
check_time(input, expected, "%Y-%m-%d", Some(now));
}
#[rstest]
#[case::from_28_feb_in_1_month("2026-02-28 1 months", "2026-03-28")]
#[case::from_28_feb_1_month_ago("2026-02-28 1 months ago", "2026-01-28")]
#[case::from_31_jan_in_1_month("2026-01-31 1 months", "2026-03-03")]
#[case::from_31_march_1_month_ago("2026-03-31 1 months ago", "2026-03-03")]
#[case::from_15_march_1_month_ago("2026-03-15 1 months ago", "2026-02-15")]
#[case::from_15_jan_in_1_month("2026-01-15 1 months", "2026-02-15")]
fn test_relative_month_time_non_leap_year(#[case] input: &str, #[case] expected: &str) {
let now = "2026-01-12"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
check_time(input, expected, "%Y-%m-%d", Some(now));
}
#[rstest]
#[case::from_31_dec_1_month_ago("2026-12-31 1 months ago", "2026-12-01")]
#[case::from_31_dec_3_month_ago("2026-12-31 3 months ago", "2026-10-01")]
#[case::from_31_mar_in_1_month("2026-03-31 1 months", "2026-05-01")]
#[case::from_31_mar_in_3_month("2026-03-31 3 months", "2026-07-01")]
fn test_relative_month_time_dest_month_does_not_have_the_day(
#[case] input: &str,
#[case] expected: &str,
) {
let now = "2026-01-12"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
check_time(input, expected, "%Y-%m-%d", Some(now));
}
#[rstest]
#[case::from_29_feb_in_1_year("2024-02-29 1 year", "2025-03-01")]
#[case::from_29_feb_1_year_ago("2024-02-29 1 year ago", "2023-03-01")]
fn test_relative_year_time_leap_year(#[case] input: &str, #[case] expected: &str) {
let now = "2026-01-12"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
check_time(input, expected, "%Y-%m-%d", Some(now));
}
#[rstest]
#[case::from_29_feb_in_1_month("2024-02-29 1 months", "2024-03-29")]
#[case::from_29_feb_1_month_ago("2024-02-29 1 months ago", "2024-01-29")]
#[case::from_31_jan_in_1_month("2024-01-31 1 months", "2024-03-02")]
#[case::from_31_march_1_month_ago("2024-03-31 1 months ago", "2024-03-02")]
#[case::from_15_march_1_month_ago("2024-03-15 1 months ago", "2024-02-15")]
#[case::from_15_jan_in_1_month("2024-01-15 1 months", "2024-02-15")]
fn test_relative_month_time_leap_year(#[case] input: &str, #[case] expected: &str) {
let now = "2026-01-12"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
check_time(input, expected, "%Y-%m-%d", Some(now));
}
#[rstest]
#[case::months_in_0_months("2026-01-12 0 months", "2026-01-12")]
#[case::months_in_negative_1_months("2026-01-12 -1 months", "2025-12-12")]
#[case::months_in_1_months("2026-01-12 1 months", "2026-02-12")]
#[case::months_in_2_months("2026-01-12 2 months", "2026-03-12")]
#[case::months_in_3_months("2026-01-12 3 months", "2026-04-12")]
#[case::months_in_4_months("2026-01-12 4 months", "2026-05-12")]
#[case::months_in_5_months("2026-01-12 5 months", "2026-06-12")]
#[case::months_in_6_months("2026-01-12 6 months", "2026-07-12")]
#[case::months_in_7_months("2026-01-12 7 months", "2026-08-12")]
#[case::months_in_8_months("2026-01-12 8 months", "2026-09-12")]
#[case::months_in_9_months("2026-01-12 9 months", "2026-10-12")]
#[case::months_in_10_months("2026-01-12 10 months", "2026-11-12")]
#[case::months_in_11_months("2026-01-12 11 months", "2026-12-12")]
#[case::months_in_12_months("2026-01-12 12 months", "2027-01-12")]
#[case::months_in_24_months("2026-01-12 24 months", "2028-01-12")]
#[case::months_in_36_months("2026-01-12 36 months", "2029-01-12")]
#[case::months_in_120_months("2026-01-12 120 months", "2036-01-12")]
#[case::months_in_240_months("2026-01-12 240 months", "2046-01-12")]
fn test_time_in_months(#[case] input: &str, #[case] expected: &str) {
let now = "2026-01-12"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
check_time(input, expected, "%Y-%m-%d", Some(now));
}
#[rstest]
#[case::decimal_1_whole("1.123456789 seconds ago")]
#[case::decimal_2_whole("12.123456789 seconds ago")]