using System.Globalization; namespace w4c_workflows.Services.Triggers; /// /// Minimal 5-field cron parser (minute hour day-of-month month day-of-week) /// supporting *, , (list), - (range) and / (step), plus /// numeric values. Enough for workflow scheduling ("0 */6 * * *", /// "0 9 * * 1-5"); no seconds/year fields. /// /// Day-of-week 0 and 7 both mean Sunday. Day matching uses AND semantics across /// day-of-month and day-of-week, so "0 9 * * 1-5" runs Mon–Fri and /// "0 0 1 * *" runs on the 1st of the month (the * field matches all /// values and therefore never filters). /// public sealed class CronExpression { private readonly CronField _minute; private readonly CronField _hour; private readonly CronField _dayOfMonth; private readonly CronField _month; private readonly CronField _dayOfWeek; private CronExpression(CronField minute, CronField hour, CronField dayOfMonth, CronField month, CronField dayOfWeek) { _minute = minute; _hour = hour; _dayOfMonth = dayOfMonth; _month = month; _dayOfWeek = dayOfWeek; } /// /// Parses a 5-field cron expression. On success is /// set and is null; otherwise the reverse. /// public static bool TryParse(string? expression, out CronExpression? parsed, out string? error) { parsed = null; error = null; if (string.IsNullOrWhiteSpace(expression)) { error = "cron expression is empty"; return false; } var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (fields.Length != 5) { error = $"cron expression must have 5 fields, got {fields.Length}"; return false; } if (!CronField.TryParse(fields[0], 0, 59, out var minute, out error)) return false; if (!CronField.TryParse(fields[1], 0, 23, out var hour, out error)) return false; if (!CronField.TryParse(fields[2], 1, 31, out var dayOfMonth, out error)) return false; if (!CronField.TryParse(fields[3], 1, 12, out var month, out error)) return false; // 0–7: 7 is the Sunday alias and is folded into 0 below. if (!CronField.TryParse(fields[4], 0, 7, out var dayOfWeek, out error)) return false; dayOfWeek!.AliasValue(7, 0); parsed = new CronExpression(minute!, hour!, dayOfMonth!, month!, dayOfWeek); return true; } /// Whether the given UTC instant matches this cron expression. public bool Matches(DateTime utc) => _minute.Matches(utc.Minute) && _hour.Matches(utc.Hour) && _dayOfMonth.Matches(utc.Day) && _month.Matches(utc.Month) && _dayOfWeek.Matches((int)utc.DayOfWeek); /// /// The first occurrence strictly after , truncated to /// the minute and evaluated in UTC. Returns null when no occurrence exists /// within a bounded 5-year horizon (a guard against malformed expressions /// such as a 31st-of-the-month-only schedule that never matches). /// /// The search jumps by field (month → day → hour → minute) instead of walking /// every minute, so an unmatchable expression costs a few thousand steps /// rather than ~2.6 million per call. /// public DateTimeOffset? GetNextOccurrence(DateTimeOffset after) { var utc = after.UtcDateTime; var cursor = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, 0, DateTimeKind.Utc) .AddMinutes(1); var horizon = cursor.AddYears(5); // Safety net for an expression that matches no real instant; the coarse // jumps above make the real worst case only a few thousand iterations. var guard = 0; while (cursor <= horizon) { if (++guard > 100_000) return null; if (!_month.Matches(cursor.Month)) { cursor = new DateTime(cursor.Year, cursor.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1); continue; } if (!MatchesDay(cursor)) { cursor = cursor.Date.AddDays(1); continue; } if (!_hour.Matches(cursor.Hour)) { cursor = cursor.Date.AddHours(cursor.Hour + 1); continue; } if (!_minute.Matches(cursor.Minute)) { cursor = cursor.AddMinutes(1); continue; } return new DateTimeOffset(cursor, TimeSpan.Zero); } return null; } /// /// Day matches when both day-of-month and day-of-week match. A * field /// matches every value, so the common case behaves as expected. /// private bool MatchesDay(DateTime utc) => _dayOfMonth.Matches(utc.Day) && _dayOfWeek.Matches((int)utc.DayOfWeek); /// One field of a cron expression, as a fixed-size allowed-value set. private sealed class CronField { private readonly int _min; private readonly bool[] _allowed; private CronField(int min, bool[] allowed) { _min = min; _allowed = allowed; } public bool Matches(int value) => value >= _min && value - _min < _allowed.Length && _allowed[value - _min]; /// Folds one value into another (Sunday alias 7 → 0). public void AliasValue(int from, int to) { if (from >= _min && from - _min < _allowed.Length) _allowed[to - _min] |= _allowed[from - _min]; } public static bool TryParse(string text, int min, int max, out CronField? field, out string? error) { field = null; error = null; var allowed = new bool[max - min + 1]; foreach (var part in text.Split(',')) { if (part.Length == 0) { error = $"empty cron field segment in '{text}'"; return false; } var step = 1; var range = part; if (part.Contains('/')) { var pieces = part.Split('/'); if (pieces.Length != 2 || !TryParseNumber(pieces[1], out step) || step <= 0) { error = $"invalid step in cron field segment '{part}'"; return false; } range = pieces[0]; } int lo, hi; if (range == "*") { lo = min; hi = max; } else if (range.Contains('-')) { var pieces = range.Split('-'); if (pieces.Length != 2 || !TryParseNumber(pieces[0], out lo) || !TryParseNumber(pieces[1], out hi)) { error = $"invalid range in cron field segment '{part}'"; return false; } } else if (TryParseNumber(range, out lo)) { hi = lo; } else { error = $"invalid value '{range}' in cron field"; return false; } if (lo < min || hi > max || lo > hi) { error = $"value out of range [{min},{max}] in cron field segment '{part}'"; return false; } for (var value = lo; value <= hi; value += step) allowed[value - min] = true; } field = new CronField(min, allowed); return true; } private static bool TryParseNumber(string text, out int value) => int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); } }