Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-clocks-format.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 65 additions & 2 deletions packages/effect/src/Cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<number>): string => {
if (values.size === 0) {
return "*"
}
const array = Array.from(values)
const segments: Array<string> = []
let index = 0
while (index < array.length) {
const start = array[index]!
const step = array[index + 1]! - start
Comment thread
tim-smart marked this conversation as resolved.
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.
*
Expand Down
56 changes: 56 additions & 0 deletions packages/effect/test/Cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 * * * *"
)
})
Comment thread
tim-smart marked this conversation as resolved.

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<number>) =>
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 = {
Expand Down
Loading