You've already forked parse_datetime
mirror of
https://github.com/uutils/parse_datetime.git
synced 2026-06-10 16:13:15 -07:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f68a01699e | |||
| 73b4b0a50c | |||
| 002ca41008 | |||
| 818cfa4ad0 | |||
| 5cf8a6fbdd | |||
| 9688db08a8 | |||
| f10a950e23 | |||
| 14bbd4e848 | |||
| dbfdea454f | |||
| 7e528ad609 | |||
| 6306843888 | |||
| d5c441bd3f | |||
| 62cb7d9687 | |||
| 642bbbf214 | |||
| 6396c07d72 | |||
| 377ba0e040 | |||
| ffb1ba35c5 | |||
| eeffa242a1 | |||
| 3a1edfb177 | |||
| 866b6b446f | |||
| 13e038a503 | |||
| 7030df6cf0 | |||
| cfbb973215 | |||
| 88a4a718b0 |
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user