diff --git a/.github/scripts/check-skill-json-fields.sh b/.github/scripts/check-skill-json-fields.sh index 8d80251..6e97a84 100755 --- a/.github/scripts/check-skill-json-fields.sh +++ b/.github/scripts/check-skill-json-fields.sh @@ -11,6 +11,8 @@ skill_file="skills/reminders-cli/SKILL.md" expected=$(sed -n '/enum EncodingKeys/,/^ }/p' "$swift_file" \ | sed -n 's/^ *case \([A-Za-z0-9_]*\).*/\1/p') +# The backticks are literal table markup, not command substitution. +# shellcheck disable=SC2016 actual=$(sed -n '//,//p' "$skill_file" \ | sed -n 's/^| `\([A-Za-z0-9_]*\)`.*/\1/p') diff --git a/.github/scripts/live-test/live-test.sh b/.github/scripts/live-test/live-test.sh index fe26472..dc8f153 100644 --- a/.github/scripts/live-test/live-test.sh +++ b/.github/scripts/live-test/live-test.sh @@ -53,7 +53,9 @@ if ! created=$(run new-list "$name" --format json 2>new-list.err); then fi echo "$created" list_id=$(jqr .calendarIdentifier <<<"$created") -[ -n "$list_id" ] && [ "$list_id" != null ] || fail "new-list returned no calendarIdentifier" +if [ -z "$list_id" ] || [ "$list_id" = null ]; then + fail "new-list returned no calendarIdentifier" +fi # --- add: due date, notes, priority, weekly repeat -------------------------- echo "--- add" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ccfc441 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,150 @@ +--- +name: Lint + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +env: + # Used by both the shell and the actions job. + SHELLCHECK_VERSION: 0.11.0 + SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 + +# Each linter is its own job so a failure names the file type. Tools are pinned to exact versions, +# and downloaded binaries are checked against the SHA-256 digest GitHub records for the release asset. +jobs: + markdown: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Files and rules come from .markdownlint-cli2.yaml, whose globs add to the action's default of the root files. + - uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 + + markdown-links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Restore lychee cache + uses: actions/cache@v6 + with: + path: .lycheecache + key: lychee-${{ github.sha }} + restore-keys: lychee- + + - uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 + with: + lycheeVersion: v0.24.2 + args: --config lychee.toml --no-progress README.md CLAUDE.md 'skills/**/*.md' + jobSummary: false + + swift: + runs-on: ubuntu-latest + env: + SWIFTLINT_VERSION: 0.65.1 + SWIFTLINT_SHA256: caeed6f4a679c35539ffaf124f6c4ab4a8416917f7d8796279dc52b74026059d + steps: + - uses: actions/checkout@v7 + + - name: Install SwiftLint + run: | + curl -fsSLo "$RUNNER_TEMP/swiftlint.zip" \ + "https://github.com/realm/SwiftLint/releases/download/$SWIFTLINT_VERSION/swiftlint_linux_amd64.zip" + echo "$SWIFTLINT_SHA256 $RUNNER_TEMP/swiftlint.zip" | sha256sum -c - + unzip -q "$RUNNER_TEMP/swiftlint.zip" -d "$RUNNER_TEMP/swiftlint" + echo "$RUNNER_TEMP/swiftlint" >> "$GITHUB_PATH" + + - name: SwiftLint + run: swiftlint lint --strict --reporter github-actions-logging + + makefile: + runs-on: ubuntu-latest + env: + CHECKMAKE_VERSION: v0.3.2 + CHECKMAKE_SHA256: e2effb876913f3ee2caef0ba35f6202c5e8a3cd55a077d8d2b9ce2034257b6af + steps: + - uses: actions/checkout@v7 + + - name: Install checkmake + run: | + mkdir -p "$RUNNER_TEMP/checkmake" + curl -fsSLo "$RUNNER_TEMP/checkmake/checkmake" \ + "https://github.com/checkmake/checkmake/releases/download/$CHECKMAKE_VERSION/checkmake-$CHECKMAKE_VERSION.linux.amd64" + echo "$CHECKMAKE_SHA256 $RUNNER_TEMP/checkmake/checkmake" | sha256sum -c - + chmod +x "$RUNNER_TEMP/checkmake/checkmake" + echo "$RUNNER_TEMP/checkmake" >> "$GITHUB_PATH" + + - name: checkmake + run: checkmake Makefile + + yaml: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Files and rules come from .yamllint.yml. + - name: yamllint + run: pipx run yamllint==1.38.0 --strict --format github . + + actions: + runs-on: ubuntu-latest + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + steps: + - uses: actions/checkout@v7 + + - name: Install actionlint + run: | + curl -fsSLo "$RUNNER_TEMP/actionlint.tar.gz" \ + "https://github.com/rhysd/actionlint/releases/download/v$ACTIONLINT_VERSION/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c - + mkdir -p "$RUNNER_TEMP/actionlint" + tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP/actionlint" actionlint + echo "$RUNNER_TEMP/actionlint" >> "$GITHUB_PATH" + + - name: Install shellcheck + run: | + curl -fsSLo "$RUNNER_TEMP/shellcheck.tar.xz" \ + "https://github.com/koalaman/shellcheck/releases/download/v$SHELLCHECK_VERSION/shellcheck-v$SHELLCHECK_VERSION.linux.x86_64.tar.xz" + echo "$SHELLCHECK_SHA256 $RUNNER_TEMP/shellcheck.tar.xz" | sha256sum -c - + tar -xJf "$RUNNER_TEMP/shellcheck.tar.xz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/shellcheck-v$SHELLCHECK_VERSION" >> "$GITHUB_PATH" + + # Also runs shellcheck over every `run:` block. + - name: actionlint + run: actionlint + + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # The runner's own shellcheck is older and reports differently, so both jobs install the pinned one. + - name: Install shellcheck + run: | + curl -fsSLo "$RUNNER_TEMP/shellcheck.tar.xz" \ + "https://github.com/koalaman/shellcheck/releases/download/v$SHELLCHECK_VERSION/shellcheck-v$SHELLCHECK_VERSION.linux.x86_64.tar.xz" + echo "$SHELLCHECK_SHA256 $RUNNER_TEMP/shellcheck.tar.xz" | sha256sum -c - + tar -xJf "$RUNNER_TEMP/shellcheck.tar.xz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/shellcheck-v$SHELLCHECK_VERSION" >> "$GITHUB_PATH" + + - name: shellcheck + run: git ls-files -z '*.sh' | xargs -0 shellcheck + + json: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Syntax only: release-please and SwiftPM own the formatting of the files they write. + - name: jq + run: git ls-files -z '*.json' '*.resolved' | xargs -0 -n1 jq empty diff --git a/.gitignore b/.gitignore index 694b877..258e006 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .build .claude reminders.tar.gz +.lycheecache diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..2663ad3 --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,13 @@ +--- +config: + # Prose is written one paragraph per line, and tables and command lines can't be wrapped. + MD013: false + +globs: + - "**/*.md" + +ignores: + # Generated by release-please. + - CHANGELOG.md + - .build/** + - .claude/** diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..cef7ab9 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,39 @@ +--- +included: + - Sources + - Tests + - Package.swift +excluded: + - .build + +disabled_rules: + # Business logic lives in Reminders.swift and the subcommands in CLI.swift by design (see + # CLAUDE.md), and test cases group every test of one area in a single class. + - file_length + - type_body_length + # The testable free functions (configureNewReminder, applyEdit, ...) take each option as its own + # defaulted parameter, so callers and tests set only what they need. + - function_parameter_count + # TODOs here record upstream limitations (Apple feedback, minimum macOS version), not open work. + - todo + +# Trailing commas in multi-line literals are the codebase's style: they keep diffs to one line. +trailing_comma: + mandatory_comma: true + +# A multi-line condition or signature puts its opening brace on its own line. +opening_brace: + ignore_multiline_function_signatures: true + ignore_multiline_statement_conditions: true + +# An exhaustive switch over an enum is one decision, not one per case. +cyclomatic_complexity: + ignores_case_statements: true + +identifier_name: + excluded: + # SwiftLint's default exclusion, which setting this list replaces. + - id + # Operands of comparators and paired test fixtures. + - a + - b diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..718862e --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,17 @@ +--- +extends: default + +ignore: | + .build/ + .claude/ + +rules: + # `on:` is a workflow key, not a boolean. + truthy: + check-keys: false + # Workflow comments and step commands explain themselves on one line. + line-length: + max: 160 + # SHA-pinned actions carry their version as `@sha # vX.Y.Z`. + comments: + min-spaces-from-content: 1 diff --git a/CLAUDE.md b/CLAUDE.md index 1f00186..939b341 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Full release package (tarball + shasums, as used for GitHub releases): `make package` - Clean build artifacts: `make clean` - Run locally without installing: `swift run reminders ...` +- Lint (as `.github/workflows/lint.yml` does): `swiftlint lint --strict`, `markdownlint-cli2`, `lychee --config lychee.toml README.md CLAUDE.md 'skills/**/*.md'`, `checkmake Makefile`, `yamllint --strict .`, `actionlint`, `git ls-files -z '*.sh' | xargs -0 shellcheck`, `git ls-files -z '*.json' '*.resolved' | xargs -0 -n1 jq empty` -There is no linter configured (no SwiftLint/SwiftFormat) — code quality is enforced only via `-warnings-as-errors` on both library targets, applied both in `Package.swift` and again explicitly in CI. +Besides `-warnings-as-errors` on both library targets (applied in `Package.swift` and again explicitly in CI), `.github/workflows/lint.yml` lints every file type in one job per linter, all blocking: Swift with SwiftLint (`.swiftlint.yml`), Markdown with markdownlint-cli2 (`.markdownlint-cli2.yaml`) and its links with lychee (`lychee.toml`), the `Makefile` with checkmake, YAML with yamllint (`.yamllint.yml`), workflows with actionlint, shell scripts with shellcheck, and JSON syntax with jq. `CHANGELOG.md` is excluded, since release-please generates it. The tool versions and download digests are pinned in the workflow. Fix a finding rather than disabling its rule; a rule is only disabled or tuned in the config where it conflicts with a deliberate project pattern, with the reason next to it, and a single exception is a `// swiftlint:disable:next ` with a comment saying why. ## Architecture @@ -30,7 +31,7 @@ Execution flows in one direction through four layers: 5. **Supporting extensions**, used by the layers above: - `NaturalLanguage.swift` — `DateComponents(argument:)` (`ExpressibleByArgument`), parses natural-language date strings like `"tomorrow 9am"` for `--due-date` options via `NSDataDetector`. Known limitation: `"next weekend"` doesn't parse (Apple Feedback FB8921206), covered by a test expecting `nil`. - `ShowAllQuery.swift` — the `show-all` parameters behind the `today`, `overdue` and `upcoming` convenience commands, built by `ShowAllQuery.today/overdue/upcoming(...)` (which take `now`/`calendar` so `ShowAllQueryTests` can pin the dates) and run through `Reminders.showAllReminders(_:outputFormat:)`. These commands must stay free of filter logic: a new shortcut is a new builder, not a new code path. - - `ReminderSelection.swift` — the EventKit-free expansion of the reminder ID arguments of `complete`, `uncomplete`, `delete` (variadic) and `edit`, `postpone` (one argument): comma-separated IDs and `-` for newline-separated IDs on stdin (injected for tests). `isBatch` depends only on the raw arguments and picks the JSON shape: the single object for one plain ID, an array otherwise. `Edit.validate()` uses it to reject new text on a batch. The commands resolve every ID with `resolveReminders(_:idsOrPrefixes:onList:)` before changing anything, stage the changes in memory (`Store.reset()` on any error), and save or remove them with `commit: false` followed by one `Store.commit()`. + - `ReminderSelection.swift` — the EventKit-free expansion of the reminder ID arguments of `complete`, `uncomplete`, `delete` (variadic) and `edit`, `postpone` (one argument): comma-separated IDs and `-` for newline-separated IDs on stdin (injected for tests). `isBatch` depends only on the raw arguments and picks the JSON shape: the single object for one plain ID, an array otherwise. `Edit.validate()` uses it to reject new text on a batch. The commands resolve every ID with `resolveReminders(_:idsOrPrefixes:onList:)` before changing anything, stage the changes in memory (`eventStore.reset()` on any error), and save or remove them with `commit: false` followed by one `eventStore.commit()`. - `Sort.swift` — `Sort`/`CustomSortOrder` enums backing `show --sort`/`--sort-order`, plus `ListSort` backing `show-lists --sort`. - `RepeatDays.swift` — `RepeatDays`, the parsed `--repeat-on` day set (names, `weekdays`/`weekends` aliases, de-duplicated and Sunday-first), plus `plainWeekdays(of:)`, which reads a rule's days only when none carries a week number (so "last Friday of the month" isn't reported as plain days). It backs the plain `on Mon, Wed` suffix, JSON `recurrenceDays` and the weekly-on-days branch of `nextOccurrence`. - `ListSummary.swift` — the `ListSummary` value type behind `show-lists` plus the EventKit-fetch-free `summarizeLists(_:reminders:now:includeCompleted:)` and `formatListSummaries(_:)`, so the per-list counting and the aligned plain output are unit-testable. Counts come from one fetch over all calendars (`fetchReminders(matching:displayOptions:)` with EventKit's incomplete-reminders predicate, or the all-reminders one for `--include-completed`), bucketed by `calendar.calendarIdentifier`. "Overdue" is the free function `isOverdue(_:now:)` in `Reminders.swift`, shared with `show --overdue` so the two views can't disagree. diff --git a/Makefile b/Makefile index 464d54a..ee62c58 100644 --- a/Makefile +++ b/Makefile @@ -2,17 +2,21 @@ RELEASE_BUILD=./.build/apple/Products/Release EXECUTABLE=reminders ARCHIVE=$(EXECUTABLE).tar.gz -.PHONY: clean build-release package +.PHONY: all clean build-release package test + +all: build-release build-release: swift build --configuration release --arch arm64 --arch x86_64 +test: + swift test -Xswiftc -warnings-as-errors + package: build-release $(RELEASE_BUILD)/$(EXECUTABLE) --generate-completion-script zsh > _reminders tar -pvczf $(ARCHIVE) _reminders -C $(RELEASE_BUILD) $(EXECUTABLE) tar -zxvf $(ARCHIVE) - @shasum -a 256 $(ARCHIVE) - @shasum -a 256 $(EXECUTABLE) + @shasum -a 256 $(ARCHIVE) $(EXECUTABLE) rm $(EXECUTABLE) _reminders clean: diff --git a/README.md b/README.md index c0d3e9a..93bbbec 100644 --- a/README.md +++ b/README.md @@ -690,8 +690,8 @@ brew install udondan/software/reminders-cli ### From GitHub releases -Download the latest release from -[here](https://github.com/udondan/reminders-cli/releases) +Download the latest release from the +[releases page](https://github.com/udondan/reminders-cli/releases) ```console tar -zxvf reminders.tar.gz diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index dd0071a..6acda59 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -117,7 +117,8 @@ private struct ShowAll: FormattedCommand { || self.overdue || self.includeOverdue) { throw ValidationError( - "Cannot combine --no-due-date with --due-date, --due-before, --due-after, --overdue, or --include-overdue") + "Cannot combine --no-due-date with --due-date, --due-before, --due-after, --overdue, " + + "or --include-overdue") } if self.completedSince != nil && !self.onlyCompleted && !self.includeCompleted { throw ValidationError( @@ -145,7 +146,8 @@ private struct ShowAll: FormattedCommand { private struct Today: FormattedCommand { static let configuration = CommandConfiguration( - abstract: "Show reminders due today or overdue (same as 'show-all --due-date today --include-overdue --sort due-date')") + abstract: "Show reminders due today or overdue " + + "(same as 'show-all --due-date today --include-overdue --sort due-date')") @Flag(help: "Only show reminders due today, without overdue ones") var noOverdue = false @@ -203,13 +205,15 @@ private struct Overdue: FormattedCommand { func run() throws { try reminders.showAllReminders( - .overdue(lists: self.list, sort: sort, sortOrder: sortOrder), outputFormat: listing.resolvedFormat, verbose: listing.verbose) + .overdue(lists: self.list, sort: sort, sortOrder: sortOrder), + outputFormat: listing.resolvedFormat, verbose: listing.verbose) } } private struct Upcoming: FormattedCommand { static let configuration = CommandConfiguration( - abstract: "Show reminders due from now through the next 7 days (same as 'show-all --due-after --due-before \"in 7 days\" --sort due-date')") + abstract: "Show reminders due from now through the next 7 days " + + "(same as 'show-all --due-after --due-before \"in 7 days\" --sort due-date')") @Option(help: "How many days ahead to look, including the whole last day") var days: Int = 7 @@ -327,7 +331,8 @@ private struct Show: FormattedCommand { || self.overdue || self.includeOverdue) { throw ValidationError( - "Cannot combine --no-due-date with --due-date, --due-before, --due-after, --overdue, or --include-overdue") + "Cannot combine --no-due-date with --due-date, --due-before, --due-after, --overdue, " + + "or --include-overdue") } if self.completedSince != nil && !self.onlyCompleted && !self.includeCompleted { throw ValidationError( @@ -390,7 +395,7 @@ private struct Add: FormattedCommand { @Option( name: [.customLong("repeat")], help: "Repeat the reminder, one of: daily, weekly, monthly, yearly") - var repeat_: Recurrence? + var repeatFrequency: Recurrence? @Option( name: .long, @@ -404,28 +409,29 @@ private struct Add: FormattedCommand { @Option( name: .long, - help: "Repeat weekly on these days, comma-separated: mon..sun or full names, or weekdays/weekends; implies --repeat weekly", + help: ArgumentHelp("Repeat weekly on these days, comma-separated: mon..sun or full names, " + + "or weekdays/weekends; implies --repeat weekly"), transform: RepeatDays.init(parsing:)) var repeatOn: RepeatDays? /// `--repeat-on` on its own means a weekly repeat. private var recurrence: Recurrence? { - repeat_ ?? (repeatOn != nil ? .weekly : nil) + repeatFrequency ?? (repeatOn != nil ? .weekly : nil) } func validate() throws { - if let repeat_ = repeat_, !repeat_.isRepresentable { + if let repeatFrequency = repeatFrequency, !repeatFrequency.isRepresentable { throw ValidationError( - "--repeat \(repeat_.rawValue) is not supported: EventKit reminders have no hourly " + "--repeat \(repeatFrequency.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 repeat_, repeatOn != nil, repeat_ != .weekly { + if let repeatFrequency, repeatOn != nil, repeatFrequency != .weekly { throw ValidationError("--repeat-on requires --repeat weekly") } if recurrence != nil && dueDate == nil { throw ValidationError( - repeat_ != nil ? "--repeat requires --due-date" : "--repeat-on requires --due-date") + repeatFrequency != nil ? "--repeat requires --due-date" : "--repeat-on requires --due-date") } if let repeatUntil, let dueDate, let endDate = recurrenceEndDate(from: repeatUntil), @@ -467,7 +473,8 @@ private struct Complete: FormattedCommand { var listNameOrId: String @Argument( - help: "The ids of the reminders to complete, or unique prefixes of at least 4 characters, see 'show' for IDs; \(batchIdsHelp)") + help: ArgumentHelp("The ids of the reminders to complete, or unique prefixes of at least 4 characters, " + + "see 'show' for IDs; \(batchIdsHelp)")) var ids: [String] = [] @Option( @@ -496,7 +503,8 @@ private struct Uncomplete: FormattedCommand { var listNameOrId: String @Argument( - help: "The ids of the reminders to uncomplete, or unique prefixes of at least 4 characters, see 'show' for IDs; \(batchIdsHelp)") + help: ArgumentHelp("The ids of the reminders to uncomplete, or unique prefixes of at least 4 characters, " + + "see 'show' for IDs; \(batchIdsHelp)")) var ids: [String] = [] @Option( @@ -525,7 +533,8 @@ private struct Delete: FormattedCommand { var listNameOrId: String @Argument( - help: "The ids of the reminders to delete, or unique prefixes of at least 4 characters, see 'show' for IDs; \(batchIdsHelp)") + help: ArgumentHelp("The ids of the reminders to delete, or unique prefixes of at least 4 characters, " + + "see 'show' for IDs; \(batchIdsHelp)")) var ids: [String] = [] @Option( @@ -569,7 +578,8 @@ private struct Edit: FormattedCommand { var listNameOrId: String @Argument( - help: "The id of the reminder to edit, or a unique prefix of at least 4 characters, see 'show' for IDs; \(batchIdsHelp)") + help: ArgumentHelp("The id of the reminder to edit, or a unique prefix of at least 4 characters, " + + "see 'show' for IDs; \(batchIdsHelp)")) var id: String @Option( @@ -597,7 +607,7 @@ private struct Edit: FormattedCommand { @Option( name: [.customLong("repeat")], help: "Set (or replace) the reminder's repeat, one of: daily, weekly, monthly, yearly") - var repeat_: Recurrence? + var repeatFrequency: Recurrence? @Option( name: .long, @@ -621,7 +631,8 @@ private struct Edit: FormattedCommand { @Option( name: .long, - help: "Repeat on these days, comma-separated: mon..sun or full names, or weekdays/weekends; replaces only the days of a weekly repeat", + help: ArgumentHelp("Repeat on these days, comma-separated: mon..sun or full names, or weekdays/weekends; " + + "replaces only the days of a weekly repeat"), transform: RepeatDays.init(parsing:)) var repeatOn: RepeatDays? @@ -648,6 +659,8 @@ private struct Edit: FormattedCommand { help: "Output format (plain or json)") var format: OutputFormat = .plain + // A flat list of independent option conflicts, one check per rule. + // swiftlint:disable:next cyclomatic_complexity func validate() throws { if !self.reminder.isEmpty && ReminderSelection.isBatch(arguments: [self.id]) { throw ValidationError("New reminder text can only be set on one reminder at a time") @@ -662,7 +675,7 @@ private struct Edit: FormattedCommand { throw ValidationError("Cannot specify both --notes and --clear-notes") } - let changesRecurrence = self.repeat_ != nil || self.repeatInterval != nil + let changesRecurrence = self.repeatFrequency != nil || self.repeatInterval != nil || self.repeatUntil != nil || self.clearRepeatEnd || self.repeatOn != nil || self.clearRepeatOn @@ -677,9 +690,9 @@ private struct Edit: FormattedCommand { if self.clearRepeat && changesRecurrence { throw ValidationError("Cannot combine --clear-repeat with another repeat option") } - if let repeat_ = repeat_, !repeat_.isRepresentable { + if let repeatFrequency = repeatFrequency, !repeatFrequency.isRepresentable { throw ValidationError( - "--repeat \(repeat_.rawValue) is not supported: EventKit reminders have no hourly " + "--repeat \(repeatFrequency.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.") } @@ -695,7 +708,7 @@ private struct Edit: FormattedCommand { if self.repeatOn != nil && self.clearRepeatOn { throw ValidationError("Cannot specify both --repeat-on and --clear-repeat-on") } - if let repeat_, repeat_ != .weekly, self.repeatOn != nil || self.clearRepeatOn { + if let repeatFrequency, repeatFrequency != .weekly, self.repeatOn != nil || self.clearRepeatOn { throw ValidationError("--repeat-on and --clear-repeat-on require a weekly repeat") } } @@ -713,7 +726,7 @@ private struct Edit: FormattedCommand { priority: self.priority, clearPriority: self.clearPriority, newListName: self.list, - newRecurrence: self.repeat_, + newRecurrence: self.repeatFrequency, newRecurrenceInterval: self.repeatInterval, newRecurrenceEndDate: self.repeatUntil, clearRecurrenceEnd: self.clearRepeatEnd, @@ -735,7 +748,8 @@ private struct Postpone: FormattedCommand { var listNameOrId: String @Argument( - help: "The id of the reminder to postpone, or a unique prefix of at least 4 characters, see 'show' for IDs; \(batchIdsHelp)") + help: ArgumentHelp("The id of the reminder to postpone, or a unique prefix of at least 4 characters, " + + "see 'show' for IDs; \(batchIdsHelp)")) var id: String @Argument( @@ -744,7 +758,8 @@ private struct Postpone: FormattedCommand { @Flag( name: .long, - help: "Move the due date to the next weekday (Mon-Fri), preserving its time of day; the reminder must already have a due date") + help: ArgumentHelp("Move the due date to the next weekday (Mon-Fri), preserving its time of day; " + + "the reminder must already have a due date")) var nextWeekday = false @Option( @@ -772,7 +787,6 @@ private struct Postpone: FormattedCommand { } } - private struct NewList: FormattedCommand { static let configuration = CommandConfiguration( abstract: "Create a new list") diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index 967c885..9f23b51 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -31,6 +31,8 @@ extension EKReminder: @retroactive Encodable { case nextDueDateLocal } + // One flat pass in key order, which the field table in SKILL.md mirrors. + // swiftlint:disable:next cyclomatic_complexity function_body_length public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: EncodingKeys.self) try container.encode(self.calendarItemExternalIdentifier, forKey: .externalId) @@ -70,11 +72,11 @@ extension EKReminder: @retroactive Encodable { try container.encodeIfPresent(localISO8601(dueDateComponents.date), forKey: .dueDateLocal) try container.encode(isDateOnly(dueDateComponents), forKey: .isAllDay) } - + if let lastModifiedDate = self.lastModifiedDate { try container.encode(format(lastModifiedDate), forKey: .lastModified) } - + if let creationDate = self.creationDate { try container.encode(format(creationDate), forKey: .creationDate) } diff --git a/Sources/RemindersLibrary/NaturalLanguage.swift b/Sources/RemindersLibrary/NaturalLanguage.swift index fc90093..32fc8f4 100644 --- a/Sources/RemindersLibrary/NaturalLanguage.swift +++ b/Sources/RemindersLibrary/NaturalLanguage.swift @@ -6,7 +6,7 @@ private let allComponents: Set = [ .era, .year, .yearForWeekOfYear, .quarter, .month, .weekOfYear, .weekOfMonth, .weekday, .weekdayOrdinal, .day, .hour, .minute, .second, .nanosecond, - .calendar, .timeZone + .calendar, .timeZone, ] let timeComponents: Set = [ .hour, .minute, .second, .nanosecond, diff --git a/Sources/RemindersLibrary/PrettyOutput.swift b/Sources/RemindersLibrary/PrettyOutput.swift index 7088cd6..9d43b6a 100644 --- a/Sources/RemindersLibrary/PrettyOutput.swift +++ b/Sources/RemindersLibrary/PrettyOutput.swift @@ -36,7 +36,8 @@ public enum ListingFormat: String, ExpressibleByArgument, CaseIterable { struct ListingOptions: ParsableArguments { @Option( name: .shortAndLong, - help: "format, one of 'plain', 'json' or 'pretty' (default: $\(ListingFormat.environmentVariable), else 'plain')") + help: ArgumentHelp("format, one of 'plain', 'json' or 'pretty' " + + "(default: $\(ListingFormat.environmentVariable), else 'plain')")) var format: ListingFormat? @Flag(name: .shortAndLong, help: "With --format pretty, show the start of each reminder's notes") @@ -297,7 +298,7 @@ private func header(for group: PrettyGroup, now: Date) -> String { /// repeat, flag, notes and ID prefix. Columns are as wide as their widest value across all groups /// (titles capped at `maximumTitleWidth`); a column that's empty in every row is left out. With /// `verbose` the notes marker is replaced by an excerpt on its own line under the title. -func formatPretty( +func formatPretty( // swiftlint:disable:this function_body_length _ groups: [PrettyGroup], now: Date, calendar: Calendar = .current, style: PrettyStyle, verbose: Bool ) -> [String] { diff --git a/Sources/RemindersLibrary/ReminderSelection.swift b/Sources/RemindersLibrary/ReminderSelection.swift index 82ced38..e085541 100644 --- a/Sources/RemindersLibrary/ReminderSelection.swift +++ b/Sources/RemindersLibrary/ReminderSelection.swift @@ -21,6 +21,8 @@ struct ReminderSelection: Equatable { init( arguments: [String], readStandardInput: () -> String = { + // Lossy on purpose: invalid bytes still reach the ID lookup and are reported there. + // swiftlint:disable:next optional_data_string_conversion String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self) } ) throws { diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 0a439a5..9e4a8e8 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -2,7 +2,7 @@ import ArgumentParser import EventKit import Foundation -private let Store = EKEventStore() +private let eventStore = EKEventStore() private let dateFormatter = RelativeDateTimeFormatter() private let recurrenceDateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -79,7 +79,8 @@ func format(_ reminder: EKReminder, id: String, listName: String? = nil) -> Stri let notesString = reminder.notes.flatMap { $0.isEmpty ? nil : " (\($0))" } ?? "" let recurrenceString = formattedRecurrence(from: reminder).map { " (\($0))" } ?? "" let flaggedString = reminder.isFlagged ? " (flagged)" : "" - return "\(listString)\(id): \(reminder.title ?? "")\(notesString)\(dateString)\(priorityString)\(recurrenceString)\(flaggedString)" + return "\(listString)\(id): \(reminder.title ?? "")" + + "\(notesString)\(dateString)\(priorityString)\(recurrenceString)\(flaggedString)" } /// The one definition of "overdue" in the CLI, shared by `show --overdue` and the counts of @@ -399,11 +400,11 @@ public enum Recurrence: String, ExpressibleByArgument { 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 + 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 } } @@ -822,19 +823,19 @@ public enum Priority: String, ExpressibleByArgument { var value: EKReminderPriority { switch self { - case .none: return .none - case .low: return .low - case .medium: return .medium - case .high: return .high + case .none: return .none + case .low: return .low + case .medium: return .medium + case .high: return .high } } init?(_ priority: EKReminderPriority) { switch priority { - case .none: return nil - case .low: self = .low - case .medium: self = .medium - case .high: self = .high + case .none: return nil + case .low: self = .low + case .medium: self = .medium + case .high: self = .high @unknown default: return nil } @@ -887,7 +888,7 @@ func configureNewReminder( /// One reminder's share of `edit`, changing it only in memory; `edit` saves the batch. Every change /// defaults to "leave as is", and a failed repeat update or schedule check is `invalid_argument`. -func applyEdit( +func applyEdit( // swiftlint:disable:this cyclomatic_complexity function_body_length to reminder: EKReminder, newText: String? = nil, newNotes: String? = nil, @@ -1049,15 +1050,15 @@ public final class Reminders { public static func requestAccess() -> (Bool, Error?) { let semaphore = DispatchSemaphore(value: 0) var grantedAccess = false - var returnError: Error? = nil + var returnError: Error? if #available(macOS 14.0, *) { - Store.requestFullAccessToReminders { granted, error in + eventStore.requestFullAccessToReminders { granted, error in grantedAccess = granted returnError = error semaphore.signal() } } else { - Store.requestAccess(to: .reminder) { granted, error in + eventStore.requestAccess(to: .reminder) { granted, error in grantedAccess = granted returnError = error semaphore.signal() @@ -1073,7 +1074,7 @@ public final class Reminders { } func getDefaultList() -> EKCalendar? { - return Store.defaultCalendarForNewReminders() + return eventStore.defaultCalendarForNewReminders() } func showLists( @@ -1100,7 +1101,7 @@ public final class Reminders { reminders = self.fetchReminders(on: calendars, displayOptions: .all) } else { reminders = self.fetchReminders( - matching: Store.predicateForIncompleteReminders( + matching: eventStore.predicateForIncompleteReminders( withDueDateStarting: nil, ending: nil, calendars: calendars), displayOptions: .incomplete) } @@ -1130,7 +1131,7 @@ public final class Reminders { let openReminders = calendars.isEmpty ? [] : self.fetchReminders( - matching: Store.predicateForIncompleteReminders( + matching: eventStore.predicateForIncompleteReminders( withDueDateStarting: nil, ending: nil, calendars: calendars), displayOptions: .incomplete) data = DoctorData( @@ -1330,7 +1331,7 @@ public final class Reminders { // All reminder lists, including read-only ones, so those get a clear refusal from // `checkListDeletion` rather than `list_not_found`. let calendar = try resolveCalendarExactly( - Store.calendars(for: .reminder), nameOrId: nameOrId) + eventStore.calendars(for: .reminder), nameOrId: nameOrId) let reminders = self.fetchReminders(on: [calendar], displayOptions: .all) try checkListDeletion( @@ -1355,7 +1356,7 @@ public final class Reminders { } do { - try Store.removeCalendar(calendar, commit: true) + try eventStore.removeCalendar(calendar, commit: true) } catch let error { throw CLIError.saveFailed(action: "delete list '\(calendar.title)'", underlying: error) } @@ -1448,7 +1449,9 @@ public final class Reminders { }) } - func delete(items selection: ReminderSelection, onListNamedOrId nameOrId: String, outputFormat: OutputFormat) throws { + func delete( + items selection: ReminderSelection, onListNamedOrId nameOrId: String, outputFormat: OutputFormat + ) throws { let calendar = try self.calendar(withNameOrId: nameOrId) // External identifiers are stable regardless of completion state, so a // reminder already marked complete can still be found and deleted by its id. @@ -1464,7 +1467,7 @@ public final class Reminders { try self.commitAll(action: "delete reminder", count: reminders.count) { for reminder in reminders { - try Store.remove(reminder, commit: false) + try eventStore.remove(reminder, commit: false) } } print(confirmation) @@ -1483,7 +1486,7 @@ public final class Reminders { outputFormat: OutputFormat) throws { let calendar = try self.calendar(withNameOrId: nameOrId) - let reminder = EKReminder(eventStore: Store) + let reminder = EKReminder(eventStore: eventStore) reminder.calendar = calendar try configureNewReminder( reminder, title: string, notes: notes, dueDateComponents: dueDateComponents, @@ -1511,7 +1514,7 @@ public final class Reminders { private func fetchReminders(on calendars: [EKCalendar], displayOptions: DisplayOptions) -> [EKReminder] { return self.fetchReminders( - matching: Store.predicateForReminders(in: calendars), displayOptions: displayOptions) + matching: eventStore.predicateForReminders(in: calendars), displayOptions: displayOptions) } /// EventKit only offers a callback-based fetch; block on it once here so every command @@ -1520,7 +1523,7 @@ public final class Reminders { private func fetchReminders(matching predicate: NSPredicate, displayOptions: DisplayOptions) -> [EKReminder] { let semaphore = DispatchSemaphore(value: 0) var fetched: [EKReminder] = [] - Store.fetchReminders(matching: predicate) { reminders in + eventStore.fetchReminders(matching: predicate) { reminders in fetched = reminders? .filter { self.shouldDisplay(reminder: $0, displayOptions: displayOptions) } ?? [] semaphore.signal() @@ -1531,7 +1534,7 @@ public final class Reminders { private func save(_ reminder: EKReminder, action: String) throws { do { - try Store.save(reminder, commit: true) + try eventStore.save(reminder, commit: true) } catch let error { throw CLIError.saveFailed(action: action, underlying: error) } @@ -1541,7 +1544,7 @@ public final class Reminders { private func saveAll(_ reminders: [EKReminder], action: String) throws { try self.commitAll(action: action, count: reminders.count) { for reminder in reminders { - try Store.save(reminder, commit: false) + try eventStore.save(reminder, commit: false) } } } @@ -1551,9 +1554,9 @@ public final class Reminders { private func commitAll(action: String, count: Int, stage: () throws -> Void) throws { do { try stage() - try Store.commit() + try eventStore.commit() } catch let error { - Store.reset() + eventStore.reset() throw CLIError.saveFailed(action: count > 1 ? action + "s" : action, underlying: error) } } @@ -1564,7 +1567,7 @@ public final class Reminders { do { try body() } catch { - Store.reset() + eventStore.reset() throw error } } @@ -1587,7 +1590,7 @@ public final class Reminders { } private func getCalendars() -> [EKCalendar] { - return Store.calendars(for: .reminder) + return eventStore.calendars(for: .reminder) .filter { $0.allowsContentModifications } } @@ -1640,7 +1643,8 @@ func selectListSource(requested: String?, from candidates: [ListSourceCandidate] private func encodeToJson(data: Encodable) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + // Encoding the CLI's own Encodable values can't fail; crashing beats printing nothing. + // swiftlint:disable:next force_try let encoded = try! encoder.encode(data) return String(data: encoded, encoding: .utf8) ?? "" } - diff --git a/Sources/RemindersLibrary/RepeatDays.swift b/Sources/RemindersLibrary/RepeatDays.swift index dd70234..7aed574 100644 --- a/Sources/RemindersLibrary/RepeatDays.swift +++ b/Sources/RemindersLibrary/RepeatDays.swift @@ -37,14 +37,26 @@ struct RepeatDays: Equatable { } } -private let weekdayNames: [(weekday: EKWeekday, short: String, full: String)] = [ - (.sunday, "sun", "sunday"), - (.monday, "mon", "monday"), - (.tuesday, "tue", "tuesday"), - (.wednesday, "wed", "wednesday"), - (.thursday, "thu", "thursday"), - (.friday, "fri", "friday"), - (.saturday, "sat", "saturday"), +private struct WeekdayName { + let weekday: EKWeekday + let short: String + let full: String + + init(_ weekday: EKWeekday, _ short: String, _ full: String) { + self.weekday = weekday + self.short = short + self.full = full + } +} + +private let weekdayNames: [WeekdayName] = [ + WeekdayName(.sunday, "sun", "sunday"), + WeekdayName(.monday, "mon", "monday"), + WeekdayName(.tuesday, "tue", "tuesday"), + WeekdayName(.wednesday, "wed", "wednesday"), + WeekdayName(.thursday, "thu", "thursday"), + WeekdayName(.friday, "fri", "friday"), + WeekdayName(.saturday, "sat", "saturday"), ] private let repeatDayAliases: [String: [EKWeekday]] = [ diff --git a/Sources/RemindersLibrary/ShowAllQuery.swift b/Sources/RemindersLibrary/ShowAllQuery.swift index d772ee8..1576987 100644 --- a/Sources/RemindersLibrary/ShowAllQuery.swift +++ b/Sources/RemindersLibrary/ShowAllQuery.swift @@ -5,11 +5,11 @@ import Foundation /// so they share every filter and sort rule with `show-all` and keep no logic of their own. The /// builders take `now` and `calendar` so tests can pin the resulting dates. struct ShowAllQuery: Equatable { - var dueOn: DateComponents? = nil + var dueOn: DateComponents? var includeOverdue = false var overdue = false - var dueBefore: DateComponents? = nil - var dueAfter: DateComponents? = nil + var dueBefore: DateComponents? + var dueAfter: DateComponents? var lists: [String] = [] var sort: Sort = .dueDate var sortOrder: CustomSortOrder = .ascending diff --git a/Sources/RemindersLibrary/Sort.swift b/Sources/RemindersLibrary/Sort.swift index 60434ff..a2c23f7 100644 --- a/Sources/RemindersLibrary/Sort.swift +++ b/Sources/RemindersLibrary/Sort.swift @@ -12,18 +12,18 @@ public enum Sort: String, Decodable, ExpressibleByArgument, CaseIterable { func sortFunction(order: CustomSortOrder) -> (EKReminder, EKReminder) -> Bool { let comparison: (Date, Date) -> Bool = order == .ascending ? (<) : (>) switch self { - case .none: return { _, _ in fatalError() } - case .creationDate: return { comparison($0.creationDate!, $1.creationDate!) } - case .dueDate: return { dueDateComparison($0, $1, using: comparison) } - case .priority: return { - let rankA = priorityRank($0) - let rankB = priorityRank($1) - if rankA != rankB { - return order == .ascending ? rankA < rankB : rankA > rankB - } - // Ties are always broken by due date ascending, regardless of --sort-order. - return dueDateComparison($0, $1, using: (<)) + case .none: return { _, _ in fatalError() } + case .creationDate: return { comparison($0.creationDate!, $1.creationDate!) } + case .dueDate: return { dueDateComparison($0, $1, using: comparison) } + case .priority: return { + let rankA = priorityRank($0) + let rankB = priorityRank($1) + if rankA != rankB { + return order == .ascending ? rankA < rankB : rankA > rankB } + // Ties are always broken by due date ascending, regardless of --sort-order. + return dueDateComparison($0, $1, using: (<)) + } } } } @@ -32,10 +32,10 @@ public enum Sort: String, Decodable, ExpressibleByArgument, CaseIterable { /// `Priority`'s declaration order doesn't match this ranking, so it's mapped explicitly here. private func priorityRank(_ reminder: EKReminder) -> Int { switch Priority(reminder.mappedPriority) ?? .none { - case .high: return 0 - case .medium: return 1 - case .low: return 2 - case .none: return 3 + case .high: return 0 + case .medium: return 1 + case .low: return 2 + case .none: return 3 } } @@ -45,10 +45,10 @@ private func dueDateComparison( _ a: EKReminder, _ b: EKReminder, using comparison: (Date, Date) -> Bool ) -> Bool { switch (a.dueDateComponents, b.dueDateComponents) { - case (.none, .none): return false - case (.none, .some): return false - case (.some, .none): return true - case (.some, .some): return comparison(a.dueDateComponents!.date!, b.dueDateComponents!.date!) + case (.none, .none): return false + case (.none, .some): return false + case (.some, .none): return true + case (.some, .some): return comparison(a.dueDateComponents!.date!, b.dueDateComponents!.date!) } } @@ -74,10 +74,10 @@ enum ListSort: String, ExpressibleByArgument, CaseIterable { func apply(to summaries: [ListSummary]) -> [ListSummary] { switch self { - case .none: return summaries - case .name: return summaries.sorted(by: nameAscending) - case .open: return summaries.sorted { descending($0.openCount, $1.openCount, thenBy: $0, $1) } - case .overdue: return summaries.sorted { descending($0.overdueCount, $1.overdueCount, thenBy: $0, $1) } + case .none: return summaries + case .name: return summaries.sorted(by: nameAscending) + case .open: return summaries.sorted { descending($0.openCount, $1.openCount, thenBy: $0, $1) } + case .overdue: return summaries.sorted { descending($0.overdueCount, $1.overdueCount, thenBy: $0, $1) } } } } diff --git a/Tests/RemindersTests/CLIRunTests.swift b/Tests/RemindersTests/CLIRunTests.swift index 884ac52..ba15fca 100644 --- a/Tests/RemindersTests/CLIRunTests.swift +++ b/Tests/RemindersTests/CLIRunTests.swift @@ -106,7 +106,8 @@ final class CLIRunTests: XCTestCase { /// Only the parse failure is exercised: a valid `show-lists` would print the machine's real /// lists on a developer machine that has granted the test runner access. func testShowListsRejectsUnknownSort() { - assertUsageError(["show-lists", "--sort", "bogus"], contains: "The value 'bogus' is invalid for '--sort '") + assertUsageError( + ["show-lists", "--sort", "bogus"], contains: "The value 'bogus' is invalid for '--sort '") } /// The convenience commands run `show-all`'s query, so an unknown `--list` fails the same way diff --git a/Tests/RemindersTests/DoctorTests.swift b/Tests/RemindersTests/DoctorTests.swift index f8f8b52..106e172 100644 --- a/Tests/RemindersTests/DoctorTests.swift +++ b/Tests/RemindersTests/DoctorTests.swift @@ -212,7 +212,8 @@ final class DoctorTests: XCTestCase { XCTAssertEqual( applicationName( fromExecutablePath: - "/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin)"), + "/Applications/Visual Studio Code.app/Contents/Frameworks/" + + "Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin)"), "Visual Studio Code") XCTAssertNil(applicationName(fromExecutablePath: "/bin/zsh")) XCTAssertNil(applicationName(fromExecutablePath: "/usr/local/.app/tool")) diff --git a/Tests/RemindersTests/FlaggedTests.swift b/Tests/RemindersTests/FlaggedTests.swift index 34b5443..221a12d 100644 --- a/Tests/RemindersTests/FlaggedTests.swift +++ b/Tests/RemindersTests/FlaggedTests.swift @@ -21,6 +21,8 @@ private final class FakeRemReminder: NSObject { } private final class FakeBackingObject: NSObject { + // The name must match the private EventKit key `readFlag` walks. + // swiftlint:disable:next identifier_name @objc let _reminder: NSObject? init(reminder: NSObject?) { diff --git a/Tests/RemindersTests/PrettyOutputTests.swift b/Tests/RemindersTests/PrettyOutputTests.swift index 9716c1f..940053e 100644 --- a/Tests/RemindersTests/PrettyOutputTests.swift +++ b/Tests/RemindersTests/PrettyOutputTests.swift @@ -215,9 +215,11 @@ final class PrettyOutputTests: XCTestCase { func testColours() { let style = PrettyStyle(enabled: true) let lines = render( - [PrettyGroup(title: "Soon", rows: [ - row("a", id: "1", due: jan(2, 10)), row("b", id: "2", completed: jan(4, 8)), - ])], + [ + PrettyGroup(title: "Soon", rows: [ + row("a", id: "1", due: jan(2, 10)), row("b", id: "2", completed: jan(4, 8)), + ]), + ], style: style) XCTAssertEqual(lines, [ "\u{1B}[1mSoon (1 open, 1 overdue, 1 completed)\u{1B}[0m", @@ -255,10 +257,12 @@ final class PrettyOutputTests: XCTestCase { let id = "44C111DE-0B69-4E96-8C93-6A5D0A6C2A17" XCTAssertEqual( format(reminder, id: id), - "44C111DE-0B69-4E96-8C93-6A5D0A6C2A17: Ship reminders-cli (Remember the changelog) (priority: high) (repeats: weekly, interval: 2)") + "44C111DE-0B69-4E96-8C93-6A5D0A6C2A17: Ship reminders-cli (Remember the changelog) " + + "(priority: high) (repeats: weekly, interval: 2)") XCTAssertEqual( format(reminder, id: id, listName: "Soon"), - "Soon: 44C111DE-0B69-4E96-8C93-6A5D0A6C2A17: Ship reminders-cli (Remember the changelog) (priority: high) (repeats: weekly, interval: 2)") + "Soon: 44C111DE-0B69-4E96-8C93-6A5D0A6C2A17: Ship reminders-cli (Remember the changelog) " + + "(priority: high) (repeats: weekly, interval: 2)") } private func plainReminder(_ title: String) -> EKReminder { @@ -336,8 +340,9 @@ final class ListingFormatTests: XCTestCase { for command in [["show", "x"], ["show-all"], ["today"], ["overdue"], ["upcoming"]] { XCTAssertNoThrow(try CLI.parseAsRoot(command + ["--format", "pretty"]), "\(command)") } - assertParseError(["add", "x", "y", "--format", "pretty"], contains: "The value 'pretty' is invalid for '--format '") - assertParseError(["show-lists", "--format", "pretty"], contains: "The value 'pretty' is invalid for '--format '") + let invalidPretty = "The value 'pretty' is invalid for '--format '" + assertParseError(["add", "x", "y", "--format", "pretty"], contains: invalidPretty) + assertParseError(["show-lists", "--format", "pretty"], contains: invalidPretty) } func testListingCommandsReportErrorsInResolvedFormat() throws { diff --git a/Tests/RemindersTests/RecurrenceTests.swift b/Tests/RemindersTests/RecurrenceTests.swift index 1fdf35d..897ba6d 100644 --- a/Tests/RemindersTests/RecurrenceTests.swift +++ b/Tests/RemindersTests/RecurrenceTests.swift @@ -419,7 +419,8 @@ final class RecurrenceTests: XCTestCase { func testNextOccurrenceDailyStepsForward() throws { let anchor = utcDate(2026, 1, 1) let rule = Recurrence.daily.recurrenceRule(interval: 1, end: nil) - let next = nextOccurrence(of: rule, anchoredAt: anchor, onOrAfter: utcDate(2026, 1, 3, 12), calendar: utcCalendar) + let next = nextOccurrence( + of: rule, anchoredAt: anchor, onOrAfter: utcDate(2026, 1, 3, 12), calendar: utcCalendar) XCTAssertEqual(next, utcDate(2026, 1, 4)) } @@ -690,7 +691,8 @@ final class RecurrenceTests: XCTestCase { setPositions: nil, end: nil) XCTAssertNil( - nextOccurrence(of: rule, anchoredAt: utcDate(2026, 1, 1), onOrAfter: utcDate(2026, 3, 1), calendar: utcCalendar)) + nextOccurrence( + of: rule, anchoredAt: utcDate(2026, 1, 1), onOrAfter: utcDate(2026, 3, 1), calendar: utcCalendar)) } func testRecurrenceRuleWithDays() throws { diff --git a/lychee.toml b/lychee.toml new file mode 100644 index 0000000..3460f71 --- /dev/null +++ b/lychee.toml @@ -0,0 +1,17 @@ +# Checks the links of README.md, CLAUDE.md and skills/**/*.md (see .github/workflows/lint.yml). + +cache = true +max_cache_age = "1d" +max_retries = 3 +retry_wait_time = 5 +timeout = 30 + +# Rate limiting (e.g. by GitHub) says nothing about whether a link works. +accept = ["200..=299", "429"] + +exclude_path = [ + # Generated by release-please, and its compare/commit links are fixed history. + "CHANGELOG.md", + ".build", + ".claude", +]