diff --git a/Sources/RemindCore/IDResolver.swift b/Sources/RemindCore/IDResolver.swift index f8c7fd2..39b1564 100644 --- a/Sources/RemindCore/IDResolver.swift +++ b/Sources/RemindCore/IDResolver.swift @@ -19,12 +19,12 @@ public enum IDResolver { } for input in inputs { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - if let index = Int(trimmed) { - guard index > 0 && index <= numericSorted.count else { - throw RemindCoreError.invalidIdentifier(trimmed) + let numericIndex = Int(trimmed) + if let index = numericIndex { + if index > 0 && index <= numericSorted.count { + appendUnique(numericSorted[index - 1]) + continue } - appendUnique(numericSorted[index - 1]) - continue } if trimmed.count < minimumPrefixLength { @@ -33,6 +33,9 @@ public enum IDResolver { let matches = sorted.filter { $0.id.lowercased().hasPrefix(trimmed.lowercased()) } if matches.isEmpty { + if numericIndex != nil { + throw RemindCoreError.invalidIdentifier(trimmed) + } throw RemindCoreError.reminderNotFound(trimmed) } if matches.count > 1 { diff --git a/Tests/RemindCoreTests/IDResolverTests.swift b/Tests/RemindCoreTests/IDResolverTests.swift index bc7244e..4b581b1 100644 --- a/Tests/RemindCoreTests/IDResolverTests.swift +++ b/Tests/RemindCoreTests/IDResolverTests.swift @@ -116,6 +116,55 @@ struct IDResolverTests { #expect(resolved.first?.title == "First") } + @Test("Resolve an out-of-range numeric token as an ID prefix") + func resolveNumericPrefix() throws { + let reminder = ReminderItem( + id: "45534C82-FE4E-4C30-ABE1-2243ED31ACC8", + title: "Numeric prefix", + notes: nil, + isCompleted: false, + completionDate: nil, + priority: .none, + dueDate: nil, + listID: "list1", + listName: "Work" + ) + + let resolved = try IDResolver.resolve(["4553"], from: [reminder]) + #expect(resolved.map(\.id) == [reminder.id]) + } + + @Test("Keep valid numeric index precedence over an ID prefix") + func validIndexPrecedesNumericPrefix() throws { + let reminders = [ + ReminderItem( + id: "0001-prefix", + title: "Prefix match", + notes: nil, + isCompleted: false, + completionDate: nil, + priority: .none, + dueDate: nil, + listID: "list1", + listName: "Work" + ), + ReminderItem( + id: "other", + title: "Numeric index", + notes: nil, + isCompleted: false, + completionDate: nil, + priority: .none, + dueDate: nil, + listID: "list1", + listName: "Work" + ), + ] + + let resolved = try IDResolver.resolve(["0001"], from: reminders, numericFrom: [reminders[1]]) + #expect(resolved.map(\.id) == [reminders[1].id]) + } + @Test("Reject short prefix") func rejectShortPrefix() { #expect(throws: RemindCoreError.invalidIdentifier("ab")) { diff --git a/docs/commands.md b/docs/commands.md index 8567547..2f7b9e1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -73,8 +73,8 @@ remindctl edit 4A83 --list-id 7A12 remindctl edit 4A83 --no-repeat ``` -`edit`, `complete`, and `delete` accept indexes from the current default listing or ID prefixes. -Numeric indexes must be positive and within the current view; out-of-range values return an error. +Commands that target reminders accept indexes from the current default listing or ID prefixes. +An in-range numeric value always selects that index. An out-of-range numeric value falls back to ID-prefix matching and returns an error when no reminder ID matches. `complete` and `delete` process each reminder once, even when repeated indexes, IDs, or prefixes identify the same reminder. Previews and delete confirmation counts use these unique targets in first-input order; every input must still be valid and unambiguous. Reminders sort by due date, then title, then stable ID so equal dates and titles do not make numeric targets depend on EventKit's fetch order. Use IDs when reminders may be added, removed, or edited between commands.