Repository: kevincianfarini/cardiologist (kotlin)

Issue:
Public API to parse cron into `PulseSchedule`
I want to parse/validate much earlier than running.

Pull request description:
Closes #162

Patch:
diff --git a/api/cardiologist.api b/api/cardiologist.api
index 1602d7e..3abe4a3 100644
--- a/api/cardiologist.api
+++ b/api/cardiologist.api
@@ -26,6 +26,7 @@ public final class io/github/kevincianfarini/cardiologist/PulseBackpressureStrat
 }
 
 public final class io/github/kevincianfarini/cardiologist/PulseSchedule {
+	public static final field Companion Lio/github/kevincianfarini/cardiologist/PulseSchedule$Companion;
 	public fun <init> (Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;)V
 	public fun equals (Ljava/lang/Object;)Z
 	public final fun getAtHours ()Ljava/util/List;
@@ -38,6 +39,10 @@ public final class io/github/kevincianfarini/cardiologist/PulseSchedule {
 	public fun toString ()Ljava/lang/String;
 }
 
+public final class io/github/kevincianfarini/cardiologist/PulseSchedule$Companion {
+	public final fun parseCron (Ljava/lang/String;)Lio/github/kevincianfarini/cardiologist/PulseSchedule;
+}
+
 public final class io/github/kevincianfarini/cardiologist/PulseScheduleBuilder {
 	public final fun atHours (I[I)V
 	public final fun atHours (Lkotlin/ranges/IntRange;[Lkotlin/ranges/IntRange;)V
diff --git a/api/cardiologist.klib.api b/api/cardiologist.klib.api
index 4e2c516..a1fc555 100644
--- a/api/cardiologist.klib.api
+++ b/api/cardiologist.klib.api
@@ -38,6 +38,10 @@ final class io.github.kevincianfarini.cardiologist/PulseSchedule { // io.github.
     final fun equals(kotlin/Any?): kotlin/Boolean // io.github.kevincianfarini.cardiologist/PulseSchedule.equals|equals(kotlin.Any?){}[0]
     final fun hashCode(): kotlin/Int // io.github.kevincianfarini.cardiologist/PulseSchedule.hashCode|hashCode(){}[0]
     final fun toString(): kotlin/String // io.github.kevincianfarini.cardiologist/PulseSchedule.toString|toString(){}[0]
+
+    final object Companion { // io.github.kevincianfarini.cardiologist/PulseSchedule.Companion|null[0]
+        final fun parseCron(kotlin/String): io.github.kevincianfarini.cardiologist/PulseSchedule // io.github.kevincianfarini.cardiologist/PulseSchedule.Companion.parseCron|parseCron(kotlin.String){}[0]
+    }
 }
 
 final class io.github.kevincianfarini.cardiologist/PulseScheduleBuilder { // io.github.kevincianfarini.cardiologist/PulseScheduleBuilder|null[0]
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/PulseSchedule.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/PulseSchedule.kt
index c6baa1b..b73d227 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/PulseSchedule.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/PulseSchedule.kt
@@ -1,6 +1,7 @@
 package io.github.kevincianfarini.cardiologist
 
 import dev.drewhamilton.poko.Poko
+import io.github.kevincianfarini.cardiologist.impl.parseCronExpression
 import kotlinx.datetime.DayOfWeek
 import kotlinx.datetime.Month
 import kotlinx.datetime.isoDayNumber
@@ -56,6 +57,32 @@ public class PulseSchedule internal constructor(
         // Don't check if onDaysOfWeek is empty because an empty set is equivalent to the wildcard `*` value in cron
         // expressions.
     }
+
+    public companion object {
+        /**
+         * Parse a cron [expression] into a [PulseSchedule].
+         *
+         * 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.
+         *
+         * @throws IllegalArgumentException Malformed cron expression or unsupported cron feature.
+         */
+        public fun parseCron(expression: String): PulseSchedule {
+            return expression.parseCronExpression()
+        }
+    }
 }
 
 public class PulseScheduleBuilder internal constructor() {
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/cron.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/cron.kt
index fa784f6..97f1136 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/cron.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/cron.kt
@@ -76,7 +76,9 @@ private fun String.parseDaysOfWeekExpressionOrNull(): Set<DayOfWeek>? {
                     4 -> DayOfWeek.THURSDAY
                     5 -> DayOfWeek.FRIDAY
                     6 -> DayOfWeek.SATURDAY
-                    else -> error("Invalid day of week integer $dayOfWeekInt.")
+                    else -> {
+                        throw IllegalArgumentException("Invalid day of week integer $dayOfWeekInt.")
+                    }
                 }
                 add(result)
             }
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
index b9ef690..6ce1187 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
@@ -137,20 +137,7 @@ public fun Clock.schedulePulse(
 /**
  * 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.
+ * See [PulseSchedule.parseCron] for supported cron expression features.
  *
  * 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.
@@ -160,6 +147,13 @@ public fun Clock.schedulePulse(
  * @param timeZone The TimeZone to schedule pulses in.
  * @throws IllegalArgumentException if [cronExpression] is not valid.
  */
+@Deprecated(
+    message = "Parse cron expression separately from scheduling a pulse",
+    replaceWith = ReplaceWith(
+        "this.schedulePulse(PulseSchedule.parseCron(cronExpression), timeZone)",
+        "io.github.kevincianfarini.cardiologist.PulseSchedule",
+    ),
+)
 @ExperimentalTime
 public fun Clock.schedulePulse(
     cronExpression: String,


Test patch:
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/ParseCronTest.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleParseCronTest.kt
similarity index 78%
rename from src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/ParseCronTest.kt
rename to src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleParseCronTest.kt
index 0aef1a7..cc047fb 100644
--- a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/ParseCronTest.kt
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleParseCronTest.kt
@@ -1,17 +1,19 @@
-package io.github.kevincianfarini.cardiologist.impl
+package io.github.kevincianfarini.cardiologist
 
-import io.github.kevincianfarini.cardiologist.PulseSchedule
 import kotlinx.datetime.DayOfWeek
 import kotlinx.datetime.Month
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFails
+import kotlin.test.assertFailsWith
 
-class ParseCronTest {
+class PulseScheduleParseCronTest {
 
     @Test
     fun incorrect_number_of_segments_errors() {
-        val e = assertFails { "".parseCronExpression() }
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("")
+        }
         assertEquals(
             expected = "'' is not a valid cron expression.",
             actual = e.message
@@ -29,7 +31,7 @@ class ParseCronTest {
                 onDaysOfWeek = emptySet(),
                 inMonths = Month.entries.toSet(),
             ),
-            actual = "* * * * *".parseCronExpression()
+            actual = PulseSchedule.parseCron("* * * * *")
         )
     }
 
@@ -44,7 +46,7 @@ class ParseCronTest {
                 onDaysOfWeek = emptySet(),
                 inMonths = Month.entries.toSet(),
             ),
-            actual = "*       * \t *   *   *".parseCronExpression()
+            actual = PulseSchedule.parseCron("*       * \t *   *   *")
         )
     }
 
@@ -59,7 +61,7 @@ class ParseCronTest {
                 onDaysOfWeek = emptySet(),
                 inMonths = Month.entries.toSet(),
             ),
-            actual = "0 * * * *".parseCronExpression()
+            actual = PulseSchedule.parseCron("0 * * * *")
         )
     }
 
@@ -74,7 +76,7 @@ class ParseCronTest {
                 onDaysOfWeek = emptySet(),
                 inMonths = Month.entries.toSet(),
             ),
-            actual = "0,5 * * * *".parseCronExpression()
+            actual = PulseSchedule.parseCron("0,5 * * * *")
         )
     }
 
@@ -89,7 +91,7 @@ class ParseCronTest {
                 onDaysOfWeek = emptySet(),
                 inMonths = Month.entries.toSet(),
             ),
-            actual = "0-5 * * * *".parseCronExpression()
+            actual = PulseSchedule.parseCron("0-5 * * * *")
         )
     }
 
@@ -104,7 +106,7 @@ class ParseCronTest {
                 onDaysOfWeek = emptySet(),
                 inMonths = Month.entries.toSet(),
             ),
-            actual = "0-5,10,15 * * * *".parseCronExpression()
+            actual = PulseSchedule.parseCron("0-5,10,15 * * * *")
         )
     }
 
@@ -119,14 +121,14 @@ class ParseCronTest {
                 onDaysOfWeek = emptySet(),
                 inMonths = Month.entries.toSet(),
             ),
-            actual = "0-5,10-15 * * * *".parseCronExpression()
+            actual = PulseSchedule.parseCron("0-5,10-15 * * * *")
         )
     }
 
     @Test
     fun minutes_double_comma_invalid() {
-        val e = assertFails {
-            "0,,1 * * * *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("0,,1 * * * *")
         }
         assertEquals(
             expected = "Cron expression is malformed: <<0,,1>> * * * *",
@@ -136,8 +138,8 @@ class ParseCronTest {
 
     @Test
     fun minutes_double_range_invalid() {
-        val e = assertFails {
-            "0--1 * * * *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("0--1 * * * *")
         }
         assertEquals(
             expected = "Cron expression is malformed: <<0--1>> * * * *",
@@ -147,8 +149,8 @@ class ParseCronTest {
 
     @Test
     fun minutes_range_no_end_invalid() {
-        val e = assertFails {
-            "0- * * * *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("0- * * * *")
         }
         assertEquals(
             expected = "Cron expression is malformed: <<0->> * * * *",
@@ -158,8 +160,8 @@ class ParseCronTest {
 
     @Test
     fun minutes_range_no_start_invalid() {
-        val e = assertFails {
-            "-1 * * * *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("-1 * * * *")
         }
         assertEquals(
             expected = "Cron expression is malformed: <<-1>> * * * *",
@@ -169,8 +171,8 @@ class ParseCronTest {
 
     @Test
     fun minutes_range_empty_invalid() {
-        val e = assertFails {
-            "5-3 * * * *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("5-3 * * * *")
         }
         assertEquals(
             expected = "Cron expression is malformed: <<5-3>> * * * *",
@@ -180,8 +182,8 @@ class ParseCronTest {
 
     @Test
     fun minutes_invalid_value() {
-        val e = assertFails {
-            "75 * * * *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("75 * * * *")
         }
         assertEquals(
             expected = "Cron expression is malformed: <<75>> * * * *",
@@ -192,8 +194,8 @@ class ParseCronTest {
 
     @Test
     fun hours_invalid_value() {
-        val e = assertFails {
-            "* 24 * * *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("* 24 * * *")
         }
         assertEquals(
             expected = "Cron expression is malformed: * <<24>> * * *",
@@ -203,8 +205,8 @@ class ParseCronTest {
 
     @Test
     fun day_of_month_invalid_value() {
-        val e = assertFails {
-            "* * 0 * *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("* * 0 * *")
         }
         assertEquals(
             expected = "Cron expression is malformed: * * <<0>> * *",
@@ -214,8 +216,8 @@ class ParseCronTest {
 
     @Test
     fun month_invalid_int_value() {
-        val e = assertFails {
-            "* * * 13 *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("* * * 13 *")
         }
         assertEquals(
             expected = "Cron expression is malformed: * * * <<13>> *",
@@ -225,8 +227,8 @@ class ParseCronTest {
 
     @Test
     fun day_of_week_invalid_int_value() {
-        val e = assertFails {
-            "* * * * 7".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("* * * * 7")
         }
         assertEquals(
             expected = "Cron expression is malformed: * * * * <<7>>",
@@ -261,7 +263,7 @@ class ParseCronTest {
                     onDaysOfWeek = emptySet(),
                     inMonths = setOf(enum),
                 ),
-                actual = "* * * $string *".parseCronExpression()
+                actual = PulseSchedule.parseCron("* * * $string *")
             )
         }
     }
@@ -288,7 +290,7 @@ class ParseCronTest {
                     onDaysOfWeek = setOf(enum),
                     inMonths = Month.entries.toSet()
                 ),
-                actual = "* * * * $string".parseCronExpression()
+                actual = PulseSchedule.parseCron("* * * * $string")
             )
         }
     }
@@ -304,14 +306,14 @@ class ParseCronTest {
                 onDaysOfWeek = DayOfWeek.entries.toSet(),
                 inMonths = Month.entries.toSet()
             ),
-            actual = "* * * * SUN-SAT".parseCronExpression()
+            actual = PulseSchedule.parseCron("* * * * SUN-SAT")
         )
     }
 
     @Test
     fun invalid_month_string() {
-        val e = assertFails {
-            "* * * WRONG *".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("* * * WRONG *")
         }
         assertEquals(
             expected = "Cron expression is malformed: * * * <<WRONG>> *",
@@ -321,8 +323,8 @@ class ParseCronTest {
 
     @Test
     fun invalid_day_of_week_string() {
-        val e = assertFails {
-            "* * * * WRONG".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("* * * * WRONG")
         }
         assertEquals(
             expected = "Cron expression is malformed: * * * * <<WRONG>>",
@@ -332,8 +334,8 @@ class ParseCronTest {
 
     @Test
     fun multiple_incorrect_values_yield_many_errors() {
-        val e = assertFails {
-            "60 24 32 13 7".parseCronExpression()
+        val e = assertFailsWith<IllegalArgumentException> {
+            PulseSchedule.parseCron("60 24 32 13 7")
         }
         assertEquals(
             expected = "Cron expression is malformed: <<60>> <<24>> <<32>> <<13>> <<7>>",
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseTests.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseTests.kt
index 4bd0b98..689df51 100644
--- a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseTests.kt
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseTests.kt
@@ -257,13 +257,6 @@ class PulseTests {
         )
     }
 
-    @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)