Repository: kevincianfarini/cardiologist (kotlin)

Issue:
Support scheduling pulses with cron expressions
We should seek to support [standard cron expressions](https://crontab.guru/) in cardiologist. Such an API should delegate to the type safe Kotlin APIs. Doing so will require several tasks to be completed before this task can be worked on: 

- [ ] Support day of week scheduling as specified in #2.
- [ ] Support lists of values that are not necessarily contiguous like cron does, eg. `1,3,4`. This will require refactoring our matching logic as shown in #92.  
- [x] Implement cron expression parsing. #91 


Once those are completed, the content of this issue is to provide the following API: 

```kt
/**
 * Schedule a [Pulse] whose beats occur in accordance with the specified [cronExpression] for a specific [timeZone].
 *
 * This function supports the standard set of characters and fields for cron, which are:
 *
 * - The `*` wildcard.
 * - Commas denoting several values. For example: 1,3,5
 * - Hyphens specifying a range of values. For example: 0-10.
 * - Mixed hyphen and commas. For example: 1,2,5-7
 * - The integers 0-59 for the minute field.
 * - The integers 0-23 for the hour field.
 * - The integers 1-31 for the day of month field.
 * - The integers 1-12 or strings JAN-DEC for the month field.
 * - The integers 0-6 or strings SUN-SAT for the day of week field.
 *
 * Furthermore, this function explicitly does not support non-standard characters or fields, including:
 * 
 * - A seconds field. 
 * - A years field. 
 * - The step character `/` specifying sequences of values.
 * - Other directives such as `@monthly`. 
 * 
 * An example of using this function is shown below to create a Pulse which beats every day at midnight. 
 * 
 * ```kt
 * clock.schedulePulse(cronExpression = "0 0 * * *")
 * ```
 *
 * Scheduling pulses is done in local time and is therefore subject to daylight savings time adjustments. Local time
 * conversion is sometimes ambiguous, and therefore it's recommended to schedule pulses in a fixed UTC offset timezone.
 * See [LocalDateTime.toInstant] for more details.
 *
 * @param cronExpression The five field cron expression which is separated by whitespace. 
 * @param timeZone The time zone which Pulse beats are scheduled to occur in.
 * @throws [IllegalArgumentException] if any field of the supplied cron expression is out of range or if the cron
 *                                    expression is malformed.
 */
public fun Clock.schedulePulse(
    cronExpression: String,
    timeZone: TimeZone = TimeZone.UTC,
): Pulse
```

Pull request description:
Closes #65

Patch:
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
index 5cdf648..b4d953c 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
@@ -1,6 +1,7 @@
 package io.github.kevincianfarini.cardiologist
 
 import io.github.kevincianfarini.cardiologist.impl.nextMatch
+import io.github.kevincianfarini.cardiologist.impl.parseCronExpression
 import kotlin.time.Duration
 import kotlinx.coroutines.flow.flow
 import kotlinx.datetime.Clock
@@ -77,6 +78,7 @@ public fun Clock.fixedPeriodPulse(period: DateTimePeriod, timeZone: TimeZone): P
  * @param atHour The hour of a day to pulse at. Null matches the whole valid range, 0..23.
  * @param onDayOfMonth The day of a month to pulse at. Null matches the whole valid range, 0..31.
  * @param inMonth The month of a year to pulse at. Null matches the whole valid range, January..December.
+ * @param timeZone The TimeZone to schedule pulses in.
  * @throws [IllegalArgumentException] if any parameter is out of the above range.
  */
 public fun Clock.schedulePulse(
@@ -86,17 +88,85 @@ public fun Clock.schedulePulse(
     atHour: Int? = null,
     onDayOfMonth: Int? = null,
     inMonth: Month? = null,
+): Pulse = schedulePulse(timeZone) {
+    atSecond?.run(this::atSeconds)
+    atMinute?.run(this::atMinutes)
+    atHour?.run(this::atHours)
+    onDayOfMonth?.run(this::onDaysOfMonth)
+    inMonth?.run(this::inMonths)
+}
+
+/**
+ * Schedule a [Pulse] whose beats occur in accordance with the [built schedule][scheduleBuilder] for a specific
+ * [timeZone].
+ *
+ * For example, the following invocation will schedule a Pulse on the fifth minute of every hour.
+ *
+ * ```kt
+ * clock.schedulePulse {
+ *   atSeconds(0)
+ *   atMinutes(5)
+ * }
+ * ```
+ *
+ * Scheduling pulses is done in local time and is therefore subject to daylight savings time adjustments. Local time
+ * conversion is sometimes ambiguous, and therefore it's recommended to schedule pulses in a fixed UTC offset timezone.
+ * See [LocalDateTime.toInstant] for more details.
+ *
+ * @param scheduleBuilder The DSL lambda for building a Pulse schedule.
+ * @param timeZone The TimeZone to schedule pulses in.
+ * @throws IllegalArgumentException if [scheduleBuilder] is not valid.
+ */
+public fun Clock.schedulePulse(
+    timeZone: TimeZone = TimeZone.UTC,
+    scheduleBuilder: PulseScheduleBuilder.() -> Unit,
+): Pulse = schedulePulse(
+    schedule = buildPulseSchedule(scheduleBuilder),
+    timeZone = timeZone,
+)
+
+/**
+ * Schedule a [Pulse] whose beats occur in accordance with the provided [cronExpression] for a specific [timeZone].
+ *
+ * This function supports the standard cron specification, which includes:
+ *
+ * - Five fields specifying the minute, hour, day of month, month, and day of week.
+ * - `*` denoting a wildcard value.
+ * - List of values using commas, like `1,5,7`.
+ * - Ranges of values, like `5-10`.
+ * - Mixed range and list values, like `5-10,20-25`.
+ * - The integers 0-59 for the minute field.
+ * - The integers 0-23 for the hour field.
+ * - The integers 1-31 for the day of week field.
+ * - The integers 1-12 for the month field.
+ * - The integers 0-6 or strings SUN-SAT for the day of week field.
+ *
+ * Functionality not articulated above is considered non-standard and is therefore explicitly not supported.
+ *
+ * Scheduling pulses is done in local time and is therefore subject to daylight savings time adjustments. Local time
+ * conversion is sometimes ambiguous, and therefore it's recommended to schedule pulses in a fixed UTC offset timezone.
+ * See [LocalDateTime.toInstant] for more details.
+ *
+ * @param cronExpression The standard cron expression.
+ * @param timeZone The TimeZone to schedule pulses in.
+ * @throws IllegalArgumentException if [cronExpression] is not valid.
+ */
+public fun Clock.schedulePulse(
+    cronExpression: String,
+    timeZone: TimeZone = TimeZone.UTC,
+): Pulse = schedulePulse(
+    schedule = cronExpression.parseCronExpression(),
+    timeZone = timeZone,
+)
+
+public fun Clock.schedulePulse(
+    schedule: PulseSchedule,
+    timeZone: TimeZone = TimeZone.UTC,
 ): Pulse {
     val flow = flow {
         var lastPulse: LocalDateTime = now().toLocalDateTime(timeZone)
         while (true) {
-            val nextPulse = lastPulse.nextMatch {
-                atSecond?.run(this::atSeconds)
-                atMinute?.run(this::atMinutes)
-                atHour?.run(this::atHours)
-                onDayOfMonth?.run(this::onDaysOfMonth)
-                inMonth?.run(this::inMonths)
-            }
+            val nextPulse = lastPulse.nextMatch(schedule)
             delayUntil(nextPulse, timeZone)
             emit(nextPulse.toInstant(timeZone))
             lastPulse = nextPulse


Test patch:
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseTests.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseTests.kt
index 6d2ef1d..e5c14b6 100644
--- a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseTests.kt
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseTests.kt
@@ -11,6 +11,7 @@ import kotlinx.coroutines.test.testTimeSource
 import kotlinx.datetime.*
 import kotlin.test.Test
 import kotlin.test.assertEquals
+import kotlin.time.Duration.Companion.minutes
 import kotlin.time.Duration.Companion.seconds
 import kotlin.time.measureTime
 
@@ -253,4 +254,18 @@ class PulseTests {
             actual = list.value,
         )
     }
+
+    @Test
+    fun schedulePulse_with_cron_expression_simple() = runTest {
+        val pulse = testClock.schedulePulse("* * * * *").take(1)
+        val duration = testTimeSource.measureTime { pulse.beat {  } }
+        assertEquals(expected = 1.minutes, actual = duration)
+    }
+
+    @Test
+    fun schedulePulse_dsl_simple() = runTest {
+        val pulse = testClock.schedulePulse { atSeconds(0) }.take(1)
+        val duration = testTimeSource.measureTime { pulse.beat {  } }
+        assertEquals(expected = 1.minutes, actual = duration)
+    }
 }
\ No newline at end of file