//! Cron expression parser and iterator //! //! Supports standard 5-field cron expressions: minute hour day month day-of-week //! //! Example: "0 0 * * *" = midnight daily //! "*/15 * * * *" = every 15 minutes //! "0 9 * * 1-5" = 9am weekdays //! //! ## Type System Notes //! //! This library demonstrates pragmatic Zig type system usage: //! //! - **Error unions** (`CronError!Cron`): Zig's way of handling fallible operations. //! Forces callers to handle errors explicitly. No hidden exceptions. //! //! - **Enums with explicit backing type** (`Field = enum(u3)`): Self-documenting, //! type-safe field indices. Compiler catches invalid field usage. //! //! - **StaticBitSet**: Perfect for bounded integer domains. O(1) lookup, compact //! representation, no allocation. Better than HashMap or []bool for this use case. //! //! - **Options struct**: Idiomatic configuration pattern from std.json, std.fmt. //! Cleaner than multiple overloads or optional parameters. //! //! - **Iterator with ?T return**: The standard Zig iterator pattern. Works naturally //! with `while (iter.next()) |item|` syntax. //! //! What we DON'T use and why: //! //! - **Comptime parsing**: Cron expressions typically come from config files, //! databases, or user input - all runtime values. Comptime validation would //! require expressions to be known at compile time, which defeats the purpose. //! //! - **Type-returning functions**: Could wrap Cron in `fn(comptime expr) type`, //! but it adds complexity without solving a real problem. Runtime parsing with //! good error messages is more practical. const std = @import("std"); /// Parsing errors with specific failure modes pub const CronError = error{ /// Expression doesn't have exactly 5 space-separated fields InvalidExpression, /// Field contains invalid syntax (bad step, unknown name, etc.) InvalidField, /// Range start is greater than end (e.g., "10-5") InvalidRange, /// Value exceeds field bounds (e.g., minute 60) OutOfRange, }; /// Field indices as a typed enum. /// Using enum(u3) because we have 5 fields (fits in 3 bits). /// This is better than raw integers because the compiler prevents mistakes. pub const Field = enum(u3) { minute = 0, hour = 1, day = 2, month = 3, dow = 4, /// Get the valid range for this field pub fn range(self: Field) struct { min: u8, max: u8 } { return switch (self) { .minute => .{ .min = 0, .max = 59 }, .hour => .{ .min = 0, .max = 23 }, .day => .{ .min = 1, .max = 31 }, .month => .{ .min = 1, .max = 12 }, .dow => .{ .min = 0, .max = 6 }, }; } }; /// Days in each month (non-leap year) const days_in_month = [12]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; /// Month name abbreviations const month_names = [_][]const u8{ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec" }; /// Day of week abbreviations (0=Sunday) const dow_names = [_][]const u8{ "sun", "mon", "tue", "wed", "thu", "fri", "sat" }; /// Bitset for a cron field. /// StaticBitSet(64) is ideal here: /// - All cron fields fit in 64 bits (largest is minute: 0-59) /// - O(1) set/test operations /// - No allocation needed /// - Compact memory representation (8 bytes) pub const FieldSet = std.StaticBitSet(64); /// Configuration options for parsing. /// Using an options struct is idiomatic Zig - see std.json.ParseOptions. pub const ParseOptions = struct { /// Day/DOW combination logic. /// true (default): day OR dow - standard cron behavior /// false: day AND dow - fcron behavior day_or: bool = true, }; /// Parsed cron expression pub const Cron = struct { minute: FieldSet, hour: FieldSet, day: FieldSet, month: FieldSet, dow: FieldSet, day_any: bool, // true if day field was "*" (unrestricted) dow_any: bool, // true if dow field was "*" (unrestricted) day_or: bool, // day/dow combination logic /// Parse a cron expression with default options. pub fn parse(expr: []const u8) CronError!Cron { return parseWithOptions(expr, .{}); } /// Parse a cron expression with custom options. pub fn parseWithOptions(expr: []const u8, options: ParseOptions) CronError!Cron { var it = std.mem.splitScalar(u8, expr, ' '); var fields: [5][]const u8 = undefined; var count: usize = 0; while (it.next()) |field| { if (field.len == 0) continue; if (count >= 5) return CronError.InvalidExpression; fields[count] = field; count += 1; } if (count != 5) return CronError.InvalidExpression; return Cron{ .minute = try parseField(fields[0], .minute), .hour = try parseField(fields[1], .hour), .day = try parseField(fields[2], .day), .month = try parseField(fields[3], .month), .dow = try parseField(fields[4], .dow), .day_any = isWildcard(fields[2]), .dow_any = isWildcard(fields[4]), .day_or = options.day_or, }; } /// Get the next occurrence after the given timestamp (microseconds since epoch). /// Returns null if no match is found within 4 years. pub fn next(self: *const Cron, from_micros: i64) ?i64 { const from_secs = @divFloor(from_micros, 1_000_000); var dt = DateTime.fromTimestamp(from_secs); // Start from next minute dt.second = 0; dt.minute += 1; dt.normalize(); // Search up to 4 years (covers leap year cycles) const max_iterations: u32 = 4 * 366 * 24 * 60; var iterations: u32 = 0; while (iterations < max_iterations) : (iterations += 1) { // Check month first (coarsest granularity) if (!self.month.isSet(dt.month)) { dt.month += 1; dt.day = 1; dt.hour = 0; dt.minute = 0; dt.normalize(); continue; } // Check day/dow combination if (!self.checkDayDow(&dt)) { dt.day += 1; dt.hour = 0; dt.minute = 0; dt.normalize(); continue; } if (!self.hour.isSet(dt.hour)) { dt.hour += 1; dt.minute = 0; dt.normalize(); continue; } if (!self.minute.isSet(dt.minute)) { dt.minute += 1; dt.normalize(); continue; } return dt.toTimestamp() * 1_000_000; } return null; } /// Get multiple next occurrences. pub fn nextN(self: *const Cron, from_micros: i64, count: usize, buf: []i64) usize { var current = from_micros; var written: usize = 0; while (written < count and written < buf.len) { if (self.next(current)) |ts| { buf[written] = ts; written += 1; current = ts; } else break; } return written; } /// Create an iterator for successive occurrences. /// Usage: `while (cron.iter(start).next()) |ts| { ... }` pub fn iter(self: *const Cron, start_micros: i64) Iterator { return .{ .cron = self, .current = start_micros }; } /// Check if a timestamp matches this cron expression. pub fn matches(self: *const Cron, micros: i64) bool { const secs = @divFloor(micros, 1_000_000); const dt = DateTime.fromTimestamp(secs); if (!self.minute.isSet(dt.minute)) return false; if (!self.hour.isSet(dt.hour)) return false; if (!self.month.isSet(dt.month)) return false; return self.checkDayDow(&dt); } fn checkDayDow(self: *const Cron, dt: *const DateTime) bool { const day_match = self.day.isSet(dt.day); const dow_match = self.dow.isSet(dt.dayOfWeek()); // Standard cron behavior: // - If day is "*", only check dow // - If dow is "*", only check day // - If both are restricted, use day_or logic if (self.day_any and self.dow_any) return true; if (self.day_any) return dow_match; if (self.dow_any) return day_match; return if (self.day_or) day_match or dow_match else day_match and dow_match; } }; /// Iterator over cron occurrences. /// Demonstrates the standard Zig iterator pattern: next() returns ?T. pub const Iterator = struct { cron: *const Cron, current: i64, /// Get next occurrence, advancing internal state. /// Returns null when no more occurrences (within 4-year search window). pub fn next(self: *Iterator) ?i64 { if (self.cron.next(self.current)) |ts| { self.current = ts; return ts; } return null; } /// Peek at next occurrence without advancing. pub fn peek(self: *const Iterator) ?i64 { return self.cron.next(self.current); } /// Reset iterator to a new starting point. pub fn reset(self: *Iterator, start_micros: i64) void { self.current = start_micros; } }; // ============================================================================ // Field Parsing // ============================================================================ fn isWildcard(field: []const u8) bool { return std.mem.eql(u8, field, "*"); } fn parseField(field: []const u8, idx: Field) CronError!FieldSet { var set = FieldSet.initEmpty(); const r = idx.range(); // Handle comma-separated values var parts = std.mem.splitScalar(u8, field, ','); while (parts.next()) |part| { try parsePart(part, &set, r.min, r.max, idx); } return set; } fn parsePart(part: []const u8, set: *FieldSet, min: u8, max: u8, idx: Field) CronError!void { var step: u8 = 1; var range_part = part; // Handle step syntax: */5, 0-30/5 if (std.mem.indexOf(u8, part, "/")) |slash_pos| { range_part = part[0..slash_pos]; step = std.fmt.parseInt(u8, part[slash_pos + 1 ..], 10) catch return CronError.InvalidField; if (step == 0) return CronError.InvalidField; } // Handle wildcard if (std.mem.eql(u8, range_part, "*")) { var v = min; while (v <= max) : (v += step) { set.set(v); if (v == max or @addWithOverflow(v, step)[1] != 0) break; } return; } // Handle range: 1-5 if (std.mem.indexOf(u8, range_part, "-")) |dash_pos| { const start = try parseValue(range_part[0..dash_pos], min, max, idx); const end = try parseValue(range_part[dash_pos + 1 ..], min, max, idx); if (start > end) return CronError.InvalidRange; var v = start; while (v <= end) : (v += step) { set.set(v); if (v == end or @addWithOverflow(v, step)[1] != 0) break; } return; } // Single value const value = try parseValue(range_part, min, max, idx); set.set(value); } fn parseValue(s: []const u8, min: u8, max: u8, idx: Field) CronError!u8 { // Try numeric first if (std.fmt.parseInt(u8, s, 10)) |v| { if (v < min or v > max) return CronError.OutOfRange; return v; } else |_| {} // Try name lookup for month/dow const lower = blk: { var buf: [3]u8 = undefined; const len = @min(s.len, 3); for (0..len) |i| { buf[i] = std.ascii.toLower(s[i]); } break :blk buf[0..len]; }; if (idx == .month) { for (month_names, 1..) |name, i| { if (std.mem.eql(u8, lower, name)) return @intCast(i); } } else if (idx == .dow) { for (dow_names, 0..) |name, i| { if (std.mem.eql(u8, lower, name)) return @intCast(i); } } return CronError.InvalidField; } // ============================================================================ // DateTime - Simple datetime for cron calculations // ============================================================================ /// Simple datetime struct for timestamp manipulation. /// Not timezone-aware - assumes UTC for simplicity. pub const DateTime = struct { year: i32, month: u8, // 1-12 day: u8, // 1-31 hour: u8, // 0-23 minute: u8, // 0-59 second: u8 = 0, pub fn fromTimestamp(secs: i64) DateTime { var remaining = secs; // Years since 1970 var year: i32 = 1970; while (true) { const days_this_year: i64 = if (isLeapYear(year)) 366 else 365; const secs_this_year = days_this_year * 86400; if (remaining < secs_this_year) break; remaining -= secs_this_year; year += 1; } // Month and day var month: u8 = 1; while (month <= 12) { var days_this_month = days_in_month[month - 1]; if (month == 2 and isLeapYear(year)) days_this_month = 29; const secs_this_month: i64 = @as(i64, days_this_month) * 86400; if (remaining < secs_this_month) break; remaining -= secs_this_month; month += 1; } const day: u8 = @intCast(@divFloor(remaining, 86400) + 1); remaining = @mod(remaining, 86400); const hour: u8 = @intCast(@divFloor(remaining, 3600)); remaining = @mod(remaining, 3600); const minute: u8 = @intCast(@divFloor(remaining, 60)); const second: u8 = @intCast(@mod(remaining, 60)); return .{ .year = year, .month = month, .day = day, .hour = hour, .minute = minute, .second = second }; } pub fn toTimestamp(self: *const DateTime) i64 { var secs: i64 = 0; // Years var y: i32 = 1970; while (y < self.year) : (y += 1) { secs += if (isLeapYear(y)) 366 * 86400 else 365 * 86400; } // Months var m: u8 = 1; while (m < self.month) : (m += 1) { var days = days_in_month[m - 1]; if (m == 2 and isLeapYear(self.year)) days = 29; secs += @as(i64, days) * 86400; } secs += @as(i64, self.day - 1) * 86400; secs += @as(i64, self.hour) * 3600; secs += @as(i64, self.minute) * 60; secs += self.second; return secs; } pub fn normalize(self: *DateTime) void { while (self.minute >= 60) { self.minute -= 60; self.hour += 1; } while (self.hour >= 24) { self.hour -= 24; self.day += 1; } while (true) { var max_day = days_in_month[self.month - 1]; if (self.month == 2 and isLeapYear(self.year)) max_day = 29; if (self.day <= max_day) break; self.day -= max_day; self.month += 1; if (self.month > 12) { self.month = 1; self.year += 1; } } while (self.month > 12) { self.month -= 12; self.year += 1; } } pub fn dayOfWeek(self: *const DateTime) u8 { // Zeller's congruence var y = self.year; var m: i32 = self.month; if (m < 3) { m += 12; y -= 1; } const k = @mod(y, 100); const j = @divFloor(y, 100); var h = self.day + @divFloor(13 * (m + 1), 5) + k + @divFloor(k, 4) + @divFloor(j, 4) - 2 * j; h = @mod(h, 7); // Convert from Saturday=0 to Sunday=0 return @intCast(@mod(h + 6, 7)); } }; fn isLeapYear(year: i32) bool { return @mod(year, 400) == 0 or (@mod(year, 4) == 0 and @mod(year, 100) != 0); } // ============================================================================ // Tests // ============================================================================ test "parse simple expressions" { const cron = try Cron.parse("0 0 * * *"); try std.testing.expect(cron.minute.isSet(0)); try std.testing.expect(!cron.minute.isSet(1)); try std.testing.expect(cron.hour.isSet(0)); try std.testing.expect(!cron.hour.isSet(1)); try std.testing.expect(cron.day.isSet(1)); try std.testing.expect(cron.day.isSet(31)); } test "parse step expressions" { const cron = try Cron.parse("*/15 * * * *"); try std.testing.expect(cron.minute.isSet(0)); try std.testing.expect(cron.minute.isSet(15)); try std.testing.expect(cron.minute.isSet(30)); try std.testing.expect(cron.minute.isSet(45)); try std.testing.expect(!cron.minute.isSet(1)); try std.testing.expect(!cron.minute.isSet(14)); } test "parse range expressions" { const cron = try Cron.parse("0 9-17 * * 1-5"); try std.testing.expect(cron.hour.isSet(9)); try std.testing.expect(cron.hour.isSet(17)); try std.testing.expect(!cron.hour.isSet(8)); try std.testing.expect(!cron.hour.isSet(18)); try std.testing.expect(cron.dow.isSet(1)); try std.testing.expect(cron.dow.isSet(5)); try std.testing.expect(!cron.dow.isSet(0)); try std.testing.expect(!cron.dow.isSet(6)); } test "parse month/dow names" { const cron = try Cron.parse("0 0 1 jan,jun,dec mon,fri"); try std.testing.expect(cron.month.isSet(1)); try std.testing.expect(cron.month.isSet(6)); try std.testing.expect(cron.month.isSet(12)); try std.testing.expect(!cron.month.isSet(2)); try std.testing.expect(cron.dow.isSet(1)); try std.testing.expect(cron.dow.isSet(5)); try std.testing.expect(!cron.dow.isSet(0)); } test "DateTime conversion" { const ts: i64 = 1705321800; // 2024-01-15 12:30:00 UTC const dt = DateTime.fromTimestamp(ts); try std.testing.expectEqual(@as(i32, 2024), dt.year); try std.testing.expectEqual(@as(u8, 1), dt.month); try std.testing.expectEqual(@as(u8, 15), dt.day); try std.testing.expectEqual(@as(u8, 12), dt.hour); try std.testing.expectEqual(@as(u8, 30), dt.minute); try std.testing.expectEqual(ts, dt.toTimestamp()); } test "next occurrence - midnight daily" { const cron = try Cron.parse("0 0 * * *"); const from: i64 = 1705320000 * 1_000_000; // 2024-01-15 12:00:00 const next_ts = cron.next(from) orelse unreachable; const dt = DateTime.fromTimestamp(@divFloor(next_ts, 1_000_000)); try std.testing.expectEqual(@as(i32, 2024), dt.year); try std.testing.expectEqual(@as(u8, 1), dt.month); try std.testing.expectEqual(@as(u8, 16), dt.day); try std.testing.expectEqual(@as(u8, 0), dt.hour); try std.testing.expectEqual(@as(u8, 0), dt.minute); } test "next occurrence - every 15 minutes" { const cron = try Cron.parse("*/15 * * * *"); const from: i64 = 1705320420 * 1_000_000; // 2024-01-15 12:07:00 const next_ts = cron.next(from) orelse unreachable; const dt = DateTime.fromTimestamp(@divFloor(next_ts, 1_000_000)); try std.testing.expectEqual(@as(u8, 12), dt.hour); try std.testing.expectEqual(@as(u8, 15), dt.minute); } test "day of week calculation" { const dt = DateTime{ .year = 2024, .month = 1, .day = 15, .hour = 0, .minute = 0 }; try std.testing.expectEqual(@as(u8, 1), dt.dayOfWeek()); // Monday const dt2 = DateTime{ .year = 2024, .month = 1, .day = 14, .hour = 0, .minute = 0 }; try std.testing.expectEqual(@as(u8, 0), dt2.dayOfWeek()); // Sunday const dt3 = DateTime{ .year = 2024, .month = 1, .day = 20, .hour = 0, .minute = 0 }; try std.testing.expectEqual(@as(u8, 6), dt3.dayOfWeek()); // Saturday } test "leap year" { try std.testing.expect(isLeapYear(2024)); try std.testing.expect(!isLeapYear(2023)); try std.testing.expect(isLeapYear(2000)); try std.testing.expect(!isLeapYear(1900)); } test "matches" { const cron = try Cron.parse("30 9 * * 1-5"); const monday_930: i64 = 1705311000 * 1_000_000; // Monday 09:30 try std.testing.expect(cron.matches(monday_930)); const sunday_930: i64 = 1705224600 * 1_000_000; // Sunday 09:30 try std.testing.expect(!cron.matches(sunday_930)); } test "iterator" { const cron = try Cron.parse("*/15 * * * *"); var it = cron.iter(1705320000 * 1_000_000); // 2024-01-15 12:00:00 // First occurrence should be 12:15 const first = it.next() orelse unreachable; var dt = DateTime.fromTimestamp(@divFloor(first, 1_000_000)); try std.testing.expectEqual(@as(u8, 12), dt.hour); try std.testing.expectEqual(@as(u8, 15), dt.minute); // Second should be 12:30 const second = it.next() orelse unreachable; dt = DateTime.fromTimestamp(@divFloor(second, 1_000_000)); try std.testing.expectEqual(@as(u8, 12), dt.hour); try std.testing.expectEqual(@as(u8, 30), dt.minute); // Third should be 12:45 const third = it.next() orelse unreachable; dt = DateTime.fromTimestamp(@divFloor(third, 1_000_000)); try std.testing.expectEqual(@as(u8, 12), dt.hour); try std.testing.expectEqual(@as(u8, 45), dt.minute); } test "day_or option" { // Default: day OR dow (standard cron) const or_cron = try Cron.parse("0 0 15 * 1"); // 15th OR Monday const and_cron = try Cron.parseWithOptions("0 0 15 * 1", .{ .day_or = false }); // 15th AND Monday // 2024-01-15 is Monday AND the 15th - both should match const monday_15th: i64 = 1705276800 * 1_000_000; try std.testing.expect(or_cron.matches(monday_15th)); try std.testing.expect(and_cron.matches(monday_15th)); // 2024-01-16 is Tuesday and 16th - OR matches (it's the 16th... wait no) // Let's use 2024-01-22 which is Monday but not the 15th const monday_22nd: i64 = 1705881600 * 1_000_000; try std.testing.expect(or_cron.matches(monday_22nd)); // OR: Monday matches try std.testing.expect(!and_cron.matches(monday_22nd)); // AND: not the 15th }