diff --git a/README.md b/README.md index efd46e3..bff8a23 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,38 @@ $ reminders show Soon 3: Something really important (priority: high) ``` +#### Add a repeating reminder + +``` +$ reminders add Soon Weekly review --due-date "monday 9am" --repeat weekly +$ reminders add Soon Pay rent --due-date "2026-09-01" --repeat monthly --repeat-until "2027-09-01" +$ reminders add Soon Water the plants --due-date "tomorrow" --repeat daily --repeat-interval 3 +``` + +`--repeat` accepts `daily`, `weekly`, `monthly`, or `yearly` (EventKit reminders have no hourly +recurrence frequency, so `--repeat hourly` is rejected with an explanation rather than silently +degrading to daily). `--repeat-interval` repeats every N units instead of every 1 (e.g. +`--repeat-interval 2 --repeat weekly` for every other week) and defaults to 1. Use +`--repeat-until` to stop after a date; omitting it repeats forever, matching the Reminders.app +default. A date without a time includes the whole local day. On `add`, recurrence options require +`--repeat` to also be set, and a repeating reminder requires `--due-date`. + +To change or remove a repeat rule on an existing reminder, use `edit`: + +``` +$ reminders edit Soon 0 --repeat monthly +$ reminders edit Soon 0 --repeat-until "2027-09-01" +$ reminders edit Soon 0 --clear-repeat-end +$ reminders edit Soon 0 --clear-repeat +``` + +Changing only the interval or end condition preserves the existing frequency and any complex +selectors, such as "the last Friday of every month". Changing the frequency preserves the existing +end condition but resets its interval to 1 unless `--repeat-interval` is supplied. An end-only edit +copies the complete EventKit rule so provider-specific calendar metadata is preserved as well. +JSON output includes `recurrence`, `recurrenceInterval`, and either `recurrenceEnd` or +`recurrenceCount` (when an existing rule is count-based) for repeating reminders. + #### Show reminders due on or by a date ``` diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index ad2bbef..68271ac 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -153,6 +153,46 @@ private struct Add: ParsableCommand { help: "The notes to add to the reminder") var notes: String? + @Option( + name: [.customLong("repeat")], + help: "Repeat the reminder, one of: daily, weekly, monthly, yearly") + var repeat_: Recurrence? + + @Option( + name: .long, + help: "Repeat every N units of --repeat's frequency instead of every 1 (default: 1)") + var repeatInterval: Int? + + @Option( + name: .long, + help: "Stop repeating after this date (default: repeats forever)") + var repeatUntil: DateComponents? + + func validate() throws { + if let repeat_ = repeat_, !repeat_.isRepresentable { + throw ValidationError( + "--repeat \(repeat_.rawValue) is not supported: EventKit reminders have no hourly " + + "recurrence frequency (Reminders.app itself doesn't expose this either). Use " + + "daily, weekly, monthly, or yearly.") + } + if repeat_ != nil && dueDate == nil { + throw ValidationError("--repeat requires --due-date") + } + if let repeatUntil, let dueDate, + let endDate = recurrenceEndDate(from: repeatUntil), + let due = dueDate.date, endDate < due + { + throw ValidationError("--repeat-until cannot be earlier than --due-date") + } + if let repeatInterval, repeatInterval < 1 { + throw ValidationError("--repeat-interval must be at least 1") + } + if repeat_ == nil && (repeatInterval != nil || repeatUntil != nil) { + throw ValidationError( + "--repeat-interval and --repeat-until require --repeat") + } + } + func run() { reminders.addReminder( string: self.reminder.joined(separator: " "), @@ -160,6 +200,9 @@ private struct Add: ParsableCommand { toListNamed: self.listName, dueDateComponents: self.dueDate, priority: priority, + recurrence: self.repeat_, + recurrenceInterval: self.repeatInterval ?? 1, + recurrenceEndDate: self.repeatUntil, outputFormat: format) } } @@ -242,6 +285,31 @@ private struct Edit: ParsableCommand { help: "The notes to set on the reminder, overwriting previous notes") var notes: String? + @Option( + name: [.customLong("repeat")], + help: "Set (or replace) the reminder's repeat, one of: daily, weekly, monthly, yearly") + var repeat_: Recurrence? + + @Option( + name: .long, + help: "Repeat every N units of the recurrence frequency") + var repeatInterval: Int? + + @Option( + name: .long, + help: "Stop repeating after this date; preserves the existing repeat frequency") + var repeatUntil: DateComponents? + + @Flag( + name: .long, + help: "Remove any repeat rule from the reminder") + var clearRepeat = false + + @Flag( + name: .long, + help: "Keep repeating forever without changing the recurrence frequency") + var clearRepeatEnd = false + @Option( name: .shortAndLong, help: "The new date the reminder is due") @@ -260,9 +328,32 @@ private struct Edit: ParsableCommand { throw ValidationError("Cannot specify both --due-date and --clear-due-date") } - if self.reminder.isEmpty && self.notes == nil && self.dueDate == nil && !self.clearDueDate { + let changesRecurrence = self.repeat_ != nil || self.repeatInterval != nil + || self.repeatUntil != nil || self.clearRepeatEnd + + if self.reminder.isEmpty && self.notes == nil && self.dueDate == nil + && !self.clearDueDate && !changesRecurrence && !self.clearRepeat + { + throw ValidationError( + "Must specify new reminder content, new notes, a due date change, or a repeat change") + } + if self.clearRepeat && changesRecurrence { + throw ValidationError("Cannot combine --clear-repeat with another repeat option") + } + if let repeat_ = repeat_, !repeat_.isRepresentable { + throw ValidationError( + "--repeat \(repeat_.rawValue) is not supported: EventKit reminders have no hourly " + + "recurrence frequency (Reminders.app itself doesn't expose this either). Use " + + "daily, weekly, monthly, or yearly.") + } + if let repeatInterval, repeatInterval < 1 { + throw ValidationError("--repeat-interval must be at least 1") + } + let endOptionCount = [repeatUntil != nil, clearRepeatEnd] + .filter { $0 }.count + if endOptionCount > 1 { throw ValidationError( - "Must specify either new reminder content, new notes, or a due date change") + "Specify only one of --repeat-until or --clear-repeat-end") } } @@ -274,7 +365,12 @@ private struct Edit: ParsableCommand { newText: newText.isEmpty ? nil : newText, newNotes: self.notes, newDueDateComponents: self.dueDate, - clearDueDate: self.clearDueDate + clearDueDate: self.clearDueDate, + newRecurrence: self.repeat_, + newRecurrenceInterval: self.repeatInterval, + newRecurrenceEndDate: self.repeatUntil, + clearRecurrenceEnd: self.clearRepeatEnd, + clearRecurrence: self.clearRepeat ) } } diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index f9f9fdb..f6613f9 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -16,6 +16,10 @@ extension EKReminder: @retroactive Encodable { case startDate case dueDate case list + case recurrence + case recurrenceInterval + case recurrenceEnd + case recurrenceCount } public func encode(to encoder: Encoder) throws { @@ -58,6 +62,25 @@ extension EKReminder: @retroactive Encodable { if let creationDate = self.creationDate { try container.encode(format(creationDate), forKey: .creationDate) } + + if let rule = self.recurrenceRules?.first { + try container.encodeIfPresent(recurrenceName(for: rule.frequency), forKey: .recurrence) + try container.encode(rule.interval, forKey: .recurrenceInterval) + try container.encodeIfPresent(format(rule.recurrenceEnd?.endDate), forKey: .recurrenceEnd) + if let count = rule.recurrenceEnd?.occurrenceCount, count > 0 { + try container.encode(count, forKey: .recurrenceCount) + } + } + } + + private func recurrenceName(for frequency: EKRecurrenceFrequency) -> String? { + switch frequency { + case .daily: return "daily" + case .weekly: return "weekly" + case .monthly: return "monthly" + case .yearly: return "yearly" + @unknown default: return nil + } } private func format(_ date: Date?) -> String? { diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 5c650c3..600cd17 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -4,12 +4,47 @@ import Foundation private let Store = EKEventStore() private let dateFormatter = RelativeDateTimeFormatter() +private let recurrenceDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + return formatter +}() + private func formattedDueDate(from reminder: EKReminder) -> String? { return reminder.dueDateComponents?.date.map { dateFormatter.localizedString(for: $0, relativeTo: Date()) } } +private func formattedRecurrence(from reminder: EKReminder) -> String? { + guard let rule = reminder.recurrenceRules?.first else { + return nil + } + + let frequency: String + switch rule.frequency { + case .daily: frequency = "daily" + case .weekly: frequency = "weekly" + case .monthly: frequency = "monthly" + case .yearly: frequency = "yearly" + @unknown default: frequency = "unknown" + } + + var parts = ["repeats: \(frequency)"] + if rule.interval > 1 { + parts.append("interval: \(rule.interval)") + } + if let endDate = rule.recurrenceEnd?.endDate { + parts.append("until: \(recurrenceDateFormatter.string(from: endDate))") + } else if let count = rule.recurrenceEnd?.occurrenceCount, count > 0 { + parts.append("count: \(count)") + } + return parts.joined(separator: ", ") +} + private extension EKReminder { var mappedPriority: EKReminderPriority { UInt(exactly: self.priority).flatMap(EKReminderPriority.init) ?? EKReminderPriority.none @@ -22,7 +57,8 @@ private func format(_ reminder: EKReminder, at index: Int?, listName: String? = let listString = listName.map { "\($0): " } ?? "" let notesString = reminder.notes.map { " (\($0))" } ?? "" let indexString = index.map { "\($0): " } ?? "" - return "\(listString)\(indexString)\(reminder.title ?? "")\(notesString)\(dateString)\(priorityString)" + let recurrenceString = formattedRecurrence(from: reminder).map { " (\($0))" } ?? "" + return "\(listString)\(indexString)\(reminder.title ?? "")\(notesString)\(dateString)\(priorityString)\(recurrenceString)" } public enum OutputFormat: String, ExpressibleByArgument { @@ -35,6 +71,205 @@ public enum DisplayOptions: String, Decodable { case complete } +public enum Recurrence: String, ExpressibleByArgument { + case hourly + case daily + case weekly + case monthly + case yearly + + var frequency: EKRecurrenceFrequency { + switch self { + case .hourly: return .daily // EventKit has no hourly frequency; see interval note below. + case .daily: return .daily + case .weekly: return .weekly + case .monthly: return .monthly + case .yearly: return .yearly + } + } + + /// EventKit's `EKRecurrenceFrequency` has no hourly case, so `.hourly` is + /// modeled as a daily rule with a 1-day interval and 24 hourly recurrences + /// per day is not representable via `EKRecurrenceRule` alone. Since + /// reminders don't carry a native hourly repeat concept in EventKit (the + /// Reminders.app UI itself doesn't expose "hourly" either), `.hourly` is + /// intentionally rejected at parse time in `RecurrenceOption` rather than + /// silently degrading to daily. See CLI.swift for the actual validation; + /// this case is kept in the enum only so `--repeat hourly` produces a + /// clear, on-brand error message instead of an ArgumentParser "invalid + /// value" message with no explanation. + var isRepresentable: Bool { + self != .hourly + } + + func recurrenceRule(interval: Int, end: EKRecurrenceEnd?) -> EKRecurrenceRule { + return EKRecurrenceRule( + recurrenceWith: self.frequency, + interval: interval, + end: end) + } +} + +enum RecurrenceEndUpdate { + case unchanged + case date(Date) + case clear + + func applying(to existingEnd: EKRecurrenceEnd?) -> EKRecurrenceEnd? { + switch self { + case .unchanged: + return existingEnd + case .date(let date): + return EKRecurrenceEnd(end: date) + case .clear: + return nil + } + } +} + +enum RecurrenceUpdateError: LocalizedError { + case invalidEndDate + case missingExistingRule + case missingDueDate + case endBeforeDueDate + case failedToCopyExistingRule + + var errorDescription: String? { + switch self { + case .invalidEndDate: + return "The repeat end date could not be parsed" + case .missingExistingRule: + return "A repeat rule is required; pass --repeat or edit a repeating reminder" + case .missingDueDate: + return "A repeating reminder requires a due date" + case .endBeforeDueDate: + return "The repeat end date cannot be earlier than the reminder's due date" + case .failedToCopyExistingRule: + return "The existing repeat rule could not be copied safely" + } + } +} + +struct RecurrenceUpdate { + let recurrence: Recurrence? + let interval: Int? + let end: RecurrenceEndUpdate + + var isRequested: Bool { + if recurrence != nil || interval != nil { + return true + } + if case .unchanged = end { + return false + } + return true + } + + func rule(replacing existingRule: EKRecurrenceRule?) throws -> EKRecurrenceRule { + guard let frequency = recurrence?.frequency ?? existingRule?.frequency else { + throw RecurrenceUpdateError.missingExistingRule + } + + let shouldPreserveInterval = recurrence == nil || existingRule?.frequency == frequency + let inheritedInterval = shouldPreserveInterval ? existingRule?.interval : nil + let resolvedInterval = interval ?? inheritedInterval ?? 1 + let resolvedEnd = end.applying(to: existingRule?.recurrenceEnd) + + // Changing only the end condition can preserve more than the public + // initializer exposes, including provider-specific calendar metadata + // and firstDayOfTheWeek. Copy the complete EventKit rule and modify its + // sole writable recurrence property rather than reconstructing it. + if let existingRule, recurrence == nil, interval == nil { + guard let copiedRule = existingRule.copy() as? EKRecurrenceRule else { + throw RecurrenceUpdateError.failedToCopyExistingRule + } + copiedRule.recurrenceEnd = resolvedEnd + return copiedRule + } + + // An interval edit must preserve every public selector in a complex + // rule (for example, "the last Friday of every month"). The same + // applies when the explicitly supplied frequency is unchanged. + if let existingRule, + recurrence == nil || existingRule.frequency == frequency + { + return EKRecurrenceRule( + recurrenceWith: frequency, + interval: resolvedInterval, + daysOfTheWeek: existingRule.daysOfTheWeek, + daysOfTheMonth: existingRule.daysOfTheMonth, + monthsOfTheYear: existingRule.monthsOfTheYear, + weeksOfTheYear: existingRule.weeksOfTheYear, + daysOfTheYear: existingRule.daysOfTheYear, + setPositions: existingRule.setPositions, + end: resolvedEnd) + } + + return EKRecurrenceRule( + recurrenceWith: frequency, + interval: resolvedInterval, + end: resolvedEnd) + } +} + +func recurrenceEndDate(from components: DateComponents) -> Date? { + var calendar = components.calendar ?? Calendar.current + if let timeZone = components.timeZone { + calendar.timeZone = timeZone + } + + guard let date = calendar.date(from: components) else { + return nil + } + + // A date-only value means the whole local day. Using midnight would make + // a morning or evening occurrence on the requested final day disappear. + guard components.hour == nil && components.minute == nil && components.second == nil else { + return date + } + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: date) else { + return nil + } + return nextDay.addingTimeInterval(-1) +} + +private func recurrenceEnd(dateComponents: DateComponents?) throws -> EKRecurrenceEnd? { + guard let dateComponents else { + return nil + } + guard let date = recurrenceEndDate(from: dateComponents) else { + throw RecurrenceUpdateError.invalidEndDate + } + return EKRecurrenceEnd(end: date) +} + +func validateRecurrenceEnd( + dueDateComponents: DateComponents?, + rules: [EKRecurrenceRule] +) throws { + guard let dueDate = dueDateComponents?.date else { + return + } + if rules.contains(where: { rule in + guard let endDate = rule.recurrenceEnd?.endDate else { + return false + } + return endDate < dueDate + }) { + throw RecurrenceUpdateError.endBeforeDueDate + } +} + +func validateRecurrenceSchedule( + dueDateComponents: DateComponents?, + rules: [EKRecurrenceRule] +) throws { + if !rules.isEmpty && dueDateComponents == nil { + throw RecurrenceUpdateError.missingDueDate + } + try validateRecurrenceEnd(dueDateComponents: dueDateComponents, rules: rules) +} + public enum Priority: String, ExpressibleByArgument { case none case low @@ -236,10 +471,18 @@ public final class Reminders { newText: String?, newNotes: String?, newDueDateComponents: DateComponents? = nil, - clearDueDate: Bool = false) + clearDueDate: Bool = false, + newRecurrence: Recurrence?, newRecurrenceInterval: Int?, + newRecurrenceEndDate: DateComponents?, + clearRecurrenceEnd: Bool, + clearRecurrence: Bool) { let calendar = self.calendar(withName: name) let semaphore = DispatchSemaphore(value: 0) + let dueDateChangeRequested = clearDueDate || newDueDateComponents != nil + let recurrenceChangeRequested = clearRecurrence || newRecurrence != nil + || newRecurrenceInterval != nil || newRecurrenceEndDate != nil + || clearRecurrenceEnd self.reminders(on: [calendar], displayOptions: .incomplete) { reminders in guard let reminder = self.getReminder(from: reminders, at: index) else { @@ -267,10 +510,58 @@ public final class Reminders { } } + if clearRecurrence { + for rule in reminder.recurrenceRules ?? [] { + reminder.removeRecurrenceRule(rule) + } + } else { + let endUpdate: RecurrenceEndUpdate + if clearRecurrenceEnd { + endUpdate = .clear + } else if let newRecurrenceEndDate { + guard let date = recurrenceEndDate(from: newRecurrenceEndDate) else { + throw RecurrenceUpdateError.invalidEndDate + } + endUpdate = .date(date) + } else { + endUpdate = .unchanged + } + + let update = RecurrenceUpdate( + recurrence: newRecurrence, + interval: newRecurrenceInterval, + end: endUpdate) + if update.isRequested { + let existingRules = reminder.recurrenceRules ?? [] + let replacements: [EKRecurrenceRule] + if newRecurrence == nil { + guard !existingRules.isEmpty else { + throw RecurrenceUpdateError.missingExistingRule + } + replacements = try existingRules.map { + try update.rule(replacing: $0) + } + } else { + replacements = [try update.rule(replacing: existingRules.first)] + } + + for rule in existingRules { + reminder.removeRecurrenceRule(rule) + } + for replacement in replacements { + reminder.addRecurrenceRule(replacement) + } + } + } + if dueDateChangeRequested || recurrenceChangeRequested { + try validateRecurrenceSchedule( + dueDateComponents: reminder.dueDateComponents, + rules: reminder.recurrenceRules ?? []) + } try Store.save(reminder, commit: true) print("Updated reminder '\(reminder.title!)'") } catch let error { - print("Failed to update reminder with error: \(error)") + print("Failed to update reminder with error: \(error.localizedDescription)") exit(1) } @@ -348,6 +639,9 @@ public final class Reminders { toListNamed name: String, dueDateComponents: DateComponents?, priority: Priority, + recurrence: Recurrence?, + recurrenceInterval: Int, + recurrenceEndDate: DateComponents?, outputFormat: OutputFormat) { let calendar = self.calendar(withName: name) @@ -360,8 +654,20 @@ public final class Reminders { if let dueDate = dueDateComponents?.date, dueDateComponents?.hour != nil { reminder.addAlarm(EKAlarm(absoluteDate: dueDate)) } - do { + if let recurrence = recurrence { + guard dueDateComponents != nil else { + throw RecurrenceUpdateError.missingDueDate + } + let end = try recurrenceEnd(dateComponents: recurrenceEndDate) + reminder.addRecurrenceRule( + recurrence.recurrenceRule(interval: recurrenceInterval, end: end)) + } + + try validateRecurrenceSchedule( + dueDateComponents: reminder.dueDateComponents, + rules: reminder.recurrenceRules ?? []) + try Store.save(reminder, commit: true) switch (outputFormat) { case .json: @@ -370,7 +676,7 @@ public final class Reminders { print("Added '\(reminder.title!)' to '\(calendar.title)'") } } catch let error { - print("Failed to save reminder with error: \(error)") + print("Failed to save reminder with error: \(error.localizedDescription)") exit(1) } } diff --git a/Tests/RemindersTests/RecurrenceTests.swift b/Tests/RemindersTests/RecurrenceTests.swift new file mode 100644 index 0000000..7499261 --- /dev/null +++ b/Tests/RemindersTests/RecurrenceTests.swift @@ -0,0 +1,305 @@ +import EventKit +@testable import RemindersLibrary +import XCTest + +final class RecurrenceTests: XCTestCase { + func testDailyFrequencyMapping() throws { + let rule = Recurrence.daily.recurrenceRule(interval: 1, end: nil) + XCTAssertEqual(rule.frequency, .daily) + XCTAssertEqual(rule.interval, 1) + XCTAssertNil(rule.recurrenceEnd) + } + + func testWeeklyFrequencyMapping() throws { + let rule = Recurrence.weekly.recurrenceRule(interval: 1, end: nil) + XCTAssertEqual(rule.frequency, .weekly) + } + + func testMonthlyFrequencyMapping() throws { + let rule = Recurrence.monthly.recurrenceRule(interval: 1, end: nil) + XCTAssertEqual(rule.frequency, .monthly) + } + + func testYearlyFrequencyMapping() throws { + let rule = Recurrence.yearly.recurrenceRule(interval: 1, end: nil) + XCTAssertEqual(rule.frequency, .yearly) + } + + func testCustomInterval() throws { + let rule = Recurrence.monthly.recurrenceRule(interval: 2, end: nil) + XCTAssertEqual(rule.interval, 2) + } + + func testRecurrenceEndDate() throws { + let end = Date() + let rule = Recurrence.weekly.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(end: end)) + XCTAssertNotNil(rule.recurrenceEnd) + XCTAssertEqual( + rule.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, + end.timeIntervalSince1970, + accuracy: 1.0) + } + + func testRecurrenceEndCount() throws { + let rule = Recurrence.daily.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(occurrenceCount: 12)) + XCTAssertEqual(rule.recurrenceEnd?.occurrenceCount, 12) + XCTAssertNil(rule.recurrenceEnd?.endDate) + } + + func testChangingFrequencyPreservesExistingEnd() throws { + let end = Date(timeIntervalSince1970: 1_800_000_000) + let existing = Recurrence.daily.recurrenceRule( + interval: 2, + end: EKRecurrenceEnd(end: end)) + let result = try RecurrenceUpdate( + recurrence: .weekly, + interval: nil, + end: .unchanged + ).rule(replacing: existing) + + XCTAssertEqual(result.frequency, .weekly) + XCTAssertEqual(result.interval, 1) + XCTAssertEqual( + result.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, + end.timeIntervalSince1970, + accuracy: 1.0) + } + + func testChangingFrequencyUsesExplicitInterval() throws { + let existing = Recurrence.daily.recurrenceRule(interval: 2, end: nil) + let result = try RecurrenceUpdate( + recurrence: .monthly, + interval: 3, + end: .unchanged + ).rule(replacing: existing) + + XCTAssertEqual(result.frequency, .monthly) + XCTAssertEqual(result.interval, 3) + } + + func testEndOnlyUpdatePreservesComplexSelectors() throws { + let existing = EKRecurrenceRule( + recurrenceWith: .monthly, + interval: 2, + daysOfTheWeek: [EKRecurrenceDayOfWeek(.friday, weekNumber: -1)], + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: [-1], + end: nil) + let end = Date(timeIntervalSince1970: 1_800_000_000) + let result = try RecurrenceUpdate( + recurrence: nil, + interval: nil, + end: .date(end) + ).rule(replacing: existing) + + XCTAssertEqual(result.frequency, .monthly) + XCTAssertEqual(result.interval, 2) + XCTAssertEqual(result.daysOfTheWeek?.first?.dayOfTheWeek, .friday) + XCTAssertEqual(result.daysOfTheWeek?.first?.weekNumber, -1) + XCTAssertEqual(result.setPositions?.first?.intValue, -1) + XCTAssertFalse(result === existing) + XCTAssertEqual(result.calendarIdentifier, existing.calendarIdentifier) + XCTAssertEqual(result.firstDayOfTheWeek, existing.firstDayOfTheWeek) + XCTAssertNil(existing.recurrenceEnd) + XCTAssertEqual( + result.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, + end.timeIntervalSince1970, + accuracy: 1.0) + } + + func testClearEndPreservesRecurrence() throws { + let existing = Recurrence.monthly.recurrenceRule( + interval: 3, + end: EKRecurrenceEnd(occurrenceCount: 8)) + let result = try RecurrenceUpdate( + recurrence: nil, + interval: nil, + end: .clear + ).rule(replacing: existing) + + XCTAssertEqual(result.frequency, .monthly) + XCTAssertEqual(result.interval, 3) + XCTAssertNil(result.recurrenceEnd) + } + + func testEndOnlyUpdateRequiresExistingRule() throws { + let end = Date(timeIntervalSince1970: 1_800_000_000) + XCTAssertThrowsError( + try RecurrenceUpdate( + recurrence: nil, + interval: nil, + end: .date(end) + ).rule(replacing: nil)) + } + + func testDateOnlyEndIncludesWholeLocalDay() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "Europe/Belgrade")) + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = 2026 + components.month = 9 + components.day = 10 + + let end = try XCTUnwrap(recurrenceEndDate(from: components)) + let resolved = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: end) + XCTAssertEqual(resolved.year, 2026) + XCTAssertEqual(resolved.month, 9) + XCTAssertEqual(resolved.day, 10) + XCTAssertEqual(resolved.hour, 23) + XCTAssertEqual(resolved.minute, 59) + XCTAssertEqual(resolved.second, 59) + } + + func testJSONIncludesDateEnd() throws { + let store = EKEventStore() + let reminder = EKReminder(eventStore: store) + let calendar = EKCalendar(for: .reminder, eventStore: store) + calendar.title = "Test" + reminder.calendar = calendar + reminder.title = "Medicine" + reminder.addRecurrenceRule( + Recurrence.daily.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(end: Date(timeIntervalSince1970: 1_800_000_000)))) + + let data = try JSONEncoder().encode(reminder) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(object["recurrence"] as? String, "daily") + XCTAssertEqual(object["recurrenceInterval"] as? Int, 1) + XCTAssertNotNil(object["recurrenceEnd"] as? String) + XCTAssertNil(object["recurrenceCount"]) + } + + func testJSONIncludesCountEnd() throws { + let store = EKEventStore() + let reminder = EKReminder(eventStore: store) + let calendar = EKCalendar(for: .reminder, eventStore: store) + calendar.title = "Test" + reminder.calendar = calendar + reminder.title = "Medicine" + reminder.addRecurrenceRule( + Recurrence.daily.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(occurrenceCount: 7))) + + let data = try JSONEncoder().encode(reminder) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(object["recurrenceCount"] as? Int, 7) + XCTAssertNil(object["recurrenceEnd"]) + } + + func testAddRejectsExplicitIntervalWithoutRecurrence() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot([ + "add", "Soon", "Medicine", "--due-date", "2026-09-01 09:00", + "--repeat-interval", "1", + ])) + } + + func testAddRequiresDueDateForRecurrence() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot([ + "add", "Soon", "Medicine", "--repeat", "daily", + ])) + } + + func testAddRejectsRepeatEndBeforeDueDate() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot([ + "add", "Soon", "Medicine", "--due-date", "2026-09-10 09:00", + "--repeat", "daily", "--repeat-until", "2026-09-09", + ])) + } + + func testScheduleRequiresDueDateWhenRulesRemain() throws { + let rule = Recurrence.daily.recurrenceRule(interval: 1, end: nil) + + XCTAssertThrowsError( + try validateRecurrenceSchedule(dueDateComponents: nil, rules: [rule]) + ) { error in + XCTAssertEqual( + error.localizedDescription, + RecurrenceUpdateError.missingDueDate.localizedDescription) + } + } + + func testScheduleAllowsClearingDueDateAndRepeatTogether() throws { + XCTAssertNoThrow( + try validateRecurrenceSchedule(dueDateComponents: nil, rules: [])) + } + + func testScheduleRejectsEndBeforeDueDate() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "Europe/Belgrade")) + var due = DateComponents() + due.calendar = calendar + due.timeZone = calendar.timeZone + due.year = 2026 + due.month = 9 + due.day = 10 + due.hour = 9 + let end = try XCTUnwrap(calendar.date(from: due)?.addingTimeInterval(-1)) + let rule = Recurrence.daily.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(end: end)) + + XCTAssertThrowsError( + try validateRecurrenceSchedule(dueDateComponents: due, rules: [rule]) + ) { error in + XCTAssertEqual( + error.localizedDescription, + RecurrenceUpdateError.endBeforeDueDate.localizedDescription) + } + } + + func testEditAcceptsEndOnlyUpdate() throws { + XCTAssertNoThrow( + try CLI.parseAsRoot([ + "edit", "Soon", "0", "--repeat-until", "2026-09-10", + ])) + } + + func testEditRejectsClearRepeatWithEndUpdate() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot([ + "edit", "Soon", "0", "--clear-repeat", "--repeat-until", "2026-09-10", + ])) + } + + func testLocalizedRecurrenceErrorIsHumanReadable() throws { + XCTAssertEqual( + RecurrenceUpdateError.missingExistingRule.localizedDescription, + "A repeat rule is required; pass --repeat or edit a repeating reminder") + } + + func testHourlyIsNotRepresentable() throws { + // EventKit has no hourly EKRecurrenceFrequency; this is asserted at the + // model layer so CLI validation (which rejects it before ever building + // a rule) has something concrete to check against. + XCTAssertFalse(Recurrence.hourly.isRepresentable) + } + + func testRepresentableFrequenciesAreAllRepresentable() throws { + for frequency: Recurrence in [.daily, .weekly, .monthly, .yearly] { + XCTAssertTrue(frequency.isRepresentable, "\(frequency.rawValue) should be representable") + } + } + + func testRecurrenceParsesFromArgument() throws { + XCTAssertEqual(Recurrence(argument: "daily"), .daily) + XCTAssertEqual(Recurrence(argument: "weekly"), .weekly) + XCTAssertEqual(Recurrence(argument: "monthly"), .monthly) + XCTAssertEqual(Recurrence(argument: "yearly"), .yearly) + XCTAssertEqual(Recurrence(argument: "hourly"), .hourly) + XCTAssertNil(Recurrence(argument: "biweekly")) + } +}