1/**
2 * Utilities for recurring calendar schedules written as cron expressions or
3 * explicit field constraints. A `Cron` value stores allowed seconds, minutes,
4 * hours, days of month, months, weekdays, and an optional time zone. The module
5 * can create or parse schedules, compare them, test whether a date matches, and
6 * find previous or next scheduled occurrences.
7 *
8 * @since 2.0.0
9 */
10import * as Arr from "./Array.ts"
11import * as Data from "./Data.ts"
12import type * as DateTime from "./DateTime.ts"
13import * as Equal from "./Equal.ts"
14import * as Equ from "./Equivalence.ts"
15import { format as formatValue } from "./Formatter.ts"
16import { constVoid, dual, pipe } from "./Function.ts"
17import * as Hash from "./Hash.ts"
18import { type Inspectable, NodeInspectSymbol } from "./Inspectable.ts"
19import * as dateTime from "./internal/dateTime.ts"
20import * as N from "./Number.ts"
21import * as Option from "./Option.ts"
22import { type Pipeable, pipeArguments } from "./Pipeable.ts"
23import { hasProperty } from "./Predicate.ts"
24import * as Result from "./Result.ts"
25import * as String from "./String.ts"
26import type { Mutable } from "./Types.ts"
27
28const TypeId = "~effect/Cron"
29
30/**
31 * Represents a cron schedule with time constraints and timezone information.
32 *
33 * **When to use**
34 *
35 * Use to represent a recurring calendar schedule that can be matched against
36 * dates or used to compute scheduled occurrences.
37 *
38 * **Details**
39 *
40 * A `Cron` instance defines when a scheduled task should run, supporting
41 * seconds, minutes, hours, days, months, and weekday constraints. It also
42 * supports timezone-aware scheduling.
43 *
44 * **Example** (Creating a cron schedule)
45 *
46 * ```ts import.meta.vitest
47 * import { Cron, DateTime } from "effect"
48 *
49 * // Create a cron that runs at 9 AM on weekdays
50 * const weekdayMorning = Cron.make({
51 * minutes: [0],
52 * hours: [9],
53 * days: [],
54 * months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
55 * weekdays: [1, 2, 3, 4, 5], // Monday to Friday
56 * tz: DateTime.zoneMakeNamedUnsafe("UTC")
57 * })
58 *
59 * // Check if a date matches the schedule
60 * Cron.match(weekdayMorning, "2023-06-05T09:00:00Z") // => true
61 * ```
62 *
63 * @see {@link make} for creating a schedule from explicit field constraints
64 * @see {@link parse} for creating a schedule from a cron expression string
65 * @see {@link match} for testing a date against a schedule
66 * @see {@link next} for finding the next scheduled occurrence
67 *
68 * @category models
69 * @since 2.0.0
70 */
71export interface Cron extends Pipeable, Equal.Equal, Inspectable {
72 readonly [TypeId]: typeof TypeId
73 readonly tz: Option.Option<DateTime.TimeZone>
74 readonly seconds: ReadonlySet<number>
75 readonly minutes: ReadonlySet<number>
76 readonly hours: ReadonlySet<number>
77 readonly days: ReadonlySet<number>
78 readonly months: ReadonlySet<number>
79 readonly weekdays: ReadonlySet<number>
80 /** @internal */
81 readonly and: boolean
82 /** @internal */
83 readonly first: {
84 readonly second: number
85 readonly minute: number
86 readonly hour: number
87 readonly day: number
88 readonly month: number
89 readonly weekday: number
90 }
91 /** @internal */
92 readonly last: {
93 readonly second: number
94 readonly minute: number
95 readonly hour: number
96 readonly day: number
97 readonly month: number
98 readonly weekday: number
99 }
100 /** @internal */
101 readonly next: {
102 readonly second: ReadonlyArray<number | undefined>
103 readonly minute: ReadonlyArray<number | undefined>
104 readonly hour: ReadonlyArray<number | undefined>
105 readonly day: ReadonlyArray<number | undefined>
106 readonly month: ReadonlyArray<number | undefined>
107 readonly weekday: ReadonlyArray<number | undefined>
108 }
109 /** @internal */
110 readonly prev: {
111 readonly second: ReadonlyArray<number | undefined>
112 readonly minute: ReadonlyArray<number | undefined>
113 readonly hour: ReadonlyArray<number | undefined>
114 readonly day: ReadonlyArray<number | undefined>
115 readonly month: ReadonlyArray<number | undefined>
116 readonly weekday: ReadonlyArray<number | undefined>
117 }
118}
119
120function toPojo(cron: Cron): Record<string, unknown> {
121 const out: Record<string, unknown> = {
122 tz: cron.tz,
123 and: cron.and,
124 seconds: Arr.fromIterable(cron.seconds),
125 minutes: Arr.fromIterable(cron.minutes),
126 hours: Arr.fromIterable(cron.hours),
127 days: Arr.fromIterable(cron.days),
128 months: Arr.fromIterable(cron.months),
129 weekdays: Arr.fromIterable(cron.weekdays)
130 }
131 return out
132}
133
134const CronProto = {
135 [TypeId]: TypeId,
136 [Equal.symbol](this: Cron, that: unknown) {
137 return isCron(that) && equals(this, that)
138 },
139 [Hash.symbol](this: Cron): number {
140 return pipe(
141 Hash.hash(this.tz),
142 Hash.combine(Hash.hash(this.and)),
143 Hash.combine(Hash.array(Arr.fromIterable(this.seconds))),
144 Hash.combine(Hash.array(Arr.fromIterable(this.minutes))),
145 Hash.combine(Hash.array(Arr.fromIterable(this.hours))),
146 Hash.combine(Hash.array(Arr.fromIterable(this.days))),
147 Hash.combine(Hash.array(Arr.fromIterable(this.months))),
148 Hash.combine(Hash.array(Arr.fromIterable(this.weekdays)))
149 )
150 },
151 toObject(this: Cron) {
152 return toPojo(this)
153 },
154 toString(this: Cron) {
155 return `Cron(${formatValue(toPojo(this))})`
156 },
157 toJSON(this: Cron) {
158 const out = toPojo(this)
159 out["_id"] = "Cron"
160 return out
161 },
162 [NodeInspectSymbol](this: Cron) {
163 return this.toJSON()
164 },
165 pipe() {
166 return pipeArguments(this, arguments)
167 }
168}
169
170/**
171 * Checks whether a given value is a Cron instance.
172 *
173 * **When to use**
174 *
175 * Use to narrow an unknown value before treating it as a `Cron` schedule.
176 *
177 * **Details**
178 *
179 * This function is a type guard that determines whether the provided
180 * value is a valid Cron instance by checking for the presence of the
181 * Cron type identifier.
182 *
183 * **Example** (Checking cron values)
184 *
185 * ```ts import.meta.vitest
186 * import { Cron } from "effect"
187 *
188 * const cron = Cron.make({
189 * minutes: [0],
190 * hours: [9],
191 * days: [1, 15],
192 * months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
193 * weekdays: [1, 2, 3, 4, 5]
194 * })
195 *
196 * Cron.isCron(cron) // => true
197 * Cron.isCron({}) // => false
198 * Cron.isCron("not a cron") // => false
199 * ```
200 *
201 * @see {@link make} for constructing a `Cron` value directly
202 * @see {@link parse} for constructing a `Cron` value from a string
203 *
204 * @category guards
205 * @since 2.0.0
206 */
207export const isCron = (u: unknown): u is Cron => hasProperty(u, TypeId)
208
209/**
210 * Creates a Cron instance from time constraints.
211 *
212 * **When to use**
213 *
214 * Use to build a cron schedule from explicit sets of allowed time-field values.
215 *
216 * **Details**
217 *
218 * Constructs a cron schedule by specifying which seconds, minutes, hours,
219 * days, months, and weekdays the schedule should match. Empty arrays leave a
220 * time unit unrestricted. If only days or weekdays are restricted, that field
221 * must match. When both are restricted, the default matches either field; set
222 * `and: true` to require both fields to match. Weekdays range from `0` (Sunday)
223 * to `7` (also Sunday). The constructor throws a `RangeError` when a field
224 * contains a non-integer or out-of-range value.
225 *
226 * **Example** (Creating schedules from constraints)
227 *
228 * ```ts import.meta.vitest
229 * import { Cron, DateTime } from "effect"
230 *
231 * const utc = DateTime.zoneMakeNamedUnsafe("UTC")
232 *
233 * // Every day at midnight
234 * const midnight = Cron.make({
235 * minutes: [0],
236 * hours: [0],
237 * days: [
238 * 1,
239 * 2,
240 * 3,
241 * 4,
242 * 5,
243 * 6,
244 * 7,
245 * 8,
246 * 9,
247 * 10,
248 * 11,
249 * 12,
250 * 13,
251 * 14,
252 * 15,
253 * 16,
254 * 17,
255 * 18,
256 * 19,
257 * 20,
258 * 21,
259 * 22,
260 * 23,
261 * 24,
262 * 25,
263 * 26,
264 * 27,
265 * 28,
266 * 29,
267 * 30,
268 * 31
269 * ],
270 * months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
271 * weekdays: [0, 1, 2, 3, 4, 5, 6],
272 * tz: utc
273 * })
274 *
275 * // Every 15 minutes during business hours on weekdays
276 * const businessHours = Cron.make({
277 * minutes: [0, 15, 30, 45],
278 * hours: [9, 10, 11, 12, 13, 14, 15, 16, 17],
279 * days: [],
280 * months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
281 * weekdays: [1, 2, 3, 4, 5], // Monday to Friday
282 * tz: utc
283 * })
284 *
285 * Cron.match(midnight, "2024-01-01T00:00:00Z") // => true
286 * Cron.match(businessHours, "2024-01-01T09:15:00Z") // => true
287 * ```
288 *
289 * @see {@link parse} for building a schedule from a cron expression string
290 *
291 * @category constructors
292 * @since 2.0.0
293 */
294export const make = (values: {
295 readonly seconds?: Iterable<number> | undefined
296 readonly minutes: Iterable<number>
297 readonly hours: Iterable<number>
298 readonly days: Iterable<number>
299 readonly months: Iterable<number>
300 readonly weekdays: Iterable<number>
301 readonly and?: boolean | undefined
302 readonly tz?: DateTime.TimeZone | undefined
303}): Cron => {
304 const o: Mutable<Cron> = Object.create(CronProto)
305 o.seconds = restrictions.seconds(values.seconds ?? [0])
306 o.minutes = restrictions.minutes(values.minutes)
307 o.hours = restrictions.hours(values.hours)
308 o.days = restrictions.days(values.days)
309 o.months = restrictions.months(values.months)
310 o.weekdays = restrictions.weekdays(values.weekdays)
311 o.and = values.and === true
312 o.tz = Option.fromUndefinedOr(values.tz)
313
314 const seconds = Array.from(o.seconds)
315 const minutes = Array.from(o.minutes)
316 const hours = Array.from(o.hours)
317 const days = Array.from(o.days)
318 const months = Array.from(o.months)
319 const weekdays = Array.from(o.weekdays)
320
321 o.first = {
322 second: seconds[0] ?? 0,
323 minute: minutes[0] ?? 0,
324 hour: hours[0] ?? 0,
325 day: days[0] ?? 1,
326 month: (months[0] ?? 1) - 1,
327 weekday: weekdays[0] ?? 0
328 }
329
330 o.last = {
331 second: seconds[seconds.length - 1] ?? 59,
332 minute: minutes[minutes.length - 1] ?? 59,
333 hour: hours[hours.length - 1] ?? 23,
334 day: days[days.length - 1] ?? 31,
335 month: (months[months.length - 1] ?? 12) - 1,
336 weekday: weekdays[weekdays.length - 1] ?? 6
337 }
338
339 o.next = {
340 second: lookup.next.second(seconds),
341 minute: lookup.next.minute(minutes),
342 hour: lookup.next.hour(hours),
343 day: lookup.next.day(days),
344 month: lookup.next.month(months),
345 weekday: lookup.next.weekday(weekdays)
346 }
347
348 o.prev = {
349 second: lookup.prev.second(seconds),
350 minute: lookup.prev.minute(minutes),
351 hour: lookup.prev.hour(hours),
352 day: lookup.prev.day(days),
353 month: lookup.prev.month(months),
354 weekday: lookup.prev.weekday(weekdays)
355 }
356
357 return o
358}
359
360const makeRestrictions = (
361 field: string,
362 min: number,
363 max: number,
364 normalize: (value: number) => number = (value) => value
365): (values: Iterable<number>) => Set<number> =>
366(values) => {
367 const restrictions: Array<number> = []
368 for (const value of values) {
369 if (!Number.isInteger(value) || value < min || value > max) {
370 throw new RangeError(`${field} must contain only integers between ${min} and ${max}`)
371 }
372 restrictions.push(normalize(value))
373 }
374 return new Set(Arr.sort(restrictions, N.Order))
375}
376
377const restrictions = {
378 seconds: makeRestrictions("seconds", 0, 59),
379 minutes: makeRestrictions("minutes", 0, 59),
380 hours: makeRestrictions("hours", 0, 23),
381 days: makeRestrictions("days", 1, 31),
382 months: makeRestrictions("months", 1, 12),
383 weekdays: makeRestrictions("weekdays", 0, 7, (value) => value === 7 ? 0 : value)
384}
385
386const makeLookupTable = (
387 size: number,
388 dir: "next" | "prev"
389): (values: ReadonlyArray<number>) => Array<number | undefined> =>
390(values) => {
391 const result = new Array(size).fill(undefined)
392 if (values.length === 0) {
393 return result
394 }
395
396 let current: number | undefined = undefined
397
398 if (dir === "next") {
399 let index = values.length - 1
400 for (let i = size - 1; i >= 0; i--) {
401 while (index >= 0 && values[index] >= i) {
402 current = values[index--]
403 }
404 result[i] = current
405 }
406 } else {
407 let index = 0
408 for (let i = 0; i < size; i++) {
409 while (index < values.length && values[index] <= i) {
410 current = values[index++]
411 }
412 result[i] = current
413 }
414 }
415
416 return result
417}
418
419const lookup = {
420 prev: {
421 second: makeLookupTable(60, "prev"),
422 minute: makeLookupTable(60, "prev"),
423 hour: makeLookupTable(24, "prev"),
424 day: makeLookupTable(32, "prev"),
425 month: makeLookupTable(13, "prev"),
426 weekday: makeLookupTable(7, "prev")
427 },
428 next: {
429 second: makeLookupTable(60, "next"),
430 minute: makeLookupTable(60, "next"),
431 hour: makeLookupTable(24, "next"),
432 day: makeLookupTable(32, "next"),
433 month: makeLookupTable(13, "next"),
434 weekday: makeLookupTable(7, "next")
435 }
436}
437
438const CronParseErrorTypeId = "~effect/Cron/CronParseError"
439
440/**
441 * Represents an error that occurs when parsing a cron expression fails.
442 *
443 * **When to use**
444 *
445 * Use to handle invalid cron expression failures returned by `parse`.
446 *
447 * **Details**
448 *
449 * This error provides information about what went wrong during parsing,
450 * including the error message and optionally the input that caused the error.
451 *
452 * **Example** (Handling cron parse failures)
453 *
454 * ```ts import.meta.vitest
455 * import { Cron, Result } from "effect"
456 *
457 * const expected = Result.fail(new Cron.CronParseError({
458 * message: "Invalid number of segments in cron expression",
459 * input: "invalid expression"
460 * }))
461 *
462 * Cron.parse("invalid expression") // => expected
463 * ```
464 *
465 * @see {@link parse} for the parser that returns this error in `Result.fail`
466 * @see {@link isCronParseError} for narrowing unknown values to this error type
467 *
468 * @category errors
469 * @since 4.0.0
470 */
471export class CronParseError extends Data.TaggedError("CronParseError")<{
472 readonly message: string
473 readonly input?: string
474}> {
475 readonly [CronParseErrorTypeId]: typeof CronParseErrorTypeId = CronParseErrorTypeId
476}
477
478/**
479 * Checks whether a given value is a CronParseError instance.
480 *
481 * **When to use**
482 *
483 * Use to narrow an unknown failure before handling it as a cron parse error.
484 *
485 * **Details**
486 *
487 * This function is a type guard that determines whether the provided
488 * value is a CronParseError by checking for the presence of the
489 * CronParseError type identifier.
490 *
491 * **Example** (Checking cron parse errors)
492 *
493 * ```ts import.meta.vitest
494 * import { Cron, Result } from "effect"
495 *
496 * Result.mapError(Cron.parse("invalid cron expression"), Cron.isCronParseError) // => Result.fail(true)
497 * Cron.isCronParseError(new Error("regular error")) // => false
498 * Cron.isCronParseError("not an error") // => false
499 * ```
500 *
501 * @see {@link CronParseError} for the parse error type
502 * @see {@link parse} for producing `CronParseError` values on invalid input
503 *
504 * @category guards
505 * @since 4.0.0
506 */
507export const isCronParseError = (u: unknown): u is CronParseError => hasProperty(u, CronParseErrorTypeId)
508
509/**
510 * Parses a cron expression safely into a `Cron` instance, returning a `Result`
511 * instead of throwing.
512 *
513 * **When to use**
514 *
515 * Use to parse cron expressions from configuration or user input while handling
516 * invalid input as a `Result`.
517 *
518 * **Details**
519 *
520 * The expression may contain five fields, where seconds default to `0`, or six
521 * fields including seconds. Fields support `*`, comma-separated values, ranges,
522 * steps, and month or weekday aliases. Invalid expressions fail with
523 * `CronParseError`. When both the day-of-month and weekday fields are
524 * restricted, a date matches if either field matches. When either field starts
525 * with `*`, both fields must match; an unrestricted field always matches.
526 *
527 * **Example** (Parsing cron expressions)
528 *
529 * ```ts import.meta.vitest
530 * import { Cron, Result } from "effect"
531 *
532 * // At 04:00 on every day-of-month from 8 through 14.
533 * const cron = Result.getOrThrow(Cron.parse("0 0 4 8-14 * *"))
534 *
535 * Array.from(cron.hours) // => [4]
536 * Array.from(cron.days) // => [8, 9, 10, 11, 12, 13, 14]
537 * ```
538 *
539 * @see {@link parseUnsafe} for throwing on invalid cron expressions
540 * @see {@link make} for constructing a schedule from explicit field constraints
541 *
542 * @category constructors
543 * @since 2.0.0
544 */
545export const parse = (cron: string, tz?: DateTime.TimeZone | string): Result.Result<Cron, CronParseError> => {
546 const segments = cron.trim().split(/\s+/).filter(String.isNonEmpty)
547 if (segments.length !== 5 && segments.length !== 6) {
548 return Result.fail(new CronParseError({ message: `Invalid number of segments in cron expression`, input: cron }))
549 }
550
551 if (segments.length === 5) {
552 segments.unshift("0")
553 }
554
555 const [seconds, minutes, hours, days, months, weekdays] = segments
556 const zone = tz === undefined || dateTime.isTimeZone(tz) ?
557 Result.succeed(tz) :
558 Result.fromOption(
559 dateTime.zoneFromString(tz),
560 () => new CronParseError({ message: `Invalid time zone in cron expression`, input: tz })
561 )
562
563 return Result.all({
564 tz: zone,
565 seconds: parseSegment(seconds, secondOptions),
566 minutes: parseSegment(minutes, minuteOptions),
567 hours: parseSegment(hours, hourOptions),
568 days: parseSegment(days, dayOptions),
569 months: parseSegment(months, monthOptions),
570 weekdays: parseSegment(weekdays, weekdayOptions)
571 }).pipe(Result.map(({ tz, seconds, minutes, hours, days, months, weekdays }) =>
572 make({
573 tz,
574 seconds: seconds.values,
575 minutes: minutes.values,
576 hours: hours.values,
577 days: days.values,
578 months: months.values,
579 weekdays: weekdays.values,
580 and: (days.wildcard || weekdays.wildcard) && days.values.size !== 0 && weekdays.values.size !== 0
581 })
582 ))
583}
584
585/**
586 * Parses a cron expression into a `Cron` instance, throwing on failure.
587 *
588 * **When to use**
589 *
590 * Use when you expect the input to be valid and want to avoid handling the
591 * `Result` type.
592 *
593 * **Example** (Parsing cron expressions unsafely)
594 *
595 * ```ts import.meta.vitest
596 * import { Cron } from "effect"
597 *
598 * // At 04:00 on every day-of-month from 8 through 14
599 * const cron = Cron.parseUnsafe("0 0 4 8-14 * *", "UTC")
600 *
601 * // With timezone
602 * const cronWithTz = Cron.parseUnsafe("0 0 9 * * *", "America/New_York")
603 *
604 * // This would throw an error
605 * // const invalid = Cron.parseUnsafe("invalid expression")
606 * Cron.match(cron, "2024-01-10T04:00:00Z") // => true
607 * Cron.match(cronWithTz, "2024-01-01T14:00:00Z") // => true
608 * ```
609 *
610 * @category constructors
611 * @since 4.0.0
612 */
613export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron => Result.getOrThrow(parse(cron, tz))
614
615/**
616 * Formats a `Cron` instance as a cron expression.
617 *
618 * **Details**
619 *
620 * The default seconds field (`0`) is omitted unless `includeSeconds` is `true`.
621 * Other seconds configurations are always included.
622 *
623 * **Gotchas**
624 *
625 * Formatting drops the timezone information and the `and` restriction between
626 * days and weekdays. Parsing the result is therefore not guaranteed to produce
627 * an equivalent schedule.
628 *
629 * **Example** (Formatting a cron expression)
630 *
631 * ```ts import.meta.vitest
632 * import { Cron } from "effect"
633 *
634 * const cron = Cron.parseUnsafe("23 0-20/2 * * 0", "UTC")
635 *
636 * Cron.format(cron) // => "23 0-20/2 * * 0"
637 * Cron.format(cron, { includeSeconds: true }) // => "0 23 0-20/2 * * 0"
638 * ```
639 *
640 * @category getters
641 * @since 4.0.0
642 */
643export const format = (cron: Cron, options?: {
644 readonly includeSeconds?: boolean | undefined
645}): string => {
646 const segments = [cron.seconds, cron.minutes, cron.hours, cron.days, cron.months, cron.weekdays]
647 .map(formatSegment)
648 return (
649 options?.includeSeconds !== true && cron.seconds.size === 1 && cron.seconds.has(0) ? segments.slice(1) : segments
650 ).join(" ")
651}
652
653const formatSegment = (values: ReadonlySet<number>): string => {
654 if (values.size === 0) {
655 return "*"
656 }
657 const array = Array.from(values)
658 const segments: Array<string> = []
659 let index = 0
660 while (index < array.length) {
661 const start = array[index]!
662 const step = array[index + 1]! - start
663 if (index + 2 < array.length && array[index + 2]! - array[index + 1]! === step) {
664 let end = index + 2
665 while (end + 1 < array.length && array[end + 1]! - array[end]! === step) {
666 end++
667 }
668 segments.push(`${start}-${array[end]}${step === 1 ? "" : `/${step}`}`)
669 index = end + 1
670 } else {
671 segments.push(`${start}`)
672 index++
673 }
674 }
675 return segments.join(",")
676}
677
678/**
679 * Returns `true` when a date/time matches a `Cron` schedule.
680 *
681 * **When to use**
682 *
683 * Use to test whether a specific date/time satisfies a cron schedule.
684 *
685 * **Details**
686 *
687 * The schedule's timezone determines which calendar fields are read from the
688 * input; the host system's timezone is used when the schedule has no timezone.
689 * Seconds, minutes, hours, and months are checked against their restrictions;
690 * an empty set leaves that field unrestricted. If only `days` or `weekdays` is
691 * restricted, that field must match. If both are restricted, either may match
692 * unless the schedule was created with `and: true`, which requires both to
693 * match.
694 *
695 * **Example** (Matching dates against a schedule)
696 *
697 * ```ts import.meta.vitest
698 * import { Cron, Result } from "effect"
699 *
700 * const cron = Result.getOrThrow(Cron.parse("0 0 4 8-14 * *", "UTC"))
701 *
702 * // Check if specific dates match
703 * Cron.match(cron, "2021-01-08T04:00:00Z") // => true
704 * Cron.match(cron, "2021-01-08T05:00:00Z") // => false
705 * Cron.match(cron, "2021-01-07T04:00:00Z") // => false
706 * ```
707 *
708 * @see {@link next} for finding the next matching date/time
709 * @see {@link prev} for finding the previous matching date/time
710 *
711 * @category predicates
712 * @since 2.0.0
713 */
714export const match = (cron: Cron, date: DateTime.DateTime.Input): boolean => {
715 const parts = dateTime.makeZonedUnsafe(date, {
716 timeZone: Option.getOrUndefined(cron.tz)
717 }).pipe(dateTime.toParts)
718
719 if (cron.seconds.size !== 0 && !cron.seconds.has(parts.second)) {
720 return false
721 }
722
723 if (cron.minutes.size !== 0 && !cron.minutes.has(parts.minute)) {
724 return false
725 }
726
727 if (cron.hours.size !== 0 && !cron.hours.has(parts.hour)) {
728 return false
729 }
730
731 if (cron.months.size !== 0 && !cron.months.has(parts.month)) {
732 return false
733 }
734
735 if (cron.days.size === 0 && cron.weekdays.size === 0) {
736 return true
737 }
738
739 if (cron.and) {
740 return (cron.days.size === 0 || cron.days.has(parts.day)) &&
741 (cron.weekdays.size === 0 || cron.weekdays.has(parts.weekDay))
742 }
743
744 if (cron.weekdays.size === 0) {
745 return cron.days.has(parts.day)
746 }
747
748 if (cron.days.size === 0) {
749 return cron.weekdays.has(parts.weekDay)
750 }
751
752 return cron.days.has(parts.day) || cron.weekdays.has(parts.weekDay)
753}
754
755const daysInMonth = (date: Date): number =>
756 new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate()
757
758/**
759 * Returns the next scheduled date/time for the given Cron instance.
760 *
761 * **When to use**
762 *
763 * Use to find the next occurrence of a cron schedule after a specific date/time
764 * or after the current time.
765 *
766 * **Details**
767 *
768 * Searches for the next date and time when the cron schedule should trigger,
769 * starting after the specified date/time or after the current time when no
770 * date is provided.
771 *
772 * **Example** (Finding the next occurrence)
773 *
774 * ```ts import.meta.vitest
775 * import { Cron, Result } from "effect"
776 *
777 * const cron = Result.getOrThrow(Cron.parse("0 0 4 8-14 * *", "UTC"))
778 *
779 * // Get next run after a specific date
780 * Cron.next(cron, "2021-01-01T00:00:00Z").toISOString() // => "2021-01-08T04:00:00.000Z"
781 * ```
782 *
783 * @see {@link prev} for finding the previous scheduled occurrence
784 * @see {@link sequence} for iterating future scheduled occurrences
785 *
786 * @category getters
787 * @since 2.0.0
788 */
789export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => {
790 return stepCron(cron, now, "next")
791}
792
793/**
794 * Returns the previous scheduled date/time for the given Cron instance.
795 *
796 * **When to use**
797 *
798 * Use to find the most recent occurrence of a cron schedule before a specific
799 * date/time or before the current time.
800 *
801 * **Details**
802 *
803 * When no date/time is provided, the search starts from the current time.
804 *
805 * **Gotchas**
806 *
807 * The search is strict: if the supplied date/time already matches the schedule,
808 * the result is the earlier occurrence.
809 *
810 * @see {@link next} for finding the next scheduled occurrence
811 *
812 * @category getters
813 * @since 3.20.0
814 */
815export const prev = (cron: Cron, now?: DateTime.DateTime.Input): Date => {
816 return stepCron(cron, now, "prev")
817}
818
819const stepCron = (cron: Cron, now: DateTime.DateTime.Input | undefined, direction: "next" | "prev"): Date => {
820 const tz = Option.getOrUndefined(cron.tz)
821 const zoned = dateTime.makeZonedUnsafe(now ?? new Date(), {
822 timeZone: tz
823 })
824
825 const reverse = direction === "prev"
826 const tick = reverse ? -1 : 1
827 const table = cron[direction]
828 const boundary = reverse ? cron.last : cron.first
829
830 const needsStep = reverse ?
831 (next: number, current: number) => next < current :
832 (next: number, current: number) => next > current
833
834 const utc = tz !== undefined && dateTime.isTimeZoneNamed(tz) && tz.id === "UTC"
835 const adjustDst = utc ? constVoid : (current: Date) => {
836 const adjusted = dateTime.makeZonedUnsafe(current, {
837 timeZone: zoned.zone,
838 adjustForTimeZone: true,
839 disambiguation: reverse ? "later" : undefined
840 }).pipe(dateTime.toDate)
841
842 const drift = current.getTime() - adjusted.getTime()
843 if (reverse ? drift !== 0 : drift > 0) {
844 current.setTime(reverse ? adjusted.getTime() : current.getTime() + drift)
845 }
846 }
847
848 const result = dateTime.mutate(zoned, (current) => {
849 current.setUTCSeconds(current.getUTCSeconds() + tick, 0)
850
851 for (let i = 0; i < 10_000; i++) {
852 if (cron.seconds.size !== 0) {
853 const currentSecond = current.getUTCSeconds()
854 const nextSecond = table.second[currentSecond]
855 if (nextSecond === undefined) {
856 current.setUTCMinutes(current.getUTCMinutes() + tick, boundary.second)
857 adjustDst(current)
858 continue
859 }
860 if (needsStep(nextSecond, currentSecond)) {
861 current.setUTCSeconds(nextSecond)
862 adjustDst(current)
863 continue
864 }
865 }
866
867 if (cron.minutes.size !== 0) {
868 const currentMinute = current.getUTCMinutes()
869 const nextMinute = table.minute[currentMinute]
870 if (nextMinute === undefined) {
871 current.setUTCHours(current.getUTCHours() + tick, boundary.minute, boundary.second)
872 adjustDst(current)
873 continue
874 }
875 if (needsStep(nextMinute, currentMinute)) {
876 current.setUTCMinutes(nextMinute, boundary.second)
877 adjustDst(current)
878 continue
879 }
880 }
881
882 if (cron.hours.size !== 0) {
883 const currentHour = current.getUTCHours()
884 const nextHour = table.hour[currentHour]
885 if (nextHour === undefined) {
886 current.setUTCDate(current.getUTCDate() + tick)
887 current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
888 adjustDst(current)
889 continue
890 }
891 if (needsStep(nextHour, currentHour)) {
892 current.setUTCHours(nextHour, boundary.minute, boundary.second)
893 adjustDst(current)
894 continue
895 }
896 }
897
898 if (cron.weekdays.size !== 0 || cron.days.size !== 0) {
899 if (cron.and) {
900 const matchesDay = cron.days.size === 0 || cron.days.has(current.getUTCDate())
901 const matchesWeekday = cron.weekdays.size === 0 || cron.weekdays.has(current.getUTCDay())
902 if (!matchesDay || !matchesWeekday) {
903 current.setUTCDate(current.getUTCDate() + tick)
904 current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
905 adjustDst(current)
906 continue
907 }
908 } else {
909 let a: number = reverse ? -Infinity : Infinity
910 let b: number = reverse ? -Infinity : Infinity
911
912 if (cron.weekdays.size !== 0) {
913 const currentWeekday = current.getUTCDay()
914 const nextWeekday = table.weekday[currentWeekday]
915 if (nextWeekday === undefined) {
916 a = reverse ?
917 boundary.weekday - 7 - currentWeekday :
918 7 - currentWeekday + boundary.weekday
919 } else {
920 a = nextWeekday - currentWeekday
921 }
922 }
923
924 if (cron.days.size !== 0 && a !== 0) {
925 const currentDay = current.getUTCDate()
926 const nextDay = table.day[currentDay]
927 if (nextDay === undefined) {
928 if (reverse) {
929 const previous = new Date(current)
930 // Day zero is the previous month's last day. These two probes cover every
931 // valid day-of-month.
932 previous.setUTCDate(0)
933 let day = table.day[previous.getUTCDate()]
934 if (day === undefined) {
935 previous.setUTCDate(0)
936 day = table.day[previous.getUTCDate()]
937 }
938 if (day === undefined) {
939 throw new Error("Unable to find cron date")
940 }
941 previous.setUTCDate(day)
942 b = (previous.getTime() - current.getTime()) / 86_400_000
943 } else {
944 b = daysInMonth(current) - currentDay + boundary.day
945 }
946 } else if (!reverse && nextDay > daysInMonth(current)) {
947 // The next matching day does not exist in the current month. Setting it
948 // directly would overflow and skip earlier matching days next month.
949 b = daysInMonth(current) - currentDay + boundary.day
950 } else {
951 b = nextDay - currentDay
952 }
953 }
954
955 const addDays = reverse ? Math.max(a, b) : Math.min(a, b)
956 if (addDays !== 0) {
957 current.setUTCDate(current.getUTCDate() + addDays)
958 current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
959 adjustDst(current)
960 continue
961 }
962 }
963 }
964
965 if (cron.months.size !== 0) {
966 const currentMonth = current.getUTCMonth() + 1
967 const nextMonth = table.month[currentMonth]
968 const clampBoundaryDay = (targetMonthIndex: number): number => {
969 const maxDayInMonth = daysInMonth(new Date(Date.UTC(current.getUTCFullYear(), targetMonthIndex + 1, 0)))
970 if (cron.days.size !== 0 && cron.weekdays.size === 0) {
971 return reverse ? table.day[maxDayInMonth] ?? maxDayInMonth : boundary.day
972 }
973 return reverse ? maxDayInMonth : 1
974 }
975 if (nextMonth === undefined) {
976 current.setUTCFullYear(current.getUTCFullYear() + tick)
977 current.setUTCMonth(boundary.month, clampBoundaryDay(boundary.month))
978 current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
979 adjustDst(current)
980 continue
981 }
982 if (needsStep(nextMonth, currentMonth)) {
983 const targetMonthIndex = nextMonth - 1
984 current.setUTCMonth(targetMonthIndex, clampBoundaryDay(targetMonthIndex))
985 current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
986 adjustDst(current)
987 continue
988 }
989 }
990
991 return
992 }
993
994 throw new Error("Unable to find cron date")
995 })
996
997 return dateTime.toDateUtc(result)
998}
999
1000/**
1001 * Returns an infinite iterator that yields dates matching the Cron schedule.
1002 *
1003 * **When to use**
1004 *
1005 * Use to lazily iterate future occurrences of a cron schedule.
1006 *
1007 * **Details**
1008 *
1009 * The iterator generates an infinite sequence of dates when the cron schedule
1010 * should trigger, starting after the specified date/time or after the current
1011 * time when no date is provided.
1012 *
1013 * **Example** (Iterating scheduled occurrences)
1014 *
1015 * ```ts import.meta.vitest
1016 * import { Cron, Result } from "effect"
1017 *
1018 * const cron = Result.getOrThrow(Cron.parse("0 0 9 * * 1-5", "UTC")) // 9 AM weekdays
1019 *
1020 * // Get first 5 occurrences
1021 * const iterator = Cron.sequence(cron, "2023-01-01T00:00:00Z")
1022 * const next5 = Array.from({ length: 5 }, () => iterator.next().value.toISOString())
1023 * const expected = [
1024 * "2023-01-02T09:00:00.000Z",
1025 * "2023-01-03T09:00:00.000Z",
1026 * "2023-01-04T09:00:00.000Z",
1027 * "2023-01-05T09:00:00.000Z",
1028 * "2023-01-06T09:00:00.000Z"
1029 * ]
1030 *
1031 * next5 // => expected
1032 * ```
1033 *
1034 * @see {@link next} for computing one next occurrence
1035 *
1036 * @category sequencing
1037 * @since 2.0.0
1038 */
1039export const sequence = function*(cron: Cron, now?: DateTime.DateTime.Input): IterableIterator<Date> {
1040 while (true) {
1041 yield now = next(cron, now)
1042 }
1043}
1044
1045/**
1046 * Equivalence instance for comparing the timezone, field restrictions, and
1047 * day-matching mode of two `Cron` schedules.
1048 *
1049 * **When to use**
1050 *
1051 * Use to compare cron schedules through APIs that accept an equivalence
1052 * relation.
1053 *
1054 * **Details**
1055 *
1056 * This comparison checks the optional timezone, the `and` day-matching mode,
1057 * seconds, minutes, hours, days, months, and weekdays.
1058 *
1059 * **Example** (Comparing schedules with equivalence)
1060 *
1061 * ```ts import.meta.vitest
1062 * import { Cron } from "effect"
1063 *
1064 * const cron1 = Cron.make({
1065 * minutes: [0, 30],
1066 * hours: [9],
1067 * days: [1, 15],
1068 * months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
1069 * weekdays: [1, 2, 3, 4, 5]
1070 * })
1071 *
1072 * const cron2 = Cron.make({
1073 * minutes: [30, 0], // Different order
1074 * hours: [9],
1075 * days: [15, 1], // Different order
1076 * months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
1077 * weekdays: [1, 2, 3, 4, 5]
1078 * })
1079 *
1080 * Cron.Equivalence(cron1, cron2) // => true
1081 * ```
1082 *
1083 * @see {@link equals} for directly comparing two `Cron` values
1084 *
1085 * @category instances
1086 * @since 2.0.0
1087 */
1088export const Equivalence: Equ.Equivalence<Cron> = Equ.make((self, that) =>
1089 Equal.equals(self.tz, that.tz) &&
1090 self.and === that.and &&
1091 restrictionsEquals(self.seconds, that.seconds) &&
1092 restrictionsEquals(self.minutes, that.minutes) &&
1093 restrictionsEquals(self.hours, that.hours) &&
1094 restrictionsEquals(self.days, that.days) &&
1095 restrictionsEquals(self.months, that.months) &&
1096 restrictionsEquals(self.weekdays, that.weekdays)
1097)
1098
1099const restrictionsArrayEquals = Equ.Array(Equ.strictEqual<number>())
1100const restrictionsEquals = (self: ReadonlySet<number>, that: ReadonlySet<number>): boolean =>
1101 restrictionsArrayEquals(Arr.fromIterable(self), Arr.fromIterable(that))
1102
1103/**
1104 * Checks whether two `Cron` instances have equal timezone values, field
1105 * restrictions, and day-matching modes.
1106 *
1107 * **When to use**
1108 *
1109 * Use to directly compare two cron schedules, including their timezones and
1110 * day-matching modes.
1111 *
1112 * **Details**
1113 *
1114 * The comparison checks the optional timezone, the `and` day-matching mode,
1115 * seconds, minutes, hours, days, months, and weekdays.
1116 *
1117 * **Example** (Checking schedule equality)
1118 *
1119 * ```ts import.meta.vitest
1120 * import { Cron } from "effect"
1121 *
1122 * const cron1 = Cron.make({
1123 * minutes: [0],
1124 * hours: [9],
1125 * days: [1, 15],
1126 * months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
1127 * weekdays: [1, 2, 3, 4, 5]
1128 * })
1129 *
1130 * const cron2 = Cron.make({
1131 * minutes: [0],
1132 * hours: [9],
1133 * days: [1, 15],
1134 * months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
1135 * weekdays: [1, 2, 3, 4, 5]
1136 * })
1137 *
1138 * Cron.equals(cron1, cron2) // => true
1139 * Cron.equals(cron1)(cron2) // => true
1140 * ```
1141 *
1142 * @see {@link Equivalence} for the reusable equivalence instance
1143 *
1144 * @category predicates
1145 * @since 2.0.0
1146 */
1147export const equals: {
1148 (that: Cron): (self: Cron) => boolean
1149 (self: Cron, that: Cron): boolean
1150} = dual(2, (self: Cron, that: Cron): boolean => Equivalence(self, that))
1151
1152interface SegmentOptions {
1153 min: number
1154 max: number
1155 aliases?: Record<string, number> | undefined
1156 normalize?: ((value: number) => number) | undefined
1157}
1158
1159interface ParsedSegment {
1160 readonly values: Set<number>
1161 readonly wildcard: boolean
1162}
1163
1164const secondOptions: SegmentOptions = {
1165 min: 0,
1166 max: 59
1167}
1168
1169const minuteOptions: SegmentOptions = {
1170 min: 0,
1171 max: 59
1172}
1173
1174const hourOptions: SegmentOptions = {
1175 min: 0,
1176 max: 23
1177}
1178
1179const dayOptions: SegmentOptions = {
1180 min: 1,
1181 max: 31
1182}
1183
1184const monthOptions: SegmentOptions = {
1185 min: 1,
1186 max: 12,
1187 aliases: {
1188 jan: 1,
1189 feb: 2,
1190 mar: 3,
1191 apr: 4,
1192 may: 5,
1193 jun: 6,
1194 jul: 7,
1195 aug: 8,
1196 sep: 9,
1197 oct: 10,
1198 nov: 11,
1199 dec: 12
1200 }
1201}
1202
1203const weekdayOptions: SegmentOptions = {
1204 min: 0,
1205 max: 7,
1206 normalize: (value) => value === 7 ? 0 : value,
1207 aliases: {
1208 sun: 0,
1209 mon: 1,
1210 tue: 2,
1211 wed: 3,
1212 thu: 4,
1213 fri: 5,
1214 sat: 6
1215 }
1216}
1217
1218const parseSegment = (
1219 input: string,
1220 options: SegmentOptions
1221): Result.Result<ParsedSegment, CronParseError> => {
1222 const values = new Set<number>()
1223 const fields = input.split(",")
1224 const first = splitStep(fields[0]!)
1225 const wildcard = first[0] === "*"
1226 const normalize = options.normalize ?? ((value: number) => value)
1227 const add = wildcard && (first[1] === undefined || first[1] === 1) ?
1228 constVoid :
1229 (value: number) => {
1230 values.add(normalize(value))
1231 }
1232
1233 for (let index = 0; index < fields.length; index++) {
1234 const field = fields[index]!
1235 const [raw, step] = index === 0 ? first : splitStep(field)
1236 if (step !== undefined) {
1237 if (!Number.isInteger(step)) {
1238 return Result.fail(new CronParseError({ message: `Expected step value to be a positive integer`, input }))
1239 }
1240 if (step < 1) {
1241 return Result.fail(new CronParseError({ message: `Expected step value to be greater than 0`, input }))
1242 }
1243 if (step > options.max) {
1244 return Result.fail(
1245 new CronParseError({ message: `Expected step value to be less than or equal to ${options.max}`, input })
1246 )
1247 }
1248 }
1249
1250 if (raw === "*") {
1251 if (index === 0 && (step === undefined || step === 1)) {
1252 continue
1253 }
1254 for (let i = options.min; i <= options.max; i += step ?? 1) {
1255 add(i)
1256 }
1257 } else {
1258 const [left, right] = splitRange(raw, options.aliases)
1259 if (!Number.isInteger(left)) {
1260 return Result.fail(new CronParseError({ message: `Expected a positive integer`, input }))
1261 }
1262 if (left < options.min || left > options.max) {
1263 return Result.fail(
1264 new CronParseError({ message: `Expected a value between ${options.min} and ${options.max}`, input })
1265 )
1266 }
1267
1268 if (right === undefined) {
1269 for (let i = left; i <= (step === undefined ? left : options.max); i += step ?? 1) {
1270 add(i)
1271 }
1272 } else {
1273 if (!Number.isInteger(right)) {
1274 return Result.fail(new CronParseError({ message: `Expected a positive integer`, input }))
1275 }
1276 if (right < options.min || right > options.max) {
1277 return Result.fail(
1278 new CronParseError({ message: `Expected a value between ${options.min} and ${options.max}`, input })
1279 )
1280 }
1281 if (left > right) {
1282 return Result.fail(new CronParseError({ message: `Invalid value range`, input }))
1283 }
1284
1285 for (let i = left; i <= right; i += step ?? 1) {
1286 add(i)
1287 }
1288 }
1289 }
1290 }
1291
1292 return Result.succeed({ values, wildcard })
1293}
1294
1295const splitStep = (input: string): [string, number | undefined] => {
1296 const separator = input.indexOf("/")
1297 if (separator !== -1) {
1298 const step = input.slice(separator + 1)
1299 return [input.slice(0, separator), decimalRegex.test(step) ? Number(step) : NaN]
1300 }
1301
1302 return [input, undefined]
1303}
1304
1305const splitRange = (input: string, aliases?: Record<string, number>): [number, number | undefined] => {
1306 const separator = input.indexOf("-")
1307 if (separator !== -1) {
1308 return [aliasOrValue(input.slice(0, separator), aliases), aliasOrValue(input.slice(separator + 1), aliases)]
1309 }
1310
1311 return [aliasOrValue(input, aliases), undefined]
1312}
1313
1314function aliasOrValue(field: string, aliases?: Record<string, number>): number {
1315 return aliases?.[String.toLowerCase(field)] ?? (decimalRegex.test(field) ? Number(field) : NaN)
1316}
1317
1318const decimalRegex = /^\d+$/