diff --git a/.changeset/warm-bulldogs-grab.md b/.changeset/warm-bulldogs-grab.md new file mode 100644 index 0000000000..c3c3bd8227 --- /dev/null +++ b/.changeset/warm-bulldogs-grab.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Added `Duration.formatIso` and `Duration.fromIso` for formatting and parsing ISO8601 durations. diff --git a/packages/effect/src/Duration.ts b/packages/effect/src/Duration.ts index 815a2771f9..1f099899e3 100644 --- a/packages/effect/src/Duration.ts +++ b/packages/effect/src/Duration.ts @@ -850,3 +850,150 @@ export const format = (self: DurationInput): string => { return pieces.join(" ") } + +/** + * Formats a Duration into an ISO8601 duration string. + * + * The ISO8601 duration format is generally specified as P[n]Y[n]M[n]DT[n]H[n]M[n]S. However, since + * the `Duration` type does not support years or months, this function will only output the days, hours, + * minutes and seconds. Thus, the effective format is P[n]DT[n]H[n]M[n]S. + * + * Milliseconds and nanoseconds are expressed as fractional seconds. + * + * @throws `RangeError` If the duration is not finite. + * + * @example + * ```ts + * import { Duration } from "effect" + * + * Duration.unsafeFormatIso(Duration.days(1)) // => "P1D" + * Duration.unsafeFormatIso(Duration.minutes(90)) // => "PT1H30M" + * Duration.unsafeFormatIso(Duration.millis(1500)) // => "PT1.5S" + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const unsafeFormatIso = (self: DurationInput): string => { + const duration = decode(self) + if (!isFinite(duration)) { + throw new RangeError("Cannot format infinite duration") + } + + const fragments = [] + const { + days, + hours, + millis, + minutes, + nanos, + seconds + } = parts(duration) + + let rest = days + if (rest >= 365) { + const years = Math.floor(rest / 365) + rest %= 365 + fragments.push(`${years}Y`) + } + + if (rest >= 30) { + const months = Math.floor(rest / 30) + rest %= 30 + fragments.push(`${months}M`) + } + + if (rest >= 7) { + const weeks = Math.floor(rest / 7) + rest %= 7 + fragments.push(`${weeks}W`) + } + + if (rest > 0) { + fragments.push(`${rest}D`) + } + + if (hours !== 0 || minutes !== 0 || seconds !== 0 || millis !== 0 || nanos !== 0) { + fragments.push("T") + + if (hours !== 0) { + fragments.push(`${hours}H`) + } + + if (minutes !== 0) { + fragments.push(`${minutes}M`) + } + + if (seconds !== 0 || millis !== 0 || nanos !== 0) { + const total = BigInt(seconds) * bigint1e9 + BigInt(millis) * bigint1e6 + BigInt(nanos) + const str = (Number(total) / 1e9).toFixed(9).replace(/\.?0+$/, "") + fragments.push(`${str}S`) + } + } + + return `P${fragments.join("") || "T0S"}` +} + +/** + * Formats a Duration into an ISO8601 duration string. + * + * Months are assumed to be 30 days and years are assumed to be 365 days. + * + * Returns `Option.none()` if the duration is infinite. + * + * @example + * ```ts + * import { Duration, Option } from "effect" + * + * Duration.formatIso(Duration.days(1)) // => Option.some("P1D") + * Duration.formatIso(Duration.minutes(90)) // => Option.some("PT1H30M") + * Duration.formatIso(Duration.millis(1500)) // => Option.some("PT1.5S") + * Duration.formatIso(Duration.infinity) // => Option.none() + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const formatIso = (self: DurationInput): Option.Option => { + const duration = decode(self) + return isFinite(duration) ? Option.some(unsafeFormatIso(duration)) : Option.none() +} + +/** + * Parses an ISO8601 duration string into a `Duration`. + * + * Months are assumed to be 30 days and years are assumed to be 365 days. + * + * @example + * ```ts + * import { Duration, Option } from "effect" + * + * Duration.fromIso("P1D") // => Option.some(Duration.days(1)) + * Duration.fromIso("PT1H") // => Option.some(Duration.hours(1)) + * Duration.fromIso("PT1M") // => Option.some(Duration.minutes(1)) + * Duration.fromIso("PT1.5S") // => Option.some(Duration.seconds(1.5)) + * ``` + * + * @since 3.13.0 + * @category conversions + */ +export const fromIso = (iso: string): Option.Option => { + const result = DURATION_ISO_REGEX.exec(iso) + if (result == null) { + return Option.none() + } + + const [years, months, weeks, days, hours, mins, secs] = result.slice(1, 8).map((_) => _ ? Number(_) : 0) + const value = years * 365 * 24 * 60 * 60 + + months * 30 * 24 * 60 * 60 + + weeks * 7 * 24 * 60 * 60 + + days * 24 * 60 * 60 + + hours * 60 * 60 + + mins * 60 + + secs + + return Option.some(seconds(value)) +} + +const DURATION_ISO_REGEX = + /^P(?!$)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?!$)(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/ diff --git a/packages/effect/test/Duration.test.ts b/packages/effect/test/Duration.test.ts index 5c97b2d2ba..f49d3f8762 100644 --- a/packages/effect/test/Duration.test.ts +++ b/packages/effect/test/Duration.test.ts @@ -520,4 +520,100 @@ describe("Duration", () => { strictEqual(Duration.toWeeks("2 weeks"), 2) strictEqual(Duration.toWeeks("14 days"), 2) }) + + it("formatIso", () => { + assertSome(Duration.formatIso(Duration.zero), "PT0S") + assertSome(Duration.formatIso(Duration.seconds(2)), "PT2S") + assertSome(Duration.formatIso(Duration.minutes(5)), "PT5M") + assertSome(Duration.formatIso(Duration.hours(3)), "PT3H") + assertSome(Duration.formatIso(Duration.days(1)), "P1D") + + assertSome(Duration.formatIso(Duration.minutes(90)), "PT1H30M") + assertSome(Duration.formatIso(Duration.hours(25)), "P1DT1H") + assertSome(Duration.formatIso(Duration.days(7)), "P1W") + assertSome(Duration.formatIso(Duration.days(10)), "P1W3D") + + assertSome(Duration.formatIso(Duration.millis(1500)), "PT1.5S") + assertSome(Duration.formatIso(Duration.micros(1500n)), "PT0.0015S") + assertSome(Duration.formatIso(Duration.nanos(1500n)), "PT0.0000015S") + + assertSome( + Duration.formatIso( + Duration.seconds( + 365 * 24 * 60 * 60 + // 1 year + 60 * 24 * 60 * 60 + // 2 months + 3 * 24 * 60 * 60 + // 3 days + 4 * 60 * 60 + // 4 hours + 5 * 60 + // 5 minutes + 6.789 // 6.789 seconds + ) + ), + "P1Y2M3DT4H5M6.789S" + ) + + assertSome( + Duration.formatIso( + Duration.days(1).pipe( + Duration.sum(Duration.hours(2)), + Duration.sum(Duration.minutes(30)) + ) + ), + "P1DT2H30M" + ) + + assertSome( + Duration.formatIso( + Duration.hours(2).pipe( + Duration.sum(Duration.minutes(30)), + Duration.sum(Duration.millis(1500)) + ) + ), + "PT2H30M1.5S" + ) + + assertSome(Duration.formatIso("1 day"), "P1D") + assertSome(Duration.formatIso("90 minutes"), "PT1H30M") + assertSome(Duration.formatIso("1.5 seconds"), "PT1.5S") + + assertNone(Duration.formatIso(Duration.infinity)) + }) + + it("fromIso", () => { + assertSome(Duration.fromIso("P1D"), Duration.days(1)) + assertSome(Duration.fromIso("PT1H"), Duration.hours(1)) + assertSome(Duration.fromIso("PT1M"), Duration.minutes(1)) + assertSome(Duration.fromIso("PT1.5S"), Duration.seconds(1.5)) + assertSome(Duration.fromIso("P1Y"), Duration.days(365)) + assertSome(Duration.fromIso("P1M"), Duration.days(30)) + assertSome(Duration.fromIso("P1W"), Duration.days(7)) + assertSome(Duration.fromIso("P1DT12H"), Duration.hours(36)) + assertSome( + Duration.fromIso("P1Y2M3DT4H5M6.789S"), + Duration.seconds( + 365 * 24 * 60 * 60 + // 1 year + 60 * 24 * 60 * 60 + // 2 months + 3 * 24 * 60 * 60 + // 3 days + 4 * 60 * 60 + // 4 hours + 5 * 60 + // 5 minutes + 6.789 // 6.789 seconds + ) + ) + + assertNone(Duration.fromIso("1D")) + assertNone(Duration.fromIso("P1H")) + assertNone(Duration.fromIso("PT1D")) + assertNone(Duration.fromIso("P1.5D")) + assertNone(Duration.fromIso("P1.5Y")) + assertNone(Duration.fromIso("P1.5M")) + assertNone(Duration.fromIso("PT1.5H")) + assertNone(Duration.fromIso("PT1.5M")) + assertNone(Duration.fromIso("PDT1H")) + assertNone(Duration.fromIso("P1D2H")) + assertNone(Duration.fromIso("P")) + assertNone(Duration.fromIso("PT")) + assertNone(Duration.fromIso("random string")) + assertNone(Duration.fromIso("P1YT")) + assertNone(Duration.fromIso("P1S")) + assertNone(Duration.fromIso("P1DT1S1H")) + }) })