diff --git a/.changeset/warm-clocks-format.md b/.changeset/warm-clocks-format.md new file mode 100644 index 00000000000..04173b8c378 --- /dev/null +++ b/.changeset/warm-clocks-format.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `Cron.format` for converting a `Cron` instance to a cron expression, with an option to include the seconds field. diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 609d030e27c..259c6a6f298 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -12,7 +12,7 @@ import * as Data from "./Data.ts" import type * as DateTime from "./DateTime.ts" import * as Equal from "./Equal.ts" import * as Equ from "./Equivalence.ts" -import { format } from "./Formatter.ts" +import { format as formatValue } from "./Formatter.ts" import { constVoid, dual, pipe } from "./Function.ts" import * as Hash from "./Hash.ts" import { type Inspectable, NodeInspectSymbol } from "./Inspectable.ts" @@ -152,7 +152,7 @@ const CronProto = { return toPojo(this) }, toString(this: Cron) { - return `Cron(${format(toPojo(this))})` + return `Cron(${formatValue(toPojo(this))})` }, toJSON(this: Cron) { const out = toPojo(this) @@ -612,6 +612,69 @@ export const parse = (cron: string, tz?: DateTime.TimeZone | string): Result.Res */ export const parseUnsafe = (cron: string, tz?: DateTime.TimeZone | string): Cron => Result.getOrThrow(parse(cron, tz)) +/** + * Formats a `Cron` instance as a cron expression. + * + * **Details** + * + * The default seconds field (`0`) is omitted unless `includeSeconds` is `true`. + * Other seconds configurations are always included. + * + * **Gotchas** + * + * Formatting drops the timezone information and the `and` restriction between + * days and weekdays. Parsing the result is therefore not guaranteed to produce + * an equivalent schedule. + * + * **Example** (Formatting a cron expression) + * + * ```ts import.meta.vitest + * import { Cron } from "effect" + * + * const cron = Cron.parseUnsafe("23 0-20/2 * * 0", "UTC") + * + * Cron.format(cron) // => "23 0-20/2 * * 0" + * Cron.format(cron, { includeSeconds: true }) // => "0 23 0-20/2 * * 0" + * ``` + * + * @category getters + * @since 4.0.0 + */ +export const format = (cron: Cron, options?: { + readonly includeSeconds?: boolean | undefined +}): string => { + const segments = [cron.seconds, cron.minutes, cron.hours, cron.days, cron.months, cron.weekdays] + .map(formatSegment) + return ( + options?.includeSeconds !== true && cron.seconds.size === 1 && cron.seconds.has(0) ? segments.slice(1) : segments + ).join(" ") +} + +const formatSegment = (values: ReadonlySet): string => { + if (values.size === 0) { + return "*" + } + const array = Array.from(values) + const segments: Array = [] + let index = 0 + while (index < array.length) { + const start = array[index]! + const step = array[index + 1]! - start + if (index + 2 < array.length && array[index + 2]! - array[index + 1]! === step) { + let end = index + 2 + while (end + 1 < array.length && array[end + 1]! - array[end]! === step) { + end++ + } + segments.push(`${start}-${array[end]}${step === 1 ? "" : `/${step}`}`) + index = end + 1 + } else { + segments.push(`${start}`) + index++ + } + } + return segments.join(",") +} + /** * Returns `true` when a date/time matches a `Cron` schedule. * diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index 3a3d1a5d2fd..d4d94266210 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -218,6 +218,62 @@ describe("Cron", () => { ) }) + it("format preserves compact cron syntax", () => { + strictEqual( + Cron.format(Cron.parseUnsafe("23 0-20/2 * * 0", "Europe/Berlin")), + "23 0-20/2 * * 0" + ) + }) + + it("format can include the default seconds field", () => { + strictEqual( + Cron.format(Cron.parseUnsafe("23 0-20/2 * * 0"), { includeSeconds: true }), + "0 23 0-20/2 * * 0" + ) + }) + + it("format compacts multiple runs within a field", () => { + strictEqual( + Cron.format(Cron.make({ + minutes: [0, 1, 2, 10, 20, 30], + hours: [], + days: [], + months: [], + weekdays: [] + })), + "0-2,10-30/10 * * * *" + ) + }) + + it("format preserves non-uniform values", () => { + strictEqual( + Cron.format(Cron.make({ + minutes: [1, 5, 11], + hours: [], + days: [], + months: [], + weekdays: [] + })), + "1,5,11 * * * *" + ) + }) + + it("format handles default, non-default, and unrestricted seconds", () => { + const format = (seconds?: Iterable) => + Cron.format(Cron.make({ + seconds, + minutes: [], + hours: [], + days: [], + months: [], + weekdays: [] + })) + + strictEqual(format(), "* * * * *") + strictEqual(format([15, 30]), "15,30 * * * * *") + strictEqual(format([]), "* * * * * *") + }) + it("make supports requiring both days and weekdays", () => { const utc = DateTime.zoneMakeNamedUnsafe("UTC") const values = {