diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/CronScheduleFormatter.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/CronScheduleFormatter.swift new file mode 100644 index 0000000..89d5412 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/CronScheduleFormatter.swift @@ -0,0 +1,211 @@ +import Foundation + +/// Human-readable rendering for `CronSchedule` values. +/// +/// Hermes stores cron schedules with a raw `expression` (`"0 */6 * * *"`) +/// plus an optional `display` label. In practice, the CLI writes both +/// fields to the same raw cron string — so UIs that render `display` +/// verbatim (both Scarf and ScarfGo, pre-fix) end up showing +/// `0 */6 * * *` to every user, technical or not. +/// +/// This formatter pattern-matches the most common cron shapes and +/// produces English phrases. Anything it doesn't recognise falls back +/// to the raw expression with a short hint, so nothing is lost. +/// +/// Not a full cron parser — covers ~95% of real-world schedules while +/// staying ~80 lines. Add patterns here as users hit unrecognised +/// shapes; the fallback already ships working. +public enum CronScheduleFormatter { + + /// Primary entry point. Returns a phrase suitable for the row + /// subtitle in Mac + ScarfGo cron lists. + public static func humanReadable(from schedule: CronSchedule) -> String { + // Trust `display` when it doesn't look like raw cron. Users + // CAN set descriptive labels via `hermes cron set-display`; + // we don't want to overwrite that. + if let display = schedule.display, + !display.isEmpty, + !looksLikeCron(display) + { + return display + } + + // Use whatever raw expression we have (preferring `expression`, + // falling back to `display` since Hermes sometimes writes the + // cron into both fields). + let expr = schedule.expression ?? schedule.display ?? "" + if !expr.isEmpty, let phrase = translate(cronExpression: expr) { + return phrase + } + + // Non-cron kinds (runAt, interval) get their own branches. + switch schedule.kind.lowercased() { + case "runat", "run_at": + if let runAt = schedule.runAt, !runAt.isEmpty { + return "Once on \(runAt)" + } + return "One-off" + case "interval": + return schedule.display ?? schedule.expression ?? "Interval" + default: + break + } + + // Final fallback: show whatever raw string we have. + return expr.isEmpty ? schedule.kind : expr + } + + /// Relative next-run phrase (`"in 4 hours"`, `"tomorrow at 9 AM"`). + /// `nil` date → `"—"`. Used by both Mac + ScarfGo cron rows. + public static func formatNextRun(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return "—" } + let style = Date.RelativeFormatStyle( + presentation: .numeric, + unitsStyle: .wide + ) + return date.formatted(style) + } + + /// Same as `formatNextRun(_:)` but accepts the ISO8601 string + /// Hermes stores in `jobs.json`. Attempts several parse strategies + /// because Hermes varies the exact serialization between versions + /// (with / without fractional seconds, with / without timezone + /// offset). On parse failure, falls back to the raw string so we + /// never blank out useful info. + public static func formatNextRun(iso: String?, now: Date = Date()) -> String { + guard let iso, !iso.isEmpty else { return "—" } + if let date = Self.isoDate(iso) { + return formatNextRun(date, now: now) + } + return iso + } + + nonisolated static func isoDate(_ iso: String) -> Date? { + let formatters: [ISO8601DateFormatter] = { + let f1 = ISO8601DateFormatter() + f1.formatOptions = [.withInternetDateTime] + let f2 = ISO8601DateFormatter() + f2.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return [f1, f2] + }() + for f in formatters { + if let d = f.date(from: iso) { return d } + } + return nil + } + + // MARK: - Implementation + + /// True when the string starts with a typical cron token + /// (``, `*`, `@`). Lets us distinguish a label like + /// "Daily release check" from a raw `0 9 * * *` in `display`. + nonisolated static func looksLikeCron(_ s: String) -> Bool { + let trimmed = s.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.first else { return false } + if first == "@" { return true } // @hourly, @daily, @weekly + if first == "*" { return true } // wildcard in minute + if first.isNumber { // "0 ..." etc. + // Only consider it cron if the string has at least 4 spaces + // (= 5 fields) or starts with a single-digit followed by + // space. Short strings like "2:00pm" should stay as labels. + let spaces = trimmed.filter { $0 == " " }.count + return spaces >= 4 + } + return false + } + + /// Translate a raw cron expression into English. Returns nil when + /// no pattern matches — caller falls back to the raw string. + nonisolated static func translate(cronExpression raw: String) -> String? { + let expr = raw.trimmingCharacters(in: .whitespaces) + + // Named macros Hermes / crontab accept as synonyms. + switch expr.lowercased() { + case "@hourly": return "Every hour" + case "@daily", "@midnight": return "Daily at midnight" + case "@weekly": return "Weekly (Sunday at midnight)" + case "@monthly": return "Monthly (1st at midnight)" + case "@yearly", "@annually": return "Yearly (Jan 1 at midnight)" + default: break + } + + let fields = expr.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard fields.count == 5 else { return nil } + let (min, hr, dom, mon, dow) = (fields[0], fields[1], fields[2], fields[3], fields[4]) + + // Every N minutes: */N * * * * + if min.hasPrefix("*/"), hr == "*", dom == "*", mon == "*", dow == "*", + let n = Int(min.dropFirst(2)) + { + return n == 1 ? "Every minute" : "Every \(n) minutes" + } + + // Every hour on minute M: M * * * * (M is a single number) + if let _ = Int(min), hr == "*", dom == "*", mon == "*", dow == "*" { + return min == "0" ? "Every hour" : "Every hour at :\(zeroPad(min))" + } + + // Every N hours at minute M: M */N * * * + if let _ = Int(min), hr.hasPrefix("*/"), dom == "*", mon == "*", dow == "*", + let n = Int(hr.dropFirst(2)) + { + let minute = min == "0" ? "" : " at :\(zeroPad(min))" + return n == 1 ? "Every hour\(minute)" : "Every \(n) hours\(minute)" + } + + // Daily at H:MM: MM H * * * + if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", dow == "*" { + return "Daily at \(formatClock(hour: h, minute: min))" + } + + // Weekdays at H:MM: MM H * * 1-5 + if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", dow == "1-5" { + return "Weekdays at \(formatClock(hour: h, minute: min))" + } + + // Weekends at H:MM: MM H * * 0,6 or 6,0 + if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", + (dow == "0,6" || dow == "6,0" || dow == "6,7") + { + return "Weekends at \(formatClock(hour: h, minute: min))" + } + + // Single weekday at H:MM: MM H * * + if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", + let d = Int(dow), (0...7).contains(d) + { + return "Every \(weekdayName(d)) at \(formatClock(hour: h, minute: min))" + } + + // Monthly on day D at H:MM: MM H D * * + if let _ = Int(min), let h = Int(hr), let d = Int(dom), mon == "*", dow == "*" { + return "Monthly on day \(d) at \(formatClock(hour: h, minute: min))" + } + + return nil + } + + private static func zeroPad(_ s: String) -> String { + s.count == 1 ? "0" + s : s + } + + /// Return "H:MM AM/PM" — 12-hour with no leading zero on the hour, + /// to match how iOS natively displays times in most list contexts. + private static func formatClock(hour h: Int, minute mStr: String) -> String { + let m = Int(mStr) ?? 0 + var h12 = h % 12 + if h12 == 0 { h12 = 12 } + let suffix = (h < 12) ? "AM" : "PM" + if m == 0 { + return "\(h12) \(suffix)" + } + let mm = m < 10 ? "0\(m)" : "\(m)" + return "\(h12):\(mm) \(suffix)" + } + + private static func weekdayName(_ d: Int) -> String { + // Cron convention: 0 and 7 are both Sunday; 1..6 are Mon..Sat. + let names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + return names[max(0, min(7, d))] + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/CronScheduleFormatterTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/CronScheduleFormatterTests.swift new file mode 100644 index 0000000..d4e2754 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/CronScheduleFormatterTests.swift @@ -0,0 +1,129 @@ +import Testing +import Foundation +@testable import ScarfCore + +@Suite("CronScheduleFormatter") +struct CronScheduleFormatterTests { + + private func cron(_ expr: String, display: String? = nil, kind: String = "cron") -> CronSchedule { + CronSchedule(kind: kind, runAt: nil, display: display, expression: expr) + } + + // MARK: - Named macros + + @Test func hourlyMacro() { + #expect(CronScheduleFormatter.humanReadable(from: cron("@hourly")) == "Every hour") + } + + @Test func dailyMacro() { + #expect(CronScheduleFormatter.humanReadable(from: cron("@daily")) == "Daily at midnight") + #expect(CronScheduleFormatter.humanReadable(from: cron("@midnight")) == "Daily at midnight") + } + + @Test func weeklyMonthlyYearlyMacros() { + #expect(CronScheduleFormatter.humanReadable(from: cron("@weekly")) == "Weekly (Sunday at midnight)") + #expect(CronScheduleFormatter.humanReadable(from: cron("@monthly")) == "Monthly (1st at midnight)") + #expect(CronScheduleFormatter.humanReadable(from: cron("@yearly")) == "Yearly (Jan 1 at midnight)") + #expect(CronScheduleFormatter.humanReadable(from: cron("@annually")) == "Yearly (Jan 1 at midnight)") + } + + // MARK: - Every N minutes / hours + + @Test func everyNMinutes() { + #expect(CronScheduleFormatter.humanReadable(from: cron("*/5 * * * *")) == "Every 5 minutes") + #expect(CronScheduleFormatter.humanReadable(from: cron("*/15 * * * *")) == "Every 15 minutes") + #expect(CronScheduleFormatter.humanReadable(from: cron("*/1 * * * *")) == "Every minute") + } + + @Test func everyHourAtMinute() { + #expect(CronScheduleFormatter.humanReadable(from: cron("0 * * * *")) == "Every hour") + #expect(CronScheduleFormatter.humanReadable(from: cron("30 * * * *")) == "Every hour at :30") + #expect(CronScheduleFormatter.humanReadable(from: cron("5 * * * *")) == "Every hour at :05") + } + + @Test func everyNHours() { + #expect(CronScheduleFormatter.humanReadable(from: cron("0 */6 * * *")) == "Every 6 hours") + #expect(CronScheduleFormatter.humanReadable(from: cron("15 */2 * * *")) == "Every 2 hours at :15") + #expect(CronScheduleFormatter.humanReadable(from: cron("0 */1 * * *")) == "Every hour") + } + + // MARK: - Daily at H / Weekdays / Weekends / single weekday + + @Test func dailyAtHour() { + #expect(CronScheduleFormatter.humanReadable(from: cron("0 9 * * *")) == "Daily at 9 AM") + #expect(CronScheduleFormatter.humanReadable(from: cron("30 14 * * *")) == "Daily at 2:30 PM") + #expect(CronScheduleFormatter.humanReadable(from: cron("0 0 * * *")) == "Daily at 12 AM") + #expect(CronScheduleFormatter.humanReadable(from: cron("0 12 * * *")) == "Daily at 12 PM") + } + + @Test func weekdaysAtHour() { + #expect(CronScheduleFormatter.humanReadable(from: cron("0 9 * * 1-5")) == "Weekdays at 9 AM") + } + + @Test func weekendsAtHour() { + #expect(CronScheduleFormatter.humanReadable(from: cron("0 10 * * 0,6")) == "Weekends at 10 AM") + #expect(CronScheduleFormatter.humanReadable(from: cron("0 10 * * 6,7")) == "Weekends at 10 AM") + } + + @Test func singleWeekdayAtHour() { + #expect(CronScheduleFormatter.humanReadable(from: cron("0 8 * * 1")) == "Every Monday at 8 AM") + #expect(CronScheduleFormatter.humanReadable(from: cron("30 17 * * 5")) == "Every Friday at 5:30 PM") + #expect(CronScheduleFormatter.humanReadable(from: cron("0 9 * * 0")) == "Every Sunday at 9 AM") + } + + // MARK: - Monthly + + @Test func monthlyOnDayAtHour() { + #expect(CronScheduleFormatter.humanReadable(from: cron("0 9 1 * *")) == "Monthly on day 1 at 9 AM") + #expect(CronScheduleFormatter.humanReadable(from: cron("30 14 15 * *")) == "Monthly on day 15 at 2:30 PM") + } + + // MARK: - Display override (user-set label) + + @Test func displayOverrideWinsWhenNonCron() { + let s = CronSchedule( + kind: "cron", + runAt: nil, + display: "Pre-standup release check", + expression: "0 9 * * 1-5" + ) + #expect(CronScheduleFormatter.humanReadable(from: s) == "Pre-standup release check") + } + + @Test func displayIgnoredWhenItLooksLikeCron() { + // Hermes CLI duplicates the cron into display — we should + // still translate it, not echo it back to the user. + let s = CronSchedule( + kind: "cron", + runAt: nil, + display: "0 */6 * * *", + expression: "0 */6 * * *" + ) + #expect(CronScheduleFormatter.humanReadable(from: s) == "Every 6 hours") + } + + // MARK: - Unknown shapes fall back gracefully + + @Test func unknownPatternReturnsRaw() { + let weird = "0,30 9,17 1,15 * *" + #expect(CronScheduleFormatter.humanReadable(from: cron(weird)) == weird) + } + + @Test func runAtKindFormatsAsOneOff() { + let s = CronSchedule(kind: "runAt", runAt: "2026-05-01 09:00", display: nil, expression: nil) + #expect(CronScheduleFormatter.humanReadable(from: s) == "Once on 2026-05-01 09:00") + } + + // MARK: - Next-run relative formatter + + @Test func nextRunNilReturnsEmDash() { + #expect(CronScheduleFormatter.formatNextRun(nil) == "—") + } + + @Test func nextRunRelativeFormatterProducesNonEmptyString() { + let inTwoHours = Date().addingTimeInterval(2 * 60 * 60) + let formatted = CronScheduleFormatter.formatNextRun(inTwoHours) + #expect(!formatted.isEmpty) + #expect(formatted != "—") + } +} diff --git a/scarf/Scarf iOS/Cron/CronListView.swift b/scarf/Scarf iOS/Cron/CronListView.swift index 1788eba..8891522 100644 --- a/scarf/Scarf iOS/Cron/CronListView.swift +++ b/scarf/Scarf iOS/Cron/CronListView.swift @@ -128,20 +128,12 @@ private struct CronRow: View { .clipShape(RoundedRectangle(cornerRadius: 4)) } } - if let schedule = job.schedule.display, !schedule.isEmpty { - Text(schedule) - .font(.caption) - .foregroundStyle(.secondary) - } else if !job.schedule.kind.isEmpty { - Text(job.schedule.kind) - .font(.caption) - .foregroundStyle(.secondary) - } - if let nextRun = job.nextRunAt { - Text("Next: \(nextRun)") - .font(.caption2) - .foregroundStyle(.tertiary) - } + Text(CronScheduleFormatter.humanReadable(from: job.schedule)) + .font(.caption) + .foregroundStyle(.secondary) + Text("Next: \(CronScheduleFormatter.formatNextRun(iso: job.nextRunAt))") + .font(.caption2) + .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index d8c9891..2ed8dd3 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -107,7 +107,7 @@ struct CronView: View { VStack(alignment: .leading, spacing: 2) { Text(job.name) .lineLimit(1) - Text(job.schedule.display ?? job.schedule.kind) + Text(CronScheduleFormatter.humanReadable(from: job.schedule)) .font(.caption) .foregroundStyle(.secondary) } @@ -173,7 +173,7 @@ struct CronView: View { .font(.title2.bold()) HStack(spacing: 16) { Label(job.state, systemImage: job.stateIcon) - Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock") + Label(CronScheduleFormatter.humanReadable(from: job.schedule), systemImage: "clock") Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle") if let deliver = job.deliveryDisplay { Label("Deliver: \(deliver)", systemImage: "paperplane") @@ -255,15 +255,21 @@ struct CronView: View { } } } - if let nextRun = job.nextRunAt { - Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle") - .font(.caption) - .foregroundStyle(.secondary) + if job.nextRunAt != nil { + Label( + "Next run: \(CronScheduleFormatter.formatNextRun(iso: job.nextRunAt))", + systemImage: "arrow.forward.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) } - if let lastRun = job.lastRunAt { - Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle") - .font(.caption) - .foregroundStyle(.secondary) + if job.lastRunAt != nil { + Label( + "Last run: \(CronScheduleFormatter.formatNextRun(iso: job.lastRunAt))", + systemImage: "arrow.backward.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) } if let error = job.lastError { Label(error, systemImage: "exclamationmark.triangle")