Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Reject conflicting list mutations and missing targets before accessing Reminders, preventing combinations such as `--delete --rename` from silently deleting a list.
- Respect `--` when interpreting help/version flags and allow the CLI executable to be renamed without breaking command resolution.
- Reject infinite geofence radii before geocoding or saving a reminder.
- Run developer checks once with the existing 90% coverage gate, pin CI to Swift 6.2 and current Node/pnpm tooling, and execute universal CLI smoke checks in CI.

## 0.3.6 - 2026-09-07
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ remindctl add "Get groceries" --location "123 Main St" --radius 200
```

Location triggers use EventKit and CoreLocation geocoding. They may depend on system location services and network availability.
Geofence radii must be finite, positive numbers of meters.

## Output

Expand Down
7 changes: 4 additions & 3 deletions Sources/remindctl/CommandRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ struct CommandRouter {
func run(argv: [String]) async -> Int32 {
var argv = normalizeArguments(argv)
argv = applyAliases(argv)
let controlArguments = argv.prefix { $0 != "--" }

if argv.contains("--version") || argv.contains("-V") {
if controlArguments.contains("--version") || controlArguments.contains("-V") {
Swift.print(version)
return 0
}

if argv.contains("--help") || argv.contains("-h") {
if controlArguments.contains("--help") || controlArguments.contains("-h") {
printHelp(for: argv)
return 0
}
Expand Down Expand Up @@ -93,7 +94,7 @@ struct CommandRouter {
private func normalizeArguments(_ argv: [String]) -> [String] {
guard !argv.isEmpty else { return argv }
var copy = argv
copy[0] = URL(fileURLWithPath: argv[0]).lastPathComponent
copy[0] = rootName
return copy
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/remindctl/Commands/AddCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ enum AddCommand {
}
}

private static func makeLocationTrigger(
static func makeLocationTrigger(
location: String?,
radius: String?,
leaving: Bool
Expand All @@ -160,7 +160,7 @@ enum AddCommand {
}

private static func parseRadius(_ value: String) throws -> Double {
guard let radius = Double(value), radius > 0 else {
guard let radius = Double(value), radius.isFinite, radius > 0 else {
throw RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")
}
return radius
Expand Down
58 changes: 42 additions & 16 deletions Sources/remindctl/Commands/ListCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import Foundation
import RemindCore

enum ListCommand {
enum Action: Equatable {
case show
case create
case delete
case rename(String)
}

static var spec: CommandSpec {
CommandSpec(
name: "list",
Expand Down Expand Up @@ -45,33 +52,28 @@ enum ListCommand {
) { values, runtime in
let names = values.positional
let listID = values.option("listID")
let renameTo = values.option("rename")
let deleteList = values.flag("delete")
let createList = values.flag("create")
let action = try action(
names: names,
listID: listID,
create: values.flag("create"),
delete: values.flag("delete"),
renameTo: values.option("rename"))
let force = values.flag("force")

let store = RemindersStore()
try await store.requestAccess()

if !names.isEmpty || listID != nil {
let isMutation = deleteList || renameTo != nil || createList
let isMutation = action != .show
if shouldReadMultipleLists(names: names, listID: listID, isMutation: isMutation) {
let reminders = try await reminders(in: names, store: store)
OutputRenderer.printReminders(reminders, format: runtime.outputFormat)
return
}

let name: String? =
if names.isEmpty {
nil
} else {
try singleListName(names, forMutation: isMutation)
}
let name = names.first
let target = try CommandHelpers.listTarget(name: name, id: listID)
if createList && listID != nil {
throw RemindCoreError.operationFailed("Use a list name, not --list-id, with --create")
}
if deleteList {
if action == .delete {
guard let target else {
throw ParsedValuesError.missingArgument("name")
}
Expand All @@ -88,7 +90,7 @@ enum ListCommand {
return
}

if let renameTo {
if case .rename(let renameTo) = action {
guard let target else {
throw ParsedValuesError.missingArgument("name")
}
Expand All @@ -100,7 +102,7 @@ enum ListCommand {
return
}

if createList {
if action == .create {
guard let name else {
throw ParsedValuesError.missingArgument("name")
}
Expand Down Expand Up @@ -147,6 +149,30 @@ enum ListCommand {
}
}

static func action(
names: [String], listID: String?, create: Bool, delete: Bool, renameTo: String?
) throws -> Action {
var actions: [Action] = []
if create { actions.append(.create) }
if delete { actions.append(.delete) }
if let renameTo { actions.append(.rename(renameTo)) }
guard actions.count <= 1 else {
throw RemindCoreError.operationFailed("Use only one of --create, --delete, or --rename")
}
guard let action = actions.first else { return .show }
guard !names.isEmpty || listID != nil else {
throw ParsedValuesError.missingArgument("name")
}
if !names.isEmpty {
_ = try singleListName(names, forMutation: true)
}
if action == .create && listID != nil {
throw RemindCoreError.operationFailed("Use a list name, not --list-id, with --create")
}
_ = try CommandHelpers.listTarget(name: names.first, id: listID)
return action
}

static func summaries(
for lists: [ReminderList],
reminders: [ReminderItem],
Expand Down
23 changes: 23 additions & 0 deletions Tests/remindctlTests/CommandRouterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Testing

@testable import remindctl

struct CommandRouterTests {
@Test("A renamed executable resolves the canonical command")
func renamedExecutable() async {
let code = await CommandRouter().run(argv: ["/tmp/custom-reminders", "completion", "bash"])
#expect(code == 0)
}

@Test("Help and version after the terminator are data", arguments: ["--help", "-h", "--version", "-V"])
func literalControlArguments(_ value: String) async {
let code = await CommandRouter().run(argv: ["remindctl", "completion", "--", value])
#expect(code == 1)
}

@Test("Help and version before the terminator remain controls", arguments: ["--help", "--version"])
func controlArguments(_ value: String) async {
let code = await CommandRouter().run(argv: ["remindctl", "completion", value, "--", "unsupported"])
#expect(code == 0)
}
}
50 changes: 50 additions & 0 deletions Tests/remindctlTests/ListActionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import RemindCore
import Testing

@testable import remindctl

struct ListActionTests {
private func action(_ arguments: [String]) throws -> ListCommand.Action {
let values = try CommandRouter().program.resolve(argv: ["remindctl", "list"] + arguments).parsedValues
return try ListCommand.action(
names: values.positional,
listID: values.option("listID"),
create: values.flag("create"),
delete: values.flag("delete"),
renameTo: values.option("rename"))
}

@Test(
"Conflicting list mutations are rejected",
arguments: [
["--create", "--delete"], ["--create", "--rename", "New"],
["--delete", "--rename", "New"], ["--create", "--delete", "--rename", "New"],
])
func conflictingMutations(_ flags: [String]) {
#expect(throws: RemindCoreError.operationFailed("Use only one of --create, --delete, or --rename")) {
try action(["Synthetic"] + flags)
}
}

@Test("Mutations require a target", arguments: [["--create"], ["--delete"], ["--rename", "New"]])
func missingTarget(_ flags: [String]) {
#expect(throws: ParsedValuesError.self) {
try action(flags)
}
}

@Test("Valid list reads and mutations retain their action")
func validActions() throws {
#expect(try action([]) == .show)
#expect(try action(["First", "Second"]) == .show)
#expect(try action(["Synthetic", "--create"]) == .create)
#expect(try action(["Synthetic", "--rename", "New"]) == .rename("New"))
#expect(try action(["--list-id", "abcd", "--delete"]) == .delete)
#expect(throws: RemindCoreError.self) {
try action(["First", "Second", "--delete"])
}
#expect(throws: RemindCoreError.self) {
try action(["--list-id", "abcd", "--create"])
}
}
}
21 changes: 21 additions & 0 deletions Tests/remindctlTests/LocationCommandTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import RemindCore
import Testing

@testable import remindctl

struct LocationCommandTests {
@Test("Location radii must be finite and positive", arguments: ["inf", "+inf", "1e309", "nan", "0", "-1"])
func invalidRadius(_ value: String) {
#expect(throws: RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")) {
try AddCommand.makeLocationTrigger(location: "Synthetic address", radius: value, leaving: false)
}
}

@Test("Valid location options preserve radius and proximity")
func validRadius() throws {
let trigger = try #require(
try AddCommand.makeLocationTrigger(location: "Synthetic address", radius: "200", leaving: true))
#expect(trigger.radius == 200)
#expect(trigger.proximity == .leaving)
}
}
9 changes: 9 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ remindctl list --list-id 7A12 --rename Archive
```

Mutating list operations accept one list name. Read-only list views can accept multiple names.
Choose only one of `--create`, `--delete`, or `--rename`, and provide its target; conflicting or missing mutation targets fail before accessing Reminders.
`list <name> --create` creates a missing list or reuses a unique matching list. Repeating it preserves the list and its reminders; `--json` reports the current incomplete and overdue counts. An ambiguous name fails instead of creating another list.
List names resolve by exact match, case-insensitive match, then a normalized match that ignores emoji and punctuation.
If a name is ambiguous, use `--list-id`.
Expand Down Expand Up @@ -144,3 +145,11 @@ Global output flags:
- `--quiet` emits minimal output.
- `--no-color` disables colored output.
- `--no-input` disables interactive prompts.

Use `--` before positional text that starts with a hyphen, including literal help or version flags:

```bash
remindctl add -- "--help"
```

Help and version flags after `--` are passed to the command as text.