From 6af704511d0eddd4dbb0469d8dc5157c4fea5967 Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sat, 4 Apr 2026 13:12:10 +0100 Subject: [PATCH 1/9] time point parser --- include/rfl/parsing/Parser.hpp | 1 + include/rfl/parsing/Parser_time_point.hpp | 146 ++++++++++++++++++++++ tests/json/test_time_point.cpp | 79 ++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 include/rfl/parsing/Parser_time_point.hpp create mode 100644 tests/json/test_time_point.cpp diff --git a/include/rfl/parsing/Parser.hpp b/include/rfl/parsing/Parser.hpp index 81eeff30..5699132e 100644 --- a/include/rfl/parsing/Parser.hpp +++ b/include/rfl/parsing/Parser.hpp @@ -34,6 +34,7 @@ #include "Parser_span.hpp" #include "Parser_string_view.hpp" #include "Parser_tagged_union.hpp" +#include "Parser_time_point.hpp" #include "Parser_tuple.hpp" #include "Parser_unique_ptr.hpp" #include "Parser_variant.hpp" diff --git a/include/rfl/parsing/Parser_time_point.hpp b/include/rfl/parsing/Parser_time_point.hpp new file mode 100644 index 00000000..624992b5 --- /dev/null +++ b/include/rfl/parsing/Parser_time_point.hpp @@ -0,0 +1,146 @@ +#ifndef RFL_PARSING_PARSER_TIME_POINT_HPP_ +#define RFL_PARSING_PARSER_TIME_POINT_HPP_ + +#include +#include +#include +#include +#include + +#include "../Result.hpp" +#include "Parent.hpp" +#include "Parser_base.hpp" +#include "schema/Type.hpp" + +namespace rfl::parsing { + +template + requires AreReaderAndWriter> +struct Parser, ProcessorsType> { + public: + using InputVarType = typename R::InputVarType; + + using ParentType = Parent; + + using TimePointType = std::chrono::time_point; + + static Result read(const R& _r, + const InputVarType& _var) noexcept { + return Parser::read(_r, _var).and_then( + from_string); + } + + template + static void write(const W& _w, const TimePointType& _tp, const P& _parent) { + Parser::write(_w, to_string(_tp), + _parent); + } + + static schema::Type to_schema( + std::map* _definitions) { + return Parser::to_schema(_definitions); + } + + private: + static std::string to_string(const TimePointType& _tp) { + const auto sys_time = + std::chrono::time_point_cast(_tp); + const auto epoch = sys_time.time_since_epoch(); + const auto secs = std::chrono::duration_cast(epoch); + const auto usecs = + std::chrono::duration_cast(epoch - secs); + + auto t = static_cast(secs.count()); + std::tm tm{}; +#if defined(_MSC_VER) || defined(__MINGW32__) + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + + char buf[32]; + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tm); + + const auto us = usecs.count(); + if (us != 0) { + char frac[16]; + // Write microseconds, then strip trailing zeros. + snprintf(frac, sizeof(frac), ".%06lld", + static_cast(us < 0 ? -us : us)); + auto len = strlen(frac); + while (len > 1 && frac[len - 1] == '0') { + --len; + } + frac[len] = '\0'; + return std::string(buf) + frac + "Z"; + } + return std::string(buf) + "Z"; + } + + static Result from_string(const std::string& _str) noexcept { + try { + std::tm tm{}; + const char* str = _str.c_str(); + const char* rest = parse_datetime(str, &tm); + if (!rest) { + return error("Could not parse time point from '" + _str + "'."); + } + + auto t = to_time_t(tm); + auto tp = std::chrono::system_clock::from_time_t(t); + + // Parse fractional seconds if present. + if (*rest == '.') { + ++rest; + long long frac = 0; + int digits = 0; + while (*rest >= '0' && *rest <= '9' && digits < 9) { + frac = frac * 10 + (*rest - '0'); + ++rest; + ++digits; + } + // Pad to microseconds (6 digits). + while (digits < 6) { + frac *= 10; + ++digits; + } + // Truncate beyond microseconds. + while (digits > 6) { + frac /= 10; + --digits; + } + tp += std::chrono::microseconds(frac); + } + + return std::chrono::time_point_cast(tp); + } catch (std::exception& e) { + return error(e.what()); + } + } + + static const char* parse_datetime(const char* _str, std::tm* _tm) { +#if defined(_MSC_VER) || defined(__MINGW32__) + std::istringstream input(_str); + input.imbue(std::locale(setlocale(LC_ALL, nullptr))); + input >> std::get_time(_tm, "%Y-%m-%dT%H:%M:%S"); + if (input.fail()) { + return nullptr; + } + return _str + static_cast(input.tellg()); +#else + return strptime(_str, "%Y-%m-%dT%H:%M:%S", _tm); +#endif + } + + static std::time_t to_time_t(std::tm& _tm) { +#if defined(_MSC_VER) || defined(__MINGW32__) + return _mkgmtime(&_tm); +#else + return timegm(&_tm); +#endif + } +}; + +} // namespace rfl::parsing + +#endif diff --git a/tests/json/test_time_point.cpp b/tests/json/test_time_point.cpp new file mode 100644 index 00000000..c1517264 --- /dev/null +++ b/tests/json/test_time_point.cpp @@ -0,0 +1,79 @@ +#include + +#include +#include +#include +#include + +namespace test_time_point { + +struct Event { + std::string name; + std::chrono::system_clock::time_point created_at; +}; + +TEST(json, test_time_point_round_trip) { + const auto now = std::chrono::system_clock::now(); + const auto event = Event{.name = "deploy", .created_at = now}; + + const auto json = rfl::json::write(event); + const auto result = rfl::json::read(json); + + ASSERT_TRUE(result && true) << result.error().what(); + EXPECT_EQ(result.value().name, "deploy"); + + // Compare with microsecond precision (our serialization format). + const auto expected = + std::chrono::time_point_cast(now); + const auto actual = std::chrono::time_point_cast( + result.value().created_at); + EXPECT_EQ(expected, actual); +} + +TEST(json, test_time_point_format) { + // 2024-01-15T12:00:00.123456Z + const auto epoch = std::chrono::system_clock::from_time_t(1705320000); + const auto tp = epoch + std::chrono::microseconds(123456); + const auto event = Event{.name = "test", .created_at = tp}; + + const auto json = rfl::json::write(event); + EXPECT_TRUE(json.find(".123456Z") != std::string::npos) << "Got: " << json; + + // Verify round-trip preserves the exact microseconds. + const auto result = rfl::json::read(json); + ASSERT_TRUE(result && true) << result.error().what(); + EXPECT_EQ(std::chrono::time_point_cast(tp), + std::chrono::time_point_cast( + result.value().created_at)); +} + +TEST(json, test_time_point_no_fractional) { + const auto tp = std::chrono::system_clock::from_time_t(1705320000); + const auto event = Event{.name = "test", .created_at = tp}; + + const auto json = rfl::json::write(event); + // Should not have fractional seconds. + EXPECT_TRUE(json.find("\"Z\"") == std::string::npos) + << "Should not be quoted Z"; + EXPECT_TRUE(json.find(".") == std::string::npos) + << "Should not have fractional part. Got: " << json; +} + +TEST(json, test_time_point_parse_various_precisions) { + // Milliseconds. + auto r1 = rfl::json::read( + R"({"name":"a","created_at":"2024-01-15T10:30:00.123Z"})"); + ASSERT_TRUE(r1 && true) << r1.error().what(); + + // Nanoseconds (truncated to microseconds). + auto r2 = rfl::json::read( + R"({"name":"b","created_at":"2024-01-15T10:30:00.123456789Z"})"); + ASSERT_TRUE(r2 && true) << r2.error().what(); + + // No fractional part. + auto r3 = rfl::json::read( + R"({"name":"c","created_at":"2024-01-15T10:30:00Z"})"); + ASSERT_TRUE(r3 && true) << r3.error().what(); +} + +} // namespace test_time_point From 23e8de757110f6a675b6774ea984eb1a4542e5b1 Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sat, 4 Apr 2026 19:48:44 +0100 Subject: [PATCH 2/9] restrict parser to system clock steady clock and hr clock causes problems --- include/rfl/parsing/Parser_time_point.hpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/include/rfl/parsing/Parser_time_point.hpp b/include/rfl/parsing/Parser_time_point.hpp index 624992b5..76249450 100644 --- a/include/rfl/parsing/Parser_time_point.hpp +++ b/include/rfl/parsing/Parser_time_point.hpp @@ -14,15 +14,19 @@ namespace rfl::parsing { -template - requires AreReaderAndWriter> -struct Parser, ProcessorsType> { +template + requires AreReaderAndWriter< + R, W, std::chrono::time_point> +struct Parser, + ProcessorsType> { public: using InputVarType = typename R::InputVarType; using ParentType = Parent; - using TimePointType = std::chrono::time_point; + using TimePointType = + std::chrono::time_point; static Result read(const R& _r, const InputVarType& _var) noexcept { From 038960713cd9075ec902a41a634aa8639aaa108e Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sat, 4 Apr 2026 20:00:30 +0100 Subject: [PATCH 3/9] nanosecond precision --- include/rfl/parsing/Parser_time_point.hpp | 27 ++++++++++++----------- tests/json/test_time_point.cpp | 12 +++++----- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/include/rfl/parsing/Parser_time_point.hpp b/include/rfl/parsing/Parser_time_point.hpp index 76249450..7aa3944d 100644 --- a/include/rfl/parsing/Parser_time_point.hpp +++ b/include/rfl/parsing/Parser_time_point.hpp @@ -48,11 +48,11 @@ struct Parser(_tp); + std::chrono::time_point_cast(_tp); const auto epoch = sys_time.time_since_epoch(); const auto secs = std::chrono::duration_cast(epoch); - const auto usecs = - std::chrono::duration_cast(epoch - secs); + const auto nsecs = + std::chrono::duration_cast(epoch - secs); auto t = static_cast(secs.count()); std::tm tm{}; @@ -65,12 +65,12 @@ struct Parser(us < 0 ? -us : us)); + // Write nanoseconds, then strip trailing zeros. + snprintf(frac, sizeof(frac), ".%09lld", + static_cast(ns < 0 ? -ns : ns)); auto len = strlen(frac); while (len > 1 && frac[len - 1] == '0') { --len; @@ -103,17 +103,18 @@ struct Parser 6) { + // Truncate beyond nanoseconds. + while (digits > 9) { frac /= 10; --digits; } - tp += std::chrono::microseconds(frac); + tp += std::chrono::duration_cast( + std::chrono::nanoseconds(frac)); } return std::chrono::time_point_cast(tp); diff --git a/tests/json/test_time_point.cpp b/tests/json/test_time_point.cpp index c1517264..17be3844 100644 --- a/tests/json/test_time_point.cpp +++ b/tests/json/test_time_point.cpp @@ -22,11 +22,11 @@ TEST(json, test_time_point_round_trip) { ASSERT_TRUE(result && true) << result.error().what(); EXPECT_EQ(result.value().name, "deploy"); - // Compare with microsecond precision (our serialization format). - const auto expected = - std::chrono::time_point_cast(now); - const auto actual = std::chrono::time_point_cast( - result.value().created_at); + // Compare at the system clock's native resolution. + const auto expected = std::chrono::time_point_cast< + std::chrono::system_clock::duration>(now); + const auto actual = std::chrono::time_point_cast< + std::chrono::system_clock::duration>(result.value().created_at); EXPECT_EQ(expected, actual); } @@ -65,7 +65,7 @@ TEST(json, test_time_point_parse_various_precisions) { R"({"name":"a","created_at":"2024-01-15T10:30:00.123Z"})"); ASSERT_TRUE(r1 && true) << r1.error().what(); - // Nanoseconds (truncated to microseconds). + // Nanoseconds. auto r2 = rfl::json::read( R"({"name":"b","created_at":"2024-01-15T10:30:00.123456789Z"})"); ASSERT_TRUE(r2 && true) << r2.error().what(); From 5d2841636479994fd52e324fe7afef3af9865519 Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sat, 4 Apr 2026 20:03:50 +0100 Subject: [PATCH 4/9] better timezone validation --- include/rfl/parsing/Parser_time_point.hpp | 5 +++++ tests/json/test_time_point.cpp | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/include/rfl/parsing/Parser_time_point.hpp b/include/rfl/parsing/Parser_time_point.hpp index 7aa3944d..530eff69 100644 --- a/include/rfl/parsing/Parser_time_point.hpp +++ b/include/rfl/parsing/Parser_time_point.hpp @@ -117,6 +117,11 @@ struct Parser(tp); } catch (std::exception& e) { return error(e.what()); diff --git a/tests/json/test_time_point.cpp b/tests/json/test_time_point.cpp index 17be3844..c46f0a86 100644 --- a/tests/json/test_time_point.cpp +++ b/tests/json/test_time_point.cpp @@ -76,4 +76,21 @@ TEST(json, test_time_point_parse_various_precisions) { ASSERT_TRUE(r3 && true) << r3.error().what(); } +TEST(json, test_time_point_reject_invalid_suffix) { + // Trailing garbage should fail. + auto r1 = rfl::json::read( + R"({"name":"a","created_at":"2024-01-15T10:30:00Invalid"})"); + EXPECT_FALSE(r1 && true); + + // No fractional part, no Z, but trailing text should fail. + auto r2 = rfl::json::read( + R"({"name":"b","created_at":"2024-01-15T10:30:00+01:00"})"); + EXPECT_FALSE(r2 && true); + + // No Z is accepted (end of string). + auto r3 = rfl::json::read( + R"({"name":"c","created_at":"2024-01-15T10:30:00"})"); + EXPECT_TRUE(r3 && true) << r3.error().what(); +} + } // namespace test_time_point From badb9a6917d5a6356cfd981776dbaa1777600173 Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sat, 4 Apr 2026 20:07:57 +0100 Subject: [PATCH 5/9] timezone locale (gemini) --- include/rfl/parsing/Parser_time_point.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/rfl/parsing/Parser_time_point.hpp b/include/rfl/parsing/Parser_time_point.hpp index 530eff69..ac5f15e7 100644 --- a/include/rfl/parsing/Parser_time_point.hpp +++ b/include/rfl/parsing/Parser_time_point.hpp @@ -131,7 +131,7 @@ struct Parser> std::get_time(_tm, "%Y-%m-%dT%H:%M:%S"); if (input.fail()) { return nullptr; From cec41fa08d9f64e44319fcb676056174f10df56f Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sat, 4 Apr 2026 20:17:39 +0100 Subject: [PATCH 6/9] formatting --- tests/json/test_time_point.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/json/test_time_point.cpp b/tests/json/test_time_point.cpp index c46f0a86..061d2fae 100644 --- a/tests/json/test_time_point.cpp +++ b/tests/json/test_time_point.cpp @@ -23,10 +23,11 @@ TEST(json, test_time_point_round_trip) { EXPECT_EQ(result.value().name, "deploy"); // Compare at the system clock's native resolution. - const auto expected = std::chrono::time_point_cast< - std::chrono::system_clock::duration>(now); - const auto actual = std::chrono::time_point_cast< - std::chrono::system_clock::duration>(result.value().created_at); + const auto expected = + std::chrono::time_point_cast(now); + const auto actual = + std::chrono::time_point_cast( + result.value().created_at); EXPECT_EQ(expected, actual); } From 0f1df2b1e48ea1742b219b3a186089df238b3b3b Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sat, 4 Apr 2026 22:27:26 +0100 Subject: [PATCH 7/9] cicd fixes --- include/rfl/parsing/Parser_time_point.hpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/include/rfl/parsing/Parser_time_point.hpp b/include/rfl/parsing/Parser_time_point.hpp index ac5f15e7..b1aba71a 100644 --- a/include/rfl/parsing/Parser_time_point.hpp +++ b/include/rfl/parsing/Parser_time_point.hpp @@ -2,6 +2,7 @@ #define RFL_PARSING_PARSER_TIME_POINT_HPP_ #include +#include #include #include #include @@ -136,7 +137,12 @@ struct Parser(input.tellg()); + const auto pos = input.tellg(); + if (pos == std::streampos(-1)) { + // Stream reached EOF after parsing — all input was consumed. + return _str + std::strlen(_str); + } + return _str + static_cast(pos); #else return strptime(_str, "%Y-%m-%dT%H:%M:%S", _tm); #endif From 01c1dc969aac977bb50c1caddd3864e8f137617c Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sun, 5 Apr 2026 11:17:22 +0100 Subject: [PATCH 8/9] timezone conversion --- include/rfl/parsing/Parser_time_point.hpp | 42 +++++++++++++++++++++-- tests/json/test_time_point.cpp | 42 +++++++++++++++++++---- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/include/rfl/parsing/Parser_time_point.hpp b/include/rfl/parsing/Parser_time_point.hpp index b1aba71a..22fc7487 100644 --- a/include/rfl/parsing/Parser_time_point.hpp +++ b/include/rfl/parsing/Parser_time_point.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -118,9 +119,16 @@ struct Parser(tp); @@ -129,6 +137,36 @@ struct Parser= '0' && c <= '9'; } + + static int two_digits(const char* s) { + return (s[0] - '0') * 10 + (s[1] - '0'); + } + + /// Parses a timezone offset like "+05:30" or "-08:00". + /// Returns the offset as a chrono duration, or std::nullopt on failure. + static std::optional parse_tz_offset(const char* _str) { + if (*_str != '+' && *_str != '-') { + return std::nullopt; + } + const int sign = (*_str == '+') ? 1 : -1; + ++_str; + // Expect HH:MM or HHMM. + if (!is_digit(_str[0]) || !is_digit(_str[1])) { + return std::nullopt; + } + const int hours = two_digits(_str); + _str += 2; + if (*_str == ':') { + ++_str; + } + if (!is_digit(_str[0]) || !is_digit(_str[1])) { + return std::nullopt; + } + const int minutes = two_digits(_str); + return std::chrono::minutes(sign * (hours * 60 + minutes)); + } + static const char* parse_datetime(const char* _str, std::tm* _tm) { #if defined(_MSC_VER) || defined(__MINGW32__) std::istringstream input(_str); diff --git a/tests/json/test_time_point.cpp b/tests/json/test_time_point.cpp index 061d2fae..01f03260 100644 --- a/tests/json/test_time_point.cpp +++ b/tests/json/test_time_point.cpp @@ -83,15 +83,45 @@ TEST(json, test_time_point_reject_invalid_suffix) { R"({"name":"a","created_at":"2024-01-15T10:30:00Invalid"})"); EXPECT_FALSE(r1 && true); - // No fractional part, no Z, but trailing text should fail. + // No Z is accepted (end of string). auto r2 = rfl::json::read( - R"({"name":"b","created_at":"2024-01-15T10:30:00+01:00"})"); - EXPECT_FALSE(r2 && true); + R"({"name":"b","created_at":"2024-01-15T10:30:00"})"); + EXPECT_TRUE(r2 && true) << r2.error().what(); +} - // No Z is accepted (end of string). +TEST(json, test_time_point_timezone_offset) { + // +05:30 means 5h30m ahead of UTC, so 10:30+05:30 = 05:00Z. + auto r1 = rfl::json::read( + R"({"name":"a","created_at":"2024-01-15T10:30:00+05:30"})"); + ASSERT_TRUE(r1 && true) << r1.error().what(); + + auto r_utc = rfl::json::read( + R"({"name":"a","created_at":"2024-01-15T05:00:00Z"})"); + ASSERT_TRUE(r_utc && true) << r_utc.error().what(); + + EXPECT_EQ( + std::chrono::time_point_cast(r1.value().created_at), + std::chrono::time_point_cast( + r_utc.value().created_at)); + + // Negative offset: -08:00 means 8h behind UTC, so 02:00-08:00 = 10:00Z. + auto r2 = rfl::json::read( + R"({"name":"b","created_at":"2024-01-15T02:00:00-08:00"})"); + ASSERT_TRUE(r2 && true) << r2.error().what(); + + auto r_utc2 = rfl::json::read( + R"({"name":"b","created_at":"2024-01-15T10:00:00Z"})"); + ASSERT_TRUE(r_utc2 && true) << r_utc2.error().what(); + + EXPECT_EQ( + std::chrono::time_point_cast(r2.value().created_at), + std::chrono::time_point_cast( + r_utc2.value().created_at)); + + // Offset with fractional seconds. auto r3 = rfl::json::read( - R"({"name":"c","created_at":"2024-01-15T10:30:00"})"); - EXPECT_TRUE(r3 && true) << r3.error().what(); + R"({"name":"c","created_at":"2024-01-15T10:30:00.5+05:30"})"); + ASSERT_TRUE(r3 && true) << r3.error().what(); } } // namespace test_time_point From 03e887d3e81223286da7fc677b6d5b89092d52ea Mon Sep 17 00:00:00 2001 From: Ron0Studios Date: Sun, 5 Apr 2026 11:23:30 +0100 Subject: [PATCH 9/9] docs --- docs/timestamps.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/timestamps.md b/docs/timestamps.md index 26b73241..6f1474ea 100644 --- a/docs/timestamps.md +++ b/docs/timestamps.md @@ -1,4 +1,4 @@ -# `rfl::Timestamp` and `std::chrono::duration` +# `rfl::Timestamp`, `std::chrono::system_clock::time_point`, and `std::chrono::duration` ## `rfl::Timestamp` @@ -45,6 +45,41 @@ const rfl::Result> result = rfl::Timestamp<"%Y-%m-%d" const rfl::Result> error = rfl::Timestamp<"%Y-%m-%d">::from_string("not a proper time format"); ``` +## `std::chrono::system_clock::time_point` + +`std::chrono::system_clock::time_point` is natively supported. It serializes as an ISO 8601 string with nanosecond precision: + +```cpp +struct Event { + std::string name; + std::chrono::system_clock::time_point created_at; +}; + +rfl::json::write(Event{.name = "deploy", .created_at = std::chrono::system_clock::now()}); +``` + +This produces: + +```json +{"name":"deploy","created_at":"2024-01-15T12:00:00.123456789Z"} +``` + +Trailing fractional zeros are stripped, so microsecond values appear as `.123456Z` and whole seconds appear without a decimal point. + +On read, the following formats are accepted: + +- `"2024-01-15T12:00:00Z"` — UTC, no fractional seconds +- `"2024-01-15T12:00:00.123Z"` — milliseconds +- `"2024-01-15T12:00:00.123456Z"` — microseconds +- `"2024-01-15T12:00:00.123456789Z"` — nanoseconds +- `"2024-01-15T12:00:00"` — no timezone suffix (assumed UTC) +- `"2024-01-15T10:30:00+05:30"` — timezone offset (converted to UTC) +- `"2024-01-15T02:00:00-08:00"` — negative offset + +Timezone offsets are converted to UTC on read. The write path always outputs UTC with the `Z` suffix. + +Only `std::chrono::system_clock::time_point` is supported — other clocks like `steady_clock` do not represent calendar time and cannot be serialized as ISO 8601. + ## `std::chrono::duration` `std::chrono::duration` types are serialized as an object with the count and unit as fields: