From d4c4e9b016b48d00a18ffa9e2642cffbeaa2e163 Mon Sep 17 00:00:00 2001 From: Marius Bancila Date: Wed, 12 Aug 2026 16:57:34 +0300 Subject: [PATCH] implement cron_prev --- README.md | 17 +++++ include/croncpp.h | 116 +++++++++++++++++++++++++++++++ test/CMakeLists.txt | 2 +- test/test_prev.cpp | 163 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 test/test_prev.cpp diff --git a/README.md b/README.md index 0f8f610..832d683 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,23 @@ catch (cron::bad_cronexpr const & ex) croncpp deliberately stays within C++11, so it does not use the calendar and time zone facilities added to `` in C++20. Time is handled through `std::mktime` and `localtime`, in local time, as described under [Time zones and daylight saving time](#time-zones-and-daylight-saving-time). +### Looking backwards + +`cron_prev()` is the counterpart of `cron_next()`: it returns the last time matching the expression that is strictly earlier than the one given. It takes the same three argument types and reports failure the same way. + +``` +auto cron = cron::make_cron("0 15 10 * * ?"); + +std::time_t now = std::time(0); +std::time_t previous = cron::cron_prev(cron, now); +``` + +It is answered by calling `cron_next()` repeatedly rather than by searching backwards, which keeps one implementation of the calendar rules instead of two. A single call costs at most **34 calls to `cron_next()`** — six to bracket the answer, twenty seven to halve the interval and one to read it off — and about **8** for an expression that fires every second. That cost follows the width of the interval searched, not how often the expression fires, so an expression firing every second of one day costs no more than one firing once a year. + +For a single lookup that is not worth thinking about. For walking a long stretch of history, call `cron_next()` forward from a starting point instead of calling `cron_prev()` in a loop. + +`cron_prev()` reaches back at most four years, the same horizon `cron_next()` searches forward. An occurrence older than that is reported as no occurrence at all. + ### When there is no next occurrence An expression can name a schedule that never comes round again, most obviously one whose years have all gone by. Each overload reports that in the way that suits its return type: diff --git a/include/croncpp.h b/include/croncpp.h index 705231d..4306a04 100644 --- a/include/croncpp.h +++ b/include/croncpp.h @@ -1566,4 +1566,120 @@ namespace cron return std::chrono::time_point_cast( std::chrono::system_clock::from_time_t(next)); } + + // The last time matching the expression that is strictly earlier than the + // one given, the counterpart of cron_next. + // + // It is answered with repeated cron_next calls rather than a search running + // backwards, so that it inherits the behaviour of the forward search rather + // than restating it in mirror image. cron_next is monotonic, so "the next + // occurrence is still earlier than the time asked about" holds for every + // instant up to the answer and for none after it, and the boundary between + // the two can be found by halving an interval. + // + // The interval starts as the smallest of a minute, an hour, a day, a month, + // a year and the four year search horizon that contains an occurrence at + // all. That costs one call per size tried, and bounds what the halving then + // has to cover. + // + // The total is at most six probes, twenty seven halvings of the four year + // interval and one final lookup, so thirty four calls; an expression firing + // every second is answered in eight. The count follows the width of the + // interval rather than the number of occurrences inside it, so an + // expression firing every second of a single day costs no more than one + // firing once a year. + template + static std::time_t cron_prev(cronexpr const & cex, std::time_t const & date) + { + if (cex.empty()) + throw bad_cronexpr("Invalid empty cron expression"); + + static std::time_t const windows[] = + { + 60, // a minute + 60 * 60, // an hour + 24 * 60 * 60, // a day + 31 * 24 * 60 * 60, // a month + 366 * 24 * 60 * 60, // a year + 4 * 366 * 24 * 60 * 60 + }; + + std::time_t low = 0; + bool bracketed = false; + + for (size_t i = 0; i < sizeof(windows) / sizeof(windows[0]); ++i) + { + // do not reach back past what a std::time_t can hold + if (date < (std::numeric_limits::min)() + windows[i]) continue; + + std::time_t const start = date - windows[i]; + std::time_t const first = cron_next(cex, start); + + if (INVALID_TIME != first && first < date) + { + low = start; + bracketed = true; + break; + } + } + + if (!bracketed) return INVALID_TIME; + + // Narrow to the last instant whose next occurrence is still earlier than + // the time asked about. The occurrence after that instant is the answer. + std::time_t high = date; + while (high - low > 1) + { + std::time_t const middle = low + (high - low) / 2; + std::time_t const next = cron_next(cex, middle); + + if (INVALID_TIME != next && next < date) low = middle; + else high = middle; + } + + std::time_t const result = cron_next(cex, low); + + return (INVALID_TIME != result && result < date) ? result : INVALID_TIME; + } + + template + static std::tm cron_prev(cronexpr const & cex, std::tm date) + { + std::time_t const original = utils::tm_to_time(date); + if (INVALID_TIME == original) return {}; + + std::time_t const result = cron_prev(cex, original); + if (INVALID_TIME == result) return {}; + + std::tm out; + if (utils::time_to_tm(&result, &out) == nullptr) return {}; + + return out; + } + + template + static std::chrono::time_point cron_prev( + cronexpr const & cex, + std::chrono::time_point const & time_point) + { + using result_type = + std::chrono::time_point; + + auto const from = std::chrono::time_point_cast< + std::chrono::system_clock::duration>(time_point); + + // An occurrence at the truncated second is still earlier than a time + // point carrying a fraction of one, so round the input up before asking. + auto tt = std::chrono::system_clock::to_time_t(from); + if (std::chrono::system_clock::from_time_t(tt) < from) { + ++tt; + } + + auto const previous = cron_prev(cex, tt); + + if (INVALID_TIME == previous) return (result_type::min)(); + + return std::chrono::time_point_cast( + std::chrono::system_clock::from_time_t(previous)); + } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e826f65..a1cb7e5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,4 +1,4 @@ -set(SOURCES main.cpp test_chrono.cpp test_day_fields.cpp test_dst.cpp test_oracle.cpp test_quartz.cpp test_special.cpp test_standard.cpp test_years.cpp) +set(SOURCES main.cpp test_chrono.cpp test_day_fields.cpp test_dst.cpp test_oracle.cpp test_prev.cpp test_quartz.cpp test_special.cpp test_standard.cpp test_years.cpp) add_executable(test_croncpp ${SOURCES}) diff --git a/test/test_prev.cpp b/test/test_prev.cpp new file mode 100644 index 0000000..fa19e99 --- /dev/null +++ b/test/test_prev.cpp @@ -0,0 +1,163 @@ +#include "catch.hpp" +#include "croncpp.h" + +#include +#include + +using namespace cron; + +namespace +{ + std::time_t time_at(std::string const & text) + { + auto date = utils::to_tm(text); + + return utils::tm_to_time(date); + } + + std::string text_at(std::time_t const t) + { + std::tm tm; + if (utils::time_to_tm(&t, &tm) == nullptr) return "invalid"; + + return utils::to_string(tm); + } + + template + std::string prev_str(std::string const & expr, std::string const & from) + { + auto cex = make_cron(expr); + auto const result = cron_prev(cex, time_at(from)); + + return INVALID_TIME == result ? "none" : text_at(result); + } + + std::string prev(std::string const & expr, std::string const & from) + { + return prev_str(expr, from); + } +} + +TEST_CASE("prev: the occurrence before a given time", "[prev]") +{ + REQUIRE(prev("0 15 10 * * ?", "2026-06-01 12:00:00") == "2026-06-01 10:15:00"); + REQUIRE(prev("0 15 10 * * ?", "2026-06-01 09:00:00") == "2026-05-31 10:15:00"); + REQUIRE(prev("0 0 12 * * *", "2026-06-01 12:00:01") == "2026-06-01 12:00:00"); +} + +TEST_CASE("prev: strictly earlier, as cron_next is strictly later", "[prev]") +{ + // asked at an occurrence, the answer is the one before it rather than itself + REQUIRE(prev("0 15 10 * * ?", "2026-06-01 10:15:00") == "2026-05-31 10:15:00"); + REQUIRE(prev("* * * * * *", "2026-06-01 10:15:00") == "2026-06-01 10:14:59"); +} + +TEST_CASE("prev: sparse expressions", "[prev]") +{ + REQUIRE(prev("0 0 0 29 2 *", "2026-06-01 00:00:00") == "2024-02-29 00:00:00"); + REQUIRE(prev("0 0 0 1 1 ?", "2026-06-01 00:00:00") == "2026-01-01 00:00:00"); + REQUIRE(prev("0 15 10 ? * 5L", "2026-06-01 00:00:00") == "2026-05-29 10:15:00"); + REQUIRE(prev("0 15 10 L * ?", "2026-06-01 00:00:00") == "2026-05-31 10:15:00"); +} + +TEST_CASE("prev: an expression with a great many occurrences in the interval", "[prev]") +{ + // every second of the 1st of January. Walking the year's occurrences one at + // a time would be 86400 steps; halving the interval is a few dozen. + REQUIRE(prev("* * * 1 1 ?", "2026-06-01 00:00:00") == "2026-01-01 23:59:59"); + REQUIRE(prev("* * * 1 1 ?", "2026-01-01 12:00:00") == "2026-01-01 11:59:59"); +} + +TEST_CASE("prev: expressions with a year", "[prev]") +{ + REQUIRE(prev("0 15 10 * * ? 2005", "2006-01-01 00:00:00") == "2005-12-31 10:15:00"); + REQUIRE(prev("0 15 10 * * ? 2005", "2005-06-01 00:00:00") == "2005-05-31 10:15:00"); + + // nothing has happened yet, so there is nothing before it + REQUIRE(prev("0 15 10 * * ? 2050", "2026-06-01 00:00:00") == "none"); + + // beyond the four year horizon the search reaches back to + REQUIRE(prev("0 15 10 * * ? 2005", "2026-06-01 00:00:00") == "none"); +} + +TEST_CASE("prev: it agrees with cron_next", "[prev]") +{ + // whatever the expression, the occurrence before a time is earlier than it, + // and the occurrence after that one is not + char const * const exprs[] = { + "* * * * * *", + "0 * * * * *", + "0 0/15 * * * *", + "0 15 10 * * ?", + "0 0 0 1 * ?", + "0 15 10 ? * 5#2", + "0 0 0 29 2 *", + }; + + std::time_t const from = time_at("2026-06-15 13:47:11"); + + for (auto const & expr : exprs) + { + auto cex = make_cron(expr); + + std::time_t at = from; + for (int step = 0; step < 5; ++step) + { + auto const previous = cron_prev(cex, at); + REQUIRE(previous != INVALID_TIME); + REQUIRE(previous < at); + + // nothing matches between the two + REQUIRE(cron_next(cex, previous) >= at); + + at = previous; + } + } +} + +TEST_CASE("prev: the std::tm overload", "[prev]") +{ + auto cex = make_cron("0 15 10 * * ?"); + auto date = utils::to_tm("2026-06-01 12:00:00"); + + auto result = cron_prev(cex, date); + REQUIRE(utils::to_string(result) == "2026-06-01 10:15:00"); + + // a zeroed tm when there is nothing before the time given + auto future = make_cron("0 15 10 * * ? 2050"); + auto none = cron_prev(future, date); + REQUIRE(none.tm_year == 0); + REQUIRE(none.tm_mday == 0); +} + +TEST_CASE("prev: the time_point overload", "[prev]") +{ + using namespace std::chrono; + + auto cex = make_cron("0 0 12 * * *"); + auto const noon = system_clock::from_time_t(time_at("2026-06-01 12:00:00")); + + REQUIRE(text_at(system_clock::to_time_t(cron_prev(cex, noon))) == "2026-05-31 12:00:00"); + + // a time point just past an occurrence still has that occurrence behind it, + // which truncation to whole seconds would otherwise hide + auto const just_after = noon + milliseconds(50); + REQUIRE(text_at(system_clock::to_time_t(cron_prev(cex, just_after))) == "2026-06-01 12:00:00"); + + // the duration of the argument is kept, and failure is reported the same + // way as for cron_next + auto const in_ms = time_point_cast(noon); + auto const out_ms = cron_prev(cex, in_ms); + static_assert(std::is_same const>::value, + "milliseconds in, milliseconds out"); + + auto future = make_cron("0 15 10 * * ? 2050"); + REQUIRE(cron_prev(future, noon) == (system_clock::time_point::min)()); +} + +TEST_CASE("prev: an empty expression is refused", "[prev]") +{ + cronexpr cex; + + REQUIRE_THROWS_AS(cron_prev(cex, time_at("2026-06-01 12:00:00")), bad_cronexpr); +}