From e871e125526831cbf16ca8906fe0c2949254b72f Mon Sep 17 00:00:00 2001 From: Rob Weaver Date: Sat, 16 May 2026 16:33:11 -0600 Subject: [PATCH] Add meeting-alerter CLI and whenparse package Implements the meeting-alerter spec: a small CLI that watches your calendar and posts a macOS notification a configurable number of minutes before each upcoming meeting. Doubles as a worked example of reusing pkg/eventkit. - cmd/meeting-alerter: list/watch/setup/install/uninstall subcommands, persisted notified state, launchd plist generator - internal/whenparse: hand-rolled natural-language time parser with ParseInstant and ParseRange, used by 'meeting-alerter list --when' - Spec at .kiro/specs/meeting-alerter/ --- .kiro/specs/meeting-alerter/.config.kiro | 1 + .kiro/specs/meeting-alerter/design.md | 260 +++++ .kiro/specs/meeting-alerter/requirements.md | 197 ++++ .kiro/specs/meeting-alerter/tasks.md | 190 ++++ cmd/meeting-alerter/doc.go | 15 + cmd/meeting-alerter/main.go | 1100 +++++++++++++++++++ cmd/meeting-alerter/main_test.go | 182 +++ cmd/meeting-alerter/notified.go | 211 ++++ cmd/meeting-alerter/notified_test.go | 363 ++++++ cmd/meeting-alerter/plist.go | 289 +++++ cmd/meeting-alerter/plist_test.go | 391 +++++++ internal/whenparse/composite.go | 134 +++ internal/whenparse/doc.go | 52 + internal/whenparse/instant.go | 98 ++ internal/whenparse/instant_test.go | 71 ++ internal/whenparse/lexer.go | 134 +++ internal/whenparse/lexer_test.go | 177 +++ internal/whenparse/parse_instant_test.go | 232 ++++ internal/whenparse/parse_range_test.go | 221 ++++ internal/whenparse/range.go | 85 ++ internal/whenparse/range_more.go | 129 +++ internal/whenparse/range_smoke_test.go | 109 ++ internal/whenparse/relative.go | 137 +++ internal/whenparse/weekday.go | 142 +++ internal/whenparse/whenparse.go | 138 +++ 25 files changed, 5058 insertions(+) create mode 100644 .kiro/specs/meeting-alerter/.config.kiro create mode 100644 .kiro/specs/meeting-alerter/design.md create mode 100644 .kiro/specs/meeting-alerter/requirements.md create mode 100644 .kiro/specs/meeting-alerter/tasks.md create mode 100644 cmd/meeting-alerter/doc.go create mode 100644 cmd/meeting-alerter/main.go create mode 100644 cmd/meeting-alerter/main_test.go create mode 100644 cmd/meeting-alerter/notified.go create mode 100644 cmd/meeting-alerter/notified_test.go create mode 100644 cmd/meeting-alerter/plist.go create mode 100644 cmd/meeting-alerter/plist_test.go create mode 100644 internal/whenparse/composite.go create mode 100644 internal/whenparse/doc.go create mode 100644 internal/whenparse/instant.go create mode 100644 internal/whenparse/instant_test.go create mode 100644 internal/whenparse/lexer.go create mode 100644 internal/whenparse/lexer_test.go create mode 100644 internal/whenparse/parse_instant_test.go create mode 100644 internal/whenparse/parse_range_test.go create mode 100644 internal/whenparse/range.go create mode 100644 internal/whenparse/range_more.go create mode 100644 internal/whenparse/range_smoke_test.go create mode 100644 internal/whenparse/relative.go create mode 100644 internal/whenparse/weekday.go create mode 100644 internal/whenparse/whenparse.go diff --git a/.kiro/specs/meeting-alerter/.config.kiro b/.kiro/specs/meeting-alerter/.config.kiro new file mode 100644 index 0000000..a019f7e --- /dev/null +++ b/.kiro/specs/meeting-alerter/.config.kiro @@ -0,0 +1 @@ +{"specId": "5a39fe9f-f879-423b-9f50-30c643b19d5d", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/meeting-alerter/design.md b/.kiro/specs/meeting-alerter/design.md new file mode 100644 index 0000000..0ea385f --- /dev/null +++ b/.kiro/specs/meeting-alerter/design.md @@ -0,0 +1,260 @@ +# Design: meeting-alerter and natural-language time windows + +## Overview + +This document is the implementation-level companion to `requirements.md`. It covers the package layout, the natural-language parser's grammar and structure, and the alerter pieces that aren't yet built (REQ-4, REQ-6). + +## Package layout (after this spec lands) + +``` +work-cal-sync/ +├── cmd/ +│ ├── work-cal-sync/ # existing, unchanged +│ ├── meeting-alerter/ # existing, gets install/uninstall + persisted state +│ └── ekprobe/ # existing, unchanged +├── pkg/ +│ └── eventkit/ # existing, unchanged +├── internal/ +│ ├── sync/ # existing, unchanged +│ └── whenparse/ # NEW: ParseInstant / ParseRange +└── docs/ + └── ... # already updated for current state +``` + +`internal/whenparse` is `internal/` rather than `pkg/` because the grammar is project-specific (phrases tuned to "I want to see calendar events" rather than general date-time parsing). If a third-party tool wants the same parser, we can lift it to `pkg/` later — same trick we did with `eventkit`. + +## `internal/whenparse` + +### Public API + +```go +package whenparse + +// ParseInstant turns an English phrase into a single point in time, +// resolved against `now`. Day-precision phrases (today, tomorrow, +// 2026-05-25, monday) snap to local midnight on that day. +func ParseInstant(phrase string, now time.Time) (time.Time, error) + +// ParseRange turns an English phrase into a [start, end) window. +// Single-instant phrases that look like a day (today, tomorrow, monday, +// 2026-05-25) become full local days. Phrases that name a duration +// (next 3 days, this week) become spans of that duration. +// +// Errors include the input string for ergonomics. +func ParseRange(phrase string, now time.Time) (start, end time.Time, err error) +``` + +`now` is injected so tests can pin time deterministically. + +### Internal pipeline + +``` +phrase ──► normalize ──► tokenize ──► classify ──► parse ──► (instant | range) +``` + +1. **normalize**: lower-case, collapse whitespace, replace common synonyms (`thru` → `through`, `&` → `and`). +2. **tokenize**: split on whitespace, recognise compound tokens like `2026-05-25` and `13:00` as single tokens. +3. **classify**: route the token sequence to one of these handlers based on a leading keyword, falling back to a literal-date check at the end: + + | Leading token | Handler | + |---|---| + | `now`, `today`, `tomorrow`, `yesterday` | `parseNamedDay` | + | `in`, `next`, `last` | `parseRelativeDuration` | + | a digit | `parseNumberLeading` (handles `3 days from now`, `3 days ago`) | + | a weekday name | `parseWeekday` | + | `this`, `next`, `last` followed by `week` | `parseWeekRange` | + | `rest` followed by `of` | `parseRestOfWeek` | + | `until` | `parseUntil` | + | otherwise | `parseAbsolute` (RFC3339 + the existing local-time formats) | + +4. **parse**: each handler returns either a `time.Time` (instant) or a `(start, end)` pair (range). Range-only callers from instants get a 24-hour window automatically. + +A small `composite()` step runs before classify when the input contains the literal word `through` (or `to` between two times when neither is `from`/`through` already): split on it, recursively parse each side as `ParseInstant`, return `[left, right)` or fail if `right` isn't after `left`. + +### Grammar reference (illustrative, not exhaustive) + +The acceptance criteria in REQ-5 are the contract. This subsection gives concrete examples for each handler so the implementation has clear targets. + +``` +parseNamedDay + "now" → instant: now + "today" → instant: midnight of today (local) + → range: [midnight today, midnight tomorrow) + "tomorrow" → range: [midnight tomorrow, midnight day after) + +parseRelativeDuration + "in 3 days" → instant: midnight on (today + 3) + "next 3 days" → range: [now, now + 72h) + "next 4 hours" → range: [now, now + 4h) + "next week" → range: [next Sunday 00:00, the Sunday after 00:00) + "last 2 weeks" → range: [now - 14d, now) + +parseNumberLeading + "3 days from now" → instant: midnight on (today + 3) + "3 days ago" → instant: midnight on (today - 3) + "2 hours from now" → instant: now + 2h + "30 minutes from now" → instant: now + 30m + +parseWeekday + "monday" → range: [Monday 00:00, Tuesday 00:00) of the next occurrence + "monday at 14:00" → instant: that Monday at 14:00 local + "monday through fri" → range: [Monday 00:00, Saturday 00:00) of the same week + +parseWeekRange + "this week" → range: [most recent Sunday 00:00, next Sunday 00:00) + "next week" → range: [next Sunday 00:00, the Sunday after 00:00) + "rest of the week" → range: [now, next Sunday 00:00) + "rest of this week" → range: [now, next Sunday 00:00) + +parseUntil + "until friday" → range: [now, end of next Friday) + "until 2026-05-25" → range: [now, end of 2026-05-25) + "until tomorrow" → range: [now, end of tomorrow) + +parseAbsolute + "2026-05-25" → instant: 2026-05-25 00:00 local + → range: [00:00, 24:00) of that day + "2026-05-25T14:00" → instant: that local time + "2026-05-25 14:00" → instant: that local time + RFC3339 → instant: as parsed +``` + +### Edge cases + +- **End of day**: "rest of the week" must include the last day fully, so `end` is the start of the **following** Sunday (Sun-Sun convention; the half-open interval keeps everything clean). +- **Weekday already passed today**: `monday` when today is Wednesday means *next* Monday, not yesterday's Monday. Anchor to "the next occurrence at or after today midnight". +- **`today` mid-day**: `today` as an instant resolves to *midnight*, not now. `now` is the only phrase that produces a non-midnight instant. This matters when chaining — `--from today --to friday` includes the morning, which is what the user means. +- **Time-of-day clamps**: `monday at 14:00` produces 14:00 local on the next Monday; if today is Monday and it's already 15:00, that means *next* Monday (a week from now). +- **Composite range with overlap**: `--from "tomorrow" --to "yesterday"` errors; the composite parser checks `end.After(start)`. +- **Relative durations carry seconds, named days don't**: `in 90 seconds` produces an exact instant; `today` produces midnight. Mixing them in a composite is fine: `--from "in 30 minutes" --to "tomorrow"` is `[now+30min, midnight+24h)`. + +## Wiring `whenparse` into the existing flags + +`cmd/meeting-alerter/main.go` currently has `parseListWindow` returning `(start, end, err)`. The contract changes from "decode this specific flag set" to "feed each flag's value through `whenparse` and assemble a window": + +```go +// Pseudo-code for the updated parser +func parseListWindow(args []string, cfg *Config, now time.Time) (time.Time, time.Time, error) { + flags := extractListFlags(args) // unchanged + + // --when wins; it specifies a complete range. + if flags.when != "" { + return whenparse.ParseRange(flags.when, now) + } + + // --minutes is an integer-only shortcut for "next N minutes". + if flags.hasMinutes { + return now, now.Add(time.Duration(flags.minutes) * time.Minute), nil + } + + // --day passes through ParseRange so it accepts everything --when does. + if flags.day != "" { + return whenparse.ParseRange(flags.day, now) + } + + // --from / --to each pass through ParseInstant. + if flags.from != "" || flags.to != "" { + if flags.from == "" || flags.to == "" { + return zero, zero, errors.New("--from and --to must be supplied together") + } + start, err := whenparse.ParseInstant(flags.from, now) + if err != nil { + return zero, zero, fmt.Errorf("--from: %w", err) + } + end, err := whenparse.ParseInstant(flags.to, now) + if err != nil { + return zero, zero, fmt.Errorf("--to: %w", err) + } + if !end.After(start) { + return zero, zero, errors.New("--to must be after --from") + } + return start, end, nil + } + + return now, now.Add(time.Duration(cfg.LookaheadMinutes) * time.Minute), nil +} +``` + +The existing flag-combination rules ("`--minutes` cannot be combined with `--from/--to/--day`") stay the same. + +The existing `meeting-alerter list` tests stay green because `--day "today"` is a recognised `whenparse` phrase. We add new tests for the natural-language phrases and remove the now-redundant `parseDayWindow` / `parseFlexibleTime` helpers. + +## `meeting-alerter install` / `uninstall` + +The launchd plist is generated from a Go template, same approach as `internal/sync/plist.go`. The bundle path is what goes in `ProgramArguments`, not the bare CLI: + +```go +// Sketch +const plistTemplate = ` + + + + Label + tel.dead.meeting-alerter + ProgramArguments + + {{.BundleBinary}} + watch + + RunAtLoad + + KeepAlive + + StandardOutPath + {{.LogPath}} + StandardErrorPath + {{.LogPath}} + +` +``` + +`{{.BundleBinary}}` resolves to `~/Applications/meeting-alerter.app/Contents/MacOS/meeting-alerter` so launchd-spawned processes get the bundle's TCC identity. `{{.LogPath}}` is `~/Library/Logs/meeting-alerter.log`. + +`install` is idempotent: it unloads any existing job before loading the new one. `uninstall` confirms with the user, unloads, removes the plist + config dir. + +## Persisted notification state (REQ-6) + +`~/.config/meeting-alerter/notified.json`: + +```json +{ + "version": 1, + "entries": [ + {"external_id": "987...", "meeting_start_unix": 1779555600}, + {"external_id": "ABC...", "meeting_start_unix": 1779559200} + ] +} +``` + +Schema-versioned so we can extend it (e.g., snooze state) without breaking loaders. Read on `watch` startup, written atomically (`os.Rename` from a sibling tmp file) after each notification. Pruned on every sweep: drop entries whose `meeting_start_unix < (now - 1h)`. + +## Logging conventions + +Existing slog conventions hold. Each subcommand begins with an `INFO` line that names the run: + +``` +meeting-alerter watch INFO "watch: started" calendar="Calendar" lookahead_minutes=60 notify_before_minutes=5 +meeting-alerter watch INFO "watch: notified" work_id=987... title="Standup" start=2026-05-20T09:30 in_minutes=5 +meeting-alerter watch DEBUG "watch: scanned" events=4 now=... +``` + +`meeting-alerter list` is silent on success except the table; failures go to stderr at ERROR. + +## Testing strategy + +- `internal/whenparse`: a single table-driven test covering every example in the grammar reference plus edge cases. Pin `now` to a fixed Wednesday so weekday-relative phrases have predictable outputs. +- `cmd/meeting-alerter`: extend the existing `parseListWindow` test to include NL phrases. The bundle-spawning code path stays unit-untestable; integration is by hand against a live calendar. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Hand-rolled grammar grows long-tail of phrases users want | Future TODO-2 (LLM fallback) covers the long tail. Until then, return a clear error with the input phrase so users know it wasn't recognised. | +| Weekday boundary ambiguity (Sun-start vs Mon-start) | Pin to Sun-start (US default). Document explicitly in the user guide; revisit when we add localisation. | +| Persisted-state file grows or corrupts | Schema-versioned + atomic writes + 1-hour-past prune sweep. Worst case on corruption: ignore the file and start fresh; the user's only loss is "every still-upcoming meeting alerts again on the next start". | + +## Out of scope (this spec) + +- Localisation of the parser to non-English phrases. +- An LLM time-conversation mode (TODO-2 in requirements). +- A Kiro-skill wrapper (TODO-1 in requirements). diff --git a/.kiro/specs/meeting-alerter/requirements.md b/.kiro/specs/meeting-alerter/requirements.md new file mode 100644 index 0000000..d4e0510 --- /dev/null +++ b/.kiro/specs/meeting-alerter/requirements.md @@ -0,0 +1,197 @@ +# Requirements Document + +Spec: meeting-alerter and natural-language time windows + +## Introduction + +The `work-cal-sync` spec (`.kiro/specs/improve-setup/`) is complete: a Go binary plus macOS `.app` bundle that mirrors a work Exchange/O365 calendar to a CalDAV server every 15 minutes. Along the way we extracted the macOS EventKit bridge into a public package (`pkg/eventkit`) with a clean Go API and a change-notification facility (`ChangeCount` / `WaitForChange` / `ObserveChanges`). + +This spec covers the next things built on top of that foundation: + +1. **`meeting-alerter`** — a small CLI/`launchd` daemon that posts a macOS notification a configurable number of minutes before each upcoming meeting. Already scaffolded (`cmd/meeting-alerter/`), partially shipped, missing scheduling and persistent state. +2. **`internal/whenparse`** — a small natural-language time-window parser, used by `meeting-alerter list` and any other CLI flag that takes a time. Replaces the current minimum (`--from`, `--to`, `--day`, `--minutes`) with consistent grammar that handles English phrases like `tomorrow`, `next 3 days`, `3 days from now`, `this week`, `monday through friday`, `until friday`. + +It also captures **future work** (TODO sections at the end) that we want to remember but aren't building yet: + +- Repackaging this whole thing as a Kiro skill so the agent can drive setup, listing, and alerting through chat. +- Routing time-window phrases through an LLM as a conversation, instead of (or alongside) the hand-rolled grammar. + +## Glossary + +- **EventKit**: Apple's framework for reading/writing calendar data. Accessed from Go via a cgo bridge in `pkg/eventkit`. +- **TCC**: macOS Transparency, Consent, and Control — the privacy subsystem that gates Calendar access. Grants are tied to a signed `.app` bundle path, which is why both tools ship as bundles. +- **`.app` bundle**: A macOS application directory (`*.app/Contents/MacOS/...`) that TCC recognises as an identity for permission grants. CLIs in `~/bin` re-invoke through the bundle via `open` for any EventKit call. +- **`launchd`**: macOS init/scheduler. Both tools register a `LaunchAgent` plist to run on a schedule (sync) or stay resident (alerter). +- **`X-WORK-CAL-ID`**: Custom iCalendar property used to identify events that originated from the work calendar, so the sync engine can match work events to their CalDAV copies and the alerter can dedupe notifications. +- **CalDAV**: RFC 4791 calendar protocol. The sync target is a self-hosted CalDAV server (Radicale, Nextcloud, etc.). +- **`whenparse`**: This spec's natural-language time grammar. Returns either a single `time.Time` (instant) or a `(start, end)` pair (range). +- **Lookahead window**: How far into the future the alerter considers events. Default 60 minutes. +- **Notify-before window**: How many minutes before a meeting the alerter posts a notification. Default 5 minutes. +- **Sweep**: The alerter's periodic recheck (every 60 seconds) that supplements push notifications from EventKit, catching meetings that enter the lookahead window without an underlying EventKit change. + +## Why now + +Two reasons: + +- The `--day` / `--from` / `--to` flags exist but each understands a slightly different syntax. Once you ship a tool that asks users for time windows, the question "what can I write here?" comes up immediately. A single grammar that all the flags understand is easier to document and easier to use. +- The alerter is genuinely useful as soon as the daemon piece is wired up. Several friction points (notification noise across restarts, lack of a `meeting-alerter install`) are small but real. + +## Architecture summary + +```text +┌─ Calendar.app ──── EventKit ────┐ +│ (Exchange/O365) │ +└─────────────────────────────────┘ + │ + ▼ +┌──────────── pkg/eventkit ──────────────────┐ +│ RequestAccess / ListExchangeCalendars │ +│ FetchEvents / ChangeCount / WaitForChange │ +│ ObserveChanges │ +└────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─ work-cal-sync ─┐ ┌─ meeting-alerter ─┐ +│ sync engine │ │ watch loop │ +│ CalDAV client │ │ list subcommand │ +│ wizard │ │ wizard │ +└─────────────────┘ └────────────-──────┘ + │ │ + ▼ ▼ + CalDAV server macOS Notification + (iCloud, etc.) Center (osascript) + + ▼ ▼ + ┌─────────── internal/whenparse ─────────────┐ + │ ParseInstant / ParseRange │ + │ Used by --from / --to / --day / --when │ + └────────────────────────────────────────────┘ +``` + +Both tools ship as macOS `.app` bundles in `~/Applications` so TCC will grant Calendar access. The CLIs on `~/bin` fork through the bundles via `open` for any EventKit call. This is the same pattern from the `improve-setup` spec; reusing it for the alerter is by design. + +## Requirements + +### REQ-1: meeting-alerter `setup` wizard + +- **What**: `meeting-alerter setup` prompts the user for a calendar, lookahead window in minutes, and notify-before window in minutes. +- **Why**: Same low-friction first-run pattern as `work-cal-sync setup`. +- **Acceptance criteria**: + - Lists Exchange/O365 calendars from EventKit (via the bundle). + - Prompts for `lookahead_minutes` (default 60). + - Prompts for `notify_before_minutes` (default 5). + - Persists `~/.config/meeting-alerter/config.json` with mode `0600`. + - Re-running shows current values as defaults. +- **Status**: Built. + +### REQ-2: `meeting-alerter list` + +- **What**: Print upcoming meetings in a configurable window. Default is the user's configured lookahead from now. +- **Why**: Confidence check after `setup`; quick day-glance utility. +- **Acceptance criteria**: + - Default window is `[now, now + lookahead_minutes)`. + - Accepts the natural-language flags described in REQ-5. + - Groups output by local date when the window spans multiple days. + - Marks all-day events as such. +- **Status**: Built with the existing `--from`/`--to`/`--day`/`--minutes` flags. NL upgrade pending (REQ-5). + +### REQ-3: `meeting-alerter watch` + +- **What**: Long-running daemon that posts a macOS notification a configurable number of minutes before each upcoming meeting. +- **Why**: The whole point of the tool. +- **Acceptance criteria**: + - Reacts to `EKEventStoreChangedNotification` (push) plus a 60-second sweep (covers events that just enter the window with no underlying change). + - Notifies each meeting at most once per `watch` lifetime. + - Posts via `osascript display notification` (no external library dependency). + - Logs structured events: `watch: started`, `watch: notified`, `watch: shutting down`. +- **Status**: Built. + +### REQ-4: `meeting-alerter install` / `uninstall` + +- **What**: Subcommands that schedule the watcher under `launchd` and tear it down cleanly. +- **Why**: Right now users have to write the plist by hand. Same convenience `work-cal-sync install` provides. +- **Acceptance criteria**: + - `install` resolves the absolute path of the running CLI binary, generates a plist with `KeepAlive=true` and `RunAtLoad=true`, writes it to `~/Library/LaunchAgents/tel.dead.meeting-alerter.plist`, loads the job. Idempotent. + - `install` triggers `setup` if no config exists. + - `uninstall` confirms with the user, unloads the job, removes the plist, removes `~/.config/meeting-alerter/`. Does not remove the bundle, the binary, or events on the calendar. + - The plist points at the bundle path (`~/Applications/meeting-alerter.app/Contents/MacOS/meeting-alerter`) rather than the bare CLI, so the launchd-spawned process inherits the right TCC attribution. +- **Status**: Not built. The user-guide currently documents a hand-written plist as a workaround. + +### REQ-5: Natural-language time windows (`internal/whenparse`) + +- **What**: A small, hand-rolled English-grammar parser that converts user-supplied phrases into either a `time.Time` (instant) or a `(start, end)` range. Used by `meeting-alerter list`'s `--from`/`--to`/`--day`/`--when` flags, and ready to be used by future flags in any tool in this repo. +- **Why**: The current flag set requires the user to remember three different syntaxes (RFC3339, `today`/`tomorrow`/YYYY-MM-DD, integer minutes). One grammar everywhere is easier to document and to use. Examples we want to support: + - `meeting-alerter list --when "today"` + - `meeting-alerter list --when "next 3 days"` + - `meeting-alerter list --from tomorrow --to "three days from now"` + - `meeting-alerter list --when "monday through friday"` + - `meeting-alerter list --when "rest of the week"` +- **Why hand-roll, not a library**: `github.com/markusmobius/go-dateparser` only handles single instants (it returns one `time.Time` per parse) and adds ~2–3 MB of locale data. Range and composite phrases would still need a custom layer on top. Hand-rolling a small grammar tailored to the known phrase set is tighter and dependency-free. +- **Acceptance criteria**: + - `ParseInstant(phrase, now) (time.Time, error)` handles: `now`, `today`, `tomorrow`, `yesterday`, `in N {minutes,hours,days,weeks}`, `N {minutes,hours,days,weeks} from now`, `N {minutes,hours,days,weeks} ago`, the seven weekday names (next occurrence, snapped to local-midnight), `today at HH:MM`, `tomorrow at HH:MM`, ` at HH:MM`, full RFC3339, and the existing local-time formats `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, `YYYY-MM-DD HH:MM`. + - `ParseRange(phrase, now) (start, end time.Time, err error)` handles: any instant from above (resolved to a 24-hour local-day window when the input is day-precision), `today`, `tomorrow`, `yesterday`, `next N {minutes,hours,days,weeks}`, `last N {minutes,hours,days,weeks}`, `this week`, `next week`, `last week`, `rest of the week`, `` (full day), ` through ` (range across the same week, clamped forward), `until ` and `until ` (now → end of that day), and composite ` through `. + - "3 days from now" resolves to the **24-hour local-day window** starting at midnight on day +3 (per the conversation that led here). "Next 3 days" resolves to a **72-hour window from now**. + - Week boundary: Sunday-start (US default). + - Phrases are case-insensitive. Whitespace is collapsed. + - Unrecognized input returns a descriptive error including the input string. + - Comprehensive table-driven tests. +- **Status**: Not built. This is the headline new feature. + +### REQ-6: Persisted notification state (`meeting-alerter watch`) + +- **What**: `watch` persists which meetings it has already alerted on, keyed by `X-WORK-CAL-ID`, so restarting the daemon doesn't re-fire alerts for meetings still in the window. +- **Why**: Right now the `notified` map is in-memory only. A `launchctl unload && load` cycle re-alerts every still-upcoming meeting. +- **Acceptance criteria**: + - State file at `~/.config/meeting-alerter/notified.json`. + - Stores a map of `external_id → meeting_start_unix`. + - Read on startup; write atomically after each notification (tmp file + rename). + - Pruned on each sweep: drop entries whose `meeting_start_unix` is more than 1 hour in the past, so the file doesn't grow unbounded. +- **Status**: Not built. The trade-off here is small — most users won't restart the daemon often — so this is lower priority than REQ-4 and REQ-5. + +### REQ-7: Documentation + +- **What**: User guide and architecture doc cover both tools end to end, plus a troubleshooting doc that captures the gotchas we hit. +- **Acceptance criteria**: + - `docs/user-guide.md`: end-to-end install, setup, day-to-day, uninstall for both tools, plus a coexistence-with-Python section. + - `docs/architecture.md`: components, data flow, TCC model, wire formats, package surface, instructions for reusing `pkg/eventkit` in third-party tools. + - `docs/troubleshooting.md`: every TCC, CalDAV, sync, and build problem we hit, with the fix. + - The README is a lean entry point that links to the three docs. +- **Status**: Built. + +## Non-requirements (out of scope) + +- Localizing the natural-language parser to anything other than English. The phrase set is intentionally English-only; a future LLM-based variant (see TODOs) would be the place to add multilingual support. +- Two-way sync. Same as the original `work-cal-sync` spec. +- A GUI or menu-bar app for either tool. +- Phone-side alerting (the macOS-only constraint inherits from EventKit). +- Recurring-event exception handling beyond what EventKit already returns. Both tools deliberately treat each event-instance EventKit reports as opaque. + +## TODO (future work, not in this spec) + +These are recorded so we don't forget them. Each is its own follow-up effort. + +### TODO-1: Repackage as a Kiro skill + +Wrap `work-cal-sync` and `meeting-alerter` as a Kiro skill so the agent can drive them through chat. Rough sketch: + +- **Skill surface**: a single skill named `calendar-tools` exposing functions like `list_meetings(window)`, `next_meeting()`, `sync_now()`, `silence_meeting(id, minutes)`, `setup_status()`. +- **Backing implementation**: the skill calls the existing CLIs via `exec`, parsing their structured (slog) output. No new EventKit work; the bundles already grant access correctly. +- **Where the skill lives**: `~/.kiro/skills/calendar-tools/` or workspace-scoped at `.kiro/skills/calendar-tools/`. POWER.md describes the skill; `commands/` directory contains shell wrappers; an MCP server (optional) exposes the same surface programmatically. +- **Why**: it's the natural endpoint for the natural-language work. Once the agent can answer "what's on my calendar tomorrow afternoon?" by calling `list_meetings`, the user never has to remember the flag syntax. + +### TODO-2: AI-driven time conversation + +Once the hand-rolled `whenparse` is in place, optionally also support an LLM-driven mode for time windows: + +- **Trigger**: a `--when` value the hand-rolled parser cannot handle, or an explicit `--ai` flag, or always when running inside a Kiro skill. +- **Behaviour**: send the phrase plus the current time and locale to a small LLM (could be a local model via Ollama for privacy, or whatever the surrounding system is configured to use), expect a JSON response of the shape: + + ```json + {"start": "2026-05-25T13:00:00-07:00", "end": "2026-05-25T17:00:00-07:00", + "interpretation": "Monday afternoon (1 PM to 5 PM local)"} + ``` + + Echo the `interpretation` back to the user so they can confirm the agent guessed right. +- **Conversation**: when the LLM is uncertain ("monday afternoon" — whose definition of afternoon?), have it ask a clarifying question rather than guessing. The CLI prints the question, reads a one-line answer, and re-queries the model with the additional context. +- **Why this comes later**: the hand-rolled grammar covers 95% of the realistic phrase set with deterministic, fast, dependency-free behaviour. The LLM path is only worth it for the long-tail phrases the grammar can't cover, and only if we're already inside a chat-driven UI (which is the Kiro skill case in TODO-1). Doing both lets the deterministic path stay fast and only burn tokens when the user writes something unusual. +- **Privacy note**: the prompt only ever needs the user-supplied phrase plus the current local time. No calendar contents leave the machine. diff --git a/.kiro/specs/meeting-alerter/tasks.md b/.kiro/specs/meeting-alerter/tasks.md new file mode 100644 index 0000000..6850c7c --- /dev/null +++ b/.kiro/specs/meeting-alerter/tasks.md @@ -0,0 +1,190 @@ +# Implementation Plan: meeting-alerter and natural-language time windows + +## Overview + +Convert the feature design into a series of prompts for a code-generation LLM that will implement each step with incremental progress. Make sure that each prompt builds on the previous prompts, and ends with wiring things together. There should be no hanging or orphaned code that isn't integrated into a previous step. Focus ONLY on tasks that involve writing, modifying, or testing code. + +The work splits cleanly into three groups: + +1. Build `internal/whenparse` (REQ-5) as a self-contained package with `ParseInstant` and `ParseRange`, then wire it into `cmd/meeting-alerter/list` so the existing `--from`/`--to`/`--day` flags accept the new grammar and a new `--when` flag is recognised. +2. Add persisted notification state to `meeting-alerter watch` (REQ-6) so restarts don't re-fire alerts for meetings still in the lookahead window. +3. Add `meeting-alerter install` and `uninstall` subcommands (REQ-4) that generate, load, and remove the launchd plist, mirroring the pattern in `internal/sync/plist.go`. + +REQ-1, REQ-2, REQ-3, REQ-7 are already implemented per the requirements doc; this plan only covers the remaining gaps. + +## Tasks + +- [x] 1. Scaffold `internal/whenparse` and build the input pipeline + - [x] 1.1 Create the package skeleton + - Create `internal/whenparse/whenparse.go` with the package doc, public `ParseInstant(phrase string, now time.Time) (time.Time, error)` and `ParseRange(phrase string, now time.Time) (start, end time.Time, err error)` signatures returning a sentinel "not implemented" error + - Create `internal/whenparse/doc.go` describing the grammar's scope, the `now`-injection contract, and the Sunday-week convention + - _Requirements: REQ-5 (public API contract; case-insensitive; descriptive error)_ + + - [x] 1.2 Implement `normalize` and `tokenize` + - Add `normalize(s string) string` (lower-case, collapse whitespace, replace `thru`→`through`, `&`→`and`) + - Add `tokenize(s string) []token` recognising compound tokens (`YYYY-MM-DD`, `HH:MM`, RFC3339, integer literals, bare words) + - Define an internal `token` struct with `kind` and `value` fields covering: word, number, date, time, datetime, RFC3339, punctuation + - _Requirements: REQ-5 (case-insensitive, whitespace collapse, recognise existing local-time formats)_ + + - [x] 1.3 Write unit tests for `normalize` and `tokenize` + - Cover whitespace collapsing, case folding, synonym substitution, compound-token recognition, mixed punctuation + - _Requirements: REQ-5 (case-insensitive, whitespace collapse)_ + +- [x] 2. Implement instant handlers in `whenparse` + - [x] 2.1 Implement `parseNamedDay` and `parseAbsolute`, plus `ParseInstant` dispatch + - `parseNamedDay`: `now` (current instant), `today`/`tomorrow`/`yesterday` (snap to local midnight) + - `parseAbsolute`: RFC3339, `2006-01-02T15:04:05`, `2006-01-02T15:04`, `2006-01-02 15:04`, `2006-01-02` (local midnight) + - Wire dispatch in `ParseInstant` so unknown phrases return an error including the input string + - _Requirements: REQ-5 (instant-handler contract; existing local-time formats; descriptive error)_ + + - [x] 2.2 Implement `parseRelativeDuration` and `parseNumberLeading` for instants + - `in N {minutes,hours,days,weeks}`: day/week values snap to local midnight on day +N; hour/minute values produce now + duration + - `N {minutes,hours,days,weeks} from now`: same semantics as `in N …` + - `N {minutes,hours,days,weeks} ago`: mirror of `from now`, going backward + - Reject range-only forms (e.g. `next 3 days`) with a clear error explaining they're valid for `ParseRange` only + - _Requirements: REQ-5 (`in N …`, `N … from now`, `N … ago`; "3 days from now" snaps to midnight on day +3)_ + + - [x] 2.3 Implement `parseWeekday` for instants + - Bare weekday names (`monday`…`sunday`) resolve to local midnight on the next occurrence at or after today + - ` at HH:MM` resolves to that wall time on the next occurrence; if today is that weekday but `HH:MM` has already passed, advance one week + - `today at HH:MM` and `tomorrow at HH:MM` resolve to that local wall time + - _Requirements: REQ-5 (weekday names; "weekday at HH:MM"; "today at HH:MM"; "tomorrow at HH:MM")_ + + - [x] 2.4 Write table-driven unit tests for `ParseInstant` + - Pin `now` to a fixed local Wednesday; cover every example in the design's grammar reference plus edge cases (weekday already passed today, time-of-day clamps, RFC3339 round-trip) + - Assert the error path returns a message containing the input phrase + - _Requirements: REQ-5 (`ParseInstant` acceptance criteria; descriptive errors)_ + +- [x] 3. Implement range handlers in `whenparse` + - [x] 3.1 Implement `parseWeekRange` and `parseRestOfWeek` + - `this week`: `[most recent Sunday 00:00, next Sunday 00:00)` (Sun-start) + - `next week`: the following Sunday→Sunday window + - `last week`: the prior Sunday→Sunday window + - `rest of the week` / `rest of this week`: `[now, next Sunday 00:00)` (the half-open interval keeps the last day fully included) + - _Requirements: REQ-5 (`this/next/last week`; `rest of the week`; Sunday-start)_ + + - [x] 3.2 Implement range forms of `parseRelativeDuration` and `parseUntil` + - `next N {minutes,hours,days,weeks}`: `[now, now + N units)` + - `last N {minutes,hours,days,weeks}`: `[now − N units, now)` + - `until `: `[now, end of next occurrence of that weekday)` + - `until `: `[now, end of that local day)` + - `until ` (today/tomorrow): `[now, end of that day)` + - _Requirements: REQ-5 (`next/last N units`; `until `; `until `)_ + + - [x] 3.3 Implement weekday ranges and composite handling, then wire `ParseRange` + - `` alone: full local day window + - ` through `: clamp forward across the same week so end is the start of the day after the second weekday + - Composite ` through ` (and ` to ` when neither side is `from`/`through`/`until`): split, recursively parse each side via `ParseInstant`, return `[left, right)`; error when `right` is not after `left` + - Promote any single-instant phrase that is day-precision (today/tomorrow/yesterday/weekday/YYYY-MM-DD) to a 24-hour local-day window when used in `ParseRange` + - Ensure `ParseRange` dispatches to all the handlers above and falls back to instant-promotion for non-range phrases + - _Requirements: REQ-5 (composite ` through `; ` through `; instant→24-hour-window promotion)_ + + - [x] 3.4 Write table-driven unit tests for `ParseRange` + - Cover every example in the design's grammar reference: `today`/`tomorrow` as ranges, `next 3 days` (72h), `in 3 days`/`3 days from now` (24-hour window on day +3), `this week`/`next week`/`last week`, `rest of the week`, `monday through friday`, `until friday`, `2026-05-25` as a full day, composite `tomorrow through friday`, and the error path for `tomorrow through yesterday` + - Pin `now` to a fixed local Wednesday so weekday math is deterministic + - _Requirements: REQ-5 (`ParseRange` acceptance criteria; "3 days from now" vs "next 3 days" distinction)_ + +- [x] 4. Checkpoint - `internal/whenparse` complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Wire `whenparse` into `meeting-alerter list` + - [x] 5.1 Add `--when` flag and replace `parseDayWindow` / `parseFlexibleTime` + - Extend the flag-extraction loop in `parseListWindowAt` to recognise `--when`/`--when=` + - Reject `--when` combined with `--from`/`--to`/`--day`/`--minutes` + - Route `--when` and `--day` through `whenparse.ParseRange`; route `--from`/`--to` through `whenparse.ParseInstant` + - Keep `--minutes` as the integer-only shortcut (no parser involvement) and the no-flags default of `[now, now + LookaheadMinutes)` + - Delete the now-unused `parseDayWindow` and `parseFlexibleTime` helpers + - Update the inline `listUsage` string and `printUsage` to document the new `--when` flag and the natural-language phrases + - _Requirements: REQ-2 (NL flags); REQ-5 (single grammar across `--from`/`--to`/`--day`/`--when`)_ + + - [x] 5.2 Extend `TestParseListWindow` for natural-language phrases + - Add cases for `--when "today"`, `--when "tomorrow"`, `--when "next 3 days"`, `--when "monday through friday"`, `--when "rest of the week"`, `--from tomorrow --to "three days from now"` + - Add error cases: `--when` combined with another window flag, unparseable phrase + - Confirm existing `--day today` / `--day tomorrow` / `--day YYYY-MM-DD` cases still pass through the new code path unchanged + - _Requirements: REQ-2 (NL flags); REQ-5 (acceptance criteria via the CLI surface)_ + +- [x] 6. Persisted notification state for `watch` + - [x] 6.1 Add a state-file module in `cmd/meeting-alerter` + - Create `cmd/meeting-alerter/notified.go` with a `notifiedState` struct (`Version int`, `Entries []notifiedEntry{ExternalID string; MeetingStartUnix int64}`) + - `loadNotifiedState() (*notifiedState, error)`: read `~/.config/meeting-alerter/notified.json`, return empty state when the file is missing, return empty state with a warning log when the file fails to parse + - `saveNotifiedState(s *notifiedState) error`: marshal with indent, write atomically (sibling `*.tmp` then `os.Rename`), mode `0600` + - `(s *notifiedState) Has(id string) bool`, `(s *notifiedState) Mark(id string, start time.Time)`, `(s *notifiedState) Prune(now time.Time)` (drop entries with `MeetingStartUnix < now − 1h`) + - _Requirements: REQ-6 (state file location, schema, atomic write, prune rule)_ + + - [x] 6.2 Replace the in-memory `notified` map in `runWatch` with the persisted state + - Load state on `watch` startup before the first `check()` call + - In `check()`, replace `notified[ev.ExternalID]` lookups with `state.Has(ev.ExternalID)`, replace assignment with `state.Mark(...)` followed by `saveNotifiedState(state)` (atomic write happens inside save) + - Call `state.Prune(now)` at the top of each sweep so the file does not grow unbounded + - Remove the now-redundant `knownToFuture` garbage-collection helper + - Log a `watch: state loaded` line on startup (entry count) and a `watch: state pruned` line when entries are dropped + - _Requirements: REQ-3 (single notification per meeting per watch lifetime — now persistent); REQ-6 (read on startup, write after each notification, prune each sweep)_ + + - [x] 6.3 Write unit tests for the state module + - Round-trip save/load via a temp directory (override `configPath` or use a constructor that takes an explicit path) + - Atomic-write behaviour: simulate failure between tmp write and rename, assert the existing file is untouched + - Prune: build a state with mixed past and future `MeetingStartUnix`, prune at a fixed `now`, assert only the past-by-more-than-1-hour entries are dropped + - Corrupt-file handling: write invalid JSON, assert load returns an empty state without error + - _Requirements: REQ-6 (atomic write, prune rule, version field)_ + +- [x] 7. Checkpoint - `watch` state persistence complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. `meeting-alerter install` / `uninstall` + - [x] 8.1 Create the plist generator + - Create `cmd/meeting-alerter/plist.go` with a `text/template`-based `generatePlist(bundleBinary, logPath string) []byte` + - Use the launchd label `tel.dead.meeting-alerter` (matches the design) + - Set `RunAtLoad=true`, `KeepAlive=true`; `ProgramArguments` is `[bundleBinary, "watch"]` + - Set `StandardOutPath`/`StandardErrorPath` to `logPath` + - XML-escape every templated value via `html.EscapeString` (mirror `internal/sync/plist.go`) + - Add `plistPath() (string, error)` returning `~/Library/LaunchAgents/tel.dead.meeting-alerter.plist` + - Add `bundleBinaryPath() (string, error)` returning `~/Applications/meeting-alerter.app/Contents/MacOS/meeting-alerter`, falling back to `/Applications/...` like `findBundlePath` does, and erroring with a clear "run `make install-meeting-alerter` first" message when neither exists + - _Requirements: REQ-4 (plist points at the bundle path; KeepAlive/RunAtLoad; label)_ + + - [x] 8.2 Implement install/uninstall helpers (file + launchctl) + - In the same file, add `installPlist(data []byte) error` (atomic write into `~/Library/LaunchAgents`, mode `0644`, dir mode `0755`) + - Add `removePlist() error` (idempotent — missing file is not an error) + - Add `loadJob() error` and `unloadJob() error` invoking `launchctl load|unload `; `unloadJob` returns nil when the plist is missing or the job is "not loaded" (copy the not-loaded-message detection from `internal/sync/plist.go` so the two CLIs stay independent) + - Plumb a `runLaunchctl` package-level var so tests can stub it + - _Requirements: REQ-4 (idempotent install; clean unload)_ + + - [x] 8.3 Add `runInstall` / `runUninstall` subcommands and dispatch + - `runInstall(args)`: trigger `runSetup` when no config exists; resolve the bundle binary path; render the plist; `unloadJob()` then `installPlist(data)` then `loadJob()` so re-running install is idempotent; print the resulting plist path and the log path on success + - `runUninstall(args)`: prompt the user with a `[y/N]` confirmation; on yes, call `unloadJob()`, `removePlist()`, then `os.RemoveAll(~/.config/meeting-alerter/)`; do **not** touch the bundle, the binary, or the log file + - Add `case "install":` and `case "uninstall":` to the `main` switch and document both in `printUsage` + - _Requirements: REQ-4 (install triggers setup when needed; uninstall confirms then removes plist and config; preserves bundle, binary, calendar)_ + + - [x] 8.4 Write unit tests for the plist generator and launchctl plumbing + - Snapshot test for `generatePlist`: assert label, `ProgramArguments` array contents, `RunAtLoad`/`KeepAlive` values, log path placement, XML-escaping of an input containing `&` and `"` + - Stub `runLaunchctl` and assert `loadJob`/`unloadJob` invoke the right arguments and that `unloadJob` swallows the "not loaded" error variants + - Round-trip `installPlist` + `removePlist` against a temp `HOME` directory; assert the file ends at mode `0644` and that re-running `removePlist` is a no-op + - _Requirements: REQ-4 (idempotent install; clean unload)_ + +- [x] 9. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + - Run `go vet ./...` and `go test ./...` from the repo root and confirm both are clean. + +## Notes + +- All tasks in this plan are required, including the test tasks. Tests ship alongside the production code they cover and must pass before each checkpoint. +- Each task references the requirement it satisfies for traceability back to `requirements.md`. +- The design has no "Correctness Properties" section, so this plan uses table-driven unit tests rather than property-based tests. The deterministic-`now` injection in `whenparse` makes the table tests fully reproducible without any randomness layer. +- `internal/whenparse` lands first (tasks 1–4) because both the `list` wiring and any future flag in this repo depend on it. Persistent state (task 6) and `install`/`uninstall` (task 8) are independent of the parser and of each other; they are sequenced separately so each can ship as a standalone change if needed. +- Out of scope per the requirements doc: localising the parser, the LLM `--ai` mode (TODO-2), and the Kiro-skill wrapper (TODO-1). These are deliberately not in the dependency graph. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "6.1", "8.1"] }, + { "id": 1, "tasks": ["1.2", "8.2"] }, + { "id": 2, "tasks": ["1.3", "2.1", "8.3"] }, + { "id": 3, "tasks": ["2.2", "2.3", "8.4"] }, + { "id": 4, "tasks": ["2.4", "3.1", "3.2"] }, + { "id": 5, "tasks": ["3.3"] }, + { "id": 6, "tasks": ["3.4", "5.1"] }, + { "id": 7, "tasks": ["5.2", "6.2"] }, + { "id": 8, "tasks": ["6.3"] } + ] +} +``` diff --git a/cmd/meeting-alerter/doc.go b/cmd/meeting-alerter/doc.go new file mode 100644 index 0000000..2c575a4 --- /dev/null +++ b/cmd/meeting-alerter/doc.go @@ -0,0 +1,15 @@ +//go:build darwin + +// Command meeting-alerter watches your work Exchange/O365 calendar via +// EventKit and posts a macOS notification a configurable number of +// minutes before each upcoming meeting. +// +// It reuses pkg/eventkit (the same EventKit bridge work-cal-sync uses) +// and is meant as a worked example of building your own EventKit-aware +// CLI. The structure mirrors work-cal-sync: a single Go binary +// distributed as a macOS .app bundle so TCC will grant Calendar access, +// configured via an interactive wizard, scheduled by launchd or run +// manually as a daemon. +// +// This package is macOS-only because EventKit is. +package main diff --git a/cmd/meeting-alerter/main.go b/cmd/meeting-alerter/main.go new file mode 100644 index 0000000..1807119 --- /dev/null +++ b/cmd/meeting-alerter/main.go @@ -0,0 +1,1100 @@ +//go:build darwin + +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "os/exec" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/djdead/work-cal-sync/internal/whenparse" + "github.com/djdead/work-cal-sync/pkg/eventkit" +) + +// main dispatches subcommands. The pattern intentionally mirrors +// cmd/work-cal-sync: a tiny switch over os.Args[1]. +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(2) + } + switch os.Args[1] { + case "setup": + runSetup(os.Args[2:]) + case "watch": + runWatch(os.Args[2:]) + case "list": + runList(os.Args[2:]) + case "install": + runInstall(os.Args[2:]) + case "uninstall": + runUninstall(os.Args[2:]) + case "request-access": + runRequestAccess(os.Args[2:]) + case "list-calendars-internal": + runListCalendarsInternal(os.Args[2:]) + case "fetch-events-internal": + runFetchEventsInternal(os.Args[2:]) + case "wait-for-change-internal": + runWaitForChangeInternal(os.Args[2:]) + case "-h", "--help", "help": + printUsage() + default: + fmt.Fprintf(os.Stderr, "meeting-alerter: unknown subcommand %q\n\n", os.Args[1]) + printUsage() + os.Exit(2) + } +} + +func printUsage() { + const text = `meeting-alerter — macOS notifications a few minutes before each work meeting. + +Usage: + meeting-alerter [subcommand] + +Subcommands: + setup interactive configuration wizard + watch run the alerter loop (typically launched by launchd or manually) + list one-shot: print meetings in a window (defaults to the configured horizon) + install schedule the watcher via launchd (runs setup if needed) + uninstall confirm, then remove the launchd job and config dir + help, -h, --help print this help text + +Common flags: + -v, --verbose enable per-event debug logging + -q, --quiet suppress per-event logging + +list-only flags: + --minutes N window of N minutes from now + --when PHRASE natural-language window (e.g. "today", + "next 3 days", "monday through friday", + "rest of the week") + --day PHRASE natural-language day or date (e.g. today, + tomorrow, monday, 2026-05-25) + --from PHRASE window start (natural-language or RFC3339) + --to PHRASE window end (natural-language or RFC3339) + +Bundled into a macOS .app at ~/Applications/meeting-alerter.app so EventKit +permission is attributed correctly. Without the bundle the watcher cannot +read your calendar. See the README for the build / install commands. +` + fmt.Print(text) +} + +// ── Config ──────────────────────────────────────────────────────────── + +// Config persists the user's choices from setup so subsequent runs can +// start without prompts. Stored as JSON at ~/.config/meeting-alerter/ +// config.json with mode 0600 (no sensitive data, but matches the +// work-cal-sync convention). +type Config struct { + WorkCalendarID string `json:"work_calendar_id"` + WorkCalendarName string `json:"work_calendar_name"` + LookaheadMinutes int `json:"lookahead_minutes"` + NotifyBeforeMins int `json:"notify_before_minutes"` +} + +func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "meeting-alerter", "config.json"), nil +} + +func loadConfig() (*Config, error) { + p, err := configPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config %s: %w", p, err) + } + return &cfg, nil +} + +func saveConfig(cfg *Config) error { + p, err := configPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0o600) +} + +// ── Logging ─────────────────────────────────────────────────────────── + +func extractVerbosity(args []string) (slog.Level, []string) { + level := slog.LevelInfo + out := args[:0] + for _, a := range args { + switch a { + case "-v", "--verbose": + level = slog.LevelDebug + case "-q", "--quiet": + level = slog.LevelInfo + default: + out = append(out, a) + } + } + return level, out +} + +func newLogger(level slog.Level) *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) +} + +// ── Subcommands ─────────────────────────────────────────────────────── + +// runSetup is the interactive wizard. It uses the bundle to enumerate +// calendars (so TCC honors Calendar access) and writes the config file. +func runSetup(args []string) { + level, _ := extractVerbosity(args) + log := newLogger(level) + + existing, err := loadConfig() + if err != nil { + log.Error("setup: load existing config failed", "err", err) + os.Exit(1) + } + + cals, err := listCalendarsViaBundle() + if err != nil { + log.Error("setup: list calendars failed", "err", err) + os.Exit(1) + } + if len(cals) == 0 { + log.Error("setup: no Exchange/O365 calendars found", + "hint", "add your work account in System Settings → Internet Accounts") + os.Exit(1) + } + + fmt.Println("\nWhich calendar should I watch?") + defaultIdx := -1 + for i, c := range cals { + marker := " " + if existing != nil && c.Identifier == existing.WorkCalendarID { + marker = "*" + defaultIdx = i + } + fmt.Printf(" %s %d) %s (%s)\n", marker, i+1, c.Title, c.SourceTitle) + } + pickIdx := promptInt("Enter number", defaultIdx+1, 1, len(cals)) + pick := cals[pickIdx-1] + + defaultLookahead := 60 + defaultNotify := 5 + if existing != nil { + if existing.LookaheadMinutes > 0 { + defaultLookahead = existing.LookaheadMinutes + } + if existing.NotifyBeforeMins > 0 { + defaultNotify = existing.NotifyBeforeMins + } + } + lookahead := promptInt("How many minutes ahead should I scan", defaultLookahead, 5, 24*60) + notifyBefore := promptInt("How many minutes before each meeting to notify", defaultNotify, 0, lookahead) + + cfg := &Config{ + WorkCalendarID: pick.Identifier, + WorkCalendarName: pick.Title, + LookaheadMinutes: lookahead, + NotifyBeforeMins: notifyBefore, + } + if err := saveConfig(cfg); err != nil { + log.Error("setup: save config failed", "err", err) + os.Exit(1) + } + + p, _ := configPath() + fmt.Printf("\n✓ Config saved to %s\n", p) + fmt.Println("Run `meeting-alerter watch` to start the alerter, or `meeting-alerter list` to preview upcoming meetings.") +} + +// runList prints upcoming meetings within the configured horizon and +// exits. Useful for verifying the wizard picked the right calendar +// without committing to a long-running watcher. +// +// Flags: +// +// --from PHRASE window start (natural-language or RFC3339) +// --to PHRASE window end (natural-language or RFC3339) +// --day PHRASE shorthand for a single-day window +// --when PHRASE full natural-language window +// --minutes window of N minutes from now +// +// Defaults to "now ... now+config.LookaheadMinutes" when no flags are +// passed. --when is mutually exclusive with every other window flag. +// --minutes cannot combine with --from/--to/--day. --from and --to +// must be supplied together. The natural-language grammar is the same +// for --when, --day, --from, and --to (see internal/whenparse for the +// full reference). +func runList(args []string) { + level, args := extractVerbosity(args) + log := newLogger(level) + + cfg := mustLoadConfig(log) + + start, end, err := parseListWindow(args, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "list: %v\n\n", err) + fmt.Fprintln(os.Stderr, listUsage) + os.Exit(2) + } + + events, err := fetchEventsViaBundle(cfg.WorkCalendarID, start, end) + if err != nil { + log.Error("list: fetch failed", "err", err) + os.Exit(1) + } + sort.Slice(events, func(i, j int) bool { return events[i].Start.Before(events[j].Start) }) + + if len(events) == 0 { + fmt.Printf("No meetings between %s and %s.\n", + start.Format("2006-01-02 15:04"), + end.Format("2006-01-02 15:04")) + return + } + fmt.Printf("Meetings between %s and %s:\n", + start.Format("2006-01-02 15:04"), + end.Format("2006-01-02 15:04")) + + // Group by local date so multi-day windows are easy to scan. + var lastDate string + for _, ev := range events { + date := ev.Start.Local().Format("Mon 2006-01-02") + if date != lastDate { + fmt.Printf("\n%s\n", date) + lastDate = date + } + timestr := ev.Start.Local().Format("15:04") + if ev.AllDay { + timestr = "all-day" + } + fmt.Printf(" %-7s — %s (%s)\n", + timestr, + displayTitle(ev.Title), + displayLocation(ev.Location)) + } +} + +const listUsage = `Usage: + meeting-alerter list [flags] + +Flags: + --minutes N window of N minutes from now + --when PHRASE natural-language window (e.g. "today", + "next 3 days", "monday through friday", + "rest of the week", "this week") + --day PHRASE natural-language day or date (e.g. today, + tomorrow, monday, 2026-05-25) + --from PHRASE window start (natural-language or RFC3339) + --to PHRASE window end (natural-language or RFC3339) + -v, --verbose debug logging + -q, --quiet suppress per-event logging` + +// parseListWindow turns the list-subcommand args into a [start,end) +// window. Returns the configured default when no flags are present. +func parseListWindow(args []string, cfg *Config) (time.Time, time.Time, error) { + return parseListWindowAt(args, cfg, time.Now()) +} + +// parseListWindowAt is parseListWindow with an explicit "now". Pulled +// out so tests can pin time without monkey-patching the clock. +func parseListWindowAt(args []string, cfg *Config, now time.Time) (time.Time, time.Time, error) { + // Default: now ... now + cfg.LookaheadMinutes. + defaultStart := now + defaultEnd := now.Add(time.Duration(cfg.LookaheadMinutes) * time.Minute) + + var ( + fromStr, toStr, dayStr, whenStr string + minutes int + hasMinutes, hasWhen bool + ) + + // Pull off recognized flags. Anything left over is an error so + // users notice typos rather than silently getting the default. + i := 0 + for i < len(args) { + a := args[i] + switch { + case a == "--from" && i+1 < len(args): + fromStr = args[i+1] + i += 2 + case strings.HasPrefix(a, "--from="): + fromStr = strings.TrimPrefix(a, "--from=") + i++ + case a == "--to" && i+1 < len(args): + toStr = args[i+1] + i += 2 + case strings.HasPrefix(a, "--to="): + toStr = strings.TrimPrefix(a, "--to=") + i++ + case a == "--day" && i+1 < len(args): + dayStr = args[i+1] + i += 2 + case strings.HasPrefix(a, "--day="): + dayStr = strings.TrimPrefix(a, "--day=") + i++ + case a == "--when" && i+1 < len(args): + whenStr = args[i+1] + hasWhen = true + i += 2 + case strings.HasPrefix(a, "--when="): + whenStr = strings.TrimPrefix(a, "--when=") + hasWhen = true + i++ + case a == "--minutes" && i+1 < len(args): + n, err := strconv.Atoi(args[i+1]) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("--minutes must be an integer, got %q", args[i+1]) + } + minutes = n + hasMinutes = true + i += 2 + case strings.HasPrefix(a, "--minutes="): + n, err := strconv.Atoi(strings.TrimPrefix(a, "--minutes=")) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("--minutes must be an integer, got %q", a) + } + minutes = n + hasMinutes = true + i++ + default: + return time.Time{}, time.Time{}, fmt.Errorf("unknown flag %q", a) + } + } + + // --when specifies a complete range; nothing else can compose + // with it without the user being explicit. + if hasWhen { + if fromStr != "" || toStr != "" || dayStr != "" || hasMinutes { + return time.Time{}, time.Time{}, errors.New("--when cannot be combined with --from/--to/--day/--minutes") + } + return whenparse.ParseRange(whenStr, now) + } + + // --minutes wins over --from/--to/--day when present; nothing + // else can compose with it without the user being explicit. + if hasMinutes { + if minutes <= 0 { + return time.Time{}, time.Time{}, fmt.Errorf("--minutes must be positive, got %d", minutes) + } + if fromStr != "" || toStr != "" || dayStr != "" { + return time.Time{}, time.Time{}, errors.New("--minutes cannot be combined with --from/--to/--day") + } + return now, now.Add(time.Duration(minutes) * time.Minute), nil + } + + if dayStr != "" { + if fromStr != "" || toStr != "" { + return time.Time{}, time.Time{}, errors.New("--day cannot be combined with --from or --to") + } + return whenparse.ParseRange(dayStr, now) + } + + if fromStr == "" && toStr == "" { + return defaultStart, defaultEnd, nil + } + if fromStr == "" || toStr == "" { + return time.Time{}, time.Time{}, errors.New("--from and --to must be supplied together") + } + start, err := whenparse.ParseInstant(fromStr, now) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("--from: %w", err) + } + end, err := whenparse.ParseInstant(toStr, now) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("--to: %w", err) + } + if !end.After(start) { + return time.Time{}, time.Time{}, errors.New("--to must be after --from") + } + return start, end, nil +} + +// runWatch is the alerter loop. It reacts to EventKit's +// store-changed notification (no polling) and additionally re-checks +// every minute so meetings that simply "arrive in the window" trigger +// notifications even when nothing on the calendar has changed. +// +// State (which events have already been notified for) is persisted to +// ~/.config/meeting-alerter/notified.json (REQ-6). The watcher reads +// it on startup so a launchd-driven restart inside the lookahead +// window doesn't re-fire alerts the user already saw, writes after +// every notification, and prunes stale entries each sweep so the file +// can't grow without bound. +func runWatch(args []string) { + level, _ := extractVerbosity(args) + log := newLogger(level) + cfg := mustLoadConfig(log) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + notify := time.Duration(cfg.NotifyBeforeMins) * time.Minute + horizon := time.Duration(cfg.LookaheadMinutes) * time.Minute + + // Load persisted notification state up front so the first check() + // can see what we've already notified for. A read failure here is + // fatal: it usually means $HOME is misconfigured, and silently + // starting fresh would re-notify every meeting in the window. + // (Missing-file and parse-error cases are handled inside + // loadNotifiedState and return an empty state, not an error.) + state, err := loadNotifiedState() + if err != nil { + log.Error("watch: load notified state failed", "err", err) + os.Exit(1) + } + log.Info("watch: state loaded", "entries", len(state.Entries)) + + tick := time.NewTicker(60 * time.Second) + defer tick.Stop() + + // changeCh is signalled whenever EventKit reports a change. Use + // the bundle-routed waiter (see runWaitForChangeInternal) so + // permission is granted via Launch Services. + changeCh := make(chan struct{}, 1) + go func() { + for ctx.Err() == nil { + if err := waitForChangeViaBundle(30 * time.Second); err != nil { + log.Debug("watch: wait-for-change errored", "err", err) + time.Sleep(5 * time.Second) + continue + } + select { + case changeCh <- struct{}{}: + default: + } + } + }() + + check := func() { + now := time.Now() + // Drop entries whose meeting is well past so the file can't + // grow unbounded over a long-running watcher. Done first so + // the rest of the sweep (and the post-notification save) + // works against a trimmed state. + if dropped := state.Prune(now); dropped > 0 { + log.Info("watch: state pruned", "dropped", dropped) + } + end := now.Add(horizon) + events, err := fetchEventsViaBundle(cfg.WorkCalendarID, now, end) + if err != nil { + log.Error("watch: fetch failed", "err", err) + return + } + log.Debug("watch: scanned", "events", len(events), "now", now) + for _, ev := range events { + if state.Has(ev.ExternalID) { + continue + } + until := time.Until(ev.Start) + if until <= notify && until > -1*time.Minute { + postNotification(ev, until, log) + state.Mark(ev.ExternalID, ev.Start) + // A failed save means the next restart may + // re-notify this meeting — annoying but not + // dangerous, so log and keep going rather than + // bailing on the whole sweep. + if err := saveNotifiedState(state); err != nil { + log.Warn("watch: save notified state failed", + "err", err, "work_id", ev.ExternalID) + } + } + } + } + + check() + log.Info("watch: started", + "calendar", cfg.WorkCalendarName, + "lookahead_minutes", cfg.LookaheadMinutes, + "notify_before_minutes", cfg.NotifyBeforeMins) + + for { + select { + case <-ctx.Done(): + log.Info("watch: shutting down") + return + case <-tick.C: + check() + case <-changeCh: + log.Debug("watch: EventKit reported change; re-scanning") + check() + } + } +} + +// postNotification displays a macOS Notification Center banner for the +// given event using osascript. We use the system's built-in +// AppleScript bridge rather than pulling in a notification library +// because it has no dependencies and works on every macOS version this +// tool supports. +func postNotification(ev eventkit.Event, until time.Duration, log *slog.Logger) { + title := fmt.Sprintf("Meeting in %d min", int(until.Round(time.Minute).Minutes())) + body := displayTitle(ev.Title) + if loc := strings.TrimSpace(ev.Location); loc != "" { + body = body + " — " + loc + } + // AppleScript string escaping: backslash and double quote. + esc := func(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s + } + script := fmt.Sprintf(`display notification "%s" with title "%s"`, esc(body), esc(title)) + cmd := exec.Command("osascript", "-e", script) + if err := cmd.Run(); err != nil { + log.Error("watch: notification failed", "err", err, "title", title, "body", body) + return + } + log.Info("watch: notified", + "work_id", ev.ExternalID, + "title", body, + "start", ev.Start, + "in_minutes", int(until.Round(time.Minute).Minutes())) +} + +// ── install / uninstall ─────────────────────────────────────────────── + +// alerterLogPath is where launchd's stdout/stderr for the watcher are +// captured. Matches the design's "{{.LogPath}} is +// ~/Library/Logs/meeting-alerter.log". Centralised so install, +// uninstall, and any future status command resolve it the same way. +func alerterLogPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, "Library", "Logs", "meeting-alerter.log"), nil +} + +// runInstall registers (or re-registers) the launchd watcher job. +// +// The flow is: +// +// 1. If no config exists, run the setup wizard first so the scheduled +// watcher has something to do once it starts (REQ-4: "install +// triggers setup when needed"). +// 2. Resolve the bundle binary path. The plist points at the bundle +// so the launchd-spawned process inherits the .app's TCC identity +// for Calendar access. +// 3. Render the plist for that binary path and the standard log +// path. +// 4. Unload any existing job, install the new plist, then load the +// job. Unload-before-load makes the operation idempotent — +// re-running install to update the bundle path is a supported +// workflow (REQ-4: "Idempotent"). +// 5. Print the resulting plist path and log path so the user knows +// where to look. +// +// On any fatal error the cause is logged and the process exits 1. +func runInstall(args []string) { + level, _ := extractVerbosity(args) + log := newLogger(level) + + // If no config exists yet, run setup first. Surfacing the + // existing-config error here (rather than swallowing it) keeps + // install honest about why setup is or isn't being invoked. + cfg, err := loadConfig() + if err != nil { + log.Error("install: load config failed", "err", err) + os.Exit(1) + } + if cfg == nil { + fmt.Println("No config found. Running setup first...") + runSetup(nil) + } + + binaryPath, err := bundleBinaryPath() + if err != nil { + log.Error("install: resolve bundle binary path failed", "err", err) + os.Exit(1) + } + + logPath, err := alerterLogPath() + if err != nil { + log.Error("install: resolve log path failed", "err", err) + os.Exit(1) + } + + data := generatePlist(binaryPath, logPath) + + // Unload any existing job before reinstalling. unloadJob is + // idempotent (no-op if the plist or job is missing), so this is + // safe on a first install too. + if err := unloadJob(); err != nil { + log.Error("install: unload existing job failed", "err", err) + os.Exit(1) + } + if err := installPlist(data); err != nil { + log.Error("install: write plist failed", "err", err) + os.Exit(1) + } + if err := loadJob(); err != nil { + log.Error("install: load job failed", "err", err) + os.Exit(1) + } + + p, err := plistPath() + if err != nil { + // Both installPlist and loadJob already resolved this path + // without error, so reaching here is unlikely; fall back to a + // generic message rather than panicking. + log.Error("install: resolve plist path failed", "err", err) + os.Exit(1) + } + + fmt.Println("Installed.") + fmt.Printf(" plist: %s\n", p) + fmt.Printf(" log: %s\n", logPath) + fmt.Println("The watcher will start now and respawn if it ever exits.") +} + +// runUninstall reverses what install did. It confirms with the user, +// unloads the job, removes the plist, and deletes the config dir. +// +// Per REQ-4 it deliberately does NOT touch: +// +// - the .app bundle in ~/Applications (the user installed it via +// `make install-meeting-alerter`; uninstall is not the right tool +// to remove it) +// - the meeting-alerter binary on $PATH (we have no idea where the +// user put it — go install, Homebrew, or manual copy) +// - the watcher's log file at ~/Library/Logs/meeting-alerter.log +// (users may want to keep it for debugging) +// - any events on the calendar itself +// +// Confirmation defaults to "no" — uninstall is destructive enough that +// an accidental Enter key should not wipe the config. A non-TTY stdin +// (piped input, scripted run) is also treated as "no" unless the user +// explicitly types `y`/`yes`. +// +// Failures during the unload/remove steps are reported but do not +// abort the uninstall: the user expects "remove everything" to do its +// best even if individual pieces are missing. +func runUninstall(args []string) { + level, _ := extractVerbosity(args) + log := newLogger(level) + + // Resolve the paths up front so the confirmation message tells + // the user exactly what will go away. + p, err := plistPath() + if err != nil { + log.Error("uninstall: resolve plist path failed", "err", err) + os.Exit(1) + } + cfgPath, err := configPath() + if err != nil { + log.Error("uninstall: resolve config path failed", "err", err) + os.Exit(1) + } + configDir := filepath.Dir(cfgPath) + + fmt.Println("This will remove the following:") + fmt.Printf(" launchd plist: %s\n", p) + fmt.Printf(" config dir: %s\n", configDir) + fmt.Println("It will NOT remove:") + fmt.Println(" - the meeting-alerter.app bundle") + fmt.Println(" - the meeting-alerter binary on your PATH") + fmt.Println(" - the watcher log at ~/Library/Logs/meeting-alerter.log") + fmt.Println(" - any events on your calendar") + fmt.Println() + + if !confirmUninstall(os.Stdin, os.Stdout) { + fmt.Println("Aborted.") + return + } + + if err := unloadJob(); err != nil { + log.Warn("uninstall: unload launchd job failed; continuing", "err", err) + } else { + fmt.Println("Unloaded launchd job.") + } + + if err := removePlist(); err != nil { + log.Warn("uninstall: remove plist failed; continuing", "err", err) + } else { + fmt.Printf("Removed plist: %s\n", p) + } + + // Removing the directory rather than just config.json sweeps up + // notified.json (REQ-6 state) and any future siblings without a + // per-file dance. We deliberately use os.RemoveAll so a missing + // directory is a no-op. + if err := os.RemoveAll(configDir); err != nil { + log.Warn("uninstall: remove config dir failed; continuing", + "path", configDir, "err", err) + } else { + fmt.Printf("Removed config dir: %s\n", configDir) + } +} + +// confirmUninstall reads a [y/N] confirmation from in. Returns true +// only when the user explicitly types y/yes (case-insensitive). +// +// When stdin is not a terminal (piped or redirected input) the answer +// still has to be an explicit y/yes — there's no implicit "yes" from a +// non-interactive run. EOF and any other answer are treated as "no", +// matching the [y/N] convention. +func confirmUninstall(in io.Reader, out io.Writer) bool { + prompt := "Continue? [y/N]: " + if _, err := fmt.Fprint(out, prompt); err != nil { + return false + } + + // bufio.NewReader handles both interactive and piped input + // uniformly; uninstall is simple enough not to need a richer + // Prompter (see internal/sync/prompt.go for that pattern). + reader := bufio.NewReader(in) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + // EOF on a non-interactive stdin → treat as "no", same as if + // the user had hit Enter at the prompt. + return false + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true + default: + return false + } +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +func mustLoadConfig(log *slog.Logger) *Config { + cfg, err := loadConfig() + if err != nil { + log.Error("load config failed", "err", err) + os.Exit(1) + } + if cfg == nil { + fmt.Fprintln(os.Stderr, "meeting-alerter: no config found. Run `meeting-alerter setup` first.") + os.Exit(1) + } + return cfg +} + +func displayTitle(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "(no title)" + } + return s +} + +func displayLocation(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "no location" + } + if len(s) > 60 { + return s[:57] + "…" + } + return s +} + +// promptInt asks for an integer in [min,max] with a default. Falls back +// to the default on empty input or non-numeric. Three retries on invalid +// input, then panics — interactive wizards aren't meant to be scripted. +func promptInt(prompt string, def, minVal, maxVal int) int { + for attempt := 0; attempt < 3; attempt++ { + fmt.Printf("%s [default %d]: ", prompt, def) + var line string + _, _ = fmt.Scanln(&line) + line = strings.TrimSpace(line) + if line == "" { + return def + } + n, err := strconv.Atoi(line) + if err != nil || n < minVal || n > maxVal { + fmt.Printf(" Please enter a number between %d and %d.\n", minVal, maxVal) + continue + } + return n + } + fmt.Fprintln(os.Stderr, "too many invalid attempts") + os.Exit(2) + return 0 +} + +// ── Bundle-routed EventKit helpers ──────────────────────────────────── +// +// macOS attributes Calendar access by Launch-Services launch, not by +// binary path. So when the foreground CLI needs an EventKit call we +// fork a child via `open meeting-alerter.app --args +// list-calendars-internal /tmp/result.json` and read the JSON result +// back. This is identical to the pattern in cmd/work-cal-sync. + +func findBundlePath() string { + home, err := os.UserHomeDir() + if err == nil { + p := filepath.Join(home, "Applications", "meeting-alerter.app") + if _, err := os.Stat(p); err == nil { + return p + } + } + p := filepath.Join("/Applications", "meeting-alerter.app") + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +func runViaBundle(args []string, stdoutPath, stderrPath string) error { + bundle := findBundlePath() + if bundle == "" { + return errors.New( + "meeting-alerter.app not found in ~/Applications or /Applications. " + + "Run `make install-meeting-alerter` first.") + } + openArgs := []string{ + "-W", "-n", "-a", bundle, + "--stdout", stdoutPath, + "--stderr", stderrPath, + "--args", + } + openArgs = append(openArgs, args...) + cmd := exec.Command("open", openArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// runViaBundleWithResult runs the bundle and treats success as either: +// - `open` exiting 0, OR +// - `open` exiting non-zero but the bundle having written to +// resultPath (a non-empty file). +// +// The dual check works around `open -W`'s race: when the bundle +// finishes before `open` can install its kqueue listener, recent macOS +// versions print "Unable to block on applications" and return 1, even +// though the bundle exited cleanly. Treating a populated resultPath as +// proof of success is more robust than parsing `open`'s message. +func runViaBundleWithResult(args []string, resultPath, stdoutPath, stderrPath string) error { + runErr := runViaBundle(args, stdoutPath, stderrPath) + if runErr == nil { + return nil + } + if info, statErr := os.Stat(resultPath); statErr == nil && info.Size() > 0 { + // Bundle succeeded; `open` just lost the race to attach. + return nil + } + return runErr +} + +func listCalendarsViaBundle() ([]eventkit.Calendar, error) { + resultPath, stdoutPath, stderrPath, cleanup := mkTempPaths() + defer cleanup() + + if err := runViaBundleWithResult( + []string{"list-calendars-internal", resultPath}, + resultPath, stdoutPath, stderrPath, + ); err != nil { + errBytes, _ := os.ReadFile(stderrPath) + return nil, fmt.Errorf("bundle: %w (stderr: %s)", err, strings.TrimSpace(string(errBytes))) + } + data, err := os.ReadFile(resultPath) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, nil + } + var out []eventkit.Calendar + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +func fetchEventsViaBundle(calendarID string, start, end time.Time) ([]eventkit.Event, error) { + resultPath, stdoutPath, stderrPath, cleanup := mkTempPaths() + defer cleanup() + + args := []string{ + "fetch-events-internal", resultPath, + calendarID, + strconv.FormatInt(start.Unix(), 10), + strconv.FormatInt(end.Unix(), 10), + } + if err := runViaBundleWithResult(args, resultPath, stdoutPath, stderrPath); err != nil { + errBytes, _ := os.ReadFile(stderrPath) + return nil, fmt.Errorf("bundle: %w (stderr: %s)", err, strings.TrimSpace(string(errBytes))) + } + data, err := os.ReadFile(resultPath) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, nil + } + var out []eventkit.Event + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +// waitForChangeViaBundle delegates to the bundle's +// wait-for-change-internal subcommand, which blocks inside the bundle +// until EventKit reports a change. It returns when the bundle exits +// (either due to a change or a timeout). +func waitForChangeViaBundle(timeout time.Duration) error { + resultPath, stdoutPath, stderrPath, cleanup := mkTempPaths() + defer cleanup() + + args := []string{ + "wait-for-change-internal", + resultPath, + strconv.FormatFloat(timeout.Seconds(), 'f', 3, 64), + } + if err := runViaBundleWithResult(args, resultPath, stdoutPath, stderrPath); err != nil { + errBytes, _ := os.ReadFile(stderrPath) + return fmt.Errorf("bundle: %w (stderr: %s)", err, strings.TrimSpace(string(errBytes))) + } + return nil +} + +func mkTempPaths() (resultPath, stdoutPath, stderrPath string, cleanup func()) { + r, _ := os.CreateTemp("", "ma-result-*.json") + resultPath = r.Name() + _ = r.Close() + o, _ := os.CreateTemp("", "ma-stdout-*") + stdoutPath = o.Name() + _ = o.Close() + e, _ := os.CreateTemp("", "ma-stderr-*") + stderrPath = e.Name() + _ = e.Close() + cleanup = func() { + os.Remove(resultPath) + os.Remove(stdoutPath) + os.Remove(stderrPath) + } + return +} + +// ── Internal subcommands (run inside the bundle) ────────────────────── + +// runRequestAccess pops the macOS Calendar permission dialog. Only used +// once after the bundle is first installed. +func runRequestAccess(args []string) { + level, _ := extractVerbosity(args) + log := newLogger(level) + log.Info("request-access starting", "auth_status_before", eventkit.AuthorizationStatus()) + granted, err := eventkit.RequestAccess(5 * time.Minute) + if err != nil { + log.Error("request-access errored", "err", err) + os.Exit(1) + } + log.Info("request-access finished", "granted", granted, "auth_status_after", eventkit.AuthorizationStatus()) + if !granted { + os.Exit(2) + } +} + +func runListCalendarsInternal(args []string) { + level, args := extractVerbosity(args) + log := newLogger(level) + if len(args) < 1 { + log.Error("list-calendars-internal: result path required") + os.Exit(2) + } + resultPath := args[0] + if granted, err := eventkit.RequestAccess(60 * time.Second); err != nil || !granted { + log.Error("list-calendars-internal: access not granted", "err", err) + os.Exit(2) + } + cals, err := eventkit.ListExchangeCalendars() + if err != nil { + log.Error("list-calendars-internal: list failed", "err", err) + os.Exit(1) + } + data, _ := json.Marshal(cals) + if err := os.WriteFile(resultPath, data, 0o600); err != nil { + log.Error("list-calendars-internal: write failed", "err", err) + os.Exit(1) + } +} + +func runFetchEventsInternal(args []string) { + level, args := extractVerbosity(args) + log := newLogger(level) + if len(args) < 4 { + log.Error("fetch-events-internal: requires resultPath calID startUnix endUnix") + os.Exit(2) + } + resultPath := args[0] + calID := args[1] + startUnix, _ := strconv.ParseInt(args[2], 10, 64) + endUnix, _ := strconv.ParseInt(args[3], 10, 64) + if granted, err := eventkit.RequestAccess(60 * time.Second); err != nil || !granted { + log.Error("fetch-events-internal: access not granted", "err", err) + os.Exit(2) + } + events, err := eventkit.FetchEvents(calID, time.Unix(startUnix, 0), time.Unix(endUnix, 0)) + if err != nil { + log.Error("fetch-events-internal: fetch failed", "err", err) + os.Exit(1) + } + data, _ := json.Marshal(events) + if err := os.WriteFile(resultPath, data, 0o600); err != nil { + log.Error("fetch-events-internal: write failed", "err", err) + os.Exit(1) + } +} + +// runWaitForChangeInternal blocks until EventKit reports a change or +// the timeout elapses, then exits. The watcher loop in the parent CLI +// invokes this through the bundle so the change observer runs with the +// bundle's TCC identity. +func runWaitForChangeInternal(args []string) { + level, args := extractVerbosity(args) + log := newLogger(level) + if len(args) < 2 { + log.Error("wait-for-change-internal: requires resultPath timeoutSeconds") + os.Exit(2) + } + resultPath := args[0] + timeoutSecs, _ := strconv.ParseFloat(args[1], 64) + if granted, err := eventkit.RequestAccess(60 * time.Second); err != nil || !granted { + log.Error("wait-for-change-internal: access not granted", "err", err) + os.Exit(2) + } + last := eventkit.ChangeCount() + next := eventkit.WaitForChange(last, time.Duration(timeoutSecs*float64(time.Second))) + // We don't need to convey *what* changed back across the process + // boundary — the parent simply re-fetches when this returns. Touch + // the result file so callers can distinguish a clean exit from a + // crash. + _ = os.WriteFile(resultPath, []byte(strconv.FormatUint(next, 10)), 0o600) +} diff --git a/cmd/meeting-alerter/main_test.go b/cmd/meeting-alerter/main_test.go new file mode 100644 index 0000000..245b3ad --- /dev/null +++ b/cmd/meeting-alerter/main_test.go @@ -0,0 +1,182 @@ +//go:build darwin + +package main + +import ( + "strings" + "testing" + "time" +) + +// TestParseListWindow exercises the flag-parsing surface for `list`. +// The fixed `now` is a Wednesday so weekday math is deterministic; +// most cases spot-check that end.After(start), while the +// natural-language cases pin exact bounds because the whole point of +// task 5.2 is verifying the whenparse wiring honours the +// instant-vs-range and 72h-vs-midnight distinctions documented in +// REQ-5. +func TestParseListWindow(t *testing.T) { + cfg := &Config{LookaheadMinutes: 60} + now := time.Date(2026, 5, 20, 9, 0, 0, 0, time.Local) // Wednesday + + // Pre-compute the calendar anchors used by the natural-language + // cases. Keeping these as named values makes the table readable + // and means an off-by-one in the parser shows up as a single + // targeted assertion failure rather than a confusing arithmetic + // expression. + midToday := time.Date(2026, 5, 20, 0, 0, 0, 0, time.Local) + midTomorrow := midToday.AddDate(0, 0, 1) + midDayAfter := midToday.AddDate(0, 0, 2) + midDayPlus3 := midToday.AddDate(0, 0, 3) + nextMonday := midToday.AddDate(0, 0, 5) // 2026-05-25 + saturdayAfter := nextMonday.AddDate(0, 0, 5) // 2026-05-30 + nextSunday := midToday.AddDate(0, 0, 4) // 2026-05-24 + day25 := time.Date(2026, 5, 25, 0, 0, 0, 0, time.Local) + day26 := day25.AddDate(0, 0, 1) + + type want struct { + start, end time.Time + errSubstr string + } + + cases := []struct { + name string + args []string + want want + }{ + { + name: "default is now plus configured lookahead", + args: nil, + want: want{}, // bounds checked specially below + }, + { + name: "minutes overrides default", + args: []string{"--minutes", "120"}, + }, + { + name: "day=today starts at now ends at midnight tomorrow", + args: []string{"--day", "today"}, + want: want{start: midToday, end: midTomorrow}, + }, + { + name: "day=tomorrow is full local day", + args: []string{"--day", "tomorrow"}, + want: want{start: midTomorrow, end: midDayAfter}, + }, + { + name: "day=YYYY-MM-DD is full local day", + args: []string{"--day", "2026-05-25"}, + want: want{start: day25, end: day26}, + }, + { + name: "from+to with seconds", + args: []string{"--from", "2026-05-20T13:00", "--to", "2026-05-20T17:00"}, + }, + { + name: "from+to equals form", + args: []string{"--from=2026-05-20T13:00", "--to=2026-05-20T17:00"}, + }, + { + name: "when=today is full local day", + args: []string{"--when", "today"}, + want: want{start: midToday, end: midTomorrow}, + }, + { + name: "when=tomorrow is full local day", + args: []string{"--when", "tomorrow"}, + want: want{start: midTomorrow, end: midDayAfter}, + }, + { + name: "when=next 3 days is a 72h rolling window", + args: []string{"--when", "next 3 days"}, + want: want{start: now, end: now.Add(72 * time.Hour)}, + }, + { + name: "when=monday through friday spans next Mon..Sat", + args: []string{"--when", "monday through friday"}, + want: want{start: nextMonday, end: saturdayAfter}, + }, + { + name: "when=rest of the week ends at next Sunday midnight", + args: []string{"--when", "rest of the week"}, + want: want{start: now, end: nextSunday}, + }, + { + name: "from tomorrow to N days from now snaps to midnight", + args: []string{"--from", "tomorrow", "--to", "3 days from now"}, + want: want{start: midTomorrow, end: midDayPlus3}, + }, + { + name: "minutes with day errors", + args: []string{"--minutes", "30", "--day", "today"}, + want: want{errSubstr: "cannot be combined"}, + }, + { + name: "day with from errors", + args: []string{"--day", "today", "--from", "2026-05-20T13:00"}, + want: want{errSubstr: "cannot be combined"}, + }, + { + name: "from without to errors", + args: []string{"--from", "2026-05-20T13:00"}, + want: want{errSubstr: "must be supplied together"}, + }, + { + name: "to before from errors", + args: []string{"--from", "2026-05-20T17:00", "--to", "2026-05-20T13:00"}, + want: want{errSubstr: "must be after"}, + }, + { + name: "unknown flag errors", + args: []string{"--bogus"}, + want: want{errSubstr: "unknown flag"}, + }, + { + name: "when combined with day errors", + args: []string{"--when", "today", "--day", "tomorrow"}, + want: want{errSubstr: "cannot be combined"}, + }, + { + name: "when combined with minutes errors", + args: []string{"--when", "today", "--minutes", "30"}, + want: want{errSubstr: "cannot be combined"}, + }, + { + name: "when with unparseable phrase errors", + args: []string{"--when", "definitelynotaphrase"}, + want: want{errSubstr: "definitelynotaphrase"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + start, end, err := parseListWindowAt(tc.args, cfg, now) + if tc.want.errSubstr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.want.errSubstr) + } + if !strings.Contains(err.Error(), tc.want.errSubstr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.want.errSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !end.After(start) { + t.Errorf("end must be after start: start=%v end=%v", start, end) + } + // When the case pins exact bounds, assert them. Cases + // that leave wantStart/wantEnd zero (default lookahead, + // --minutes, --from/--to literals already covered by the + // whenparse unit tests) only get the ordering check + // above, matching the original test's spot-check style. + if !tc.want.start.IsZero() && !start.Equal(tc.want.start) { + t.Errorf("start = %v, want %v", start, tc.want.start) + } + if !tc.want.end.IsZero() && !end.Equal(tc.want.end) { + t.Errorf("end = %v, want %v", end, tc.want.end) + } + }) + } +} diff --git a/cmd/meeting-alerter/notified.go b/cmd/meeting-alerter/notified.go new file mode 100644 index 0000000..71b1307 --- /dev/null +++ b/cmd/meeting-alerter/notified.go @@ -0,0 +1,211 @@ +//go:build darwin + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "time" +) + +// Persisted notification state for `watch`. +// +// The file lives at ~/.config/meeting-alerter/notified.json and tracks +// which meetings the watcher has already alerted on, keyed by the +// EventKit external id. Restarting the daemon with the file in place +// stops the watcher from re-firing alerts for meetings that are still +// inside the lookahead window. See REQ-6 in the spec for the contract. +// +// The schema is intentionally versioned so we can extend it later +// (snooze state, suppress-after timestamps, etc.) without breaking +// existing files. The current writer always emits Version 1. + +const notifiedStateVersion = 1 + +// pruneCutoff is how far back into the past an entry is kept before +// Prune drops it. One hour matches the design's "don't grow unbounded" +// requirement while still tolerating a meeting that ran long. +const pruneCutoff = time.Hour + +// notifiedStatePathOverride is set by tests to redirect reads and +// writes away from the user's real config directory. Production code +// leaves it empty so resolveNotifiedStatePath falls back to the +// per-user location under $HOME. +var notifiedStatePathOverride string + +// notifiedState is the on-disk shape of notified.json. Entries is a +// list rather than a map so the JSON is stable in diff-friendly order +// and tests can compare snapshots without sorting. +type notifiedState struct { + Version int `json:"version"` + Entries []notifiedEntry `json:"entries"` +} + +// notifiedEntry records that we have already posted a notification for +// the meeting identified by ExternalID, whose start instant is +// MeetingStartUnix (seconds since the epoch). Storing the start lets +// Prune drop entries whose meeting is well in the past without needing +// to consult EventKit again. +type notifiedEntry struct { + ExternalID string `json:"external_id"` + MeetingStartUnix int64 `json:"meeting_start_unix"` +} + +// resolveNotifiedStatePath returns the path notified.json should be +// read from and written to. Tests override the package-level variable +// to redirect the location; production reads $HOME. +func resolveNotifiedStatePath() (string, error) { + if notifiedStatePathOverride != "" { + return notifiedStatePathOverride, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".config", "meeting-alerter", "notified.json"), nil +} + +// loadNotifiedState reads the persisted notification state. A missing +// file is not an error — the watcher simply starts with an empty +// state. A malformed file is also not fatal: we log a warning and +// start fresh, so the user's only loss is "every still-upcoming +// meeting alerts again". This is consistent with the design's "worst +// case on corruption: ignore the file and start fresh" guidance. +func loadNotifiedState() (*notifiedState, error) { + p, err := resolveNotifiedStatePath() + if err != nil { + return nil, err + } + empty := ¬ifiedState{Version: notifiedStateVersion} + + data, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return empty, nil + } + return nil, fmt.Errorf("read notified state %s: %w", p, err) + } + var s notifiedState + if err := json.Unmarshal(data, &s); err != nil { + slog.Warn("notified state: parse failed; starting fresh", + "path", p, "err", err) + return empty, nil + } + if s.Version == 0 { + s.Version = notifiedStateVersion + } + return &s, nil +} + +// saveNotifiedState writes the state atomically: marshal indented, +// write to a sibling .tmp file, fsync-by-rename. The destination +// permissions are 0600 because the file lives under the user's config +// dir and only the owning user should read it. The directory is +// created with 0700 so Save works on a fresh machine. +func saveNotifiedState(s *notifiedState) error { + if s == nil { + return errors.New("save notified state: state is nil") + } + p, err := resolveNotifiedStatePath() + if err != nil { + return err + } + dir := filepath.Dir(p) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create notified state dir %s: %w", dir, err) + } + + if s.Version == 0 { + s.Version = notifiedStateVersion + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("encode notified state: %w", err) + } + + tmp, err := os.CreateTemp(dir, "notified-*.json.tmp") + if err != nil { + return fmt.Errorf("create temp notified state: %w", err) + } + tmpPath := tmp.Name() + // Best-effort cleanup if anything below fails before the rename. + // After a successful rename the remove is a harmless no-op. + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp notified state %s: %w", tmpPath, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp notified state %s: %w", tmpPath, err) + } + if err := os.Chmod(tmpPath, 0o600); err != nil { + return fmt.Errorf("chmod temp notified state %s: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, p); err != nil { + return fmt.Errorf("rename temp notified state to %s: %w", p, err) + } + return nil +} + +// Has reports whether the given external id has already been +// notified. +func (s *notifiedState) Has(id string) bool { + if s == nil { + return false + } + for _, e := range s.Entries { + if e.ExternalID == id { + return true + } + } + return false +} + +// Mark records that id has been notified, with the meeting starting +// at start. If id is already in the state, the start time is updated +// rather than duplicated — this matters when a meeting moves and gets +// re-notified inside the same watch lifetime. +func (s *notifiedState) Mark(id string, start time.Time) { + if s == nil { + return + } + startUnix := start.Unix() + for i := range s.Entries { + if s.Entries[i].ExternalID == id { + s.Entries[i].MeetingStartUnix = startUnix + return + } + } + s.Entries = append(s.Entries, notifiedEntry{ + ExternalID: id, + MeetingStartUnix: startUnix, + }) +} + +// Prune drops entries whose meeting started more than pruneCutoff (one +// hour) before now. Returns the number of entries that were removed +// so callers can log when the file shrinks. Prune is a no-op when the +// state is nil or empty. +func (s *notifiedState) Prune(now time.Time) int { + if s == nil || len(s.Entries) == 0 { + return 0 + } + threshold := now.Add(-pruneCutoff).Unix() + kept := s.Entries[:0] + dropped := 0 + for _, e := range s.Entries { + if e.MeetingStartUnix < threshold { + dropped++ + continue + } + kept = append(kept, e) + } + s.Entries = kept + return dropped +} diff --git a/cmd/meeting-alerter/notified_test.go b/cmd/meeting-alerter/notified_test.go new file mode 100644 index 0000000..1dff20c --- /dev/null +++ b/cmd/meeting-alerter/notified_test.go @@ -0,0 +1,363 @@ +//go:build darwin + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// withNotifiedPath redirects notified.json reads and writes into a +// per-test temp directory. The returned path is where the production +// code will read from and write to for the duration of the test. The +// override is restored on cleanup so tests stay independent. +func withNotifiedPath(t *testing.T) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "notified.json") + prev := notifiedStatePathOverride + notifiedStatePathOverride = p + t.Cleanup(func() { notifiedStatePathOverride = prev }) + return p +} + +// --- round-trip --- + +func TestSaveLoad_RoundTrip(t *testing.T) { + withNotifiedPath(t) + + want := ¬ifiedState{ + Version: notifiedStateVersion, + Entries: []notifiedEntry{ + {ExternalID: "abc", MeetingStartUnix: 1779555600}, + {ExternalID: "def", MeetingStartUnix: 1779559200}, + {ExternalID: "ghi", MeetingStartUnix: 1779562800}, + }, + } + + if err := saveNotifiedState(want); err != nil { + t.Fatalf("saveNotifiedState: %v", err) + } + + got, err := loadNotifiedState() + if err != nil { + t.Fatalf("loadNotifiedState: %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("round-trip mismatch:\n got %#v\nwant %#v", got, want) + } + if got.Version != notifiedStateVersion { + t.Errorf("Version after round-trip = %d, want %d", got.Version, notifiedStateVersion) + } +} + +func TestSaveNotifiedState_FilePermIsOwnerOnly(t *testing.T) { + p := withNotifiedPath(t) + + s := ¬ifiedState{ + Version: notifiedStateVersion, + Entries: []notifiedEntry{{ExternalID: "x", MeetingStartUnix: 1}}, + } + if err := saveNotifiedState(s); err != nil { + t.Fatalf("saveNotifiedState: %v", err) + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("notified.json mode = %v, want 0o600", got) + } +} + +func TestSaveNotifiedState_NoTempFilesLeftOnSuccess(t *testing.T) { + p := withNotifiedPath(t) + dir := filepath.Dir(p) + + s := ¬ifiedState{ + Version: notifiedStateVersion, + Entries: []notifiedEntry{{ExternalID: "x", MeetingStartUnix: 1}}, + } + if err := saveNotifiedState(s); err != nil { + t.Fatalf("saveNotifiedState: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("readdir: %v", err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Errorf("leftover temp file after successful save: %s", e.Name()) + } + } +} + +// --- atomic write --- + +// TestSaveNotifiedState_AtomicWrite_RenameFailure simulates a failure +// between the tmp-file write and the final rename: we put a directory +// at the destination path so os.Rename(file, dir) returns EISDIR. The +// contract is that the existing on-disk state at that path is +// untouched and no .tmp files are left behind. +func TestSaveNotifiedState_AtomicWrite_RenameFailure(t *testing.T) { + p := withNotifiedPath(t) + dir := filepath.Dir(p) + + // Place a directory exactly where notified.json would live. The + // CreateTemp + Write + Close + Chmod steps succeed (the parent + // dir is writable), but Rename onto an existing directory fails. + if err := os.Mkdir(p, 0o700); err != nil { + t.Fatalf("seed dir at destination path: %v", err) + } + // Drop a sentinel file inside so we can confirm the directory is + // not silently replaced. + sentinel := filepath.Join(p, "sentinel") + if err := os.WriteFile(sentinel, []byte("UNTOUCHED"), 0o600); err != nil { + t.Fatalf("write sentinel: %v", err) + } + + next := ¬ifiedState{ + Version: notifiedStateVersion, + Entries: []notifiedEntry{{ExternalID: "WOULD_REPLACE", MeetingStartUnix: 99999}}, + } + if err := saveNotifiedState(next); err == nil { + t.Fatal("saveNotifiedState should fail when destination is a directory") + } + + // The existing thing at p must still be a directory with our + // sentinel intact. The atomic-write contract was honoured. + info, err := os.Stat(p) + if err != nil { + t.Fatalf("stat destination after failed save: %v", err) + } + if !info.IsDir() { + t.Errorf("destination at %s is no longer a directory after failed save", p) + } + got, err := os.ReadFile(sentinel) + if err != nil { + t.Fatalf("read sentinel after failed save: %v", err) + } + if string(got) != "UNTOUCHED" { + t.Errorf("sentinel content = %q, want UNTOUCHED (directory was modified)", got) + } + + // And no .tmp files leaked into the parent directory: the + // deferred cleanup in saveNotifiedState should have removed them. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("readdir parent: %v", err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Errorf("leftover temp file after failed save: %s", e.Name()) + } + } +} + +// --- prune --- + +func TestNotifiedState_Prune_DropsOnlyEntriesOlderThanOneHour(t *testing.T) { + now := time.Date(2026, 5, 25, 12, 0, 0, 0, time.UTC) + + s := ¬ifiedState{ + Version: notifiedStateVersion, + Entries: []notifiedEntry{ + {ExternalID: "two-hours-ago", MeetingStartUnix: now.Add(-2 * time.Hour).Unix()}, + {ExternalID: "exactly-one-hour-ago", MeetingStartUnix: now.Add(-time.Hour).Unix()}, + {ExternalID: "thirty-minutes-ago", MeetingStartUnix: now.Add(-30 * time.Minute).Unix()}, + {ExternalID: "five-minutes-ago", MeetingStartUnix: now.Add(-5 * time.Minute).Unix()}, + {ExternalID: "now", MeetingStartUnix: now.Unix()}, + {ExternalID: "in-one-hour", MeetingStartUnix: now.Add(time.Hour).Unix()}, + }, + } + + dropped := s.Prune(now) + if dropped != 1 { + t.Errorf("Prune returned %d, want 1", dropped) + } + + // "two-hours-ago" should be the only one dropped. The 1h-old + // entry sits exactly at the cutoff and the implementation uses a + // strict `<` comparison, so it must be kept. + wantIDs := []string{ + "exactly-one-hour-ago", + "thirty-minutes-ago", + "five-minutes-ago", + "now", + "in-one-hour", + } + gotIDs := make([]string, 0, len(s.Entries)) + for _, e := range s.Entries { + gotIDs = append(gotIDs, e.ExternalID) + } + if !reflect.DeepEqual(gotIDs, wantIDs) { + t.Errorf("kept entries = %v, want %v", gotIDs, wantIDs) + } +} + +func TestNotifiedState_Prune_NilOrEmptyIsNoOp(t *testing.T) { + if got := (*notifiedState)(nil).Prune(time.Now()); got != 0 { + t.Errorf("Prune on nil = %d, want 0", got) + } + + s := ¬ifiedState{Version: notifiedStateVersion} + if got := s.Prune(time.Now()); got != 0 { + t.Errorf("Prune on empty = %d, want 0", got) + } + if s.Entries != nil && len(s.Entries) != 0 { + t.Errorf("empty Entries should remain empty, got %v", s.Entries) + } +} + +// --- corrupt-file handling --- + +func TestLoadNotifiedState_CorruptFileReturnsEmptyStateNoError(t *testing.T) { + p := withNotifiedPath(t) + + if err := os.WriteFile(p, []byte("this is not { valid json"), 0o600); err != nil { + t.Fatalf("write corrupt file: %v", err) + } + + got, err := loadNotifiedState() + if err != nil { + t.Errorf("loadNotifiedState should swallow parse errors, got: %v", err) + } + if got == nil { + t.Fatal("loadNotifiedState returned nil state") + } + if got.Version != notifiedStateVersion { + t.Errorf("Version = %d, want %d (current writer version)", got.Version, notifiedStateVersion) + } + if len(got.Entries) != 0 { + t.Errorf("Entries = %v, want empty", got.Entries) + } +} + +func TestLoadNotifiedState_MissingFileReturnsEmptyStateNoError(t *testing.T) { + withNotifiedPath(t) // path does not exist on disk yet + + got, err := loadNotifiedState() + if err != nil { + t.Errorf("loadNotifiedState on missing file: %v", err) + } + if got == nil { + t.Fatal("loadNotifiedState returned nil state") + } + if got.Version != notifiedStateVersion { + t.Errorf("Version = %d, want %d", got.Version, notifiedStateVersion) + } + if len(got.Entries) != 0 { + t.Errorf("Entries = %v, want empty", got.Entries) + } +} + +// LoadNotifiedState defaults Version to notifiedStateVersion when the +// on-disk file omits the field (or stores a zero). This protects +// readers that skipped past v1 from confusing a missing field with a +// brand-new state. +func TestLoadNotifiedState_DefaultsVersionWhenZero(t *testing.T) { + p := withNotifiedPath(t) + + // Hand-craft a JSON file with no version field. + raw, _ := json.Marshal(map[string]any{ + "entries": []notifiedEntry{{ExternalID: "x", MeetingStartUnix: 42}}, + }) + if err := os.WriteFile(p, raw, 0o600); err != nil { + t.Fatalf("write versionless file: %v", err) + } + + got, err := loadNotifiedState() + if err != nil { + t.Fatalf("loadNotifiedState: %v", err) + } + if got.Version != notifiedStateVersion { + t.Errorf("Version = %d, want %d (defaulted)", got.Version, notifiedStateVersion) + } + if len(got.Entries) != 1 || got.Entries[0].ExternalID != "x" { + t.Errorf("Entries = %v, want one entry id=x", got.Entries) + } +} + +// --- Has / Mark --- + +func TestNotifiedState_HasAndMark(t *testing.T) { + s := ¬ifiedState{Version: notifiedStateVersion} + + if s.Has("alpha") { + t.Errorf("fresh state should not contain alpha") + } + + start := time.Date(2026, 5, 25, 9, 30, 0, 0, time.UTC) + s.Mark("alpha", start) + + if !s.Has("alpha") { + t.Errorf("Has(alpha) after Mark = false, want true") + } + if len(s.Entries) != 1 || s.Entries[0].MeetingStartUnix != start.Unix() { + t.Errorf("first Mark produced unexpected entries: %+v", s.Entries) + } + + // Re-marking the same id with a new start time updates in place + // rather than appending a duplicate. + newStart := start.Add(15 * time.Minute) + s.Mark("alpha", newStart) + if len(s.Entries) != 1 { + t.Errorf("re-Mark added a duplicate, len=%d", len(s.Entries)) + } + if s.Entries[0].MeetingStartUnix != newStart.Unix() { + t.Errorf("MeetingStartUnix after re-Mark = %d, want %d", + s.Entries[0].MeetingStartUnix, newStart.Unix()) + } + + // Marking a different id appends. + s.Mark("beta", start) + if len(s.Entries) != 2 { + t.Errorf("Mark(beta) should append, len=%d", len(s.Entries)) + } +} + +func TestNotifiedState_HasOnNilStateIsFalse(t *testing.T) { + if (*notifiedState)(nil).Has("x") { + t.Errorf("Has on nil state should be false") + } +} + +// --- save/load via the public API preserves entries through rename --- + +// Sanity check: after a save followed by a load, the JSON on disk +// contains the version field and one entry per Mark call. This guards +// against silent encoder regressions. +func TestSaveNotifiedState_OnDiskJSONShape(t *testing.T) { + p := withNotifiedPath(t) + + s := ¬ifiedState{} + s.Mark("evt-1", time.Unix(1700000000, 0)) + s.Mark("evt-2", time.Unix(1700003600, 0)) + + if err := saveNotifiedState(s); err != nil { + t.Fatalf("saveNotifiedState: %v", err) + } + + raw, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read file: %v", err) + } + var decoded notifiedState + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode written file: %v", err) + } + if decoded.Version != notifiedStateVersion { + t.Errorf("on-disk version = %d, want %d", decoded.Version, notifiedStateVersion) + } + if len(decoded.Entries) != 2 { + t.Errorf("on-disk entries = %d, want 2", len(decoded.Entries)) + } +} diff --git a/cmd/meeting-alerter/plist.go b/cmd/meeting-alerter/plist.go new file mode 100644 index 0000000..f088e77 --- /dev/null +++ b/cmd/meeting-alerter/plist.go @@ -0,0 +1,289 @@ +//go:build darwin + +package main + +import ( + "bytes" + "errors" + "fmt" + "html" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" +) + +// plistLabel is the launchd label for the meeting-alerter watcher +// daemon. Distinct from the work-cal-sync labels so the two tools can +// coexist on the same machine and be managed independently. +const plistLabel = "tel.dead.meeting-alerter" + +// plistTemplate renders the launchd plist for the watcher. Unlike +// work-cal-sync (which runs every 15 minutes via StartInterval), the +// alerter is a long-running daemon: RunAtLoad=true plus KeepAlive=true +// means launchd starts it as soon as the agent is loaded and respawns +// it if it ever exits. ProgramArguments points at the bundle's +// embedded binary so EventKit attributes Calendar access to the +// signed .app, not to the bare CLI on $PATH. +// +// Every templated value passes through `html.EscapeString` so values +// like `Tom & Jerry.app` don't break the XML — same defensive choice as +// internal/sync/plist.go. +const plistTemplate = ` + + + + Label + {{.Label | xml}} + + ProgramArguments + + {{.BundleBinary | xml}} + watch + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + {{.LogPath | xml}} + + StandardErrorPath + {{.LogPath | xml}} + + +` + +// compiledPlistTemplate is parsed once at init so generatePlist is a +// pure formatter at call time and any template error surfaces during +// development rather than at runtime. +var compiledPlistTemplate = template.Must( + template.New("plist").Funcs(template.FuncMap{ + "xml": html.EscapeString, + }).Parse(plistTemplate), +) + +// generatePlist renders the launchd plist that runs `meeting-alerter +// watch` from the bundle binary at bundleBinary, redirecting stdout +// and stderr to logPath. XML metacharacters in either argument are +// escaped, so callers can pass paths containing `&`, `<`, etc. +// +// The template and its data shape are fixed at compile time, so +// rendering can't fail at runtime; we panic in the impossible case +// that it does because that signals a programming error. +func generatePlist(bundleBinary, logPath string) []byte { + data := struct { + Label string + BundleBinary string + LogPath string + }{ + Label: plistLabel, + BundleBinary: bundleBinary, + LogPath: logPath, + } + + var buf bytes.Buffer + if err := compiledPlistTemplate.Execute(&buf, data); err != nil { + panic("meeting-alerter: render plist template: " + err.Error()) + } + return buf.Bytes() +} + +// plistPath returns the absolute path where the launchd plist lives: +// ~/Library/LaunchAgents/tel.dead.meeting-alerter.plist. Surfaced as +// its own function so install/uninstall (and tests) share one source +// of truth. +func plistPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, "Library", "LaunchAgents", plistLabel+".plist"), nil +} + +// bundleBinaryPath returns the absolute path to the meeting-alerter +// binary inside the installed .app bundle. It mirrors findBundlePath's +// search order — `~/Applications/meeting-alerter.app` first, then +// `/Applications/meeting-alerter.app` — and fails with a clear, +// actionable message when neither exists. +// +// We deliberately keep the search lookup independent from +// findBundlePath: the install/uninstall flow needs the binary path +// (Contents/MacOS/...), while the bundle-routed EventKit helpers need +// the .app directory. Sharing the search logic via a small private +// helper would be possible but adds no real benefit. +func bundleBinaryPath() (string, error) { + const binaryRel = "Contents/MacOS/meeting-alerter" + + if home, err := os.UserHomeDir(); err == nil { + bundle := filepath.Join(home, "Applications", "meeting-alerter.app") + if _, err := os.Stat(bundle); err == nil { + return filepath.Join(bundle, binaryRel), nil + } + } + + bundle := filepath.Join("/Applications", "meeting-alerter.app") + if _, err := os.Stat(bundle); err == nil { + return filepath.Join(bundle, binaryRel), nil + } + + return "", fmt.Errorf( + "meeting-alerter.app not found in ~/Applications or /Applications. " + + "Run `make install-meeting-alerter` first.") +} + +// plistDirPerm is the file mode for the LaunchAgents directory. +// launchd needs to read it, so 0o755 is the conventional choice. +const plistDirPerm os.FileMode = 0o755 + +// plistFilePerm is the file mode for the installed plist. launchd +// needs to read it; the file contains no secrets so 0o644 is +// appropriate. +const plistFilePerm os.FileMode = 0o644 + +// installPlist writes data (typically the output of generatePlist) to +// plistPath() with appropriate permissions. The write is atomic: data +// goes to a sibling temp file in the same directory which is renamed +// over the destination, so launchd never sees a partial plist. +// +// installPlist creates ~/Library/LaunchAgents if it doesn't already +// exist and overwrites any existing plist at the destination — +// re-running install to update the bundle path is the expected use +// case. +func installPlist(data []byte) error { + p, err := plistPath() + if err != nil { + return err + } + dir := filepath.Dir(p) + + if err := os.MkdirAll(dir, plistDirPerm); err != nil { + return fmt.Errorf("create LaunchAgents dir %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, "plist-*.tmp") + if err != nil { + return fmt.Errorf("create temp plist in %s: %w", dir, err) + } + tmpPath := tmp.Name() + // Best-effort cleanup: if anything below fails before the rename, + // the temp file is removed. After a successful rename, the temp + // file no longer exists and Remove returns ErrNotExist (ignored). + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp plist %s: %w", tmpPath, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp plist %s: %w", tmpPath, err) + } + if err := os.Chmod(tmpPath, plistFilePerm); err != nil { + return fmt.Errorf("chmod temp plist %s: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, p); err != nil { + return fmt.Errorf("rename temp plist to %s: %w", p, err) + } + return nil +} + +// removePlist deletes the installed plist file. It is idempotent: a +// missing file returns nil. Removing the file is independent of +// whether the job is currently loaded; callers that want a clean +// uninstall should call unloadJob first. +func removePlist() error { + p, err := plistPath() + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove plist %s: %w", p, err) + } + return nil +} + +// runLaunchctl is the hook used by loadJob and unloadJob to invoke +// launchctl(1). Tests replace this with a stub so they can run without +// touching the user's launchd state. The default implementation shells +// out to launchctl and captures combined stdout+stderr output so +// callers can include it in error messages and the not-loaded-message +// detection. +// +// We deliberately keep this independent of internal/sync's +// runLaunchctl: the two CLIs should be able to ship and be tested in +// isolation, even though the helper looks the same. Copying the +// not-loaded-message detection below has the same motivation. +var runLaunchctl = func(args ...string) ([]byte, error) { + cmd := exec.Command("launchctl", args...) + return cmd.CombinedOutput() +} + +// loadJob runs `launchctl load ` to register the launchd job. +// It is not idempotent on its own (launchctl errors if the job is +// already loaded); callers that want re-runnable installs should call +// unloadJob first. +func loadJob() error { + p, err := plistPath() + if err != nil { + return err + } + out, runErr := runLaunchctl("load", p) + if runErr != nil { + return fmt.Errorf("launchctl load %s: %w (output=%q)", + p, runErr, strings.TrimSpace(string(out))) + } + return nil +} + +// unloadJob runs `launchctl unload `. It is idempotent: if the +// plist file is missing or the job is not currently loaded, unloadJob +// returns nil. Other failures are surfaced with the launchctl output +// for context. +func unloadJob() error { + p, err := plistPath() + if err != nil { + return err + } + // If the plist doesn't exist, there's nothing for launchctl to + // unload — and launchctl would error with "No such file or + // directory". Short-circuit to keep the happy path quiet. + if _, err := os.Stat(p); errors.Is(err, fs.ErrNotExist) { + return nil + } + + out, runErr := runLaunchctl("unload", p) + if runErr == nil { + return nil + } + if isNotLoadedMessage(string(out)) { + return nil + } + return fmt.Errorf("launchctl unload %s: %w (output=%q)", + p, runErr, strings.TrimSpace(string(out))) +} + +// isNotLoadedMessage reports whether a launchctl error message +// indicates the job simply wasn't loaded — the case unloadJob +// swallows. launchctl's wording has varied between macOS versions, so +// we match a handful of common phrases case-insensitively. Copied +// verbatim from internal/sync/plist.go so the two CLIs evolve +// independently. +func isNotLoadedMessage(s string) bool { + s = strings.ToLower(s) + switch { + case strings.Contains(s, "could not find specified service"): + return true + case strings.Contains(s, "no such process"): + return true + case strings.Contains(s, "not loaded"): + return true + case strings.Contains(s, "service is not loaded"): + return true + } + return false +} diff --git a/cmd/meeting-alerter/plist_test.go b/cmd/meeting-alerter/plist_test.go new file mode 100644 index 0000000..d9edec8 --- /dev/null +++ b/cmd/meeting-alerter/plist_test.go @@ -0,0 +1,391 @@ +//go:build darwin + +package main + +import ( + "encoding/xml" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// plistDoc / plistDict mirror the subset of the launchd plist we want +// to assert on. The plist is a flat alternation of / +// value elements, so we capture each kind of value separately and +// check positions by index against the keys list. +type plistDoc struct { + XMLName xml.Name `xml:"plist"` + Dict plistDict `xml:"dict"` +} + +type plistDict struct { + Inner string `xml:",innerxml"` +} + +// withHome temporarily swaps $HOME so plistPath() and +// bundleBinaryPath() resolve into a temp directory rather than the +// user's real home. +func withHome(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", dir) +} + +// withLaunchctl swaps in a fake runLaunchctl for the duration of a +// single test and records the calls it received. +func withLaunchctl(t *testing.T, fn func(args ...string) ([]byte, error)) *[][]string { + t.Helper() + calls := &[][]string{} + prev := runLaunchctl + runLaunchctl = func(args ...string) ([]byte, error) { + dup := append([]string(nil), args...) + *calls = append(*calls, dup) + return fn(args...) + } + t.Cleanup(func() { runLaunchctl = prev }) + return calls +} + +// --- generatePlist snapshot --- + +func TestGeneratePlist_IsValidXML(t *testing.T) { + out := generatePlist("/Users/me/Applications/meeting-alerter.app/Contents/MacOS/meeting-alerter", + "/Users/me/Library/Logs/meeting-alerter.log") + + var doc plistDoc + if err := xml.Unmarshal(out, &doc); err != nil { + t.Fatalf("generatePlist output is not valid XML: %v\n---\n%s", err, out) + } + if doc.Dict.Inner == "" { + t.Fatal("plist has no contents") + } +} + +func TestGeneratePlist_ContainsExpectedKeysAndValues(t *testing.T) { + bundle := "/Users/me/Applications/meeting-alerter.app/Contents/MacOS/meeting-alerter" + logPath := "/Users/me/Library/Logs/meeting-alerter.log" + out := string(generatePlist(bundle, logPath)) + + // Label uses the package-level constant. + wants := []string{ + "Label", + "" + plistLabel + "", + "ProgramArguments", + "" + bundle + "", + "watch", + "RunAtLoad", + "KeepAlive", + "StandardOutPath", + "" + logPath + "", + "StandardErrorPath", + } + for _, w := range wants { + if !strings.Contains(out, w) { + t.Errorf("generatePlist output missing %q\n---\n%s", w, out) + } + } +} + +func TestGeneratePlist_ProgramArgumentsHasBundleBinaryThenWatch(t *testing.T) { + bundle := "/opt/meeting-alerter.app/Contents/MacOS/meeting-alerter" + out := string(generatePlist(bundle, "/tmp/ma.log")) + + idxBundle := strings.Index(out, ""+bundle+"") + idxWatch := strings.Index(out, "watch") + if idxBundle < 0 || idxWatch < 0 { + t.Fatalf("ProgramArguments entries not found:\n%s", out) + } + if idxBundle > idxWatch { + t.Errorf("expected bundle binary before 'watch' in ProgramArguments") + } +} + +func TestGeneratePlist_RunAtLoadAndKeepAliveAreTrue(t *testing.T) { + out := string(generatePlist("/bin/ma", "/var/log/ma.log")) + + // Both keys must be followed by . Look for the key, then + // the next non-whitespace tag should be . + for _, key := range []string{"RunAtLoad", "KeepAlive"} { + needle := "" + key + "" + idx := strings.Index(out, needle) + if idx < 0 { + t.Errorf("missing key %s", key) + continue + } + after := strings.TrimLeft(out[idx+len(needle):], " \t\r\n") + if !strings.HasPrefix(after, "") { + t.Errorf("%s should be , got prefix %q", key, firstLine(after)) + } + } +} + +func TestGeneratePlist_StdoutAndStderrPointAtSameLog(t *testing.T) { + logPath := "/Users/me/Library/Logs/meeting-alerter.log" + out := string(generatePlist("/bin/ma", logPath)) + + for _, key := range []string{"StandardOutPath", "StandardErrorPath"} { + needle := "" + key + "" + idx := strings.Index(out, needle) + if idx < 0 { + t.Errorf("missing key %s", key) + continue + } + after := strings.TrimLeft(out[idx+len(needle):], " \t\r\n") + want := "" + logPath + "" + if !strings.HasPrefix(after, want) { + t.Errorf("%s value = %q, want %q", key, firstLine(after), want) + } + } +} + +func TestGeneratePlist_EscapesXMLMetacharacters(t *testing.T) { + // Use a path containing & and " — both must be XML-escaped so the + // resulting plist parses cleanly. + bundle := `/bin/Tom & "Jerry"/meeting-alerter` + out := string(generatePlist(bundle, "/tmp/ma.log")) + + // Raw, unescaped values must NOT appear. + if strings.Contains(out, "Tom & \"Jerry\"") { + t.Errorf("ampersand/quotes in bundle path were not escaped:\n%s", out) + } + + // Escaped forms MUST appear. html.EscapeString uses & and + // " for ampersand and double-quote respectively. + if !strings.Contains(out, "Tom & ") { + t.Errorf("expected & in escaped output:\n%s", out) + } + if !strings.Contains(out, ""Jerry"") { + t.Errorf("expected "Jerry" in escaped output:\n%s", out) + } + + // Escaped output must still be valid XML. + var doc plistDoc + if err := xml.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("escaped plist is not valid XML: %v", err) + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +// --- plistPath / installPlist / removePlist round-trip --- + +func TestPlistPath_UsesHomeAndLabel(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + + got, err := plistPath() + if err != nil { + t.Fatalf("plistPath: %v", err) + } + want := filepath.Join(dir, "Library", "LaunchAgents", plistLabel+".plist") + if got != want { + t.Errorf("plistPath = %q, want %q", got, want) + } +} + +func TestInstallRemove_RoundTrip(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + + data := generatePlist("/bin/ma", "/tmp/ma.log") + if err := installPlist(data); err != nil { + t.Fatalf("installPlist: %v", err) + } + + p, err := plistPath() + if err != nil { + t.Fatalf("plistPath: %v", err) + } + + got, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read installed plist: %v", err) + } + if string(got) != string(data) { + t.Errorf("installed plist contents differ from generated bytes") + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("stat plist: %v", err) + } + if info.Mode().Perm() != plistFilePerm { + t.Errorf("plist mode = %v, want %v", info.Mode().Perm(), plistFilePerm) + } + if plistFilePerm != 0o644 { + t.Errorf("plistFilePerm = %v, want 0o644", plistFilePerm) + } + + // First removePlist removes the file. + if err := removePlist(); err != nil { + t.Fatalf("removePlist: %v", err) + } + if _, err := os.Stat(p); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("plist still present after removePlist: %v", err) + } + + // Re-running removePlist must be a no-op (idempotent). + if err := removePlist(); err != nil { + t.Errorf("second removePlist should be a no-op, got: %v", err) + } +} + +func TestInstallPlist_CreatesLaunchAgentsDirectory(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + + la := filepath.Join(dir, "Library", "LaunchAgents") + if _, err := os.Stat(la); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("LaunchAgents dir should not pre-exist: %v", err) + } + + if err := installPlist([]byte("data")); err != nil { + t.Fatalf("installPlist: %v", err) + } + if _, err := os.Stat(la); err != nil { + t.Errorf("expected LaunchAgents dir created: %v", err) + } +} + +// --- loadJob / unloadJob with stubbed runLaunchctl --- + +// fakeExitError is a non-nil error returned alongside fake launchctl +// output to simulate a failing exec.Command. The exec.ExitError type +// requires an os.ProcessState we can't easily fake, but loadJob / +// unloadJob only care that the error is non-nil. +type fakeExitError struct{ msg string } + +func (e *fakeExitError) Error() string { return e.msg } + +func TestLoadJob_InvokesLaunchctlLoad(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + calls := withLaunchctl(t, func(args ...string) ([]byte, error) { + return nil, nil + }) + + if err := loadJob(); err != nil { + t.Fatalf("loadJob: %v", err) + } + if len(*calls) != 1 { + t.Fatalf("expected 1 launchctl call, got %d", len(*calls)) + } + got := (*calls)[0] + p, _ := plistPath() + if len(got) != 2 || got[0] != "load" || got[1] != p { + t.Errorf("launchctl args = %v, want [load %s]", got, p) + } +} + +func TestLoadJob_PropagatesError(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + withLaunchctl(t, func(args ...string) ([]byte, error) { + return []byte("permission denied"), &fakeExitError{msg: "exit status 1"} + }) + + err := loadJob() + if err == nil { + t.Fatal("loadJob: expected error, got nil") + } + if !strings.Contains(err.Error(), "launchctl load") { + t.Errorf("error should mention launchctl load: %v", err) + } + if !strings.Contains(err.Error(), "permission denied") { + t.Errorf("error should include launchctl output: %v", err) + } +} + +func TestUnloadJob_NoPlistIsNoOp(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + calls := withLaunchctl(t, func(args ...string) ([]byte, error) { + t.Fatalf("launchctl should not be invoked when plist is missing") + return nil, nil + }) + + if err := unloadJob(); err != nil { + t.Errorf("unloadJob with missing plist: %v", err) + } + if len(*calls) != 0 { + t.Errorf("expected no launchctl calls, got %d", len(*calls)) + } +} + +func TestUnloadJob_InvokesLaunchctlUnloadWhenPlistExists(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + if err := installPlist([]byte("data")); err != nil { + t.Fatalf("installPlist: %v", err) + } + calls := withLaunchctl(t, func(args ...string) ([]byte, error) { + return nil, nil + }) + + if err := unloadJob(); err != nil { + t.Fatalf("unloadJob: %v", err) + } + if len(*calls) != 1 { + t.Fatalf("expected 1 launchctl call, got %d", len(*calls)) + } + got := (*calls)[0] + p, _ := plistPath() + if len(got) != 2 || got[0] != "unload" || got[1] != p { + t.Errorf("launchctl args = %v, want [unload %s]", got, p) + } +} + +func TestUnloadJob_SwallowsNotLoadedVariants(t *testing.T) { + // All four wording variants documented in the implementation + // notes: launchctl's exact phrasing has shifted over the macOS + // versions and unloadJob must treat all of them as success. + notLoadedMessages := []string{ + "Could not find specified service", + "No such process", + "Service is not loaded", + "service is not loaded", + } + + for _, msg := range notLoadedMessages { + t.Run(msg, func(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + if err := installPlist([]byte("data")); err != nil { + t.Fatalf("installPlist: %v", err) + } + withLaunchctl(t, func(args ...string) ([]byte, error) { + return []byte(msg), &fakeExitError{msg: "exit status 1"} + }) + + if err := unloadJob(); err != nil { + t.Errorf("unloadJob should swallow %q, got: %v", msg, err) + } + }) + } +} + +func TestUnloadJob_ReturnsOtherErrors(t *testing.T) { + dir := t.TempDir() + withHome(t, dir) + if err := installPlist([]byte("data")); err != nil { + t.Fatalf("installPlist: %v", err) + } + withLaunchctl(t, func(args ...string) ([]byte, error) { + return []byte("permission denied"), &fakeExitError{msg: "exit status 1"} + }) + + err := unloadJob() + if err == nil { + t.Fatal("unloadJob: expected error for unrelated launchctl failure") + } + if !strings.Contains(err.Error(), "permission denied") { + t.Errorf("error should include launchctl output: %v", err) + } +} diff --git a/internal/whenparse/composite.go b/internal/whenparse/composite.go new file mode 100644 index 0000000..69d0729 --- /dev/null +++ b/internal/whenparse/composite.go @@ -0,0 +1,134 @@ +package whenparse + +import ( + "fmt" + "time" +) + +// parseWeekdayRange handles the three-token shape +// " through ". Both endpoints anchor to the next +// occurrence of the named weekday, so the window starts at local +// midnight on that next-occurrence day. The end is the start of the +// day AFTER the second weekday, expressed as +// +// end = start + ((endWeekday - startWeekday + 7) % 7 + 1) * 24h +// +// which keeps the span inside a single Sunday-to-Sunday week and +// fully includes the named end day. For example, on a Wednesday the +// phrase "monday through friday" resolves to +// `[next Mon 00:00, next Sat 00:00)` — the same Mon→Fri week +// containing both endpoints. The bare single-token "" case +// is left to ParseRange's instant-promotion fallback (parseWeekday +// returns midnight on the next occurrence, which promotes cleanly +// to a 24-hour window). +// +// Anything that isn't exactly three tokens of the right shape, or +// whose middle word isn't "through", is left for the next handler. +func parseWeekdayRange(tokens []token, now time.Time) (time.Time, time.Time, bool, error) { + if len(tokens) != 3 { + return time.Time{}, time.Time{}, false, nil + } + if tokens[0].kind != tokWord || tokens[1].kind != tokWord || tokens[2].kind != tokWord { + return time.Time{}, time.Time{}, false, nil + } + if tokens[1].value != "through" { + return time.Time{}, time.Time{}, false, nil + } + startWD, ok := weekdayNames[tokens[0].value] + if !ok { + return time.Time{}, time.Time{}, false, nil + } + endWD, ok := weekdayNames[tokens[2].value] + if !ok { + return time.Time{}, time.Time{}, false, nil + } + start := nextWeekdayOnOrAfter(localMidnight(now), startWD) + // (endWD - startWD + 7) % 7 lands on the same-week offset; the + // extra +1 day promotes the half-open interval past the named + // end weekday so the range fully includes it. + offset := (int(endWD)-int(startWD)+7)%7 + 1 + end := start.AddDate(0, 0, offset) + return start, end, true, nil +} + +// parseComposite handles " through " and the +// " to " shape when neither side begins with one +// of the range-leader words (from / through / until). Each side is +// parsed via parseInstantTokens, and the result is the half-open +// interval `[left, right)`. When right is not strictly after left, +// returns a descriptive error rather than fabricating an empty or +// negative window. +// +// matched is true when the splitter recognises the phrase shape but +// the surrounding parse failed (e.g. one side could not be parsed, +// or right ≤ left); in that case err carries the diagnosis. matched +// is false when the phrase contains no recognisable composite +// separator at all, leaving the caller free to try other strategies +// (instant promotion, etc.). +func parseComposite(tokens []token, now time.Time) (time.Time, time.Time, bool, error) { + sepIdx, sepWord := findCompositeSeparator(tokens) + if sepIdx < 0 { + return time.Time{}, time.Time{}, false, nil + } + left := tokens[:sepIdx] + right := tokens[sepIdx+1:] + if len(left) == 0 || len(right) == 0 { + return time.Time{}, time.Time{}, true, + fmt.Errorf("composite %q is missing one side", sepWord) + } + start, err := parseInstantTokens(left, now) + if err != nil { + return time.Time{}, time.Time{}, true, + fmt.Errorf("left side of %q: %w", sepWord, err) + } + end, err := parseInstantTokens(right, now) + if err != nil { + return time.Time{}, time.Time{}, true, + fmt.Errorf("right side of %q: %w", sepWord, err) + } + if !end.After(start) { + return time.Time{}, time.Time{}, true, + fmt.Errorf("end (%s) is not after start (%s)", + end.Format(time.RFC3339), start.Format(time.RFC3339)) + } + return start, end, true, nil +} + +// findCompositeSeparator scans tokens for the splitter word that +// makes the phrase a composite range. "through" is always a valid +// separator. "to" only counts when the phrase doesn't already begin +// with one of the range-leader words (from / through / until) — that +// way phrases like "from monday to friday" or "until tuesday" stay +// claimed by their dedicated handlers (or by the leader rule) rather +// than being shredded into instants. When neither separator is +// present, sepIdx is -1. +// +// The first matching position wins so a phrase like "tomorrow +// through friday through saturday" resolves left-to-right; we don't +// try to be clever about nested composites because none of the +// supported phrases require it. +func findCompositeSeparator(tokens []token) (int, string) { + for i, tok := range tokens { + if tok.kind != tokWord { + continue + } + if tok.value == "through" { + return i, "through" + } + } + if len(tokens) > 0 && tokens[0].kind == tokWord { + switch tokens[0].value { + case "from", "through", "until": + return -1, "" + } + } + for i, tok := range tokens { + if tok.kind != tokWord { + continue + } + if tok.value == "to" { + return i, "to" + } + } + return -1, "" +} diff --git a/internal/whenparse/doc.go b/internal/whenparse/doc.go new file mode 100644 index 0000000..418e33b --- /dev/null +++ b/internal/whenparse/doc.go @@ -0,0 +1,52 @@ +// Package whenparse turns short English phrases describing time +// windows into either a single point in time (an "instant") or a +// half-open [start, end) range. It is used by the meeting-alerter CLI +// to power the --from, --to, --day, and --when flags, and is intended +// to be reusable by any other flag in this repo that takes a time. +// +// # Scope +// +// The grammar is intentionally narrow and English-only. It covers the +// phrase set documented in the meeting-alerter spec (REQ-5): +// +// - Named instants: now, today, tomorrow, yesterday. +// - Relative durations: "in N {minutes,hours,days,weeks}", +// "N {minutes,hours,days,weeks} from now", +// "N {minutes,hours,days,weeks} ago", +// "next N …", "last N …". +// - Weekdays: bare weekday names, " at HH:MM", +// " through ". +// - Week ranges: "this week", "next week", "last week", +// "rest of the week". +// - Open-ended ranges: "until ", "until ", +// "until ". +// - Composites: " through ". +// - Absolute timestamps: full RFC3339, plus the local-time formats +// YYYY-MM-DD, YYYY-MM-DDTHH:MM, and YYYY-MM-DD HH:MM. +// +// Phrases are case-insensitive and tolerant of collapsed whitespace. +// Unrecognized input returns a descriptive error including the input +// string. Anything outside this set is deliberately not supported; +// the spec's TODO-2 covers the LLM fallback that would handle the +// long tail. +// +// # The now-injection contract +// +// Every public function takes the current time as an explicit +// parameter. The package never reads the wall clock itself. This +// keeps tests deterministic (pin now to a fixed local Wednesday and +// every weekday-relative phrase has a predictable answer) and lets +// callers feed in a synthesized time when wiring the parser into +// non-real-time contexts. now's location (time.Location) is the +// timezone all day-precision phrases resolve against, so callers +// should pass a now in the user's local timezone. +// +// # Sunday-week convention +// +// Week boundaries are Sunday-start (US default). "this week" is the +// half-open interval [most recent Sunday 00:00, the following Sunday +// 00:00); "next week" and "last week" shift that interval by seven +// days. "rest of the week" is [now, the following Sunday 00:00). +// The half-open shape keeps the last day of the week fully included +// without the off-by-one juggling a closed interval would require. +package whenparse diff --git a/internal/whenparse/instant.go b/internal/whenparse/instant.go new file mode 100644 index 0000000..8dda83b --- /dev/null +++ b/internal/whenparse/instant.go @@ -0,0 +1,98 @@ +package whenparse + +import ( + "fmt" + "strings" + "time" +) + +// instantHandler is the signature shared by every handler in the +// ParseInstant dispatch chain. matched reports whether this handler +// claimed the input — the dispatcher uses that bit to decide whether +// to fall through to the next handler or surface a parse error from +// this one. err is only meaningful when matched is true; an +// unmatched handler returns the zero values. +type instantHandler func(tokens []token, now time.Time) (t time.Time, matched bool, err error) + +// parseNamedDay handles the four bare keywords that name a single +// instant: "now", "today", "tomorrow", "yesterday". "now" returns the +// caller's now verbatim; the other three snap to local midnight on +// the day they refer to. Anything longer than one word, or one word +// that is not in this set, is left for the next handler. +func parseNamedDay(tokens []token, now time.Time) (time.Time, bool, error) { + if len(tokens) != 1 || tokens[0].kind != tokWord { + return time.Time{}, false, nil + } + switch tokens[0].value { + case "now": + return now, true, nil + case "today": + return localMidnight(now), true, nil + case "tomorrow": + return localMidnight(now).Add(24 * time.Hour), true, nil + case "yesterday": + return localMidnight(now).Add(-24 * time.Hour), true, nil + } + return time.Time{}, false, nil +} + +// parseAbsolute handles the supported absolute timestamp shapes: full +// RFC3339 plus the local-time shorthands YYYY-MM-DD, +// YYYY-MM-DDTHH:MM, YYYY-MM-DDTHH:MM:SS, and YYYY-MM-DD HH:MM. The +// lexer lower-cases its input, so the canonical RFC3339 'T' and 'Z' +// arrive here as 't' and 'z'; we upper-case them before handing the +// string to time.Parse so the standard layouts match. The local-time +// shorthands are parsed with time.ParseInLocation using now's +// location, so the user's wall time resolves in the user's timezone +// rather than UTC. +func parseAbsolute(tokens []token, now time.Time) (time.Time, bool, error) { + if len(tokens) != 1 { + return time.Time{}, false, nil + } + tok := tokens[0] + switch tok.kind { + case tokRFC3339: + // time.RFC3339 demands upper-case T and Z; the lexer preserved + // the rest of the string verbatim, so a simple replace gives + // the canonical form. + s := strings.ReplaceAll(tok.value, "t", "T") + s = strings.ReplaceAll(s, "z", "Z") + parsed, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, true, fmt.Errorf("whenparse: invalid RFC3339 timestamp %q: %w", tok.value, err) + } + return parsed, true, nil + case tokDateTime: + // The lexer accepts variants with and without seconds, joined + // by either 't' or a single space. Upper-case the 't' (if any) + // so the standard layouts apply, then try them in decreasing + // specificity until one matches. + s := strings.Replace(tok.value, "t", "T", 1) + layouts := []string{ + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04", + } + for _, layout := range layouts { + if parsed, err := time.ParseInLocation(layout, s, now.Location()); err == nil { + return parsed, true, nil + } + } + return time.Time{}, true, fmt.Errorf("whenparse: invalid datetime literal %q", tok.value) + case tokDate: + parsed, err := time.ParseInLocation("2006-01-02", tok.value, now.Location()) + if err != nil { + return time.Time{}, true, fmt.Errorf("whenparse: invalid date literal %q: %w", tok.value, err) + } + return parsed, true, nil + } + return time.Time{}, false, nil +} + +// localMidnight returns 00:00:00 on the local calendar day of t, +// preserving t's location. Used by every day-precision handler so the +// resolution rule lives in exactly one place. +func localMidnight(t time.Time) time.Time { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) +} diff --git a/internal/whenparse/instant_test.go b/internal/whenparse/instant_test.go new file mode 100644 index 0000000..4f1a55f --- /dev/null +++ b/internal/whenparse/instant_test.go @@ -0,0 +1,71 @@ +package whenparse + +import ( + "strings" + "testing" + "time" +) + +// TestParseInstant_dispatch is a thin sanity check that ParseInstant +// reaches each of the task 2.1 handlers and surfaces a descriptive +// error for unrecognised phrases. The comprehensive grammar coverage +// lives in task 2.4's table-driven suite; this file only confirms +// the dispatch wiring works for the shapes 2.1 added. +func TestParseInstant_dispatch(t *testing.T) { + loc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + t.Fatalf("LoadLocation: %v", err) + } + // Pinned to a Wednesday afternoon so day-precision phrases have + // predictable outputs. + now := time.Date(2026, 5, 20, 14, 30, 0, 0, loc) + midnight := time.Date(2026, 5, 20, 0, 0, 0, 0, loc) + rfcWant, _ := time.Parse(time.RFC3339, "2026-05-25T14:00:30-07:00") + + cases := []struct { + name string + in string + want time.Time + }{ + {"now returns now verbatim", "now", now}, + {"case-insensitive", "NOW", now}, + {"whitespace tolerant", " today ", midnight}, + {"today snaps to local midnight", "today", midnight}, + {"tomorrow is midnight + 24h", "tomorrow", midnight.Add(24 * time.Hour)}, + {"yesterday is midnight - 24h", "yesterday", midnight.Add(-24 * time.Hour)}, + {"bare date", "2026-05-25", time.Date(2026, 5, 25, 0, 0, 0, 0, loc)}, + {"datetime with seconds", "2026-05-25T14:00:30", time.Date(2026, 5, 25, 14, 0, 30, 0, loc)}, + {"datetime without seconds", "2026-05-25T14:00", time.Date(2026, 5, 25, 14, 0, 0, 0, loc)}, + {"datetime with space separator", "2026-05-25 14:00", time.Date(2026, 5, 25, 14, 0, 0, 0, loc)}, + {"RFC3339 with offset", "2026-05-25T14:00:30-07:00", rfcWant}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseInstant(tc.in, now) + if err != nil { + t.Fatalf("ParseInstant(%q): unexpected error %v", tc.in, err) + } + if !got.Equal(tc.want) { + t.Fatalf("ParseInstant(%q) = %s, want %s", tc.in, got.Format(time.RFC3339), tc.want.Format(time.RFC3339)) + } + }) + } + + t.Run("unrecognised phrase mentions input", func(t *testing.T) { + bogus := "definitelynotaphrase" + _, err := ParseInstant(bogus, now) + if err == nil { + t.Fatalf("ParseInstant(%q): expected error, got nil", bogus) + } + if !strings.Contains(err.Error(), bogus) { + t.Fatalf("ParseInstant(%q) error %q does not mention input phrase", bogus, err.Error()) + } + }) + + t.Run("empty phrase is an error", func(t *testing.T) { + _, err := ParseInstant(" ", now) + if err == nil { + t.Fatalf("ParseInstant(empty): expected error, got nil") + } + }) +} diff --git a/internal/whenparse/lexer.go b/internal/whenparse/lexer.go new file mode 100644 index 0000000..617515f --- /dev/null +++ b/internal/whenparse/lexer.go @@ -0,0 +1,134 @@ +package whenparse + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +// tokenKind enumerates the lexical categories produced by tokenize. +// The set is intentionally narrow: each kind corresponds to a shape +// the higher-level handlers in this package care about. +type tokenKind int + +const ( + tokWord tokenKind = iota // bare alphabetic word + tokNumber // integer literal + tokDate // YYYY-MM-DD + tokTime // HH:MM (also tolerates H:MM) + tokDateTime // YYYY-MM-DD plus HH:MM(:SS) joined by `t` or a single space + tokRFC3339 // RFC3339 timestamp with mandatory seconds and timezone + tokPunctuation // any single non-word character that survived normalization +) + +// String renders a tokenKind for diagnostics. The names match the +// vocabulary used in the design document. +func (k tokenKind) String() string { + switch k { + case tokWord: + return "word" + case tokNumber: + return "number" + case tokDate: + return "date" + case tokTime: + return "time" + case tokDateTime: + return "datetime" + case tokRFC3339: + return "rfc3339" + case tokPunctuation: + return "punctuation" + } + return "unknown" +} + +// token is the unit produced by tokenize. value holds the literal +// substring from the already-normalized input, so callers do not have +// to re-trim or re-lowercase. +type token struct { + kind tokenKind + value string +} + +// thruRe rewrites only the bare word `thru`, leaving longer words +// such as `thrust` or `throughput` alone. +var thruRe = regexp.MustCompile(`\bthru\b`) + +// normalize prepares input for tokenize: lower-case the phrase, +// expand two well-known synonyms (`thru` → `through`, `&` → `and`), +// then collapse any run of whitespace down to a single ASCII space. +// The result is idempotent — feeding it back into normalize produces +// the same string. +func normalize(s string) string { + s = strings.ToLower(s) + // Substitute synonyms before whitespace collapsing so the new + // word boundaries created by the substitution are still picked + // up by the final Fields/Join pass. `&` is expanded with + // surrounding spaces so `monday&friday` becomes three tokens + // rather than one fused word. + s = strings.ReplaceAll(s, "&", " and ") + s = thruRe.ReplaceAllString(s, "through") + s = strings.Join(strings.Fields(s), " ") + return s +} + +// lexMatchers are the patterns tokenize tries at each scanner +// position. Order is significant: more specific compound shapes must +// be tried first so they are not stolen by a shorter pattern — a +// bare date would otherwise eat the date prefix of an RFC3339 stamp. +// Each pattern is anchored to the current position with `^`. +// +// normalize lower-cases the input, so the canonical RFC3339 `T` and +// `Z` appear here as `t` and `z`. The parsing layer is responsible +// for re-uppercasing before handing the literal to time.Parse. +var lexMatchers = []struct { + re *regexp.Regexp + kind tokenKind +}{ + {regexp.MustCompile(`^\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:z|[+-]\d{2}:\d{2})`), tokRFC3339}, + {regexp.MustCompile(`^\d{4}-\d{2}-\d{2}[t ]\d{1,2}:\d{2}(?::\d{2})?`), tokDateTime}, + {regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`), tokDate}, + {regexp.MustCompile(`^\d{1,2}:\d{2}`), tokTime}, + {regexp.MustCompile(`^\d+`), tokNumber}, + {regexp.MustCompile(`^[a-z]+`), tokWord}, +} + +// tokenize splits a phrase into a flat slice of tokens. Whitespace is +// treated as a token separator, except when it appears inside the +// recognised `YYYY-MM-DD HH:MM` datetime compound — that case is +// matched as a single token before the scanner ever sees the space +// as a delimiter. +// +// tokenize calls normalize first, so callers do not have to. Anything +// that none of the matchers accept is emitted as a single punctuation +// token rather than silently dropped, leaving the parsing layer free +// to decide what (if anything) to do with it. +func tokenize(s string) []token { + s = normalize(s) + var tokens []token + i := 0 + for i < len(s) { + if s[i] == ' ' { + i++ + continue + } + matched := false + for _, m := range lexMatchers { + if loc := m.re.FindStringIndex(s[i:]); loc != nil { + tokens = append(tokens, token{kind: m.kind, value: s[i : i+loc[1]]}) + i += loc[1] + matched = true + break + } + } + if matched { + continue + } + // Single rune of punctuation; emit it and advance one rune. + r, size := utf8.DecodeRuneInString(s[i:]) + tokens = append(tokens, token{kind: tokPunctuation, value: string(r)}) + i += size + } + return tokens +} diff --git a/internal/whenparse/lexer_test.go b/internal/whenparse/lexer_test.go new file mode 100644 index 0000000..20fcbc8 --- /dev/null +++ b/internal/whenparse/lexer_test.go @@ -0,0 +1,177 @@ +package whenparse + +import ( + "reflect" + "testing" +) + +// TestNormalize covers the four jobs normalize is responsible for: +// case folding, whitespace collapsing, the `&` → ` and ` substitution, +// and the bare-word `thru` → `through` substitution. Each row is a +// small, focused example so a single failing assertion points at one +// behaviour. +func TestNormalize(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + // Case folding. + {"uppercase folds to lowercase", "TODAY", "today"}, + {"mixed case folds to lowercase", "Monday Through Friday", "monday through friday"}, + + // Whitespace collapsing. + {"collapses multiple spaces", "next 3 days", "next 3 days"}, + {"collapses tabs to single space", "monday\tthrough\tfriday", "monday through friday"}, + {"collapses newlines and mixed whitespace", "rest \n\t of the\nweek", "rest of the week"}, + {"trims leading and trailing whitespace", " today ", "today"}, + {"empty input stays empty", "", ""}, + {"whitespace-only input becomes empty", " \t\n ", ""}, + + // Synonym substitution. + {"thru as bare word becomes through", "monday thru friday", "monday through friday"}, + {"thru with mixed case becomes through", "Monday THRU Friday", "monday through friday"}, + {"thrust does not get rewritten", "thrust", "thrust"}, + {"throughput does not get rewritten", "throughput", "throughput"}, + {"ampersand becomes and with surrounding spaces", "saturday & sunday", "saturday and sunday"}, + {"ampersand without surrounding spaces splits the word", "monday&friday", "monday and friday"}, + {"multiple ampersands all expand", "a&b&c", "a and b and c"}, + + // Combined transforms. + {"folds case, expands thru, collapses whitespace at once", + " MONDAY thru FRIDAY ", "monday through friday"}, + {"folds case, expands &, collapses whitespace at once", + " Saturday & SUNDAY ", "saturday and sunday"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalize(tt.in); got != tt.want { + t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// TestNormalizeIdempotent asserts that running normalize twice yields +// the same string as running it once. The downstream tokenize relies +// on the output being already-normalized, so accidental non-idempotence +// would silently break tokenization. +func TestNormalizeIdempotent(t *testing.T) { + inputs := []string{ + " MONDAY thru FRIDAY ", + "saturday & sunday", + "next 3 days", + "2026-05-25T14:00:00Z", + "rest of the week", + "thrust through the door", + "a&b&c", + "", + " ", + } + for _, in := range inputs { + t.Run(in, func(t *testing.T) { + once := normalize(in) + twice := normalize(once) + if once != twice { + t.Errorf("normalize not idempotent for %q: once=%q twice=%q", in, once, twice) + } + }) + } +} + +// TestTokenize covers each tokenKind plus the multi-token phrases +// that appear in the design's grammar reference. Tokenize calls +// normalize first, so inputs here can be raw user phrases (mixed case, +// extra whitespace) without any pre-processing. +func TestTokenize(t *testing.T) { + // Small constructors keep each row readable. Token values come + // from the already-normalized input, so they are lower-case. + w := func(v string) token { return token{kind: tokWord, value: v} } + n := func(v string) token { return token{kind: tokNumber, value: v} } + d := func(v string) token { return token{kind: tokDate, value: v} } + tm := func(v string) token { return token{kind: tokTime, value: v} } + dt := func(v string) token { return token{kind: tokDateTime, value: v} } + rfc := func(v string) token { return token{kind: tokRFC3339, value: v} } + p := func(v string) token { return token{kind: tokPunctuation, value: v} } + + tests := []struct { + name string + in string + want []token + }{ + // Single-token shapes. + {"empty input yields no tokens", "", nil}, + {"whitespace-only input yields no tokens", " \t ", nil}, + {"single bare word", "today", []token{w("today")}}, + {"single integer", "3", []token{n("3")}}, + {"bare date", "2026-05-25", []token{d("2026-05-25")}}, + {"bare time HH:MM", "13:00", []token{tm("13:00")}}, + {"bare time with single-digit hour", "9:30", []token{tm("9:30")}}, + {"datetime with T separator", "2026-05-25T14:00", []token{dt("2026-05-25t14:00")}}, + {"datetime with space separator", "2026-05-25 14:00", []token{dt("2026-05-25 14:00")}}, + {"datetime with seconds and T", "2026-05-25T14:00:00", []token{dt("2026-05-25t14:00:00")}}, + {"rfc3339 with Z", "2026-05-25T14:00:00Z", []token{rfc("2026-05-25t14:00:00z")}}, + {"rfc3339 with positive offset", "2026-05-25T14:00:00+02:00", []token{rfc("2026-05-25t14:00:00+02:00")}}, + {"rfc3339 with negative offset", "2026-05-25T14:00:00-07:00", []token{rfc("2026-05-25t14:00:00-07:00")}}, + {"rfc3339 with fractional seconds and Z", "2026-05-25T14:00:00.123Z", []token{rfc("2026-05-25t14:00:00.123z")}}, + + // Mixed punctuation surfaces as punctuation tokens. + {"comma between words", "today, friday", + []token{w("today"), p(","), w("friday")}}, + {"semicolon between words", "today; friday", + []token{w("today"), p(";"), w("friday")}}, + {"stray colon does not get absorbed by time matcher", + "foo: bar", []token{w("foo"), p(":"), w("bar")}}, + {"slash between dates", + "2026-05-25/2026-05-26", + []token{d("2026-05-25"), p("/"), d("2026-05-26")}}, + + // Multi-token phrases from the grammar reference. + {"monday through friday", "monday through friday", + []token{w("monday"), w("through"), w("friday")}}, + {"monday thru friday is normalized to through", + "monday thru friday", + []token{w("monday"), w("through"), w("friday")}}, + {"next 3 days", "next 3 days", + []token{w("next"), n("3"), w("days")}}, + {"in 3 days", "in 3 days", + []token{w("in"), n("3"), w("days")}}, + {"3 days from now", "3 days from now", + []token{n("3"), w("days"), w("from"), w("now")}}, + {"tomorrow at HH:MM", "tomorrow at 14:00", + []token{w("tomorrow"), w("at"), tm("14:00")}}, + {"weekday at HH:MM", "monday at 9:30", + []token{w("monday"), w("at"), tm("9:30")}}, + {"rest of the week", "rest of the week", + []token{w("rest"), w("of"), w("the"), w("week")}}, + {"until weekday", "until friday", + []token{w("until"), w("friday")}}, + {"until date", "until 2026-05-25", + []token{w("until"), d("2026-05-25")}}, + {"composite through with two dates", + "2026-05-25 through 2026-05-30", + []token{d("2026-05-25"), w("through"), d("2026-05-30")}}, + + // Whitespace and case tolerance flows through tokenize. + {"extra whitespace is collapsed before tokenizing", + " MONDAY through FRIDAY ", + []token{w("monday"), w("through"), w("friday")}}, + {"ampersand expands into a separate word", + "saturday & sunday", + []token{w("saturday"), w("and"), w("sunday")}}, + + // Mixed shapes in one phrase. + {"date plus time treated as two tokens when not joined by T or single space", + "2026-05-25, 14:00", + []token{d("2026-05-25"), p(","), tm("14:00")}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tokenize(tt.in) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("tokenize(%q)\n got = %+v\nwant = %+v", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/whenparse/parse_instant_test.go b/internal/whenparse/parse_instant_test.go new file mode 100644 index 0000000..d7db03d --- /dev/null +++ b/internal/whenparse/parse_instant_test.go @@ -0,0 +1,232 @@ +package whenparse + +import ( + "strings" + "testing" + "time" +) + +// pinnedNow is the deterministic anchor every ParseInstant grammar +// case in this file resolves against: Wednesday, 20 May 2026 at 14:30 +// local in America/Los_Angeles. Wednesday is mid-week so weekday +// arithmetic is symmetric in both directions, the non-UTC timezone +// catches any handler that accidentally resolves against UTC, and +// 14:30 leaves room for both "earlier today" and "later today" HH:MM +// cases without the math touching a day boundary. +func pinnedNow(t *testing.T) (now, midnight time.Time, loc *time.Location) { + t.Helper() + loc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + t.Fatalf("LoadLocation: %v", err) + } + now = time.Date(2026, 5, 20, 14, 30, 0, 0, loc) + if now.Weekday() != time.Wednesday { + t.Fatalf("test fixture drifted: pinned now is %s, want Wednesday", now.Weekday()) + } + midnight = time.Date(2026, 5, 20, 0, 0, 0, 0, loc) + return now, midnight, loc +} + +// TestParseInstant_grammar exercises every shape the design's grammar +// reference lists for ParseInstant, plus the edge cases called out in +// task 2.4: weekday-already-passed-today, time-of-day clamps, +// today/tomorrow at HH:MM, and an RFC3339 round-trip. Every case +// resolves against the same pinned now so weekday math is +// deterministic and easy to read. +func TestParseInstant_grammar(t *testing.T) { + now, midnight, loc := pinnedNow(t) + + // Helper for building a local time at a specific date+wall-clock. + at := func(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, loc) + } + + cases := []struct { + name string + in string + want time.Time + }{ + // ---- parseNamedDay ---- + {"now returns now verbatim", "now", now}, + {"today snaps to local midnight", "today", midnight}, + {"tomorrow is midnight + 24h", "tomorrow", midnight.Add(24 * time.Hour)}, + {"yesterday is midnight - 24h", "yesterday", midnight.Add(-24 * time.Hour)}, + + // ---- parseRelativeDuration ("in N units") ---- + // Day/week values snap to local midnight on day +N. + {"in 3 days snaps to midnight on day +3", "in 3 days", at(2026, 5, 23, 0, 0)}, + {"in 1 day singular form", "in 1 day", at(2026, 5, 21, 0, 0)}, + {"in 1 week is midnight + 7 days", "in 1 week", at(2026, 5, 27, 0, 0)}, + // Hour/minute values produce now + duration (no midnight snap). + {"in 30 minutes is now + 30m", "in 30 minutes", now.Add(30 * time.Minute)}, + {"in 2 hours is now + 2h", "in 2 hours", now.Add(2 * time.Hour)}, + + // ---- parseNumberLeading ("N units from now" / "ago") ---- + {"3 days from now matches in 3 days", "3 days from now", at(2026, 5, 23, 0, 0)}, + {"30 minutes from now is now + 30m", "30 minutes from now", now.Add(30 * time.Minute)}, + {"2 hours from now is now + 2h", "2 hours from now", now.Add(2 * time.Hour)}, + {"3 days ago is midnight on day -3", "3 days ago", at(2026, 5, 17, 0, 0)}, + {"30 minutes ago is now - 30m", "30 minutes ago", now.Add(-30 * time.Minute)}, + + // ---- parseWeekday: bare weekday names ---- + // All weekday math is anchored at today's midnight (Wed + // 2026-05-20). The rule "next occurrence at or after today" + // means a Wednesday phrase resolves to today, not next week. + {"sunday is the upcoming Sunday midnight", "sunday", at(2026, 5, 24, 0, 0)}, + {"monday is 5 days away from a Wednesday", "monday", at(2026, 5, 25, 0, 0)}, + {"tuesday is 6 days away from a Wednesday", "tuesday", at(2026, 5, 26, 0, 0)}, + {"wednesday on Wednesday resolves to today", "wednesday", midnight}, + {"thursday is tomorrow's midnight", "thursday", at(2026, 5, 21, 0, 0)}, + {"friday is two days out", "friday", at(2026, 5, 22, 0, 0)}, + {"saturday is three days out", "saturday", at(2026, 5, 23, 0, 0)}, + + // ---- parseWeekday: " at HH:MM" ---- + {"monday at 09:00 is next Monday morning", "monday at 09:00", at(2026, 5, 25, 9, 0)}, + {"friday at 17:30 is this Friday evening", "friday at 17:30", at(2026, 5, 22, 17, 30)}, + // Today is Wednesday, now is 14:30 — 10:00 already passed + // today, so this advances one week per the design. + {"wednesday at 10:00 advances one week when already passed", "wednesday at 10:00", at(2026, 5, 27, 10, 0)}, + // Today is Wednesday, now is 14:30 — 16:00 still ahead today, + // so no advance. + {"wednesday at 16:00 stays on today when still ahead", "wednesday at 16:00", at(2026, 5, 20, 16, 0)}, + + // ---- parseWeekday: "today at HH:MM" / "tomorrow at HH:MM" ---- + // Design rule: "today at HH:MM" resolves to today's local + // wall time, even if HH:MM is in the past relative to now. + {"today at 09:00 stays in the past", "today at 09:00", at(2026, 5, 20, 9, 0)}, + {"today at 16:00 is later this afternoon", "today at 16:00", at(2026, 5, 20, 16, 0)}, + {"tomorrow at 09:00 is tomorrow morning", "tomorrow at 09:00", at(2026, 5, 21, 9, 0)}, + {"tomorrow at 23:59 is end of tomorrow", "tomorrow at 23:59", at(2026, 5, 21, 23, 59)}, + + // ---- parseAbsolute ---- + {"YYYY-MM-DD is local midnight", "2026-05-25", at(2026, 5, 25, 0, 0)}, + {"YYYY-MM-DDTHH:MM is local wall time", "2026-05-25T14:00", at(2026, 5, 25, 14, 0)}, + {"YYYY-MM-DDTHH:MM:SS preserves seconds", "2026-05-25T14:00:30", time.Date(2026, 5, 25, 14, 0, 30, 0, loc)}, + {"YYYY-MM-DD HH:MM with space separator", "2026-05-25 14:00", at(2026, 5, 25, 14, 0)}, + + // ---- Whitespace and case folding ---- + {"case-insensitive named day", "TODAY", midnight}, + {"case-insensitive weekday", "MONDAY", at(2026, 5, 25, 0, 0)}, + {"collapsed whitespace", " in 3 days ", at(2026, 5, 23, 0, 0)}, + {"mixed-case weekday at HH:MM", "Friday at 17:30", at(2026, 5, 22, 17, 30)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseInstant(tc.in, now) + if err != nil { + t.Fatalf("ParseInstant(%q): unexpected error %v", tc.in, err) + } + if !got.Equal(tc.want) { + t.Fatalf("ParseInstant(%q)\n got %s\n want %s", + tc.in, got.Format(time.RFC3339), tc.want.Format(time.RFC3339)) + } + }) + } +} + +// TestParseInstant_RFC3339RoundTrip verifies that parsing the output +// of time.RFC3339 formatting recovers an equivalent instant. This is +// the "RFC3339 round-trip" edge case called out in task 2.4. We use +// time.Time.Equal rather than == because RFC3339 carries a fixed +// offset rather than the original time.Location pointer. +func TestParseInstant_RFC3339RoundTrip(t *testing.T) { + now, _, loc := pinnedNow(t) + + samples := []time.Time{ + now, // 2026-05-20T14:30 PT + time.Date(2026, 5, 25, 9, 0, 0, 0, loc), + time.Date(2026, 12, 31, 23, 59, 59, 0, loc), + time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + for _, sample := range samples { + t.Run(sample.Format(time.RFC3339), func(t *testing.T) { + got, err := ParseInstant(sample.Format(time.RFC3339), now) + if err != nil { + t.Fatalf("round-trip parse: %v", err) + } + if !got.Equal(sample) { + t.Fatalf("round-trip mismatch\n got %s\n want %s", + got.Format(time.RFC3339), sample.Format(time.RFC3339)) + } + }) + } +} + +// TestParseInstant_errors covers the error paths called out in task +// 2.4: a bogus phrase must surface the input verbatim, empty or +// whitespace-only input must error, and the range-only forms ("next +// N units", "last N units") must point the caller at ParseRange so +// they understand why an instant call was rejected. +func TestParseInstant_errors(t *testing.T) { + now, _, _ := pinnedNow(t) + + t.Run("bogus phrase mentions input", func(t *testing.T) { + bogus := "definitelynotaphrase" + _, err := ParseInstant(bogus, now) + if err == nil { + t.Fatalf("expected error for %q, got nil", bogus) + } + if !strings.Contains(err.Error(), bogus) { + t.Fatalf("error %q does not mention input phrase %q", err.Error(), bogus) + } + }) + + t.Run("multi-word bogus phrase mentions input", func(t *testing.T) { + bogus := "the third week of the next month" + _, err := ParseInstant(bogus, now) + if err == nil { + t.Fatalf("expected error for %q, got nil", bogus) + } + if !strings.Contains(err.Error(), bogus) { + t.Fatalf("error %q does not mention input phrase %q", err.Error(), bogus) + } + }) + + t.Run("empty phrase errors", func(t *testing.T) { + _, err := ParseInstant("", now) + if err == nil { + t.Fatalf("expected error for empty phrase, got nil") + } + }) + + t.Run("whitespace-only phrase errors", func(t *testing.T) { + _, err := ParseInstant(" \t ", now) + if err == nil { + t.Fatalf("expected error for whitespace-only phrase, got nil") + } + }) + + // "next 3 days" and "last 2 weeks" are valid ParseRange phrases + // but not ParseInstant phrases. parseRelativeDuration claims them + // so it can return a clear pointer to ParseRange instead of a + // generic "unrecognized phrase" message — the error text should + // reflect that. + t.Run("next N units rejected with range hint", func(t *testing.T) { + phrase := "next 3 days" + _, err := ParseInstant(phrase, now) + if err == nil { + t.Fatalf("expected error for %q, got nil", phrase) + } + msg := err.Error() + if !strings.Contains(msg, phrase) { + t.Fatalf("error %q does not mention input phrase %q", msg, phrase) + } + if !strings.Contains(strings.ToLower(msg), "range") && + !strings.Contains(msg, "ParseRange") { + t.Fatalf("error %q should mention range / ParseRange", msg) + } + }) + + t.Run("last N units rejected with range hint", func(t *testing.T) { + phrase := "last 2 weeks" + _, err := ParseInstant(phrase, now) + if err == nil { + t.Fatalf("expected error for %q, got nil", phrase) + } + msg := err.Error() + if !strings.Contains(strings.ToLower(msg), "range") && + !strings.Contains(msg, "ParseRange") { + t.Fatalf("error %q should mention range / ParseRange", msg) + } + }) +} diff --git a/internal/whenparse/parse_range_test.go b/internal/whenparse/parse_range_test.go new file mode 100644 index 0000000..ef0c6f2 --- /dev/null +++ b/internal/whenparse/parse_range_test.go @@ -0,0 +1,221 @@ +package whenparse + +import ( + "strings" + "testing" + "time" +) + +// TestParseRange_grammar exercises every shape the design's grammar +// reference lists for ParseRange, anchored at the same pinned now +// the ParseInstant tests use (Wednesday 2026-05-20 14:30 in +// America/Los_Angeles). Anchoring on a Wednesday keeps weekday +// arithmetic symmetric in both directions, and the non-UTC location +// catches any handler that accidentally resolves against UTC. +// +// Key REQ-5 distinction exercised here: "next 3 days" is a 72-hour +// rolling window from now (no midnight snap), while "in 3 days" and +// "3 days from now" snap to a 24-hour window starting at midnight on +// day +3 — same head/tail of the calendar even though the second +// shape is an instant promoted to a range. +func TestParseRange_grammar(t *testing.T) { + // Discard the midnight return: the table below builds its own + // dates via mid()/at(), so we only need now and loc. + now, _, loc := pinnedNow(t) + + // at builds a local time at a specific date+wall-clock; mid is + // the same shape with the time zeroed (so `[mid(d), mid(d+1))` + // is the canonical 24-hour local-day window). + at := func(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, loc) + } + mid := func(year int, month time.Month, day int) time.Time { + return at(year, month, day, 0, 0) + } + + cases := []struct { + name string + in string + wantStart time.Time + wantEnd time.Time + }{ + // ---- Day-precision instants promote to a 24-hour window ---- + // today / tomorrow / yesterday all land on local midnight in + // ParseInstant, so the range fallback widens them to a full + // local day. + {"today is the local 24-hour window", "today", mid(2026, 5, 20), mid(2026, 5, 21)}, + {"tomorrow is the next local 24-hour window", "tomorrow", mid(2026, 5, 21), mid(2026, 5, 22)}, + {"yesterday is the prior local 24-hour window", "yesterday", mid(2026, 5, 19), mid(2026, 5, 20)}, + + // ---- Bare weekday and absolute date promote to a 24-hour window ---- + // "monday" resolves to midnight on the next Monday at-or-after + // today; "2026-05-25" resolves to local midnight on that date. + // Both are day-precision, so ParseRange widens them to one day. + {"bare weekday promotes to next occurrence", "monday", mid(2026, 5, 25), mid(2026, 5, 26)}, + {"YYYY-MM-DD promotes to local 24-hour window", "2026-05-25", mid(2026, 5, 25), mid(2026, 5, 26)}, + + // ---- "next/last N units" — exact-duration windows ---- + // REQ-5 distinction: "next 3 days" is a 72-hour rolling + // window, NOT the calendar days +1/+2/+3. + {"next 3 days is a 72h rolling window", "next 3 days", now, now.Add(72 * time.Hour)}, + {"next 4 hours is a 4h window", "next 4 hours", now, now.Add(4 * time.Hour)}, + {"next 30 minutes is a 30m window", "next 30 minutes", now, now.Add(30 * time.Minute)}, + {"last 2 weeks is now-14d to now", "last 2 weeks", now.Add(-14 * 24 * time.Hour), now}, + + // ---- "in N " / "N from now" — instant promotion ---- + // REQ-5 distinction: these snap to midnight on day +N and + // promote to the 24-hour window on that day. So "in 3 days" + // from a Wednesday is the calendar day +3 (Saturday 5/23), + // not now+72h. + {"in 3 days is the day-+3 24h window", "in 3 days", mid(2026, 5, 23), mid(2026, 5, 24)}, + {"3 days from now mirrors in 3 days", "3 days from now", mid(2026, 5, 23), mid(2026, 5, 24)}, + + // ---- Sunday-start week windows ---- + // pinnedNow is Wed 2026-05-20; the most recent Sunday is + // 2026-05-17. Anchoring this here makes the boundaries + // readable in the test rather than buried in arithmetic. + {"this week is Sun..next Sun", "this week", mid(2026, 5, 17), mid(2026, 5, 24)}, + {"next week is Sun(+7)..Sun(+14)", "next week", mid(2026, 5, 24), mid(2026, 5, 31)}, + {"last week is Sun(-7)..Sun", "last week", mid(2026, 5, 10), mid(2026, 5, 17)}, + + // ---- "rest of the week" / "rest of this week" ---- + // Half-open [now, next Sunday 00:00). Both phrasings produce + // the same window so users can pick whichever reads more + // naturally without changing semantics. + {"rest of the week starts at now", "rest of the week", now, mid(2026, 5, 24)}, + {"rest of this week is the same shape", "rest of this week", now, mid(2026, 5, 24)}, + + // ---- Weekday ranges ---- + // " through " is owned by the dedicated + // handler (it advances past the named end weekday); the + // composite splitter would otherwise stop at the second + // weekday's bare midnight and clip the range short. + {"monday through friday spans Mon..Sat", "monday through friday", mid(2026, 5, 25), mid(2026, 5, 30)}, + {"monday through monday is a single day", "monday through monday", mid(2026, 5, 25), mid(2026, 5, 26)}, + + // ---- "until <…>" forms ---- + // Each "until" target resolves to the END of the local day + // it names, expressed as midnight of the next day. The start + // is now, so the window is [now, end of day). + {"until friday ends at end-of-Friday", "until friday", now, mid(2026, 5, 23)}, + {"until 2026-05-25 ends at end-of-that-day", "until 2026-05-25", now, mid(2026, 5, 26)}, + {"until today ends at end-of-today", "until today", now, mid(2026, 5, 21)}, + {"until tomorrow ends at end-of-tomorrow", "until tomorrow", now, mid(2026, 5, 22)}, + + // ---- Composite " through " / " to " ---- + // Each side is parsed via ParseInstant, then the half-open + // interval is [left, right). "to" is only honoured when + // neither side begins with from/through/until, so plain + // "tomorrow to friday" stays a composite. + {"composite tomorrow through friday", "tomorrow through friday", mid(2026, 5, 21), mid(2026, 5, 22)}, + {"composite tomorrow to friday matches through", "tomorrow to friday", mid(2026, 5, 21), mid(2026, 5, 22)}, + + // ---- Case folding & whitespace ---- + // ParseRange tokenises through the same lexer as + // ParseInstant, so case and whitespace handling should be + // equivalent. A single sample in each axis is enough to + // catch a regression in the dispatch wiring. + {"case-insensitive named day", "TODAY", mid(2026, 5, 20), mid(2026, 5, 21)}, + {"collapsed whitespace in next-N-days", " next 3 days ", now, now.Add(72 * time.Hour)}, + } + + // Validates: Requirements REQ-5 (ParseRange acceptance criteria; + // "3 days from now" vs "next 3 days" distinction). + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotStart, gotEnd, err := ParseRange(tc.in, now) + if err != nil { + t.Fatalf("ParseRange(%q): unexpected error %v", tc.in, err) + } + if !gotStart.Equal(tc.wantStart) { + t.Fatalf("ParseRange(%q) start\n got %s\n want %s", + tc.in, gotStart.Format(time.RFC3339), tc.wantStart.Format(time.RFC3339)) + } + if !gotEnd.Equal(tc.wantEnd) { + t.Fatalf("ParseRange(%q) end\n got %s\n want %s", + tc.in, gotEnd.Format(time.RFC3339), tc.wantEnd.Format(time.RFC3339)) + } + // Half-open windows must always have end strictly after + // start; a regression that flipped the order would otherwise + // pass the equality checks above only by accident. + if !gotEnd.After(gotStart) { + t.Fatalf("ParseRange(%q) produced empty/inverted window: [%s, %s)", + tc.in, gotStart.Format(time.RFC3339), gotEnd.Format(time.RFC3339)) + } + }) + } +} + +// TestParseRange_grammar_errors covers every error path the design +// calls out for ParseRange: a composite whose end is at or before +// its start, a non-day-precision instant offered as a range, and +// empty / whitespace / bogus phrases. Each assertion that the input +// phrase is echoed back is deliberate — REQ-5 requires descriptive +// errors so users can tell which phrase the parser rejected. +func TestParseRange_grammar_errors(t *testing.T) { + now, _, _ := pinnedNow(t) + + t.Run("composite tomorrow through yesterday is rejected", func(t *testing.T) { + // end = today midnight, start = tomorrow midnight, so end is + // 24h BEFORE start. The composite handler must reject this + // rather than silently produce a negative-length window. + phrase := "tomorrow through yesterday" + _, _, err := ParseRange(phrase, now) + if err == nil { + t.Fatalf("expected error for %q, got nil", phrase) + } + if !strings.Contains(err.Error(), phrase) { + t.Fatalf("error %q does not mention input phrase %q", err.Error(), phrase) + } + }) + + t.Run("non-midnight instant now is rejected", func(t *testing.T) { + // "now" resolves to a single instant that is NOT local + // midnight, so the day-precision promotion declines and + // ParseRange must surface a clear pointer instead of + // fabricating an arbitrary window. + _, _, err := ParseRange("now", now) + if err == nil { + t.Fatalf("expected error for \"now\", got nil") + } + if !strings.Contains(err.Error(), "now") { + t.Fatalf("error %q does not mention input phrase \"now\"", err.Error()) + } + }) + + t.Run("empty phrase errors", func(t *testing.T) { + _, _, err := ParseRange("", now) + if err == nil { + t.Fatalf("expected error for empty phrase, got nil") + } + }) + + t.Run("whitespace-only phrase errors", func(t *testing.T) { + _, _, err := ParseRange(" \t ", now) + if err == nil { + t.Fatalf("expected error for whitespace-only phrase, got nil") + } + }) + + t.Run("bogus phrase mentions input", func(t *testing.T) { + bogus := "definitelynotaphrase" + _, _, err := ParseRange(bogus, now) + if err == nil { + t.Fatalf("expected error for %q, got nil", bogus) + } + if !strings.Contains(err.Error(), bogus) { + t.Fatalf("error %q does not mention input %q", err.Error(), bogus) + } + }) + + t.Run("multi-word bogus phrase mentions input", func(t *testing.T) { + bogus := "the third quarter of next year" + _, _, err := ParseRange(bogus, now) + if err == nil { + t.Fatalf("expected error for %q, got nil", bogus) + } + if !strings.Contains(err.Error(), bogus) { + t.Fatalf("error %q does not mention input %q", err.Error(), bogus) + } + }) +} diff --git a/internal/whenparse/range.go b/internal/whenparse/range.go new file mode 100644 index 0000000..0c5d85c --- /dev/null +++ b/internal/whenparse/range.go @@ -0,0 +1,85 @@ +package whenparse + +import "time" + +// rangeHandler is the signature shared by every handler in the +// ParseRange dispatch chain. matched reports whether this handler +// claimed the input — the dispatcher uses that bit to decide whether +// to fall through to the next handler or surface a parse error from +// this one. err is only meaningful when matched is true; an +// unmatched handler returns the zero values. +type rangeHandler func(tokens []token, now time.Time) (start, end time.Time, matched bool, err error) + +// mostRecentSunday returns local midnight on the most recent Sunday +// at or before t. When t falls on a Sunday, it returns midnight of +// that same day (delta of 0). The arithmetic is +// `((weekday - Sunday) + 7) % 7` days back from local midnight, so +// the result is always within the seven days ending at t inclusive. +// +// This is the anchor for every Sunday-start week computation in the +// package; centralising it here keeps the convention in one place. +func mostRecentSunday(t time.Time) time.Time { + mid := localMidnight(t) + delta := (int(mid.Weekday()) - int(time.Sunday) + 7) % 7 + return mid.AddDate(0, 0, -delta) +} + +// parseWeekRange handles the three two-word phrases that name a +// whole week: "this week", "next week", and "last week". Week +// boundaries are Sunday-start (US default), so the windows are +// +// this week → [mostRecentSunday(now), mostRecentSunday(now)+7d) +// next week → [mostRecentSunday(now)+7d, mostRecentSunday(now)+14d) +// last week → [mostRecentSunday(now)-7d, mostRecentSunday(now)) +// +// Anything that is not exactly two tokens of the right shape, or +// whose first word is something other than this/next/last, is left +// for the next handler. +func parseWeekRange(tokens []token, now time.Time) (time.Time, time.Time, bool, error) { + if len(tokens) != 2 || tokens[0].kind != tokWord || tokens[1].kind != tokWord { + return time.Time{}, time.Time{}, false, nil + } + if tokens[1].value != "week" { + return time.Time{}, time.Time{}, false, nil + } + sunday := mostRecentSunday(now) + switch tokens[0].value { + case "this": + return sunday, sunday.AddDate(0, 0, 7), true, nil + case "next": + return sunday.AddDate(0, 0, 7), sunday.AddDate(0, 0, 14), true, nil + case "last": + return sunday.AddDate(0, 0, -7), sunday, true, nil + } + return time.Time{}, time.Time{}, false, nil +} + +// parseRestOfWeek handles "rest of the week" and "rest of this +// week", both of which produce the half-open interval +// `[now, next Sunday 00:00)`. The half-open shape is deliberate: it +// keeps the last day of the week fully included without any +// off-by-one juggling that a closed interval would require, and it +// matches the rest of the package's range conventions. +// +// Both phrases are exactly four words, so the shape check is rigid: +// any input that is not four word-tokens, or whose first/second/last +// words are wrong, falls through to the next handler. The third +// slot accepts either "the" or "this". +func parseRestOfWeek(tokens []token, now time.Time) (time.Time, time.Time, bool, error) { + if len(tokens) != 4 { + return time.Time{}, time.Time{}, false, nil + } + for _, tok := range tokens { + if tok.kind != tokWord { + return time.Time{}, time.Time{}, false, nil + } + } + if tokens[0].value != "rest" || tokens[1].value != "of" || tokens[3].value != "week" { + return time.Time{}, time.Time{}, false, nil + } + if tokens[2].value != "the" && tokens[2].value != "this" { + return time.Time{}, time.Time{}, false, nil + } + end := mostRecentSunday(now).AddDate(0, 0, 7) + return now, end, true, nil +} diff --git a/internal/whenparse/range_more.go b/internal/whenparse/range_more.go new file mode 100644 index 0000000..5e69347 --- /dev/null +++ b/internal/whenparse/range_more.go @@ -0,0 +1,129 @@ +package whenparse + +import ( + "fmt" + "strconv" + "time" +) + +// rangeUnitDuration converts a canonical duration unit (as returned +// by durationUnit) into a time.Duration. Unlike applyOffset, the +// range form treats every unit as an exact duration — "next 3 days" +// means 72 hours from now, not midnight-to-midnight on day +3, per +// REQ-5's distinction between instant ("3 days from now" snaps to +// midnight) and range ("next 3 days" is a 72-hour window). +// +// An unrecognised unit (programmer error — durationUnit should have +// rejected it already) returns 0 and false so callers can decline to +// claim the input. +func rangeUnitDuration(unit string) (time.Duration, bool) { + switch unit { + case "minute": + return time.Minute, true + case "hour": + return time.Hour, true + case "day": + return 24 * time.Hour, true + case "week": + return 7 * 24 * time.Hour, true + } + return 0, false +} + +// parseRelativeDurationRange handles the three-token shapes +// "next N " and "last N " where is one of the +// four duration keywords (minutes, hours, days, weeks). The result +// is a half-open interval whose width is exactly N units treated as +// a wall-clock duration: +// +// next 3 days → [now, now + 72h) +// next 4 hours → [now, now + 4h) +// next 30 minutes → [now, now + 30m) +// last 2 weeks → [now - 14d, now) +// +// Day and week values intentionally do NOT snap to local midnight +// here — that's the instant form's job. A user asking for "next 3 +// days" gets a 72-hour rolling window from this moment, which is +// what the design's grammar reference specifies. +// +// Anything that isn't exactly three tokens of the right shape, or +// whose first word isn't "next"/"last", is left for the next +// handler. +func parseRelativeDurationRange(tokens []token, now time.Time) (time.Time, time.Time, bool, error) { + if len(tokens) != 3 { + return time.Time{}, time.Time{}, false, nil + } + if tokens[0].kind != tokWord || tokens[1].kind != tokNumber || tokens[2].kind != tokWord { + return time.Time{}, time.Time{}, false, nil + } + leader := tokens[0].value + if leader != "next" && leader != "last" { + return time.Time{}, time.Time{}, false, nil + } + unit, ok := durationUnit(tokens[2].value) + if !ok { + return time.Time{}, time.Time{}, false, nil + } + dur, ok := rangeUnitDuration(unit) + if !ok { + return time.Time{}, time.Time{}, false, nil + } + n, err := strconv.Atoi(tokens[1].value) + if err != nil { + return time.Time{}, time.Time{}, true, fmt.Errorf("invalid number %q in %q: %w", tokens[1].value, leader, err) + } + span := time.Duration(n) * dur + if leader == "next" { + return now, now.Add(span), true, nil + } + return now.Add(-span), now, true, nil +} + +// parseUntil handles two-token phrases led by the word "until": +// +// until today → [now, end of today) end = midnight+24h +// until tomorrow → [now, end of tomorrow) end = midnight+48h +// until → [now, end of next occurrence of that weekday) +// If today is the named weekday, that's end of +// today (midnight+24h). Otherwise it's the next +// occurrence's midnight + 24h. +// until → [now, end of that local day) (date midnight+24h) +// +// "Until" with a bare RFC3339 / datetime literal is intentionally +// not handled here — those phrases name a specific instant the +// caller can already supply via the composite " through +// " form. Keeping until tied to day-precision endpoints +// matches the design's grammar reference. +// +// Anything that isn't a two-token phrase whose first word is "until" +// is left for the next handler. +func parseUntil(tokens []token, now time.Time) (time.Time, time.Time, bool, error) { + if len(tokens) != 2 { + return time.Time{}, time.Time{}, false, nil + } + if tokens[0].kind != tokWord || tokens[0].value != "until" { + return time.Time{}, time.Time{}, false, nil + } + tok := tokens[1] + switch tok.kind { + case tokWord: + switch tok.value { + case "today": + return now, localMidnight(now).Add(24 * time.Hour), true, nil + case "tomorrow": + return now, localMidnight(now).Add(48 * time.Hour), true, nil + } + if wd, ok := weekdayNames[tok.value]; ok { + target := nextWeekdayOnOrAfter(localMidnight(now), wd) + return now, target.Add(24 * time.Hour), true, nil + } + return time.Time{}, time.Time{}, true, fmt.Errorf("unknown day %q after \"until\"", tok.value) + case tokDate: + parsed, err := time.ParseInLocation("2006-01-02", tok.value, now.Location()) + if err != nil { + return time.Time{}, time.Time{}, true, fmt.Errorf("invalid date %q after \"until\": %w", tok.value, err) + } + return now, parsed.Add(24 * time.Hour), true, nil + } + return time.Time{}, time.Time{}, true, fmt.Errorf("unsupported value after \"until\": %q", tok.value) +} diff --git a/internal/whenparse/range_smoke_test.go b/internal/whenparse/range_smoke_test.go new file mode 100644 index 0000000..36e0045 --- /dev/null +++ b/internal/whenparse/range_smoke_test.go @@ -0,0 +1,109 @@ +package whenparse + +import ( + "strings" + "testing" + "time" +) + +// TestParseRange_smoke is a lightweight wiring check for task 3.3. +// The comprehensive table-driven coverage lives in task 3.4; this +// file only confirms that the composite splitter, weekday-range +// handler, and instant-promotion fallback are reachable through +// ParseRange and produce the expected windows. +func TestParseRange_smoke(t *testing.T) { + loc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + t.Fatalf("LoadLocation: %v", err) + } + now := time.Date(2026, 5, 20, 14, 30, 0, 0, loc) // Wednesday + at := func(year int, month time.Month, day int) time.Time { + return time.Date(year, month, day, 0, 0, 0, 0, loc) + } + + cases := []struct { + name string + in string + wantStart time.Time + wantEnd time.Time + }{ + // Bare weekday: instant-promotion fallback gives a + // 24-hour window at the next occurrence's midnight. + {"bare weekday promotes to local day", "monday", at(2026, 5, 25), at(2026, 5, 26)}, + {"weekday on same day promotes to today", "wednesday", at(2026, 5, 20), at(2026, 5, 21)}, + // today / tomorrow / yesterday should also promote. + {"today promotes to local day", "today", at(2026, 5, 20), at(2026, 5, 21)}, + {"tomorrow promotes to next day", "tomorrow", at(2026, 5, 21), at(2026, 5, 22)}, + {"yesterday promotes to previous day", "yesterday", at(2026, 5, 19), at(2026, 5, 20)}, + // YYYY-MM-DD bare date. + {"bare date promotes to local day", "2026-05-25", at(2026, 5, 25), at(2026, 5, 26)}, + // Weekday range: end is the start of the day AFTER Friday. + {"monday through friday spans the week", "monday through friday", at(2026, 5, 25), at(2026, 5, 30)}, + {"weekday range single day", "monday through monday", at(2026, 5, 25), at(2026, 5, 26)}, + // Composite mixed shapes. + {"composite tomorrow through friday", "tomorrow through friday", at(2026, 5, 21), at(2026, 5, 22)}, + {"composite via to keyword", "tomorrow to friday", at(2026, 5, 21), at(2026, 5, 22)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotStart, gotEnd, err := ParseRange(tc.in, now) + if err != nil { + t.Fatalf("ParseRange(%q): unexpected error %v", tc.in, err) + } + if !gotStart.Equal(tc.wantStart) { + t.Fatalf("ParseRange(%q) start\n got %s\n want %s", + tc.in, gotStart.Format(time.RFC3339), tc.wantStart.Format(time.RFC3339)) + } + if !gotEnd.Equal(tc.wantEnd) { + t.Fatalf("ParseRange(%q) end\n got %s\n want %s", + tc.in, gotEnd.Format(time.RFC3339), tc.wantEnd.Format(time.RFC3339)) + } + }) + } +} + +// TestParseRange_errors confirms the error paths surfaced by the +// 3.3 work: a non-day-precision instant must be rejected, a +// composite with end ≤ start must error, and unrecognized phrases +// must mention the input. +func TestParseRange_errors(t *testing.T) { + loc, _ := time.LoadLocation("America/Los_Angeles") + now := time.Date(2026, 5, 20, 14, 30, 0, 0, loc) + + t.Run("now alone is rejected as a range", func(t *testing.T) { + // "now" resolves to a non-midnight instant, so range + // promotion declines and we expect a clear error. + _, _, err := ParseRange("now", now) + if err == nil { + t.Fatalf("expected error for \"now\", got nil") + } + }) + + t.Run("rfc3339 mid-day instant is rejected as a range", func(t *testing.T) { + _, _, err := ParseRange("2026-05-25T14:00:30-07:00", now) + if err == nil { + t.Fatalf("expected error for non-midnight instant, got nil") + } + }) + + t.Run("composite with end before start errors", func(t *testing.T) { + _, _, err := ParseRange("tomorrow through yesterday", now) + if err == nil { + t.Fatalf("expected error for inverted composite, got nil") + } + if !strings.Contains(err.Error(), "tomorrow through yesterday") { + t.Fatalf("error %q does not mention input phrase", err.Error()) + } + }) + + t.Run("unrecognized phrase mentions input", func(t *testing.T) { + bogus := "definitelynotaphrase" + _, _, err := ParseRange(bogus, now) + if err == nil { + t.Fatalf("expected error for %q, got nil", bogus) + } + if !strings.Contains(err.Error(), bogus) { + t.Fatalf("error %q does not mention input %q", err.Error(), bogus) + } + }) +} diff --git a/internal/whenparse/relative.go b/internal/whenparse/relative.go new file mode 100644 index 0000000..eec8d6c --- /dev/null +++ b/internal/whenparse/relative.go @@ -0,0 +1,137 @@ +package whenparse + +import ( + "fmt" + "strconv" + "time" +) + +// durationUnit recognises the four supported duration keywords and +// returns the canonical singular form. Singular and plural arrive +// pre-folded by the lexer's case normalisation. Unknown words return +// ("", false) so the caller can fall through to the next handler +// rather than emit an error. +func durationUnit(value string) (string, bool) { + switch value { + case "minute", "minutes": + return "minute", true + case "hour", "hours": + return "hour", true + case "day", "days": + return "day", true + case "week", "weeks": + return "week", true + } + return "", false +} + +// applyOffset applies n units of the given canonical duration unit +// to now, in the direction indicated by forward. Day and week values +// snap to local midnight per REQ-5 ("3 days from now" → midnight on +// day +3); hour and minute values produce now ± exact duration. +// +// The signature assumes unit has already been validated by +// durationUnit; an unrecognised unit is a programmer error and falls +// through to a no-op return of now. +func applyOffset(now time.Time, n int, unit string, forward bool) time.Time { + sign := 1 + if !forward { + sign = -1 + } + switch unit { + case "minute": + return now.Add(time.Duration(sign*n) * time.Minute) + case "hour": + return now.Add(time.Duration(sign*n) * time.Hour) + case "day": + // AddDate handles DST correctly for date-only resolution, + // which is what we want when snapping to local midnight. + return localMidnight(now).AddDate(0, 0, sign*n) + case "week": + return localMidnight(now).AddDate(0, 0, sign*n*7) + } + return now +} + +// parseRelativeDuration handles the leading-keyword shapes "in N +// units", "next N units", and "last N units". Only "in" produces a +// single instant: day and week values snap to local midnight on day +// (or week) +N, hour and minute values resolve to now + duration. +// +// "next" and "last" are range-only forms (e.g. "next 3 days" means +// the [now, now+72h) span). We match them here only so we can return +// a clear error pointing the caller at ParseRange, rather than let +// the dispatcher fall through to parseAbsolute and surface a generic +// "unrecognized phrase" message. +func parseRelativeDuration(tokens []token, now time.Time) (time.Time, bool, error) { + if len(tokens) != 3 { + return time.Time{}, false, nil + } + if tokens[0].kind != tokWord || tokens[1].kind != tokNumber || tokens[2].kind != tokWord { + return time.Time{}, false, nil + } + leader := tokens[0].value + if leader != "in" && leader != "next" && leader != "last" { + return time.Time{}, false, nil + } + unit, ok := durationUnit(tokens[2].value) + if !ok { + // Some other "in " shape we do not recognise; + // let the dispatcher try the remaining handlers. + return time.Time{}, false, nil + } + n, err := strconv.Atoi(tokens[1].value) + if err != nil { + return time.Time{}, true, fmt.Errorf("invalid number %q in %q: %w", tokens[1].value, leader, err) + } + if leader != "in" { + phrase := leader + " " + tokens[1].value + " " + tokens[2].value + return time.Time{}, true, fmt.Errorf("%q is a range; use ParseRange instead", phrase) + } + return applyOffset(now, n, unit, true), true, nil +} + +// parseNumberLeading handles the trailing-keyword shapes "N units +// from now" (forward) and "N units ago" (backward). Both produce a +// single instant with the same day/week-snap and hour/minute-add +// rules as parseRelativeDuration's "in" form. +func parseNumberLeading(tokens []token, now time.Time) (time.Time, bool, error) { + // " from now" — 4 tokens. + if len(tokens) == 4 { + if tokens[0].kind != tokNumber || tokens[1].kind != tokWord || + tokens[2].kind != tokWord || tokens[3].kind != tokWord { + return time.Time{}, false, nil + } + if tokens[2].value != "from" || tokens[3].value != "now" { + return time.Time{}, false, nil + } + unit, ok := durationUnit(tokens[1].value) + if !ok { + return time.Time{}, false, nil + } + n, err := strconv.Atoi(tokens[0].value) + if err != nil { + return time.Time{}, true, fmt.Errorf("invalid number %q: %w", tokens[0].value, err) + } + return applyOffset(now, n, unit, true), true, nil + } + // " ago" — 3 tokens. + if len(tokens) == 3 { + if tokens[0].kind != tokNumber || tokens[1].kind != tokWord || tokens[2].kind != tokWord { + return time.Time{}, false, nil + } + if tokens[2].value != "ago" { + return time.Time{}, false, nil + } + unit, ok := durationUnit(tokens[1].value) + if !ok { + return time.Time{}, false, nil + } + n, err := strconv.Atoi(tokens[0].value) + if err != nil { + return time.Time{}, true, fmt.Errorf("invalid number %q: %w", tokens[0].value, err) + } + return applyOffset(now, n, unit, false), true, nil + } + return time.Time{}, false, nil +} diff --git a/internal/whenparse/weekday.go b/internal/whenparse/weekday.go new file mode 100644 index 0000000..20c88bb --- /dev/null +++ b/internal/whenparse/weekday.go @@ -0,0 +1,142 @@ +package whenparse + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// weekdayNames maps the seven English weekday words the parser +// recognises onto the standard library's time.Weekday constants. The +// lexer lower-cases its input, so all keys are lower case here. Map +// lookups double as a membership test for "is this token a weekday +// name", letting parseWeekday cleanly delegate single-word inputs to +// the bare-weekday branch. +var weekdayNames = map[string]time.Weekday{ + "sunday": time.Sunday, + "monday": time.Monday, + "tuesday": time.Tuesday, + "wednesday": time.Wednesday, + "thursday": time.Thursday, + "friday": time.Friday, + "saturday": time.Saturday, +} + +// parseWeekday handles the day-anchored instant phrases that name a +// weekday or use `today`/`tomorrow` together with a wall-clock time: +// +// monday → midnight on the next Monday at-or-after today +// monday at 14:00 → that Monday's wall clock at 14:00; if today +// is Monday and 14:00 has already passed, +// advance one week +// today at 14:00 → today's local 14:00 (even if past) +// tomorrow at 14:00 → tomorrow's local 14:00 +// +// `today` and `tomorrow` as bare single-word phrases are claimed +// earlier by parseNamedDay; this handler only sees them in the +// three-token ` at HH:MM` shape, so there is no overlap. +func parseWeekday(tokens []token, now time.Time) (time.Time, bool, error) { + switch len(tokens) { + case 1: + return parseBareWeekday(tokens, now) + case 3: + return parseDayAtTime(tokens, now) + } + return time.Time{}, false, nil +} + +// parseBareWeekday resolves a single weekday word to local midnight +// on the next occurrence at or after today. If today already is the +// named weekday, today's midnight wins — "monday" on a Monday means +// today, not seven days from now. +func parseBareWeekday(tokens []token, now time.Time) (time.Time, bool, error) { + if tokens[0].kind != tokWord { + return time.Time{}, false, nil + } + wd, ok := weekdayNames[tokens[0].value] + if !ok { + return time.Time{}, false, nil + } + target := nextWeekdayOnOrAfter(localMidnight(now), wd) + return target, true, nil +} + +// parseDayAtTime resolves the three-token ` at HH:MM` shape, +// where `` is either a weekday name or one of the bare keywords +// `today`/`tomorrow`. The middle token must literally be the word +// `at`; anything else is left for later handlers. +// +// For weekdays, the rule mirrors parseBareWeekday but anchors to the +// requested wall time. The one wrinkle is the "already passed" +// check: when today is the named weekday and `now` has crossed the +// requested HH:MM, the result advances by seven days so the user +// never gets a result in the past. +func parseDayAtTime(tokens []token, now time.Time) (time.Time, bool, error) { + if tokens[0].kind != tokWord || tokens[1].kind != tokWord || tokens[2].kind != tokTime { + return time.Time{}, false, nil + } + if tokens[1].value != "at" { + return time.Time{}, false, nil + } + hour, minute, err := parseHHMM(tokens[2].value) + if err != nil { + return time.Time{}, true, err + } + loc := now.Location() + day := tokens[0].value + switch day { + case "today": + base := localMidnight(now) + return time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, loc), true, nil + case "tomorrow": + base := localMidnight(now).Add(24 * time.Hour) + return time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, loc), true, nil + } + wd, ok := weekdayNames[day] + if !ok { + return time.Time{}, false, nil + } + target := nextWeekdayOnOrAfter(localMidnight(now), wd) + target = time.Date(target.Year(), target.Month(), target.Day(), hour, minute, 0, 0, loc) + // "If today is that weekday but HH:MM has already passed, advance + // one week." We compare to now rather than midnight because the + // HH:MM is wall-clock time on today's date. + if now.Weekday() == wd && !target.After(now) { + target = target.Add(7 * 24 * time.Hour) + } + return target, true, nil +} + +// nextWeekdayOnOrAfter returns midnight on the soonest day whose +// weekday matches wd, starting from base (which must already be a +// midnight). Same-day wins: if base.Weekday() == wd, base itself is +// returned. The arithmetic uses ((wd - base.Weekday()) + 7) % 7 so +// the result is always in [0,6] days ahead. +func nextWeekdayOnOrAfter(base time.Time, wd time.Weekday) time.Time { + delta := (int(wd) - int(base.Weekday()) + 7) % 7 + return base.Add(time.Duration(delta) * 24 * time.Hour) +} + +// parseHHMM splits an HH:MM token (the lexer already validated the +// shape) into integer hour and minute, rejecting out-of-range values +// the lexer's regex allows. The lexer accepts 1–2 digit hours and +// always 2-digit minutes, so we only have to bounds-check here. +func parseHHMM(s string) (hour, minute int, err error) { + parts := strings.Split(s, ":") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("whenparse: invalid time %q", s) + } + hour, err = strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("whenparse: invalid hour in %q: %w", s, err) + } + minute, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("whenparse: invalid minute in %q: %w", s, err) + } + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, 0, fmt.Errorf("whenparse: time %q out of range", s) + } + return hour, minute, nil +} diff --git a/internal/whenparse/whenparse.go b/internal/whenparse/whenparse.go new file mode 100644 index 0000000..e7310a3 --- /dev/null +++ b/internal/whenparse/whenparse.go @@ -0,0 +1,138 @@ +package whenparse + +import ( + "fmt" + "time" +) + +// instantHandlers is the ordered dispatch chain ParseInstant walks. +// Each handler decides whether it recognises the token shape; the +// first one that claims the input wins. Adding a new handler is a +// one-line append. parseAbsolute lives at the end because it is the +// catch-all for raw-literal shapes that earlier handlers do not +// touch — putting it earlier would let it steal phrases that mix a +// literal with a keyword. +var instantHandlers = []instantHandler{ + parseNamedDay, + parseRelativeDuration, + parseNumberLeading, + parseWeekday, + parseAbsolute, +} + +// rangeHandlers is the ordered dispatch chain ParseRange walks. Each +// handler decides whether it recognises the token shape; the first +// one that claims the input wins. New range handlers append to this +// slice as they ship. The order matters when two handlers might both +// claim the same token shape — early entries get first refusal — so +// place more specific handlers ahead of more permissive ones. +// +// parseWeekdayRange is listed before any general "through"-splitting +// composite logic because it owns the " through " +// shape: that variant promotes to the day after the second weekday, +// whereas the composite splitter would parse each side as an +// instant and stop at the bare midnight of the second weekday. +var rangeHandlers = []rangeHandler{ + parseWeekRange, + parseRestOfWeek, + parseWeekdayRange, + parseRelativeDurationRange, + parseUntil, +} + +// ParseInstant turns an English phrase into a single point in time, +// resolved against now. Day-precision phrases (today, tomorrow, +// 2026-05-25, monday) snap to local midnight on that day. now's +// time.Location is used as the local timezone for every day-precision +// resolution. +// +// Returns a descriptive error including the input phrase when the +// input is not recognised. +func ParseInstant(phrase string, now time.Time) (time.Time, error) { + tokens := tokenize(phrase) + if len(tokens) == 0 { + return time.Time{}, fmt.Errorf("whenparse: ParseInstant(%q): empty phrase", phrase) + } + t, err := parseInstantTokens(tokens, now) + if err != nil { + return time.Time{}, fmt.Errorf("whenparse: ParseInstant(%q): %w", phrase, err) + } + return t, nil +} + +// parseInstantTokens runs the instantHandlers chain against an +// already-tokenized phrase. ParseInstant calls it after tokenizing, +// and ParseRange calls it both for the instant-promotion fallback +// and for each side of a composite split. Centralising the +// dispatch loop avoids re-tokenizing the same input in those cases. +// +// The returned error is bare (no "whenparse: ParseInstant(...)" +// envelope); callers wrap with their own context so the user sees +// which surface — instant or range — surfaced the failure. +func parseInstantTokens(tokens []token, now time.Time) (time.Time, error) { + for _, h := range instantHandlers { + t, matched, err := h(tokens, now) + if !matched { + continue + } + if err != nil { + return time.Time{}, err + } + return t, nil + } + return time.Time{}, fmt.Errorf("unrecognized phrase") +} + +// ParseRange turns an English phrase into a half-open [start, end) +// window. Single-instant phrases that are day-precision (today, +// tomorrow, yesterday, weekday names, YYYY-MM-DD, "in N days"…) +// promote to a 24-hour local-day window starting at the resolved +// midnight. Phrases that name a duration (next 3 days, this week) +// become spans of that duration. Composite " through +// " phrases — and the equivalent " to " +// shape when neither side begins with from / through / until — +// resolve each side via ParseInstant. +// +// Returns a descriptive error including the input phrase when the +// input is not recognised, when end is not strictly after start, or +// when a non-day-precision instant is supplied where a range is +// required. +func ParseRange(phrase string, now time.Time) (start, end time.Time, err error) { + tokens := tokenize(phrase) + if len(tokens) == 0 { + return time.Time{}, time.Time{}, fmt.Errorf("whenparse: ParseRange(%q): empty phrase", phrase) + } + for _, h := range rangeHandlers { + s, e, matched, herr := h(tokens, now) + if !matched { + continue + } + if herr != nil { + return time.Time{}, time.Time{}, fmt.Errorf("whenparse: ParseRange(%q): %w", phrase, herr) + } + return s, e, nil + } + if s, e, matched, cerr := parseComposite(tokens, now); matched { + if cerr != nil { + return time.Time{}, time.Time{}, fmt.Errorf("whenparse: ParseRange(%q): %w", phrase, cerr) + } + return s, e, nil + } + // Fallback: if the phrase resolves to a single instant, promote + // it to a 24-hour local-day window when it lands on local + // midnight (today / tomorrow / yesterday / weekday names / + // YYYY-MM-DD / "in N days"). Non-midnight instants are valid + // ParseInstant inputs but ambiguous as ranges, so reject them + // with a clear pointer rather than fabricate an arbitrary + // window. + if t, ierr := parseInstantTokens(tokens, now); ierr == nil { + if t.Equal(localMidnight(t)) { + return t, t.Add(24 * time.Hour), nil + } + return time.Time{}, time.Time{}, fmt.Errorf( + "whenparse: ParseRange(%q): phrase resolves to a single instant, not a range", + phrase, + ) + } + return time.Time{}, time.Time{}, fmt.Errorf("whenparse: ParseRange(%q): unrecognized phrase", phrase) +}