diff --git a/Sources/remindctl/Commands/ListCommand.swift b/Sources/remindctl/Commands/ListCommand.swift index ac20ba8..ba53d1e 100644 --- a/Sources/remindctl/Commands/ListCommand.swift +++ b/Sources/remindctl/Commands/ListCommand.swift @@ -7,7 +7,7 @@ enum ListCommand { CommandSpec( name: "list", abstract: "List reminder lists or show list contents", - discussion: "Without a name, shows all lists. With a name, shows reminders in that list.", + discussion: "Without a name, shows all lists. With one or more names, shows reminders in those lists. Management actions remain single-list only.", signature: CommandSignatures.withRuntimeFlags( CommandSignature( arguments: [ @@ -31,21 +31,28 @@ enum ListCommand { usageExamples: [ "remindctl list", "remindctl list Work", + "remindctl list Work Personal", "remindctl list Work --rename Office", "remindctl list Work --delete", "remindctl list Projects --create", ] ) { values, runtime in - let name = values.argument(0) + let listNames = requestedListNames(from: values.positional) let renameTo = values.option("rename") let deleteList = values.flag("delete") let createList = values.flag("create") let force = values.flag("force") + try validateSingleTargetAction( + listNames: listNames, + renameTo: renameTo, + delete: deleteList, + create: createList + ) let store = RemindersStore() try await store.requestAccess() - if let name { + if let name = listNames.first { if deleteList { if !force && !runtime.noInput && Console.isTTY { if !Console.confirm("Delete list \"\(name)\"?", defaultValue: false) { @@ -80,7 +87,18 @@ enum ListCommand { return } - let reminders = try await store.reminders(in: name) + if listNames.count == 1 { + let reminders = try await store.reminders(in: name) + OutputRenderer.printReminders(reminders, format: runtime.outputFormat) + return + } + + var reminderGroups: [[ReminderItem]] = [] + reminderGroups.reserveCapacity(listNames.count) + for listName in listNames { + reminderGroups.append(try await store.reminders(in: listName)) + } + let reminders = mergeReminderGroups(reminderGroups) OutputRenderer.printReminders(reminders, format: runtime.outputFormat) return } @@ -109,4 +127,43 @@ enum ListCommand { OutputRenderer.printLists(summaries, format: runtime.outputFormat) } } + + static func requestedListNames(from positional: [String]) -> [String] { + positional.compactMap { rawName in + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? nil : name + } + } + + static func validateSingleTargetAction( + listNames: [String], + renameTo: String?, + delete: Bool, + create: Bool + ) throws { + guard listNames.count > 1 else { return } + + if renameTo != nil { + throw RemindCoreError.operationFailed("--rename requires exactly one list name") + } + if delete { + throw RemindCoreError.operationFailed("--delete requires exactly one list name") + } + if create { + throw RemindCoreError.operationFailed("--create requires exactly one list name") + } + } + + static func mergeReminderGroups(_ groups: [[ReminderItem]]) -> [ReminderItem] { + var merged: [ReminderItem] = [] + var seenIDs = Set() + + for group in groups { + for reminder in group where seenIDs.insert(reminder.id).inserted { + merged.append(reminder) + } + } + + return merged + } } diff --git a/Tests/remindctlTests/ListCommandTests.swift b/Tests/remindctlTests/ListCommandTests.swift new file mode 100644 index 0000000..d98700e --- /dev/null +++ b/Tests/remindctlTests/ListCommandTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing + +@testable import RemindCore +@testable import remindctl + +@MainActor +struct ListCommandTests { + @Test("Requested list names preserve order and trim whitespace") + func requestedListNamesPreserveOrder() { + let names = ListCommand.requestedListNames(from: [" Grocery Store ", "COSTCO", " Personal "]) + #expect(names == ["Grocery Store", "COSTCO", "Personal"]) + } + + @Test("Requested list names drop empty inputs") + func requestedListNamesDropEmptyInputs() { + let names = ListCommand.requestedListNames(from: ["", " ", "\n", "Work"]) + #expect(names == ["Work"]) + } + + @Test("Management actions reject multiple list names") + func managementActionsRejectMultipleNames() { + #expect(throws: RemindCoreError.operationFailed("--rename requires exactly one list name")) { + try ListCommand.validateSingleTargetAction( + listNames: ["Work", "Personal"], + renameTo: "Office", + delete: false, + create: false + ) + } + + #expect(throws: RemindCoreError.operationFailed("--delete requires exactly one list name")) { + try ListCommand.validateSingleTargetAction( + listNames: ["Work", "Personal"], + renameTo: nil, + delete: true, + create: false + ) + } + + #expect(throws: RemindCoreError.operationFailed("--create requires exactly one list name")) { + try ListCommand.validateSingleTargetAction( + listNames: ["Work", "Personal"], + renameTo: nil, + delete: false, + create: true + ) + } + } + + @Test("Reminder groups merge by id and preserve first-seen order") + func mergeReminderGroupsPreservesFirstSeenOrder() { + let work = [ + makeReminder(id: "1", title: "Alpha", listID: "work", listName: "Work"), + makeReminder(id: "2", title: "Beta", listID: "work", listName: "Work"), + ] + let personal = [ + makeReminder(id: "2", title: "Beta duplicate", listID: "personal", listName: "Personal"), + makeReminder(id: "3", title: "Gamma", listID: "personal", listName: "Personal"), + ] + + let merged = ListCommand.mergeReminderGroups([work, personal]) + + #expect(merged.map(\.id) == ["1", "2", "3"]) + #expect(merged.map(\.title) == ["Alpha", "Beta", "Gamma"]) + } + + @Test("Repeated list groups do not duplicate reminders") + func repeatedListGroupsDoNotDuplicateOutput() { + let groceries = [ + makeReminder(id: "1", title: "Milk", listID: "groceries", listName: "Groceries"), + makeReminder(id: "2", title: "Eggs", listID: "groceries", listName: "Groceries"), + ] + + let merged = ListCommand.mergeReminderGroups([groceries, groceries]) + + #expect(merged.map(\.id) == ["1", "2"]) + } + + @Test("List help includes multi-list example") + func listHelpIncludesMultiListExample() { + #expect(ListCommand.spec.discussion?.contains("one or more names") == true) + #expect(ListCommand.spec.usageExamples.contains("remindctl list Work Personal")) + } + + private func makeReminder(id: String, title: String, listID: String, listName: String) -> ReminderItem { + ReminderItem( + id: id, + title: title, + notes: nil, + isCompleted: false, + completionDate: nil, + priority: .none, + dueDate: nil, + listID: listID, + listName: listName + ) + } +}