Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 61 additions & 4 deletions Sources/remindctl/Commands/ListCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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<String>()

for group in groups {
for reminder in group where seenIDs.insert(reminder.id).inserted {
merged.append(reminder)
}
}

return merged
}
}
99 changes: 99 additions & 0 deletions Tests/remindctlTests/ListCommandTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}