Skip to content

Commit aede53f

Browse files
committed
refine timezone handling
1 parent 5889dae commit aede53f

File tree

3 files changed

+167
-69
lines changed

3 files changed

+167
-69
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
build/*
2+
.cache/*

include/mgutility/chrono/parse.hpp

Lines changed: 136 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,18 @@
3030
#include "mgutility/std/charconv.hpp"
3131
#include "mgutility/std/string_view.hpp"
3232

33-
#include <array>
3433
#include <cctype>
3534
#include <chrono>
35+
#include <cstddef>
36+
#include <cstdint>
3637
#include <stdexcept>
3738
#include <type_traits>
3839

39-
//NOLINTBEGIN(modernize-concat-nested-namespaces)
40+
// NOLINTBEGIN(modernize-concat-nested-namespaces)
4041
namespace mgutility {
4142
namespace chrono {
4243
namespace detail {
43-
//NOLINTEND(modernize-concat-nested-namespaces)
44+
// NOLINTEND(modernize-concat-nested-namespaces)
4445

4546
/**
4647
* @brief Extended tm structure with milliseconds.
@@ -71,6 +72,16 @@ MGUTILITY_CNSTXPR auto parse_integer(T &result, mgutility::string_view str,
7172
return error.ec;
7273
}
7374

75+
/**
76+
* @brief Returns the absolute value of an integer.
77+
*
78+
* @param value
79+
* @return constexpr int32_t
80+
*/
81+
constexpr int32_t abs(int32_t value) noexcept {
82+
return value >= 0 ? value : -value;
83+
}
84+
7485
/**
7586
* @brief Checks if a value is within a given range.
7687
*
@@ -79,8 +90,9 @@ MGUTILITY_CNSTXPR auto parse_integer(T &result, mgutility::string_view str,
7990
* @param max The maximum acceptable value.
8091
* @throws std::out_of_range if the value is out of range.
8192
*/
82-
template <typename T>
83-
MGUTILITY_CNSTXPR auto check_range(const T& value, const T& min, const T& max) -> std::errc {
93+
template <typename T>
94+
MGUTILITY_CNSTXPR auto check_range(const T &value, const T &min, const T &max)
95+
-> std::errc {
8496
if (value < min || value > max) {
8597
return std::errc::result_out_of_range;
8698
}
@@ -97,39 +109,62 @@ auto MGUTILITY_CNSTXPR is_leap_year(int32_t year) -> bool {
97109
return (year % 4) == 0 && ((year % 100) != 0 || (year % 400) == 0);
98110
}
99111

112+
/**
113+
* @brief Returns the number of days in a given month of a given year.
114+
*
115+
* @param year
116+
* @param month
117+
* @return int32_t
118+
*/
119+
// NOLINTNEXTLINE
120+
auto MGUTILITY_CNSTXPR days_in_month(int32_t year, int32_t month) -> int32_t {
121+
// NOLINTNEXTLINE
122+
constexpr int days_per_month[] = {31, 28, 31, 30, 31, 30,
123+
31, 31, 30, 31, 30, 31};
124+
if (month < 0 || month > 11) {
125+
return 0; // Invalid month
126+
}
127+
if (month == 1) { // February
128+
return is_leap_year(year) ? 29 : 28;
129+
}
130+
// NOLINTNEXTLINE
131+
return days_per_month[month];
132+
}
133+
100134
/**
101135
* @brief Converts a tm structure to a time_t value.
102136
*
103137
* @param tm The tm structure to convert.
104138
* @return std::time_t The corresponding time_t value.
105139
* @throws std::out_of_range if any tm value is out of valid range.
106140
*/
107-
MGUTILITY_CNSTXPR auto mktime(std::time_t &result, std::tm &time_struct) -> std::errc {
108-
MGUTILITY_CNSTXPR std::array<std::array<uint32_t, 12>, 2> num_of_days{
109-
{{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30,
110-
31}, // 365 days in a common year
111-
{31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30,
112-
31}}}; // 366 days in a leap year
113-
141+
MGUTILITY_CNSTXPR auto mktime(std::time_t &result, std::tm &time_struct)
142+
-> std::errc {
114143
result = 0;
115144

116145
// Check for out of range values in tm structure
117-
if (time_struct.tm_mon > 12 || time_struct.tm_mon < 0 || time_struct.tm_mday > 31 || time_struct.tm_min > 60 ||
146+
if (time_struct.tm_mon > 12 || time_struct.tm_mon < 0 ||
147+
time_struct.tm_mday > 31 || time_struct.tm_min > 60 ||
118148
time_struct.tm_sec > 60 || time_struct.tm_hour > 24) {
119149
return std::errc::result_out_of_range;
120150
}
121151

122152
time_struct.tm_year += 1900;
123153

154+
if (days_in_month(time_struct.tm_year, time_struct.tm_mon) <
155+
time_struct.tm_mday) {
156+
return std::errc::result_out_of_range;
157+
}
158+
124159
// Calculate the number of days since 1970
125160
for (auto i{1970}; i < time_struct.tm_year; ++i) {
126161
result += is_leap_year(i) ? 366 : 365;
127162
}
128163

129164
// Add the days for the current year
130165
for (auto i{0}; i < time_struct.tm_mon; ++i) {
131-
//NOLINTNEXTLINE
132-
result += num_of_days[is_leap_year(time_struct.tm_year)][static_cast<std::size_t>(i)];
166+
// NOLINTNEXTLINE
167+
result += days_in_month(time_struct.tm_year, i);
133168
}
134169

135170
result += time_struct.tm_mday - 1; // nth day since 1970
@@ -149,54 +184,82 @@ MGUTILITY_CNSTXPR auto mktime(std::time_t &result, std::tm &time_struct) -> std:
149184
* @param tm The tm structure to adjust.
150185
* @param offset The timezone offset in hours and minutes.
151186
*/
152-
MGUTILITY_CNSTXPR auto handle_timezone(tm &time_struct, int32_t offset) -> void {
153-
const auto minute = offset % 100;
154-
const auto hour = offset / 100;
155-
156-
if (offset < 0) {
157-
if (time_struct.tm_min + minute < 0) {
158-
time_struct.tm_min += 60 - minute;
159-
time_struct.tm_hour -= 1;
160-
if (time_struct.tm_hour < 0) {
161-
time_struct.tm_hour += 24;
162-
time_struct.tm_mday -= 1;
163-
}
164-
} else {
165-
time_struct.tm_min += minute;
166-
}
187+
MGUTILITY_CNSTXPR auto handle_timezone(tm &time_struct, int32_t offset)
188+
-> std::errc {
189+
// Validate offset: HHMM format, minutes 0-59, hours 0-23
190+
const int32_t abs_offset = abs(offset);
191+
const int32_t minutes = abs_offset % 100;
192+
const int32_t hours = abs_offset / 100;
193+
if (minutes > 59 || hours > 23) {
194+
return std::errc::invalid_argument;
195+
}
167196

168-
if (time_struct.tm_hour + hour < 0) {
169-
time_struct.tm_hour += 24 + hour;
170-
time_struct.tm_mday -= 1;
171-
} else {
172-
time_struct.tm_hour += hour;
173-
}
174-
} else {
175-
if (time_struct.tm_min + minute >= 60) {
176-
time_struct.tm_min -= 60 - minute;
177-
time_struct.tm_hour += 1;
178-
if (time_struct.tm_hour >= 24) {
179-
time_struct.tm_hour -= 24;
180-
time_struct.tm_mday += 1;
197+
// Validate input tm structure (basic checks)
198+
if (time_struct.tm_mon < 0 || time_struct.tm_mon > 11 ||
199+
time_struct.tm_mday < 1 || time_struct.tm_year < 0) {
200+
return std::errc::invalid_argument;
201+
}
202+
203+
// Apply offset (positive or negative)
204+
const int32_t total_minutes =
205+
time_struct.tm_min + (offset >= 0 ? minutes : -minutes);
206+
const int32_t total_hours =
207+
time_struct.tm_hour + (offset >= 0 ? hours : -hours);
208+
209+
// Normalize minutes (-59 to 119 -> 0-59 with hour carry)
210+
time_struct.tm_min = total_minutes % 60;
211+
int minute_carry = total_minutes / 60;
212+
if (total_minutes < 0 && total_minutes % 60 != 0) {
213+
minute_carry -= 1;
214+
time_struct.tm_min += 60;
215+
}
216+
217+
// Normalize hours (-23 to 47 -> 0-23 with day carry)
218+
time_struct.tm_hour = (total_hours + minute_carry) % 24;
219+
int day_carry = (total_hours + minute_carry) / 24;
220+
if (total_hours + minute_carry < 0 &&
221+
(total_hours + minute_carry) % 24 != 0) {
222+
day_carry -= 1;
223+
time_struct.tm_hour += 24;
224+
}
225+
226+
// Normalize days, months, and years
227+
int days = time_struct.tm_mday + day_carry;
228+
int months = time_struct.tm_mon;
229+
int years = time_struct.tm_year;
230+
231+
// Handle negative days
232+
while (days <= 0) {
233+
months -= 1;
234+
if (months < 0) {
235+
months += 12;
236+
years -= 1;
237+
if (years < 0) {
238+
return std::errc::result_out_of_range; // Year underflow
181239
}
182-
} else {
183-
time_struct.tm_min += minute;
184240
}
241+
days += days_in_month(years, months);
242+
}
185243

186-
if (time_struct.tm_hour + hour >= 24) {
187-
time_struct.tm_hour += hour - 24;
188-
time_struct.tm_mday += 1;
189-
if (time_struct.tm_mon == 11 && time_struct.tm_mday > 31) {
190-
time_struct.tm_mday = 1;
191-
time_struct.tm_mon = 0;
192-
} else if (time_struct.tm_mday > 30) {
193-
time_struct.tm_mday = 1;
194-
time_struct.tm_mon += 1;
244+
// Handle day overflow
245+
while (days > days_in_month(years, months)) {
246+
days -= days_in_month(years, months);
247+
months += 1;
248+
if (months > 11) {
249+
months -= 12;
250+
years += 1;
251+
if (years > 9999 - 1900) { // Avoid overflow (arbitrary limit)
252+
return std::errc::result_out_of_range;
195253
}
196-
} else {
197-
time_struct.tm_hour += hour;
198254
}
199255
}
256+
257+
// Update tm structure
258+
time_struct.tm_mday = days;
259+
time_struct.tm_mon = months;
260+
time_struct.tm_year = years;
261+
262+
return std::errc{};
200263
}
201264

202265
// Free parsing functions
@@ -220,7 +283,8 @@ MGUTILITY_CNSTXPR auto parse_day(detail::tm &result, string_view date_str,
220283
if (error != std::errc{}) {
221284
return error;
222285
}
223-
error = check_range(result.tm_mday, 1, 31);
286+
error = check_range(result.tm_mday, 1,
287+
days_in_month(result.tm_year, result.tm_mon));
224288
return error;
225289
}
226290

@@ -264,15 +328,18 @@ MGUTILITY_CNSTXPR auto parse_fraction(detail::tm &result, string_view date_str,
264328
return error;
265329
}
266330

267-
MGUTILITY_CNSTXPR auto parse_timezone_offset(detail::tm &result, string_view date_str,
268-
uint32_t &next) -> std::errc {
331+
MGUTILITY_CNSTXPR auto parse_timezone_offset(detail::tm &result,
332+
string_view date_str,
333+
uint32_t &next) -> std::errc {
269334
std::errc error{};
335+
// NOLINTNEXTLINE [bugprone-inc-dec-in-conditions]
270336
if (--next < date_str.size() && date_str[next] == 'Z') {
271-
handle_timezone(result, 0);
337+
error = handle_timezone(result, 0);
272338
return error;
273339
}
274340

275-
if (next >= date_str.size() || (date_str[next] != '+' && date_str[next] != '-')) {
341+
if (next >= date_str.size() ||
342+
(date_str[next] != '+' && date_str[next] != '-')) {
276343
return std::errc::invalid_argument;
277344
}
278345

@@ -294,13 +361,13 @@ MGUTILITY_CNSTXPR auto parse_timezone_offset(detail::tm &result, string_view dat
294361
return error;
295362
}
296363

297-
const int32_t offset = hour * 100 + minute;
364+
const int32_t offset = (hour * 100) + minute;
298365
error = check_range(offset, 0, 1200);
299366
if (error != std::errc{}) {
300367
return error;
301368
}
302369

303-
handle_timezone(result, sign == '+' ? -offset : offset);
370+
error = handle_timezone(result, sign == '+' ? -offset : offset);
304371
return error;
305372
}
306373

@@ -345,11 +412,11 @@ MGUTILITY_CNSTXPR auto get_time(detail::tm &result, string_view format,
345412
error = parse_day(result, date_str, next);
346413
break;
347414
case 'F': {
348-
//NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
415+
// NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
349416
error = parse_year(result, date_str, next);
350-
//NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
417+
// NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
351418
error = parse_month(result, date_str, next);
352-
//NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
419+
// NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
353420
error = parse_day(result, date_str, next);
354421
} break;
355422
case 'H':
@@ -362,11 +429,11 @@ MGUTILITY_CNSTXPR auto get_time(detail::tm &result, string_view format,
362429
error = parse_second(result, date_str, next);
363430
break;
364431
case 'T': {
365-
//NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
432+
// NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
366433
error = parse_hour(result, date_str, next);
367-
//NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
434+
// NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
368435
error = parse_minute(result, date_str, next);
369-
//NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
436+
// NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
370437
error = parse_second(result, date_str, next);
371438
} break;
372439
case 'f':

tests/test_chrono_parse.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,34 @@ TEST_CASE("testing the iso8601 parsing") {
2424
REQUIRE_THROWS(mgutility::chrono::parse("%FT%T}", "2023-04-30T16:22:18"));
2525
REQUIRE_THROWS(mgutility::chrono::parse("{%F %T}", "2023-04-30T16:22:18"));
2626
REQUIRE_THROWS(mgutility::chrono::parse("{:%F %T}", "2023-04-30T16:22:18"));
27+
28+
29+
// Leap year: Feb 29 should work in a leap year
30+
CHECK(to_milliseconds(mgutility::chrono::parse("{:%FT%T}", "2020-02-29T12:00:00")) == milliseconds(1582977600000));
31+
32+
// Non-leap year: Feb 29 should not exist
33+
REQUIRE_THROWS(mgutility::chrono::parse("{:%FT%T}", "2021-02-29T12:00:00"));
34+
35+
// Leap year, with timezone offset: UTC should equal local time with -0000
36+
CHECK(to_milliseconds(mgutility::chrono::parse("{:%FT%T%z}", "2016-02-29T05:00:00-0000")) == milliseconds(1456722000000));
37+
38+
// February 28 + 1 day (should rollover to Mar 1 on non-leap year)
39+
CHECK(to_milliseconds(mgutility::chrono::parse("{:%FT%T}", "2021-03-01T00:00:00")) == milliseconds(1614556800000));
40+
41+
// Year rollover: Dec 31 to Jan 1
42+
CHECK(to_milliseconds(mgutility::chrono::parse("{:%FT%T}", "2022-12-31T23:59:59")) == milliseconds(1672531199000));
43+
CHECK(to_milliseconds(mgutility::chrono::parse("{:%FT%T}", "2023-01-01T00:00:00")) == milliseconds(1672531200000));
44+
45+
// Month boundary rollover: Apr 30 + 1 day == May 1
46+
CHECK(to_milliseconds(mgutility::chrono::parse("{:%FT%T}", "2023-05-01T00:00:00")) == milliseconds(1682899200000));
47+
48+
// Invalid date: April 31
49+
REQUIRE_THROWS(mgutility::chrono::parse("{:%FT%T}", "2023-04-31T12:00:00"));
50+
51+
// Edge timezone: Feb 29 with offset
52+
CHECK(to_milliseconds(mgutility::chrono::parse("{:%FT%T%z}", "2016-02-29T23:59:59+0000")) == milliseconds(1456790399000));
53+
CHECK(to_milliseconds(mgutility::chrono::parse("{:%FT%T%z}", "2016-02-29T12:00:00-1200")) == milliseconds(1456790400000));
54+
55+
// Invalid date/time format
56+
REQUIRE_THROWS(mgutility::chrono::parse("{:%FT%T}", "not-a-date"));
2757
}

0 commit comments

Comments
 (0)