diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c6e4df..051ba98 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,5 @@ cmake_minimum_required(VERSION 3.6) -set(OUTPUT_LOCATION ${CMAKE_CURRENT_LIST_DIR}/out/) - add_subdirectory(libcron) add_subdirectory(test) diff --git a/libcron/CMakeLists.txt b/libcron/CMakeLists.txt index feb4610..51992ba 100644 --- a/libcron/CMakeLists.txt +++ b/libcron/CMakeLists.txt @@ -13,18 +13,20 @@ add_library(${PROJECT_NAME} include/libcron/Cron.h include/libcron/CronClock.h include/libcron/CronData.h + include/libcron/CronRandomization.h include/libcron/CronSchedule.h include/libcron/DateTime.h include/libcron/Task.h include/libcron/TimeTypes.h src/CronClock.cpp src/CronData.cpp + src/CronRandomization.cpp src/CronSchedule.cpp src/Task.cpp) target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_LIST_DIR}/externals/date/include - PUBLIC ${CMAKE_CURRENT_LIST_DIR}/include) + PUBLIC include) set_target_properties(${PROJECT_NAME} PROPERTIES ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/out" diff --git a/libcron/include/libcron/CronData.h b/libcron/include/libcron/CronData.h index 94ede9b..ae42392 100644 --- a/libcron/include/libcron/CronData.h +++ b/libcron/include/libcron/CronData.h @@ -11,9 +11,12 @@ namespace libcron class CronData { public: + static const std::array months_with_31; + static CronData create(const std::string& cron_expression); CronData(); + CronData(const CronData&) = default; bool is_valid() const @@ -61,6 +64,7 @@ namespace libcron static bool has_any_in_range(const std::set& set, uint8_t low, uint8_t high) { bool found = false; + for (auto i = low; !found && i <= high; ++i) { found |= set.find(static_cast(i)) != set.end(); @@ -69,8 +73,10 @@ namespace libcron return found; } - private: + template + bool convert_from_string_range_to_number_range(const std::string& range, std::set& numbers); + private: void parse(const std::string& cron_expression); template @@ -142,6 +148,7 @@ namespace libcron for (const auto& name : names) { std::regex m(name, std::regex_constants::ECMAScript | std::regex_constants::icase); + for (auto& part : parts) { std::string replaced; @@ -155,7 +162,6 @@ namespace libcron } return process_parts(parts, numbers); - } template @@ -163,60 +169,9 @@ namespace libcron { bool res = true; - T left; - T right; - uint8_t step_start; - uint8_t step; - for (const auto& p : parts) { - if (p == "*" || p == "?") - { - // We treat the ignore-character '?' the same as the full range being allowed. - add_full_range(numbers); - } - else if (is_number(p)) - { - res &= add_number(numbers, std::stoi(p)); - } - else if (get_range(p, left, right)) - { - // A range can be written as both 1-22 or 22-1, meaning totally different ranges. - // First case is 1...22 while 22-1 is only four hours: 22, 23, 0, 1. - if (left <= right) - { - for (auto v = value_of(left); v <= value_of(right); ++v) - { - res &= add_number(numbers, v); - } - } - else - { - // 'left' and 'right' are not in value order. First, get values between 'left' and T::Last, inclusive - for (auto v = value_of(left); v <= value_of(T::Last); ++v) - { - res &= add_number(numbers, v); - } - - // Next, get values between T::First and 'right', inclusive. - for (auto v = value_of(T::First); v <= value_of(right); ++v) - { - res &= add_number(numbers, v); - } - } - } - else if (get_step(p, step_start, step)) - { - // Add from step_start to T::Last with a step of 'step' - for (auto v = step_start; v <= value_of(T::Last); v += step) - { - res &= add_number(numbers, v); - } - } - else - { - res = false; - } + res &= convert_from_string_range_to_number_range(p, numbers); } return res; @@ -263,7 +218,8 @@ namespace libcron if (std::regex_match(s.begin(), s.end(), match, range)) { int raw_start; - if(match[1].str() == "*") + + if (match[1].str() == "*") { raw_start = value_of(T::First); } @@ -326,5 +282,64 @@ namespace libcron && is_between(high, value_of(T::First), value_of(T::Last)); } + template + bool CronData::convert_from_string_range_to_number_range(const std::string& range, std::set& numbers) + { + T left; + T right; + uint8_t step_start; + uint8_t step; + bool res = true; + + if (range == "*" || range == "?") + { + // We treat the ignore-character '?' the same as the full range being allowed. + add_full_range(numbers); + } + else if (is_number(range)) + { + res = add_number(numbers, std::stoi(range)); + } + else if (get_range(range, left, right)) + { + // A range can be written as both 1-22 or 22-1, meaning totally different ranges. + // First case is 1...22 while 22-1 is only four hours: 22, 23, 0, 1. + if (left <= right) + { + for (auto v = value_of(left); v <= value_of(right); ++v) + { + res &= add_number(numbers, v); + } + } + else + { + // 'left' and 'right' are not in value order. First, get values between 'left' and T::Last, inclusive + for (auto v = value_of(left); v <= value_of(T::Last); ++v) + { + res = add_number(numbers, v); + } + + // Next, get values between T::First and 'right', inclusive. + for (auto v = value_of(T::First); v <= value_of(right); ++v) + { + res = add_number(numbers, v); + } + } + } + else if (get_step(range, step_start, step)) + { + // Add from step_start to T::Last with a step of 'step' + for (auto v = step_start; v <= value_of(T::Last); v += step) + { + res = add_number(numbers, v); + } + } + else + { + res = false; + } + + return res; + } } diff --git a/libcron/include/libcron/CronRandomization.h b/libcron/include/libcron/CronRandomization.h new file mode 100644 index 0000000..34c7524 --- /dev/null +++ b/libcron/include/libcron/CronRandomization.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include +#include "CronData.h" + +namespace libcron +{ + class CronRandomization + { + public: + std::tuple parse(const std::string& cron_schedule); + + CronRandomization(); + + CronRandomization(const CronRandomization&) = delete; + + CronRandomization & operator=(const CronRandomization &) = delete; + + private: + template + std::pair get_random_in_range(const std::string& section, + int& selected_value, + std::pair limit = std::make_pair(-1, -1)); + + std::pair day_limiter(const std::set& month); + int cap(int value, int lower, int upper); + + std::regex const rand_expression{ R"#([rR]\((\d+)\-(\d+)\))#", std::regex_constants::ECMAScript }; + std::random_device rd{}; + std::mt19937 twister; + }; + + template + std::pair CronRandomization::get_random_in_range(const std::string& section, + int& selected_value, + std::pair limit) + { + auto res = std::make_pair(true, std::string{}); + selected_value = -1; + + std::smatch random_match; + + if (std::regex_match(section.begin(), section.end(), random_match, rand_expression)) + { + // Random range, get left and right numbers. + auto left = std::stoi(random_match[1].str()); + auto right = std::stoi(random_match[2].str()); + + if (limit.first != -1 && limit.second != -1) + { + left = cap(left, limit.first, limit.second); + right = cap(right, limit.first, limit.second); + } + + libcron::CronData cd; + std::set numbers; + res.first = cd.convert_from_string_range_to_number_range( + std::to_string(left) + "-" + std::to_string(right), numbers); + + // Remove items outside limits. +// for(auto it = numbers.begin(); it != numbers.end();) +// { +// if(CronData::value_of(*it) < limit.first || CronData::value_of(*it) > limit.second) +// { +// it = numbers.erase(it); +// } +// else +// { +// ++it; +// } +// } + + if (res.first) + { + // Generate random indexes to select one of the numbers in the range. + std::uniform_int_distribution<> dis(0, static_cast(numbers.size() - 1)); + + // Select the random number to use as the schedule + auto it = numbers.begin(); + std::advance(it, dis(twister)); + selected_value = CronData::value_of(*it); + res.second = std::to_string(selected_value); + } + } + else + { + // Not random, just append input to output. + res.second = section; + } + + return res; + } +} diff --git a/libcron/include/libcron/TimeTypes.h b/libcron/include/libcron/TimeTypes.h index a3238d3..18aa133 100644 --- a/libcron/include/libcron/TimeTypes.h +++ b/libcron/include/libcron/TimeTypes.h @@ -6,38 +6,50 @@ namespace libcron { enum class Seconds : int8_t { - First = 0, - Last = 59 + First = 0, + Last = 59 }; enum class Minutes : int8_t { - First = 0, - Last = 59 + First = 0, + Last = 59 }; enum class Hours : int8_t { - First = 0, - Last = 23 + First = 0, + Last = 23 }; enum class DayOfMonth : uint8_t { - First = 1, - Last = 31 + First = 1, + Last = 31 }; enum class Months : uint8_t { - First = 1, - Last = 12 + First = 1, + January = First, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December = 12, + Last = December }; enum class DayOfWeek : uint8_t { - // Sunday = 0 ... Saturday = 6 - First = 0, - Last = 6, + // Sunday = 0 ... Saturday = 6 + First = 0, + Last = 6, }; } diff --git a/libcron/src/CronData.cpp b/libcron/src/CronData.cpp index dcfb902..1407fe3 100644 --- a/libcron/src/CronData.cpp +++ b/libcron/src/CronData.cpp @@ -5,6 +5,13 @@ using namespace date; namespace libcron { + const constexpr std::array CronData::months_with_31{ Months::January, + Months::March, + Months::May, + Months::July, + Months::August, + Months::October, + Months::December }; CronData CronData::create(const std::string& cron_expression) { @@ -15,16 +22,16 @@ namespace libcron } CronData::CronData() - : month_names({"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"}), - day_names({"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}) + : month_names({ "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" }), + day_names({ "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT" }) { } void CronData::parse(const std::string& cron_expression) { // First, split on white-space. We expect six parts. - std::regex split{R"#(^\s*(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s*$)#", - std::regex_constants::ECMAScript}; + std::regex split{ R"#(^\s*(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s*$)#", + std::regex_constants::ECMAScript }; std::smatch match; @@ -48,13 +55,12 @@ namespace libcron std::string r = "["; r += token; r += "]"; - std::regex splitter{r, std::regex_constants::ECMAScript}; + std::regex splitter{ r, std::regex_constants::ECMAScript }; std::copy(std::sregex_token_iterator(s.begin(), s.end(), splitter, -1), std::sregex_token_iterator(), std::back_inserter(res)); - return res; } @@ -90,17 +96,15 @@ namespace libcron // Make sure that if the days contains only 31, at least one month allows that date. if (day_of_month.size() == 1 && day_of_month.find(DayOfMonth::Last) != day_of_month.end()) { - constexpr std::array months_with_31{1, 3, 5, 7, 8, 10, 12}; - res = false; + for (size_t i = 0; !res && i < months_with_31.size(); ++i) { - res = months.find(static_cast(months_with_31[i])) != months.end(); + res = months.find(months_with_31[i]) != months.end(); } } } - return res; } @@ -114,12 +118,12 @@ namespace libcron // '?' as the ignore flag, although it is functionally equivalent to '*'. auto check = [](const std::string& l, std::string r) - { - return l == "*" && (r != "*" || r == "?"); - }; + { + return l == "*" && (r != "*" || r == "?"); + }; return (dom == "?" || dow == "?") || check(dom, dow) || check(dow, dom); } -} \ No newline at end of file +} diff --git a/libcron/src/CronRandomization.cpp b/libcron/src/CronRandomization.cpp new file mode 100644 index 0000000..62d7188 --- /dev/null +++ b/libcron/src/CronRandomization.cpp @@ -0,0 +1,110 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace libcron +{ + CronRandomization::CronRandomization() + : twister(rd()) + { + } + + std::tuple CronRandomization::parse(const std::string& cron_schedule) + { + // Split on space to get each separate part, six parts expected + std::regex split{ R"#(^\s*(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s+(.*?)\s*$)#", + std::regex_constants::ECMAScript }; + + std::smatch all_sections; + + std::string final_cron_schedule; + + auto res = std::regex_match(cron_schedule.begin(), cron_schedule.end(), all_sections, split); + + if (res) + { + int selected_value = -1; + auto second = get_random_in_range(all_sections[1].str(), selected_value); + res = second.first; + final_cron_schedule = second.second; + + auto minute = get_random_in_range(all_sections[2].str(), selected_value); + res &= minute.first; + final_cron_schedule += " " + minute.second; + + auto hour = get_random_in_range(all_sections[3].str(), selected_value); + res &= hour.first; + final_cron_schedule += " " + hour.second; + + // Do Month before DayOfMonth to allow capping the allowed range. + auto month = get_random_in_range(all_sections[5].str(), selected_value); + res &= month.first; + + std::set month_range{}; + if (selected_value == -1) + { + // Month is not specific, we need to get the 'max minimum' day of month to use. + CronData cr; + res &= cr.convert_from_string_range_to_number_range(all_sections[5].str(), month_range); + } + else + { + month_range.emplace(static_cast(selected_value)); + } + + auto limits = day_limiter(month_range); + + auto day_of_month = get_random_in_range(all_sections[4].str(), + selected_value, + limits); + + res &= day_of_month.first; + final_cron_schedule += " " + day_of_month.second + " " + month.second; + + auto day_of_week = get_random_in_range(all_sections[6].str(), selected_value); + res &= day_of_week.first; + final_cron_schedule += " " + day_of_week.second; + } + + return { res, final_cron_schedule }; + } + + std::pair CronRandomization::day_limiter(const std::set& months) + { + auto max = 31; + + for (auto month : months) + { + if (month == Months::February) + { + // Limit to 29 days, possibly causing delaying schedule until next leap year. + max = std::min(max, 29); + } + else if (std::find(CronData::months_with_31.begin(), + CronData::months_with_31.end(), + month) == CronData::months_with_31.end()) + { + max = std::min(max, 30); + } + else + { + max = std::min(max, 31); + } + } + + auto res = std::pair{ CronData::value_of(DayOfMonth::First), max }; + + return res; + } + + int CronRandomization::cap(int value, int lower, int upper) + { + return std::max(std::min(value, upper), lower); + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4dbdcda..da2349b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -18,11 +18,13 @@ include_directories( add_executable( ${PROJECT_NAME} CronDataTest.cpp - CronScheduleTest.cpp CronTest.cpp) + CronRandomizationTest.cpp + CronScheduleTest.cpp + CronTest.cpp) target_link_libraries(${PROJECT_NAME} libcron) set_target_properties(${PROJECT_NAME} PROPERTIES - ARCHIVE_OUTPUT_DIRECTORY "${OUTPUT_LOCATION}" - LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_LOCATION}" - RUNTIME_OUTPUT_DIRECTORY "${OUTPUT_LOCATION}") \ No newline at end of file + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/out" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/out" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/out") \ No newline at end of file diff --git a/test/CronRandomizationTest.cpp b/test/CronRandomizationTest.cpp new file mode 100644 index 0000000..6b6e8df --- /dev/null +++ b/test/CronRandomizationTest.cpp @@ -0,0 +1,88 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace libcron; + +SCENARIO("Randomize all the things") +{ + const char* full_random = "R(0-59) R(0-59) R(0-23) R(1-31) R(1-12) ?"; + Cron<> cron; + + GIVEN(full_random) + { + THEN("Only valid schedules generated") + { + libcron::CronRandomization cr; + std::unordered_map> results{}; + + for (int i = 0; i < 1000; ++i) + { + auto res = cr.parse(full_random); + REQUIRE(std::get<0>(res)); + auto schedule = std::get<1>(res); + + auto start = schedule.begin(); + auto space = std::find(schedule.begin(), schedule.end(), ' '); + + for (int section = 0; start != schedule.end() && section < 5; ++section) + { + auto& map = results[section]; + auto s = std::string{start, space}; + auto value = std::stoi(s); + map[value]++; + start = space + 1; + space = std::find(start, schedule.end(), ' '); + } + + REQUIRE(results.size() == 5); + INFO("schedule:" << schedule); + REQUIRE(cron.add_schedule("validate schedule", schedule, []() {})); + } + } + } +} + +SCENARIO("Randomize all the things with reverse ranges") +{ + // Only generate DayOfMonth up to 28 to prevent failing tests where the month doesn't have more days. + const char* full_random = "R(45-15) R(30-0) R(18-2) R(28-15) 2 ?"; + Cron<> cron; + + GIVEN(full_random) + { + THEN("Only valid schedules generated") + { + libcron::CronRandomization cr; + std::unordered_map> results{}; + + for (int i = 0; i < 1000; ++i) + { + auto res = cr.parse(full_random); + REQUIRE(std::get<0>(res)); + auto schedule = std::get<1>(res); + + auto start = schedule.begin(); + auto space = std::find(schedule.begin(), schedule.end(), ' '); + + for (int section = 0; start != schedule.end() && section < 5; ++section) + { + auto& map = results[section]; + auto s = std::string{start, space}; + auto value = std::stoi(s); + map[value]++; + start = space + 1; + space = std::find(start, schedule.end(), ' '); + } + + REQUIRE(results.size() == 5); + INFO("schedule:" << schedule); + REQUIRE(cron.add_schedule("validate schedule", schedule, []() {})); + } + } + } +} diff --git a/test/out/cron_test b/test/out/cron_test new file mode 100755 index 0000000..3d03f55 Binary files /dev/null and b/test/out/cron_test differ