Repository: kevincianfarini/cardiologist (kotlin)

Issue:
Expose DSL for complex Pulse schedules.
Rather than offering an API that exposes many different sets of values after #92 is completed, we should offer an ergonomic DSL. Something like the following: 

```kt
val schedule: PulseSchedule = buildPulseSchedule {
  atSeconds(0, 30)
  atMinutes(5, 10, 25, 45)
  atHours(0, 6, 12, 18)
  onDaysOfMonth(1, 8, 15, 21)
  inMonths(Month.October, Month.December)
  onDaysOfWeek(DayOfWeek.Monday, DayOfWeek.Friday)
}

clock.schedulePulse(timezone = tz, schedule = schedule)
```

We may also consider additionally exposing builder like syntax directly from a schedule function. 

```kt
clock.schedulePulse(timezone) {
  atSeconds(0, 30)
  atMinutes(5, 10, 25, 45)
  atHours(0, 6, 12, 18)
  onDaysOfMonth(1, 8, 15, 21)
  inMonths(Month.October, Month.December)
  onDaysOfWeek(DayOfWeek.Monday, DayOfWeek.Friday)
}
```

Pull request description:
Closes #93

Patch:
diff --git a/build.gradle.kts b/build.gradle.kts
index ed37f40..6e1af87 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,6 +6,7 @@ plugins {
     alias(libs.plugins.dokka)
     alias(libs.plugins.kmpmt)
     alias(libs.plugins.kotlin.multiplatform)
+    alias(libs.plugins.poko)
     alias(libs.plugins.publish)
 }
 
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4943029..77d4033 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,6 +4,7 @@ kmpmt = "0.1.1"
 kotlin = "2.1.20"
 kotlinx-coroutines = "1.10.2"
 kotlinx-datetime = "0.6.2"
+poko = "0.18.6"
 publish = "0.30.0"
 
 [libraries]
@@ -16,4 +17,5 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.
 dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
 kmpmt = { id = "com.jakewharton.kmp-missing-targets", version.ref = "kmpmt" }
 kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+poko = { id = "dev.drewhamilton.poko", version.ref = "poko" }
 publish = { id = "com.vanniktech.maven.publish", version.ref = "publish" }
\ No newline at end of file
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/PulseSchedule.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/PulseSchedule.kt
new file mode 100644
index 0000000..0b68f05
--- /dev/null
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/PulseSchedule.kt
@@ -0,0 +1,143 @@
+package io.github.kevincianfarini.cardiologist
+
+import dev.drewhamilton.poko.Poko
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.Month
+
+/**
+ * A [Pulse] schedule that can be used with [schedulePulse] to define complex schedules.
+ *
+ * @constructor Creates a new PulseSchedule. Second values must be in range 0..59, minute values must be in range 0..59,
+ *              hour values must be in range 0..23, and day of month values must be in range 1..31. This constructor also
+ *              requires that seconds, minutes, hours, days of month, and months cannot be empty sets.
+ * @throws IllegalArgumentException if the constructor is called with any of our bounds value or an improperly empty set.
+ */
+@Poko
+public class PulseSchedule(
+    public val atSeconds: Set<Int>,
+    public val atMinutes: Set<Int>,
+    public val atHours: Set<Int>,
+    public val onDaysOfMonth: Set<Int>,
+    public val inMonths: Set<Month>,
+    public val onDaysOfWeek: Set<DayOfWeek>,
+) {
+    init {
+        require(!atSeconds.any { it !in 0..59 }) { "Seconds has an out of bound value: $atSeconds" }
+        require(atSeconds.isNotEmpty()) { "Seconds cannot be empty!" }
+        require(!atMinutes.any { it !in 0..59 }) { "Minutes has an out of bound value: $atMinutes" }
+        require(atMinutes.isNotEmpty()) { "Minutes cannot be empty!" }
+        require(!atHours.any { it !in 0..23 }) { "Hours has an out of bound value: $atHours" }
+        require(atHours.isNotEmpty()) { "Hours cannot be empty!" }
+        require(!onDaysOfMonth.any { it !in 1..31 }) { "Days of month has an out of bound value: $onDaysOfMonth" }
+        require(onDaysOfMonth.isNotEmpty()) { "Days of month cannot be empty!" }
+        require(inMonths.isNotEmpty()) { "Months cannot be empty!" }
+        // Don't check if onDaysOfWeek is empty because an empty set is equivalent to the wildcard `*` value in cron
+        // expressions.
+    }
+}
+
+public class PulseScheduleBuilder internal constructor() {
+
+    private var _atSeconds: Set<Int>? = null
+    private var _atMinutes: Set<Int>? = null
+    private var _atHours: Set<Int> ? = null
+    private var _onDaysOfMonth: Set<Int>? = null
+    private var _inMonths: Set<Month>? = null
+    private var _onDaysOfWeek: Set<DayOfWeek>? = null
+
+    public fun atSeconds(value: Int, vararg values: Int) {
+        _atSeconds = buildSet {
+            _atSeconds?.let(this::addAll)
+            add(value)
+            values.forEach(this::add)
+        }
+    }
+
+    public fun atMinutes(value: Int, vararg values: Int) {
+        _atMinutes = buildSet {
+            _atMinutes?.let(this::addAll)
+            add(value)
+            values.forEach(this::add)
+        }
+    }
+
+    public fun atHours(value: Int, vararg values: Int) {
+        _atHours = buildSet {
+            _atHours?.let(this::addAll)
+            add(value)
+            values.forEach(this::add)
+        }
+    }
+
+    public fun onDaysOfMonth(value: Int, vararg values: Int) {
+        _onDaysOfMonth = buildSet {
+            _onDaysOfMonth?.let(this::addAll)
+            add(value)
+            values.forEach(this::add)
+        }
+    }
+
+    public fun inMonths(value: Month, vararg values: Month) {
+        _inMonths = buildSet {
+            _inMonths?.let(this::addAll)
+            add(value)
+            values.forEach(this::add)
+        }
+    }
+
+    public fun onDaysOfWeek(value: DayOfWeek, vararg values: DayOfWeek) {
+        _onDaysOfWeek = buildSet {
+            _onDaysOfWeek?.let(this::addAll)
+            add(value)
+            values.forEach(this::add)
+        }
+    }
+
+    internal fun build(): PulseSchedule = PulseSchedule(
+        atSeconds = _atSeconds ?: (0..59).toSet(),
+        atMinutes = _atMinutes ?: (0..59).toSet(),
+        atHours = _atHours ?: (0..23).toSet(),
+        onDaysOfMonth = _onDaysOfMonth ?: (1..31).toSet(),
+        inMonths = _inMonths ?: Month.entries.toSet(),
+        onDaysOfWeek = _onDaysOfWeek ?: emptySet(),
+    )
+}
+
+/**
+ * Build a complex [PulseSchedule] with a DSL.
+ *
+ * For example, the following code will build a [schedule][PulseSchedule] that, when used with [schedulePulse], will
+ * beat once per minute at the 0th second on Mondays and Fridays.
+ *
+ * ```kt
+ * val schedule = buildPulseSchedule {
+ *   atSeconds(0)
+ *   onDaysOfWeek(DayOfWeek.MONDAY, DayOfWeek.FRIDAY)
+ * }
+ * ```
+ *
+ * Multiple successive calls to the same function on [PulseScheduleBuilder] are additive. This is useful for building
+ * schedules imperatively. For example, the following will create a schedule that, when used with [schedulePulse], will
+ * beat on three random seconds of each minute.
+ *
+ * ```kt
+ * val schedule = buildPulseSchedule {
+ *   val random = Random.Default
+ *   repeat(3) {
+ *     atSeconds(random.nextInt(0..59))
+ *   }
+ * }
+ * ```
+ *
+ * The valid values for each function are:
+ *
+ * - `atSeconds`: 0..59
+ * - `atMinutes`: 0..59
+ * - `atHours`: 0..23
+ * - `onDaysOfMonth`: 1..31
+ *
+ * @throws IllegalArgumentException if the [builder] lambda completes with any values outside the above range.
+ */
+public fun buildPulseSchedule(builder: PulseScheduleBuilder.() -> Unit): PulseSchedule {
+    return PulseScheduleBuilder().apply(builder).build()
+}
\ No newline at end of file


Test patch:
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleBuilderTest.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleBuilderTest.kt
new file mode 100644
index 0000000..40a7df0
--- /dev/null
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleBuilderTest.kt
@@ -0,0 +1,208 @@
+package io.github.kevincianfarini.cardiologist
+
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.Month
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PulseScheduleBuilderTest {
+
+    @Test
+    fun empty_builder_produces_default_value() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule { },
+    )
+
+    @Test
+    fun simple_seconds() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = setOf(0, 30),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            atSeconds(0, 30)
+        },
+    )
+
+    @Test
+    fun multiple_seconds_calls_are_additive() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = setOf(0, 15, 30, 45),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            atSeconds(0, 30)
+            atSeconds(15, 45)
+        },
+    )
+
+    @Test
+    fun simple_minutes() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = setOf(0, 30),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            atMinutes(0, 30)
+        },
+    )
+
+    @Test
+    fun multiple_minutes_calls_are_additive() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = setOf(0, 15, 30, 45),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            atMinutes(0, 30)
+            atMinutes(15, 45)
+        },
+    )
+
+    @Test
+    fun simple_hours() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = setOf(0, 12),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            atHours(0, 12)
+        },
+    )
+
+    @Test
+    fun multiple_hours_are_additive() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = setOf(0, 6, 12, 18),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            atHours(0, 12)
+            atHours(6, 18)
+        },
+    )
+
+    @Test
+    fun simple_days_of_month() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = setOf(1, 15),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            onDaysOfMonth(1, 15)
+        },
+    )
+
+    @Test
+    fun multiple_days_of_month_calls_are_additive() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = setOf(1, 7, 15, 21),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            onDaysOfMonth(1, 15)
+            onDaysOfMonth(7, 21)
+        },
+    )
+
+    @Test
+    fun simple_months() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = setOf(Month.OCTOBER, Month.DECEMBER),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            inMonths(Month.OCTOBER, Month.DECEMBER)
+        },
+    )
+
+    @Test
+    fun multiple_months_calls_are_additive() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = setOf(Month.OCTOBER, Month.DECEMBER, Month.MAY),
+            onDaysOfWeek = emptySet(),
+        ),
+        actual = buildPulseSchedule {
+            inMonths(Month.OCTOBER, Month.DECEMBER)
+            inMonths(Month.MAY)
+        },
+    )
+
+    @Test
+    fun simple_day_of_week() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
+        ),
+        actual = buildPulseSchedule {
+            onDaysOfWeek(DayOfWeek.MONDAY, DayOfWeek.FRIDAY)
+        },
+    )
+
+    @Test
+    fun multiple_day_of_week_calls_are_additive() = assertEquals(
+        expected = PulseSchedule(
+            atSeconds = (0..59).toSet(),
+            atMinutes = (0..59).toSet(),
+            atHours = (0..23).toSet(),
+            onDaysOfMonth = (1..31).toSet(),
+            inMonths = Month.entries.toSet(),
+            onDaysOfWeek = setOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY),
+        ),
+        actual = buildPulseSchedule {
+            onDaysOfWeek(DayOfWeek.MONDAY, DayOfWeek.FRIDAY)
+            onDaysOfWeek(DayOfWeek.WEDNESDAY)
+        },
+    )
+}
\ No newline at end of file
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleTest.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleTest.kt
new file mode 100644
index 0000000..4a88444
--- /dev/null
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/PulseScheduleTest.kt
@@ -0,0 +1,171 @@
+package io.github.kevincianfarini.cardiologist
+
+import kotlinx.datetime.Month
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+
+class PulseScheduleTest {
+
+    @Test
+    fun second_out_of_range_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = setOf(0, 75),
+                atMinutes = (0..59).toSet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                inMonths = Month.entries.toSet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Seconds has an out of bound value: [0, 75]",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun empty_second_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = emptySet(),
+                atMinutes = (0..59).toSet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                inMonths = Month.entries.toSet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Seconds cannot be empty!",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun minute_out_of_range_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = (0..59).toSet(),
+                atMinutes = setOf(0, 75),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                inMonths = Month.entries.toSet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Minutes has an out of bound value: [0, 75]",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun empty_minute_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = (0..59).toSet(),
+                atMinutes = emptySet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                inMonths = Month.entries.toSet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Minutes cannot be empty!",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun hour_out_of_range_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = (0..59).toSet(),
+                atMinutes = (0..59).toSet(),
+                atHours = setOf(0, 24),
+                onDaysOfMonth = (1..31).toSet(),
+                inMonths = Month.entries.toSet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Hours has an out of bound value: [0, 24]",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun empty_hour_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = (0..59).toSet(),
+                atMinutes = (0..59).toSet(),
+                atHours = emptySet(),
+                onDaysOfMonth = (1..31).toSet(),
+                inMonths = Month.entries.toSet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Hours cannot be empty!",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun day_of_month_out_of_range_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = (0..59).toSet(),
+                atMinutes = (0..59).toSet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = setOf(0),
+                inMonths = Month.entries.toSet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Days of month has an out of bound value: [0]",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun empty_day_of_month_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = (0..59).toSet(),
+                atMinutes = (0..59).toSet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = emptySet(),
+                inMonths = Month.entries.toSet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Days of month cannot be empty!",
+            actual = e.message,
+        )
+    }
+
+    @Test
+    fun empty_month_fails() {
+        val e = assertFails {
+            PulseSchedule(
+                atSeconds = (0..59).toSet(),
+                atMinutes = (0..59).toSet(),
+                atHours = (0..23).toSet(),
+                onDaysOfMonth = (1..31).toSet(),
+                inMonths = emptySet(),
+                onDaysOfWeek = emptySet(),
+            )
+        }
+        assertEquals(
+            expected = "Months cannot be empty!",
+            actual = e.message,
+        )
+    }
+}
\ No newline at end of file