w4c-workflows-api/Services/Triggers/CronExpression.cs

189 lines
6.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Globalization;
namespace w4c_workflows.Services.Triggers;
/// <summary>
/// Minimal 5-field cron parser (minute hour day-of-month month day-of-week)
/// supporting <c>*</c>, <c>,</c> (list), <c>-</c> (range) and <c>/</c> (step), plus
/// numeric values. Enough for workflow scheduling (<c>"0 */6 * * *"</c>,
/// <c>"0 9 * * 1-5"</c>); 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 <c>"0 9 * * 1-5"</c> runs MonFri and
/// <c>"0 0 1 * *"</c> runs on the 1st of the month (the <c>*</c> field matches all
/// values and therefore never filters).
/// </summary>
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;
}
/// <summary>
/// Parses a 5-field cron expression. On success <paramref name="parsed"/> is
/// set and <paramref name="error"/> is null; otherwise the reverse.
/// </summary>
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;
// 07: 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;
}
/// <summary>Whether the given UTC instant matches this cron expression.</summary>
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);
/// <summary>
/// The first occurrence strictly after <paramref name="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).
/// </summary>
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);
while (cursor <= horizon)
{
if (Matches(cursor))
return new DateTimeOffset(cursor, TimeSpan.Zero);
cursor = cursor.AddMinutes(1);
}
return null;
}
/// <summary>One field of a cron expression, as a fixed-size allowed-value set.</summary>
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];
/// <summary>Folds one value into another (Sunday alias 7 → 0).</summary>
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);
}
}